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:
parent
dc20078ad9
commit
1fb59ecb71
4 changed files with 84 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue