From 2a3497c97fcefa4f1dcdf4e06d096c282398c180 Mon Sep 17 00:00:00 2001 From: Pravin Barton <9560941+isc-pbarton@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:41:06 -0400 Subject: [PATCH 1/8] local dev container allows customizing iris port in .env file for better worktrees experience --- .gitignore | 3 ++- CONTRIBUTING.md | 6 +++++- docker-compose.yml | 2 +- iriscli | 5 ++++- 4 files changed, 12 insertions(+), 4 deletions(-) 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/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/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= From b4e88d731276af859c09bb1fca206362fe117150 Mon Sep 17 00:00:00 2001 From: Pravin Barton <9560941+isc-pbarton@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:38:11 -0400 Subject: [PATCH 2/8] test: characterize SourceControl.Git.Change queue behavior --- test/UnitTest/SourceControl/Git/Change.cls | 167 +++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 test/UnitTest/SourceControl/Git/Change.cls diff --git a/test/UnitTest/SourceControl/Git/Change.cls b/test/UnitTest/SourceControl/Git/Change.cls new file mode 100644 index 00000000..12798238 --- /dev/null +++ b/test/UnitTest/SourceControl/Git/Change.cls @@ -0,0 +1,167 @@ +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 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) +} + +} From 5b4ca9ba0cd7c5af45786e89a969edded318d399 Mon Sep 17 00:00:00 2001 From: Pravin Barton <9560941+isc-pbarton@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:47:33 -0400 Subject: [PATCH 3/8] refactor: move inherited members into SourceControl.Git.Change --- cls/SourceControl/Git/Change.cls | 170 +++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/cls/SourceControl/Git/Change.cls b/cls/SourceControl/Git/Change.cls index 61e154de..f8892d76 100644 --- a/cls/SourceControl/Git/Change.cls +++ b/cls/SourceControl/Git/Change.cls @@ -10,6 +10,49 @@ Property BackupName As %String [ Calculated, SqlComputeCode = {s {*} = ""}, SqlC /// 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 ]; Index ChangeList On (ItemFile, CommittedTime) [ Data = Action, Unique ]; @@ -203,6 +246,133 @@ 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. +/// EnforceSourcesPath and Bulk are accepted for signature compatibility and ignored. +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, EnforceSourcesPath As %Boolean = 1, Bulk As %Boolean) 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. +/// Bulk is accepted for signature compatibility and ignored. +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, Bulk As %Boolean) 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. +/// RefreshUncommitted is accepted for signature compatibility and ignored. +ClassMethod ListUncommitted(ByRef UncommittedList = "", IncludeRevert As %Boolean = 0, RefreshUncommitted As %Boolean = 1) 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") { } From 4a4c9e559e8e3af0b175696806422c0c898fbd09 Mon Sep 17 00:00:00 2001 From: Pravin Barton <9560941+isc-pbarton@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:03:58 -0400 Subject: [PATCH 4/8] refactor: remove SourceControl.Git.Change dependency on %Studio.SourceControl.Change (#989) --- cls/SourceControl/Git/Change.cls | 31 ++++++++----------- cls/SourceControl/Git/Extension.cls | 2 +- cls/SourceControl/Git/Utils.cls | 2 +- .../Git/NoProductChangeDependency.cls | 22 +++++++++++++ 4 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls diff --git a/cls/SourceControl/Git/Change.cls b/cls/SourceControl/Git/Change.cls index f8892d76..cf673aa6 100644 --- a/cls/SourceControl/Git/Change.cls +++ b/cls/SourceControl/Git/Change.cls @@ -1,14 +1,12 @@ 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 ]; - -/// Returns the name of this Item in the file system -Property ExternalName 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; /// Data global for this class, used by BuildNewIndexes(). Must match the Storage definition below. Parameter DataGlobal = "^Studio.SourceControl.ChangeD"; @@ -106,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 @@ -171,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 @@ -223,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 } } @@ -270,9 +268,8 @@ ClassMethod GetUncommitted(Filename As %String, ByRef Action As %String, ByRef I quit $$$OK } -/// Adds a new item to the uncommitted queue, or updates it if it is already there. -/// EnforceSourcesPath and Bulk are accepted for signature compatibility and ignored. -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, EnforceSourcesPath As %Boolean = 1, Bulk As %Boolean) As %Status +/// 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) @@ -295,9 +292,8 @@ ClassMethod SetUncommitted(Filename As %String, Action As %String, InternalName quit obj.%Save() } -/// Updates the details of an item already in the uncommitted queue. -/// Bulk is accepted for signature compatibility and ignored. -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, Bulk As %Boolean) As %Status +/// 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) @@ -327,8 +323,7 @@ ClassMethod UpdateUncommitted(Filename As %String, Action As %String, InternalNa /// 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. -/// RefreshUncommitted is accepted for signature compatibility and ignored. -ClassMethod ListUncommitted(ByRef UncommittedList = "", IncludeRevert As %Boolean = 0, RefreshUncommitted As %Boolean = 1) As %Status +ClassMethod ListUncommitted(ByRef UncommittedList = "", IncludeRevert As %Boolean = 0) As %Status { do ..BuildNewIndexes() &sql(DECLARE UncommittedCursor CURSOR FOR 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/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/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls b/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls new file mode 100644 index 00000000..ca8d4284 --- /dev/null +++ b/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls @@ -0,0 +1,22 @@ +/// 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") + &sql(FETCH ClassCursor) + } + &sql(CLOSE ClassCursor) +} + +} From 5cd9b387d7e89e8e5a753382fd986ff2fa601c76 Mon Sep 17 00:00:00 2001 From: Pravin Barton <9560941+isc-pbarton@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:17:56 -0400 Subject: [PATCH 5/8] fix: read production changes from SourceControl_Git.Change (#989) --- cls/SourceControl/Git/Production.cls | 2 +- test/UnitTest/SourceControl/Git/Change.cls | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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/test/UnitTest/SourceControl/Git/Change.cls b/test/UnitTest/SourceControl/Git/Change.cls index 12798238..879575d0 100644 --- a/test/UnitTest/SourceControl/Git/Change.cls +++ b/test/UnitTest/SourceControl/Git/Change.cls @@ -164,4 +164,25 @@ Method TestRowWithOrphanedTrailingSlotIsReadable() do ..Cleanup(filename) } +/// GetUserProductionChanges must read the Embedded Git change table, not the product one +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) +} + } From 44e7ee686b9e97fd30084b9d4511cd3d57a6a4a0 Mon Sep 17 00:00:00 2001 From: Pravin Barton <9560941+isc-pbarton@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:29:51 -0400 Subject: [PATCH 6/8] docs: note shared-storage limitation of TestGetUserProductionChanges --- test/UnitTest/SourceControl/Git/Change.cls | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/UnitTest/SourceControl/Git/Change.cls b/test/UnitTest/SourceControl/Git/Change.cls index 879575d0..f9aad959 100644 --- a/test/UnitTest/SourceControl/Git/Change.cls +++ b/test/UnitTest/SourceControl/Git/Change.cls @@ -164,7 +164,15 @@ Method TestRowWithOrphanedTrailingSlotIsReadable() do ..Cleanup(filename) } -/// GetUserProductionChanges must read the Embedded Git change table, not the product one +/// 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() From 0985f4fff265210b825fac8e608acdb48d537695 Mon Sep 17 00:00:00 2001 From: Pravin Barton <9560941+isc-pbarton@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:44:25 -0400 Subject: [PATCH 7/8] docs: changelog for %Studio.SourceControl.Change removal (#989) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 9acfa1d83a045a715114d4511896b7022c6fe20f Mon Sep 17 00:00:00 2001 From: Pravin Barton <9560941+isc-pbarton@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:10:30 -0400 Subject: [PATCH 8/8] test: guard against %Studio_SourceControl.Change SQL-name reference and cover CSP-delete acceptance --- cls/SourceControl/Git/Change.cls | 4 ++-- test/UnitTest/SourceControl/Git/Change.cls | 11 +++++++++++ .../SourceControl/Git/NoProductChangeDependency.cls | 1 + 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/cls/SourceControl/Git/Change.cls b/cls/SourceControl/Git/Change.cls index cf673aa6..18bb5942 100644 --- a/cls/SourceControl/Git/Change.cls +++ b/cls/SourceControl/Git/Change.cls @@ -67,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 { diff --git a/test/UnitTest/SourceControl/Git/Change.cls b/test/UnitTest/SourceControl/Git/Change.cls index f9aad959..e9c74d18 100644 --- a/test/UnitTest/SourceControl/Git/Change.cls +++ b/test/UnitTest/SourceControl/Git/Change.cls @@ -134,6 +134,17 @@ Method TestSetUncommittedRejectsMissingFile() 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. diff --git a/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls b/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls index ca8d4284..db45cb8b 100644 --- a/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls +++ b/test/UnitTest/SourceControl/Git/NoProductChangeDependency.cls @@ -14,6 +14,7 @@ Method TestNoReferenceToProductChangeClass() 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)