fix: force-stage .gsd/milestones/ artifacts when .gsd is a symlink (#2104) (#2112)

When .gsd is a symlink (external state projects), autoCommit silently
drops new milestone artifacts because:
1. nativeAddAllWithExclusions falls back to plain `git add -A` (symlink
   pathspec rejection: "beyond a symbolic link")
2. `.gsd` is in .gitignore, so new .gsd/ files are invisible to git add

`git add -f` also fails through symlinks, so this fix uses
`git hash-object -w` + `git update-index --add --cacheinfo` to bypass
the symlink restriction entirely, staging each milestone artifact by
hashing its content and inserting the blob directly into the index.

Includes a reproduction test that creates a repo with .gsd as a symlink,
adds new files under .gsd/milestones/, and verifies they are staged.

Fixes #2104

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-22 19:05:05 -04:00 committed by GitHub
parent 5ecf047553
commit a6f8f77bbc
2 changed files with 120 additions and 2 deletions

View file

@ -9,8 +9,8 @@
*/
import { execFileSync, execSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import { gsdRoot } from "./paths.js";
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
@ -486,11 +486,80 @@ export class GitServiceImpl {
// git add -A already skips it and the exclusions are harmless no-ops.
const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
nativeAddAllWithExclusions(this.basePath, allExclusions);
// Force-add .gsd/milestones/ when .gsd is a symlink (#2104).
// When .gsd is a symlink (external state projects), ensureGitignore adds
// `.gsd` to .gitignore. The nativeAddAllWithExclusions call above falls
// back to plain `git add -A` (symlink pathspec rejection), which respects
// .gitignore and silently skips new .gsd/milestones/ files.
//
// `git add -f` also fails with "beyond a symbolic link", so we use
// `git hash-object -w` + `git update-index --add --cacheinfo` to bypass
// the symlink restriction entirely. This stages each milestone artifact
// individually by hashing the file content and updating the index directly.
const gsdPath = join(this.basePath, ".gsd");
const milestonesDir = join(gsdPath, "milestones");
try {
if (
existsSync(gsdPath) &&
lstatSync(gsdPath).isSymbolicLink() &&
existsSync(milestonesDir)
) {
this._forceAddMilestoneArtifacts(milestonesDir);
}
} catch {
// Non-fatal: if force-add fails, the commit proceeds without these files.
// This matches existing behavior where milestone artifacts were silently
// omitted — but now we at least attempt to include them.
}
}
/** Tracks whether runtime file cleanup has run this session. */
private _runtimeFilesCleanedUp = false;
/**
* Recursively collect all files under a directory.
* Returns paths relative to `basePath` (e.g. ".gsd/milestones/M009/SUMMARY.md").
*/
private _collectFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...this._collectFiles(full));
} else if (entry.isFile()) {
files.push(relative(this.basePath, full));
}
}
return files;
}
/**
* Stage milestone artifacts through a symlinked .gsd directory (#2104).
*
* `git add` (even with `-f`) refuses to stage files "beyond a symbolic link".
* This method bypasses that restriction by hashing each file with
* `git hash-object -w` and inserting the blob into the index with
* `git update-index --add --cacheinfo 100644 <hash> <path>`.
*/
private _forceAddMilestoneArtifacts(milestonesDir: string): void {
const files = this._collectFiles(milestonesDir);
for (const filePath of files) {
const hash = execFileSync("git", ["hash-object", "-w", filePath], {
cwd: this.basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
env: GIT_NO_PROMPT_ENV,
}).trim();
execFileSync("git", ["update-index", "--add", "--cacheinfo", "100644", hash, filePath], {
cwd: this.basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
env: GIT_NO_PROMPT_ENV,
});
}
}
/**
* Stage files (smart staging) and commit.
* Returns the commit message string on success, or null if nothing to commit.

View file

@ -1411,6 +1411,55 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ─── autoCommit: symlinked .gsd stages new milestone artifacts (#2104) ──
console.log("\n=== autoCommit: symlinked .gsd stages new milestone artifacts (#2104) ===");
{
// Reproduction: when .gsd is a symlink (external state project),
// autoCommit silently fails to stage NEW .gsd/milestones/ files because:
// 1. nativeAddAllWithExclusions falls back to plain `git add -A` (symlink)
// 2. `.gsd` is in .gitignore → new .gsd/ files are invisible to `git add`
// The fix: smartStage() force-adds .gsd/milestones/ after the normal staging.
const repo = initTempRepo();
// Create an external .gsd directory and symlink it into the repo
const externalGsd = mkdtempSync(join(tmpdir(), "gsd-external-symlink-"));
mkdirSync(join(externalGsd, "milestones", "M009"), { recursive: true });
mkdirSync(join(externalGsd, "activity"), { recursive: true });
mkdirSync(join(externalGsd, "runtime"), { recursive: true });
symlinkSync(externalGsd, join(repo, ".gsd"));
// .gitignore blocks .gsd (as ensureGitignore would do for symlink projects)
writeFileSync(join(repo, ".gitignore"), ".gsd\n");
run("git add .gitignore && git commit -m 'add gitignore'", 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;");
const svc = new GitServiceImpl(repo);
const msg = svc.autoCommit("complete-milestone", "M009");
assertTrue(msg !== null, "symlink autoCommit: commit succeeds");
const committed = run("git show --name-only HEAD", repo);
assertTrue(committed.includes("src/feature.ts"), "symlink autoCommit: source file committed");
assertTrue(committed.includes(".gsd/milestones/M009/M009-SUMMARY.md"),
"symlink autoCommit: new M009-SUMMARY.md is committed (not silently dropped)");
assertTrue(committed.includes(".gsd/milestones/M009/S01-SUMMARY.md"),
"symlink autoCommit: new S01-SUMMARY.md is committed");
assertTrue(committed.includes(".gsd/milestones/M009/T01-VERIFY.json"),
"symlink autoCommit: new T01-VERIFY.json is committed");
try { rmSync(repo, { recursive: true, force: true }); } catch {}
try { rmSync(externalGsd, { recursive: true, force: true }); } catch {}
}
report();
}