diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 2834aa22b..5d6b7deeb 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -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) diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 33dc2c514..88c41bcac 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -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; } } diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index bd4ae4b68..46f438110 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -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)}`); } } diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index 8d70fa556..0a201b6f4 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -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 { 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(); }