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)
+}
+
+}