fix(git): use git add -u in symlink .gsd fallback to prevent hang (#3299)

* fix(git): use git add -u in symlink .gsd fallback to prevent hang on large repos (#1977)

When .gsd is a symlink, nativeAddAllWithExclusions falls back from
pathspec exclusions (which git rejects with "beyond a symbolic link").
The previous fallback used nativeAddAll (git add -A), which traverses
the entire working tree — hanging indefinitely on repos with large
untracked data directories (444GB+ observed).

Change the fallback to git add -u, which only stages changes to
already-tracked files. This is O(tracked) instead of O(filesystem),
making it safe for repos with massive untracked trees.

The tradeoff — new untracked files are not staged in the symlink
fallback path — is acceptable because auto-commit primarily captures
changes the agent made to existing tracked files.

Regression of #1712 which introduced the symlink fallback but chose
git add -A. That fix assumed .gitignore covered all large directories,
but scientific/ML repos often have large untracked data dirs not in
.gitignore.

Fixes #1977

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: retrigger CI

* fix(test): update symlink autoCommit test to use tracked file modification

The PR changed the symlink fallback from git add -A to git add -u.
git add -u only stages changes to tracked files; untracked files are
skipped. The existing test created a new untracked file which git add -u
ignores, causing autoCommit to return null (nothing to commit).

Pre-commit the source file before the scenario and modify it so git add
-u can stage the tracked change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: trek-e <trek-e@users.noreply.github.com>
This commit is contained in:
Tom Boucher 2026-04-05 01:05:03 -04:00 committed by GitHub
parent c91b6eca9c
commit fa3ca6206e
2 changed files with 32 additions and 10 deletions

View file

@ -724,10 +724,12 @@ export function nativeAddAllWithExclusions(basePath: string, exclusions: readonl
return;
}
// When .gsd is a symlink, git rejects `:!.gsd/...` pathspecs with
// "beyond a symbolic link". Fall back to plain `git add -A` which
// respects .gitignore (where .gsd/ is listed by default).
// "beyond a symbolic link". Fall back to `git add -u` which only
// stages changes to already-tracked files — O(tracked) not O(filesystem).
// Using `git add -A` here would traverse the entire working tree,
// hanging indefinitely on repos with large untracked data dirs. (#1977)
if (stderr.includes("beyond a symbolic link")) {
nativeAddAll(basePath);
gitFileExec(basePath, ["add", "-u"]);
return;
}
throw new GSDError(GSD_GIT_ERROR, `git add -A with exclusions failed in ${basePath}: ${getErrorMessage(err)}`);

View file

@ -1246,7 +1246,7 @@ describe('git-service', async () => {
test('nativeAddAllWithExclusions: symlinked .gsd fallback', () => {
// When .gsd is a symlink, git rejects `:!.gsd/...` pathspecs with
// "fatal: pathspec '...' is beyond a symbolic link". The fix falls
// back to plain `git add -A`, which respects .gitignore.
// back to `git add -u` (tracked files only), NOT `git add -A`.
const repo = initTempRepo();
// Create the real .gsd directory outside the repo, then symlink it
@ -1258,11 +1258,18 @@ describe('git-service', async () => {
// Symlink .gsd -> external directory
symlinkSync(externalGsd, join(repo, ".gsd"));
// Add .gitignore so git add -A fallback skips .gsd/
// Add .gitignore so .gsd/ is ignored
writeFileSync(join(repo, ".gitignore"), ".gsd\n");
// Create a real file that should be staged
// Create a tracked file and commit it, then modify it
createFile(repo, "src/app.ts", "export const x = 1;");
run("git add -A", repo);
run('git commit -m "add app"', repo);
writeFileSync(join(repo, "src/app.ts"), "export const x = 2;");
// Create an untracked file simulating large data (NOT in .gitignore)
// This is the key scenario: large untracked dirs that git add -A would traverse
createFile(repo, "data/large-model.bin", "pretend this is 10GB");
// nativeAddAllWithExclusions should NOT throw despite .gsd being a symlink
let threw = false;
@ -1274,9 +1281,15 @@ describe('git-service', async () => {
}
assert.ok(!threw, "nativeAddAllWithExclusions does not throw with symlinked .gsd");
// Verify the real file was staged
// Verify the tracked modified file was staged
const staged = run("git diff --cached --name-only", repo);
assert.ok(staged.includes("src/app.ts"), "real file staged despite symlinked .gsd");
assert.ok(staged.includes("src/app.ts"), "modified tracked file staged despite symlinked .gsd");
// CRITICAL: untracked files must NOT be staged — the symlink fallback
// should use `git add -u` (tracked only), not `git add -A` (all files).
// Using `git add -A` on a repo with large untracked data dirs hangs. (#1977)
assert.ok(!staged.includes("data/large-model.bin"),
"symlink fallback must not stage untracked files (would hang on large repos)");
assert.ok(!staged.includes(".gsd"), ".gsd content not staged");
rmSync(repo, { recursive: true, force: true });
@ -1435,13 +1448,20 @@ describe('git-service', async () => {
run('git add .gitignore', repo);
run('git commit -m "add gitignore"', repo);
// Pre-commit a tracked source file so git add -u can stage modifications.
// The symlink fallback uses git add -u (tracked files only), so the file
// must be tracked before the autoCommit scenario runs.
createFile(repo, "src/feature.ts", "export const feature = true;");
run('git add src/feature.ts', repo);
run('git commit -m "add feature"', repo);
// Simulate new milestone artifacts created during execution
writeFileSync(join(externalGsd, "milestones", "M009", "M009-SUMMARY.md"), "# M009 Summary");
writeFileSync(join(externalGsd, "milestones", "M009", "S01-SUMMARY.md"), "# S01 Summary");
writeFileSync(join(externalGsd, "milestones", "M009", "T01-VERIFY.json"), '{"passed":true}');
// Also create a normal source file change
createFile(repo, "src/feature.ts", "export const feature = true;");
// Modify the tracked source file — git add -u will stage this change
writeFileSync(join(repo, "src/feature.ts"), "export const feature = false; // updated");
const svc = new GitServiceImpl(repo);
const msg = svc.autoCommit("complete-milestone", "M009");