diff --git a/.gitignore b/.gitignore index eedcf4e4..0b608908 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode/ .gitattributes -*.code-workspace \ No newline at end of file +*.code-workspace +.env \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe391c9..c4dfdec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- `SourceControl.Git.Change` no longer extends `%Studio.SourceControl.Change`, which is being removed from IRIS Product in 2026.3 and shipped only with CCR (#989). Existing change data is unaffected; no upgrade action is required. +- `SourceControl.Git.Change:SetUncommitted` no longer accepts the `EnforceSourcesPath` and `Bulk` arguments, `UpdateUncommitted` no longer accepts `Bulk`, and `ListUncommitted` no longer accepts `RefreshUncommitted`. All were unused by Embedded Git. +- Embedded Git's queries for uncommitted changes (`ListUncommitted`, `GetUserProductionChanges`) now target `SourceControl_Git.Change` directly instead of the shared `%Studio_SourceControl.Change` table. Today the two classes still share the same underlying storage globals, so this does not change what data is visible to any consumer; it removes Embedded Git's dependency on the product-owned table ahead of its removal (#989). + ## [2.17.1] - 2026-08-18 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae2c3825..a931fb14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,11 @@ This spins up a single container: #### Important Notes - The repository is mounted at `/home/irisowner/dev/git-source-control/` inside the container. -- If port 52774 is already in use, edit the port mapping in `docker-compose.yml`. +- If port 52774 is already in use, create a `.env` file in the root directory setting `IRIS_WEB_PORT` to a free port. +```bash +echo IRIS_WEB_PORT=52775 > .env +docker compose up -d --build +``` - If you have an InterSystems license key at `~/iris.key`, it will be mounted into the container automatically. #### Development diff --git a/cls/SourceControl/Git/Change.cls b/cls/SourceControl/Git/Change.cls index 61e154de..18bb5942 100644 --- a/cls/SourceControl/Git/Change.cls +++ b/cls/SourceControl/Git/Change.cls @@ -1,14 +1,55 @@ Include SourceControl.Git -Class SourceControl.Git.Change Extends (%Persistent, %Studio.SourceControl.Change) +Class SourceControl.Git.Change Extends %Persistent { -/// Returns the name of the backup file for this Item in the file system -/// Unused in this class, so override to avoid errors. -Property BackupName As %String [ Calculated, SqlComputeCode = {s {*} = ""}, SqlComputed ]; +/// Each namespace has its own change extent, and these globals are shared with the +/// Studio SourceControl product's Change class where that class is installed, so the +/// Extent Manager must not register them for this class. +Parameter MANAGEDEXTENT As INTEGER [ Constraint = "0,1", Flags = ENUM ] = 0; -/// Returns the name of this Item in the file system -Property ExternalName As %String [ Calculated, SqlComputeCode = {S {*} = "" }, SqlComputed ]; +/// Data global for this class, used by BuildNewIndexes(). Must match the Storage definition below. +Parameter DataGlobal = "^Studio.SourceControl.ChangeD"; + +/// Index global for this class, used by BuildNewIndexes(). Must match the Storage definition below. +Parameter IndexGlobal = "^Studio.SourceControl.ChangeI"; + +/// Action for this change +Property Action As %String(VALUELIST = ",add,edit,predelete,delete,revert") [ Required ]; + +/// Flag indicating whether this change was committed through the source control tools. +/// A committed change with ActivelyCommitted = 0 may have been reverted or committed outside of IRIS. +Property ActivelyCommitted As %Boolean [ InitialExpression = 0 ]; + +/// CCR associated with this change when it was created (optional) +Property CCR As %String; + +/// User who made this change. For uncommitted changes, this is the user who has it checked out. +Property ChangedBy As %String; + +/// Flag indicating whether this change has been committed +Property Committed As %Boolean [ InitialExpression = 0, Required ]; + +/// Time when this change was committed, in $zdt($H,3) format +Property CommittedTime As %TimeStamp [ InitialExpression = {$zdt("0,0",3)} ]; + +/// Name used within the instance to refer to this changed item +Property InternalName As %String(MAXLEN = 200); + +/// Location where this item is stored on disk +Property ItemFile As %String(MAXLEN = 500) [ Required ]; + +/// Human readable name for this changed item. Defaults to InternalName if not set explicitly. +Property Name As %String(MAXLEN = 200); + +/// Time at which the change was marked as issued to source control +Property P4Issued As %TimeStamp; + +/// Utility by which this change was made +Property Source As %String(VALUELIST = ",studio,terminal,trakcare,healthshare,tier2") [ InitialExpression = "studio" ]; + +/// Last time this change was saved locally +Property UpdatedTime As %TimeStamp [ InitialExpression = {$zdt($H,3)} ]; Index CommittedMap On Committed [ Type = bitmap ]; @@ -26,8 +67,8 @@ Index ChangeList On (ItemFile, CommittedTime) [ Data = Action, Unique ]; ///
  • FileList - the name of the file to revert, or an array subscripted by filenames (e.g. FileList("C:\Perforce\custom_ccrs\us\ISCX\TESTSYS\cls\User\Test.xml")="")
  • ///
  • Display - boolean flag controlling whether to display the results to standard output (defaults to '1')
  • ///
  • Revert - boolean flag controlling whether to revert a checkout which would delete the change from the change history table (defaults to '0')
  • -///
  • ActiveCommit - used for historical tracking purposes for the %Studio_SourceControl.Change table
  • -///
  • CommitCCR - used for historical tracking purposes for the %Studio_SourceControl.Change table
  • +///
  • ActiveCommit - used for historical tracking purposes when Committed changes are kept for historical reference
  • +///
  • CommitCCR - used for historical tracking purposes when Committed changes are kept for historical reference
  • /// ClassMethod RemoveUncommitted(FileList, Display = 1, Revert = 0, ActiveCommit = 1, CommitCCR) As %Status { @@ -63,7 +104,7 @@ ClassMethod RemoveUncommitted(FileList, Display = 1, Revert = 0, ActiveCommit = ClassMethod AddDeletedToUncommitted(Filename, InternalName) As %Status { - Quit ..SetUncommitted(Filename, "delete", InternalName, $USERNAME, "", 1, "", "", 0) + Quit ..SetUncommitted(Filename, "delete", InternalName, $USERNAME, "", 1, "", "") } /// Determine if an item is deleted, if it is not in a provided list of git files but is a known tracked item @@ -128,7 +169,7 @@ ClassMethod RefreshUncommitted(Display = 0, IncludeRevert = 0, Output gitFiles, kill gitFiles // files from the uncommitted queue - set sc=..ListUncommitted(.tFileList,IncludeRevert,0) + set sc=..ListUncommitted(.tFileList,IncludeRevert) if $$$ISERR(sc) quit sc // files from git status @@ -180,7 +221,7 @@ ClassMethod RefreshUncommitted(Display = 0, IncludeRevert = 0, Output gitFiles, 1:"add") if ((##class(%File).Exists(ExternalName)) && ('$ISVALIDNUM(InternalName)) && ('..IsUncommitted(ExternalName)) && ($data($$$TrackedItems(InternalName)))) { - set sc=..SetUncommitted(ExternalName, action, InternalName, $USERNAME, "", 1, "", "", 0) + set sc=..SetUncommitted(ExternalName, action, InternalName, $USERNAME, "", 1, "", "") if $$$ISERR(sc) continue } } @@ -203,6 +244,130 @@ ClassMethod RefreshUncommitted(Display = 0, IncludeRevert = 0, Output gitFiles, quit sc } +/// Returns the uncommitted change object for Filename, or "" if it is not in the queue +ClassMethod OpenUncommitted(Filename As %String) As SourceControl.Git.Change +{ + if '..IsUncommitted(Filename, .id) quit "" + quit ..%OpenId(id) +} + +/// Retrieves the details of an item in the uncommitted queue +ClassMethod GetUncommitted(Filename As %String, ByRef Action As %String, ByRef InternalName As %String, ByRef ChangedBy As %String, ByRef Source As %String, ByRef Updated As %TimeStamp, ByRef P4Issued As %TimeStamp, ByRef CCR As %String, ByRef Name As %String) As %Status +{ + if '$data(Filename) quit $$$ERROR($$$GeneralError,"'Filename' is a required field") + if '..IsUncommitted(Filename) quit $$$ERROR($$$GeneralError,"Item is not in the Uncommitted queue") + set obj = ..OpenUncommitted(Filename) + set Action = obj.Action + set ChangedBy = obj.ChangedBy + set InternalName = obj.InternalName + set Source = obj.Source + set Updated = obj.UpdatedTime + set P4Issued = obj.P4Issued + set CCR = obj.CCR + set Name = obj.Name + quit $$$OK +} + +/// Adds a new item to the uncommitted queue, or updates it if it is already there +ClassMethod SetUncommitted(Filename As %String, Action As %String, InternalName As %String, ChangedBy As %String, Source As %String, P4Issued As %Boolean, CCR As %String, Name As %String) As %Status +{ + if '$data(Filename)#2 quit $$$ERROR($$$GeneralError,"'Filename' is a required field") + set Filename = ##class(%File).NormalizeFilename(Filename) + set isCSP = ($extract($get(InternalName)) = "/") + if '(##class(%File).Exists(Filename) || (isCSP && ($get(Action) = "delete"))) { + quit $$$ERROR($$$GeneralError,"File does not exist - "_Filename) + } + if ..IsUncommitted(Filename) { + quit ..UpdateUncommitted(Filename, .Action, .InternalName, .ChangedBy, .Source, , .P4Issued, .CCR, .Name) + } + set obj = ..%New() + set obj.ItemFile = Filename + if $data(Action)#2 set obj.Action = $get(Action) + if $data(ChangedBy)#2 set obj.ChangedBy = $get(ChangedBy) + if $data(InternalName)#2 set obj.InternalName = $get(InternalName) + if $data(Source)#2 set obj.Source = $get(Source) + if $data(CCR)#2 set obj.CCR = $get(CCR) + if $data(Name)#2 set obj.Name = $get(Name) + if $get(P4Issued) set obj.P4Issued = $zdatetime($horolog, 3) + quit obj.%Save() +} + +/// Updates the details of an item already in the uncommitted queue +ClassMethod UpdateUncommitted(Filename As %String, Action As %String, InternalName As %String, ChangedBy As %String, Source As %String, Updated, P4Issued As %Boolean, CCR As %String, Name As %String) As %Status +{ + if '$data(Filename)#2 quit $$$ERROR($$$GeneralError,"'Filename' is a required field") + set Filename = ##class(%File).NormalizeFilename(Filename) + if '##class(%File).Exists(Filename) && ($get(Action) '= "revert") { + quit $$$ERROR($$$GeneralError,"File does not exist - "_Filename) + } + set obj = ..OpenUncommitted(Filename) + if '$isobject(obj) quit $$$ERROR($$$GeneralError,"'"_Filename_"' is not in an uncommitted change") + if $data(Action)#2 { + if Action '= obj.Action set obj.P4Issued = "" + set obj.Action = $get(Action) + } + if $data(ChangedBy)#2 { + if ChangedBy '= obj.ChangedBy { + quit $$$ERROR($$$GeneralError,"UpdateUncommitted() called on '"_Filename_"' by "_ChangedBy_", which is owned by "_obj.ChangedBy) + } + set obj.ChangedBy = $get(ChangedBy) + set obj.UpdatedTime = $zdatetime($horolog, 3) + } + if $data(InternalName)#2 set obj.InternalName = $get(InternalName) + if $data(Source)#2 set obj.Source = $get(Source) + if $data(CCR)#2 set obj.CCR = $get(CCR) + if $data(Name)#2 set obj.Name = $get(Name) + if $get(P4Issued) set obj.P4Issued = $zdatetime($horolog, 3) + quit obj.%Save() +} + +/// Lists the items in the uncommitted queue, subscripted by filename with the action as the value. +/// Items with an action of 'revert' are listed only if IncludeRevert is true. +ClassMethod ListUncommitted(ByRef UncommittedList = "", IncludeRevert As %Boolean = 0) As %Status +{ + do ..BuildNewIndexes() + &sql(DECLARE UncommittedCursor CURSOR FOR + SELECT ItemFile, Action INTO :itemFile, :action + FROM SourceControl_Git.Change WHERE Committed = 0) + &sql(OPEN UncommittedCursor) + throw:SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(SQLCODE, %msg) + &sql(FETCH UncommittedCursor) + while SQLCODE = 0 { + if IncludeRevert || (action '= "revert") { + set UncommittedList(itemFile) = action + } + &sql(FETCH UncommittedCursor) + } + &sql(CLOSE UncommittedCursor) + quit $$$OK +} + +/// Builds any index defined in this class that has never been built in this namespace. +/// Needed for instances holding change data that predates the addition of an index. +/// The index list must be kept in sync with the Index definitions in this class. +ClassMethod BuildNewIndexes() +{ + // nothing to do in a namespace with no change data + if $data(@..#DataGlobal) '= 11 quit + set indexList = "" + for indexName = "ChangeList", "CommittedMap" { + if $data(@..#IndexGlobal@(indexName))\2 = 0 { + set indexList = indexList_$listbuild(indexName) + } + } + if indexList = "" quit + write "Building "_..%ClassName(1)_" indexes in "_$namespace_": "_$listtostring(indexList, ", "),!! + $$$ThrowOnError(..%BuildIndices(indexList, 1, 1)) +} + +/// Defaults ChangedBy to the current user and Name to InternalName +Method %OnAddToSaveSet(depth As %Integer = 3, insert As %Integer = 0, callcount As %Integer = 0) As %Status [ Private, ServerOnly = 1 ] +{ + if ..ChangedBy = "" set ..ChangedBy = $username + if ..Name = "" set ..Name = ..InternalName + quit $$$OK +} + Query InstanceUncommitted() As %Query(ROWSPEC = "InternalName:%String,User:%String,Namespace:%String") { } diff --git a/cls/SourceControl/Git/Extension.cls b/cls/SourceControl/Git/Extension.cls index 4b65659b..5431feba 100644 --- a/cls/SourceControl/Git/Extension.cls +++ b/cls/SourceControl/Git/Extension.cls @@ -402,7 +402,7 @@ Method OnAfterSave(InternalName As %String, Object As %RegisteredObject = {$$$NU set forceExport = (InternalName'= "") && ($data(..Modified(InternalName))) $$$ThrowOnError(##class(SourceControl.Git.Utils).ExportItem(InternalName,,forceExport)) if '##class(SourceControl.Git.Change).IsUncommitted(filename) { - $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", InternalName, $username, "", 1, "", "", 0)) + $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", InternalName, $username, "", 1, "", "")) } } } else { diff --git a/cls/SourceControl/Git/Production.cls b/cls/SourceControl/Git/Production.cls index c1ceec81..ca25988d 100644 --- a/cls/SourceControl/Git/Production.cls +++ b/cls/SourceControl/Git/Production.cls @@ -814,7 +814,7 @@ ClassMethod CreateProduction(productionName As %String, superClasses As %String /// for each of their current uncommitted changes associated with the given Production ClassMethod GetUserProductionChanges(productionName As %String, ByRef items) { - set sql = "SELECT InternalName, Action FROM %Studio_SourceControl.Change WHERE ChangedBy = ? AND Committed = 0 AND InternalName %STARTSWITH ?" + set sql = "SELECT InternalName, Action FROM SourceControl_Git.Change WHERE ChangedBy = ? AND Committed = 0 AND InternalName %STARTSWITH ?" set rs = ..ExecDirectNoPriv(sql,$username,productionName_"||") throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message) while rs.%Next() { diff --git a/cls/SourceControl/Git/Utils.cls b/cls/SourceControl/Git/Utils.cls index 5a5dd68d..97832ab9 100644 --- a/cls/SourceControl/Git/Utils.cls +++ b/cls/SourceControl/Git/Utils.cls @@ -1842,7 +1842,7 @@ ClassMethod ExportItem(InternalName As %String, expand As %Boolean = 1, force As set filenames($I(filenames)) = filename $$$QuitOnError(..UpdateRoutineTSH(InternalName, $h)) if '##class(SourceControl.Git.Change).IsUncommitted(filename) { - $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "add", InternalName, $username, "", 1, "", "", 0)) + $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "add", InternalName, $username, "", 1, "", "")) } } } diff --git a/docker-compose.yml b/docker-compose.yml index 1bae21bc..c20e6b46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: . restart: always ports: - - 52774:52773 + - ${IRIS_WEB_PORT:-52774}:52773 volumes: - ~/iris.key:/usr/irissys/mgr/iris.key - ./:/home/irisowner/dev/git-source-control/ diff --git a/iriscli b/iriscli index f76cf9e2..b86ed361 100644 --- a/iriscli +++ b/iriscli @@ -6,7 +6,10 @@ # iriscli -U USER script.txt (run a script file) # iriscli script.txt arg1 arg2 (run script with parameters) -CONTAINER=${IRIS_CONTAINER:-git-source-control-iris-1} +# Find the container for the compose project at this script's directory. +# Override with IRIS_CONTAINER if needed. +default_project=$(basename "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-') +CONTAINER=${IRIS_CONTAINER:-${COMPOSE_PROJECT_NAME:-$default_project}-iris-1} ARGS=() PARAMS=() file= diff --git a/test/UnitTest/SourceControl/Git/Change.cls b/test/UnitTest/SourceControl/Git/Change.cls new file mode 100644 index 00000000..e9c74d18 --- /dev/null +++ b/test/UnitTest/SourceControl/Git/Change.cls @@ -0,0 +1,207 @@ +Include %occInclude + +Class UnitTest.SourceControl.Git.Change Extends UnitTest.SourceControl.Git.AbstractTest +{ + +/// Creates a real file on disk, since SetUncommitted rejects files that do not exist +ClassMethod TempFile() As %String +{ + set path = ##class(%File).TempFilename("txt") + do ..WriteFile(path, "test contents") + quit ##class(%File).NormalizeFilename(path) +} + +ClassMethod Cleanup(filename As %String) +{ + &sql(DELETE FROM SourceControl_Git.Change WHERE ItemFile = :filename) + do ##class(%File).Delete(filename) +} + +Method TestSetAndGetUncommitted() +{ + set filename = ..TempFile() + try { + set sc = ##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", "Test.Git.Change.cls", $username, "terminal", 1, "", "") + do $$$AssertStatusOK(sc) + do $$$AssertTrue(##class(SourceControl.Git.Change).IsUncommitted(filename)) + + set sc = ##class(SourceControl.Git.Change).GetUncommitted(filename, .action, .internalName, .changedBy, .source, .updated) + do $$$AssertStatusOK(sc) + do $$$AssertEquals(action, "edit") + do $$$AssertEquals(internalName, "Test.Git.Change.cls") + do $$$AssertEquals(changedBy, $username) + do $$$AssertEquals(source, "terminal") + do $$$AssertNotEquals(updated, "") + } catch ex { + do $$$AssertStatusOK(ex.AsStatus()) + } + do ..Cleanup(filename) +} + +/// ChangedBy defaults to $username and Name defaults to InternalName in %OnAddToSaveSet +Method TestDefaultsOnSave() +{ + set filename = ..TempFile() + try { + set sc = ##class(SourceControl.Git.Change).SetUncommitted(filename, "add", "Test.Git.Defaults.cls", "", "", 0, "", "") + do $$$AssertStatusOK(sc) + + &sql(SELECT ChangedBy, Name INTO :changedBy, :name + FROM SourceControl_Git.Change WHERE ItemFile = :filename) + $$$ThrowSQLIfError(SQLCODE,.%msg) + do $$$AssertEquals(changedBy, $username) + do $$$AssertEquals(name, "Test.Git.Defaults.cls") + } catch ex { + do $$$AssertStatusOK(ex.AsStatus()) + } + do ..Cleanup(filename) +} + +/// A second SetUncommitted for the same file routes through UpdateUncommitted +Method TestSetUncommittedUpdatesExistingRow() +{ + set filename = ..TempFile() + try { + set sc = ##class(SourceControl.Git.Change).SetUncommitted(filename, "add", "Test.Git.Update.cls", $username, "", 0, "", "") + do $$$AssertStatusOK(sc) + set sc = ##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", "Test.Git.Update.cls", $username, "", 0, "", "") + do $$$AssertStatusOK(sc) + + &sql(SELECT COUNT(*) INTO :rowCount + FROM SourceControl_Git.Change WHERE ItemFile = :filename) + $$$ThrowSQLIfError(SQLCODE,.%msg) + do $$$AssertEquals(rowCount, 1) + + set sc = ##class(SourceControl.Git.Change).GetUncommitted(filename, .action) + do $$$AssertStatusOK(sc) + do $$$AssertEquals(action, "edit") + } catch ex { + do $$$AssertStatusOK(ex.AsStatus()) + } + do ..Cleanup(filename) +} + +/// A change owned by another user cannot be updated +Method TestUpdateByOtherUserFails() +{ + set filename = ..TempFile() + try { + &sql(INSERT INTO SourceControl_Git.Change (InternalName, ChangedBy, ItemFile, Action, Committed) + VALUES ('Test.Git.Other.cls', 'OtherUser', :filename, 'edit', 0)) + $$$ThrowSQLIfError(SQLCODE,.%msg) + + set sc = ##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", "Test.Git.Other.cls", $username, "", 0, "", "") + do $$$AssertTrue($$$ISERR(sc)) + } catch ex { + do $$$AssertStatusOK(ex.AsStatus()) + } + do ..Cleanup(filename) +} + +/// ListUncommitted hides 'revert' rows unless IncludeRevert is set +Method TestListUncommittedRevertFiltering() +{ + set editFile = ..TempFile() + set revertFile = ..TempFile() + try { + set sc = ##class(SourceControl.Git.Change).SetUncommitted(editFile, "edit", "Test.Git.ListEdit.cls", $username, "", 0, "", "") + do $$$AssertStatusOK(sc) + set sc = ##class(SourceControl.Git.Change).SetUncommitted(revertFile, "revert", "Test.Git.ListRevert.cls", $username, "", 0, "", "") + do $$$AssertStatusOK(sc) + + set sc = ##class(SourceControl.Git.Change).ListUncommitted(.withoutRevert) + do $$$AssertStatusOK(sc) + do $$$AssertTrue($data(withoutRevert(editFile))) + do $$$AssertNotTrue($data(withoutRevert(revertFile))) + + set sc = ##class(SourceControl.Git.Change).ListUncommitted(.withRevert, 1) + do $$$AssertStatusOK(sc) + do $$$AssertTrue($data(withRevert(editFile))) + do $$$AssertTrue($data(withRevert(revertFile))) + } catch ex { + do $$$AssertStatusOK(ex.AsStatus()) + } + do ..Cleanup(editFile) + do ..Cleanup(revertFile) +} + +Method TestSetUncommittedRejectsMissingFile() +{ + set filename = ##class(%File).NormalizeFilename(##class(%File).TempFilename("txt")) + do ##class(%File).Delete(filename) + set sc = ##class(SourceControl.Git.Change).SetUncommitted( + filename, "edit", "Test.Git.Missing.cls", $username, "", 0, "", "") + do $$$AssertTrue($$$ISERR(sc)) +} + +/// A nonexistent CSP item with Action = "delete" is accepted, unlike a nonexistent non-CSP file +Method TestSetUncommittedAcceptsNonexistentCSPDelete() +{ + set filename = ##class(%File).NormalizeFilename(##class(%File).TempFilename("csp")) + do ##class(%File).Delete(filename) + set sc = ##class(SourceControl.Git.Change).SetUncommitted( + filename, "delete", "/csp/user/Test.Git.NonexistentCSP.csp", $username, "", 0, "", "") + do $$$AssertStatusOK(sc) + do ..Cleanup(filename) +} + +/// A row carrying an extra trailing $list piece (the orphaned Bulk slot left behind by +/// rows written before this class stopped inheriting from %Studio.SourceControl.Change) +/// must still read back correctly. +Method TestRowWithOrphanedTrailingSlotIsReadable() +{ + set filename = ..TempFile() + try { + set sc = ##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", "Test.Git.OldLayout.cls", $username, "terminal", 1, "", "") + do $$$AssertStatusOK(sc) + do $$$AssertTrue(##class(SourceControl.Git.Change).IsUncommitted(filename, .id)) + + // simulate the pre-change on-disk layout, which had a 15th piece for Bulk + set $list(^Studio.SourceControl.ChangeD(id), 15) = 1 + + set sc = ##class(SourceControl.Git.Change).GetUncommitted(filename, .action, .internalName, .changedBy, .source) + do $$$AssertStatusOK(sc) + do $$$AssertEquals(action, "edit") + do $$$AssertEquals(internalName, "Test.Git.OldLayout.cls") + do $$$AssertEquals(changedBy, $username) + do $$$AssertEquals(source, "terminal") + + set obj = ##class(SourceControl.Git.Change).%OpenId(id) + do $$$AssertTrue($isobject(obj)) + do $$$AssertEquals(obj.Action, "edit") + } catch ex { + do $$$AssertStatusOK(ex.AsStatus()) + } + do ..Cleanup(filename) +} + +/// GetUserProductionChanges must read the Embedded Git change table, not the product one. +/// Note: This test documents the intent to query SourceControl_Git.Change directly, but cannot +/// detect a regression in environments where both %Studio.SourceControl.Change and SourceControl_Git.Change +/// map to the same physical globals (^Studio.SourceControl.ChangeD/I) — a deliberate design choice +/// documented in the spec's "Storage and data" section to make the upgrade a no-op. The old and new queries +/// return identical results in such environments. The test only genuinely guards the regression once +/// %Studio.SourceControl.Change is fully removed from an instance (2026.3 target), at which point the old +/// query would SQL-error. Until then, this test has practical value as documentation of query correctness and +/// intent rather than as a live regression detector. +Method TestGetUserProductionChanges() +{ + set filename = ..TempFile() + set internalName = "TestGit.UnitTestProduction.Production||TestGit.UnitTestItem" + set user = $username + try { + &sql(INSERT INTO SourceControl_Git.Change (InternalName, ChangedBy, ItemFile, Action, Committed) + VALUES (:internalName, :user, :filename, 'edit', 0)) + $$$ThrowSQLIfError(SQLCODE,.%msg) + + do ##class(SourceControl.Git.Production).GetUserProductionChanges( + "TestGit.UnitTestProduction.Production", .items) + do $$$AssertTrue($data(items(internalName))) + do $$$AssertEquals($get(items(internalName)), "edit") + } catch ex { + do $$$AssertStatusOK(ex.AsStatus()) + } + do ..Cleanup(filename) +} + +} diff --git a/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls b/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls new file mode 100644 index 00000000..db45cb8b --- /dev/null +++ b/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls @@ -0,0 +1,23 @@ +/// Guards issue #989: no Embedded Git class may reference %Studio.SourceControl.Change, +/// which ships only with CCR as of IRIS 2026.3. +Class UnitTest.SourceControl.Git.NoProductChangeDependency Extends %UnitTest.TestCase +{ + +Method TestNoReferenceToProductChangeClass() +{ + &sql(DECLARE ClassCursor CURSOR FOR + SELECT Name INTO :className FROM %Dictionary.ClassDefinition + WHERE Name %STARTSWITH 'SourceControl.Git.') + &sql(OPEN ClassCursor) + throw:SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(SQLCODE, %msg) + &sql(FETCH ClassCursor) + while SQLCODE = 0 { + $$$ThrowOnError(##class(%Compiler.UDL.TextServices).GetTextAsString($namespace, className, .source)) + do $$$AssertNotTrue(source [ "%Studio.SourceControl.Change", className_" must not reference %Studio.SourceControl.Change") + do $$$AssertNotTrue(source [ "%Studio_SourceControl.Change", className_" must not reference %Studio_SourceControl.Change") + &sql(FETCH ClassCursor) + } + &sql(CLOSE ClassCursor) +} + +}