55 "os"
66 "os/exec"
77 "path/filepath"
8+ "runtime"
89 "strings"
910 "testing"
1011
@@ -41,6 +42,10 @@ func TestLoadParity(t *testing.T) {
4142 {Name : "index version 4" , Setup : setupIndexV4 },
4243 {Name : "fully packed objects" , Setup : setupPacked },
4344 {Name : "packed ahead and behind" , Setup : setupPackedAheadBehind },
45+ {Name : "staged delete only" , Setup : setupStagedDelete },
46+ {Name : "staged mode change" , Setup : setupStagedModeChange },
47+ {Name : "conflict in fresh subdirectory" , Setup : setupConflictInSubdir },
48+ {Name : "working mode change" , Setup : setupWorkingModeChange },
4449 }
4550
4651 // Every case runs under both stat strategies: the in-walk comparison
@@ -69,6 +74,58 @@ func TestLoadParity(t *testing.T) {
6974 }
7075}
7176
77+ // TestLoadFallsBackOnInexactRename covers the C4 contract decision: a
78+ // staged rename whose content also changed leaves an unpaired add and an
79+ // unpaired delete, which git may pair through similarity detection. The
80+ // engine cannot, so it must error into the exec fallback instead of
81+ // reporting A+D where git reports R.
82+ func TestLoadFallsBackOnInexactRename (t * testing.T ) {
83+ skipIfNoGit (t )
84+ hermeticHome (t )
85+
86+ dir := t .TempDir ()
87+ initGitRepo (t , dir )
88+
89+ writeFile (t , dir , "old-name.txt" , "a body large enough for gits similarity detection to pair the rename\n " )
90+ runGit (t , dir , "add" , "." )
91+ runGit (t , dir , "commit" , "-q" , "-m" , "base" )
92+
93+ runGit (t , dir , "mv" , "old-name.txt" , "new-name.txt" )
94+ writeFile (t , dir , "new-name.txt" , "a body large enough for gits similarity detection to pair the rename, edited\n " )
95+ runGit (t , dir , "add" , "new-name.txt" )
96+
97+ _ , err := Load (Options {
98+ WorktreeGitDir : gitPath (t , dir , "--git-dir" ),
99+ CommonGitDir : gitPath (t , dir , "--git-common-dir" ),
100+ RepoRoot : gitPath (t , dir , "--show-toplevel" ),
101+ })
102+ assert .Error (t , err )
103+ }
104+
105+ // TestLoadParityTypechangeSymlink replaces a tracked file with a symlink:
106+ // porcelain reports T, which both engines must count as nothing. Skipped on
107+ // Windows, where creating symlinks requires elevated privileges.
108+ func TestLoadParityTypechangeSymlink (t * testing.T ) {
109+ if runtime .GOOS == goosWindows {
110+ t .Skip ("symlink creation requires privileges on Windows" )
111+ }
112+ skipIfNoGit (t )
113+ hermeticHome (t )
114+
115+ dir := t .TempDir ()
116+ initGitRepo (t , dir )
117+
118+ writeFile (t , dir , "target.txt" , "target\n " )
119+ writeFile (t , dir , "swap.txt" , "file\n " )
120+ runGit (t , dir , "add" , "." )
121+ runGit (t , dir , "commit" , "-q" , "-m" , "base" )
122+
123+ require .NoError (t , os .Remove (filepath .Join (dir , "swap.txt" )))
124+ require .NoError (t , os .Symlink ("target.txt" , filepath .Join (dir , "swap.txt" )))
125+
126+ assertParity (t , dir , "" )
127+ }
128+
72129// TestLoadParityLinkedWorktree exercises a `git worktree add` checkout,
73130// where WorktreeGitDir (HEAD/index) and CommonGitDir (objects/refs) live in
74131// different places on disk.
@@ -129,7 +186,6 @@ func setupDirtyMix(t *testing.T, dir string) {
129186 writeFile (t , dir , "same-size.txt" , "hello\n " )
130187 writeFile (t , dir , "diff-size.txt" , "short\n " )
131188 writeFile (t , dir , "to-delete.txt" , "bye\n " )
132- writeFile (t , dir , "staged-delete.txt" , "gone\n " )
133189 writeFile (t , dir , "staged-modify.txt" , "orig\n " )
134190 writeFile (t , dir , "rename-src.txt" , "rename me please, needs enough content to be detected as a rename by gits similarity heuristic\n " )
135191 runGit (t , dir , "add" , "." )
@@ -142,11 +198,10 @@ func setupDirtyMix(t *testing.T, dir string) {
142198 // unstaged delete
143199 require .NoError (t , os .Remove (filepath .Join (dir , "to-delete.txt" )))
144200
145- // staged add
201+ // staged add; a staged delete alongside would trip the rename-ambiguity
202+ // fallback, so pure deletion coverage lives in its own scenario
146203 writeFile (t , dir , "staged-add.txt" , "new\n " )
147204 runGit (t , dir , "add" , "staged-add.txt" )
148- // staged delete
149- runGit (t , dir , "rm" , "-q" , "staged-delete.txt" )
150205 // staged modify
151206 writeFile (t , dir , "staged-modify.txt" , "changed\n " )
152207 runGit (t , dir , "add" , "staged-modify.txt" )
@@ -283,6 +338,60 @@ func setupPackedAheadBehind(t *testing.T, dir string) {
283338 runGit (t , dir , "prune-packed" )
284339}
285340
341+ // setupStagedDelete stages a deletion with no staged addition alongside, so
342+ // the exact-rename pairing has nothing to pair and no fallback triggers.
343+ func setupStagedDelete (t * testing.T , dir string ) {
344+ writeFile (t , dir , "keep.txt" , "keep\\ n" )
345+ writeFile (t , dir , "doomed.txt" , "doomed\\ n" )
346+ runGit (t , dir , "add" , "." )
347+ runGit (t , dir , "commit" , "-q" , "-m" , "base" )
348+
349+ runGit (t , dir , "rm" , "-q" , "doomed.txt" )
350+ }
351+
352+ // setupStagedModeChange stages an executable-bit flip via update-index,
353+ // which works on every platform because no filesystem mode is involved.
354+ func setupStagedModeChange (t * testing.T , dir string ) {
355+ writeFile (t , dir , "script.sh" , "#!/bin/sh\\ n" )
356+ runGit (t , dir , "add" , "." )
357+ runGit (t , dir , "commit" , "-q" , "-m" , "base" )
358+
359+ runGit (t , dir , "update-index" , "--chmod=+x" , "script.sh" )
360+ }
361+
362+ // setupConflictInSubdir produces a both-added conflict on a path whose
363+ // directory holds no other tracked file, so the untracked-directory
364+ // collapsing must still recognize it as tracked.
365+ func setupConflictInSubdir (t * testing.T , dir string ) {
366+ writeFile (t , dir , "base.txt" , "base\\ n" )
367+ runGit (t , dir , "add" , "." )
368+ runGit (t , dir , "commit" , "-q" , "-m" , "base" )
369+
370+ runGit (t , dir , "checkout" , "-q" , "-b" , "feature" )
371+ writeFile (t , dir , "sub/only.txt" , "from feature\\ n" )
372+ runGit (t , dir , "add" , "." )
373+ runGit (t , dir , "commit" , "-q" , "-m" , "feature adds sub" )
374+
375+ runGit (t , dir , "checkout" , "-q" , "main" )
376+ writeFile (t , dir , "sub/only.txt" , "from main\\ n" )
377+ runGit (t , dir , "add" , "." )
378+ runGit (t , dir , "commit" , "-q" , "-m" , "main adds sub" )
379+
380+ runGitAllowFail (t , dir , "merge" , "-q" , "feature" )
381+ }
382+
383+ // setupWorkingModeChange flips the on-disk executable bit of a tracked
384+ // file. Meaningful only where the filesystem records the bit; on Windows
385+ // core.filemode=false makes it a no-op clean scenario, which is itself
386+ // worth asserting.
387+ func setupWorkingModeChange (t * testing.T , dir string ) {
388+ writeFile (t , dir , "script.sh" , "#!/bin/sh\\ n" )
389+ runGit (t , dir , "add" , "." )
390+ runGit (t , dir , "commit" , "-q" , "-m" , "base" )
391+
392+ require .NoError (t , os .Chmod (filepath .Join (dir , "script.sh" ), 0o755 ))
393+ }
394+
286395func setupDetached (t * testing.T , dir string ) {
287396 writeFile (t , dir , "a.txt" , "a\n " )
288397 runGit (t , dir , "add" , "." )
0 commit comments