fix: handle symlinked .gsd in git add pathspec exclusions (#1712) (#1756)

When .gsd is a symlink, git rejects `:!.gsd/...` pathspecs with
"beyond a symbolic link". nativeAddAllWithExclusions now catches this
error and falls back to plain `git add -A` (which respects .gitignore).

Auto-commit failures in postUnit are elevated from debug-only to a
visible warning notification so silent work loss is surfaced.

Closes #1712

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-21 09:04:46 -06:00 committed by GitHub
parent dc20078ad9
commit 1fb59ecb71
4 changed files with 84 additions and 3 deletions

View file

@ -158,6 +158,7 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
}
} catch (e) {
debugLog("postUnit", { phase: "auto-commit", error: String(e) });
ctx.ui.notify(`Auto-commit failed: ${String(e).split("\n")[0]}`, "warning");
}
// GitHub sync (non-blocking, opt-in)

View file

@ -38,6 +38,7 @@ import {
nudgeGitBranchCache,
} from "./worktree.js";
import { MergeConflictError, readIntegrationBranch, RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
import { debugLog } from "./debug-logger.js";
import { parseRoadmap } from "./files.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import {
@ -800,7 +801,8 @@ function autoCommitDirtyState(cwd: string): boolean {
"chore: auto-commit before milestone merge",
);
return result !== null;
} catch {
} catch (e) {
debugLog("autoCommitDirtyState", { error: String(e) });
return false;
}
}

View file

@ -698,12 +698,19 @@ export function nativeAddAllWithExclusions(basePath: string, exclusions: readonl
env: GIT_NO_PROMPT_ENV,
});
} catch (err: unknown) {
const stderr = (err as { stderr?: string })?.stderr ?? "";
// git exits 1 when pathspec exclusions reference paths already covered
// by .gitignore. The staging itself succeeds — only suppress that case.
const stderr = (err as { stderr?: string })?.stderr ?? "";
if (stderr.includes("ignored by one of your .gitignore files")) {
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).
if (stderr.includes("beyond a symbolic link")) {
nativeAddAll(basePath);
return;
}
throw new GSDError(GSD_GIT_ERROR, `git add -A with exclusions failed in ${basePath}: ${getErrorMessage(err)}`);
}
}

View file

@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, symlinkSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
@ -18,6 +18,7 @@ import {
type PreMergeCheckResult,
type TaskCommitContext,
} from "../git-service.ts";
import { nativeAddAllWithExclusions } from "../native-git-bridge.ts";
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
@ -1232,6 +1233,76 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ─── nativeAddAllWithExclusions: symlinked .gsd fallback ───────────────
console.log("\n=== 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.
const repo = initTempRepo();
// Create the real .gsd directory outside the repo, then symlink it
const externalGsd = mkdtempSync(join(tmpdir(), "gsd-external-"));
mkdirSync(join(externalGsd, "activity"), { recursive: true });
writeFileSync(join(externalGsd, "activity", "log.jsonl"), "log data");
writeFileSync(join(externalGsd, "STATE.md"), "# State");
// Symlink .gsd -> external directory
symlinkSync(externalGsd, join(repo, ".gsd"));
// Add .gitignore so git add -A fallback skips .gsd/
writeFileSync(join(repo, ".gitignore"), ".gsd\n");
// Create a real file that should be staged
createFile(repo, "src/app.ts", "export const x = 1;");
// nativeAddAllWithExclusions should NOT throw despite .gsd being a symlink
let threw = false;
try {
nativeAddAllWithExclusions(repo, RUNTIME_EXCLUSION_PATHS);
} catch (e) {
threw = true;
console.error(" unexpected error:", e);
}
assertTrue(!threw, "nativeAddAllWithExclusions does not throw with symlinked .gsd");
// Verify the real file was staged
const staged = run("git diff --cached --name-only", repo);
assertTrue(staged.includes("src/app.ts"), "real file staged despite symlinked .gsd");
assertTrue(!staged.includes(".gsd"), ".gsd content not staged");
rmSync(repo, { recursive: true, force: true });
rmSync(externalGsd, { recursive: true, force: true });
}
// ─── nativeAddAllWithExclusions: non-symlinked .gsd still works ───────
console.log("\n=== nativeAddAllWithExclusions: non-symlinked .gsd still works ===");
{
// Verify the normal (non-symlink) case still works with pathspec exclusions
const repo = initTempRepo();
createFile(repo, ".gsd/activity/log.jsonl", "log data");
createFile(repo, ".gsd/STATE.md", "# State");
createFile(repo, "src/code.ts", "export const y = 2;");
let threw = false;
try {
nativeAddAllWithExclusions(repo, RUNTIME_EXCLUSION_PATHS);
} catch {
threw = true;
}
assertTrue(!threw, "nativeAddAllWithExclusions works with normal .gsd directory");
const staged = run("git diff --cached --name-only", repo);
assertTrue(staged.includes("src/code.ts"), "real file staged with normal .gsd");
rmSync(repo, { recursive: true, force: true });
}
report();
}