diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index d49e3c7d5..8bafe8311 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -37,13 +37,13 @@ import { resolveGitHeadPath, nudgeGitBranchCache, } from "./worktree.js"; -import { MergeConflictError, readIntegrationBranch } from "./git-service.js"; +import { MergeConflictError, readIntegrationBranch, RUNTIME_EXCLUSION_PATHS } from "./git-service.js"; import { parseRoadmap } from "./files.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { nativeGetCurrentBranch, nativeWorkingTreeStatus, - nativeAddAll, + nativeAddAllWithExclusions, nativeCommit, nativeCheckoutBranch, nativeMergeSquash, @@ -768,7 +768,7 @@ function autoCommitDirtyState(cwd: string): boolean { try { const status = nativeWorkingTreeStatus(cwd); if (!status) return false; - nativeAddAll(cwd); + nativeAddAllWithExclusions(cwd, RUNTIME_EXCLUSION_PATHS); const result = nativeCommit( cwd, "chore: auto-commit before milestone merge", diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index d71f148c5..094ac4352 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -24,7 +24,7 @@ import { nativeDetectMainBranch, nativeBranchExists, nativeHasChanges, - nativeAddAll, + nativeAddAllWithExclusions, nativeResetPaths, nativeHasStagedChanges, nativeCommit, @@ -385,7 +385,9 @@ export class GitServiceImpl { this._runtimeFilesCleanedUp = true; } - // Stage everything, then unstage excluded paths. + // Stage everything using pathspec exclusions so excluded paths are never + // hashed by git. The old approach of `git add -A` followed by unstaging + // hangs indefinitely on repos with large untracked artifact trees (#1605). // // Exclude only RUNTIME paths from staging — not the entire .gsd/ directory. // When .gsd/milestones/ files are already tracked in the index (projects @@ -395,13 +397,9 @@ export class GitServiceImpl { // the second half of a milestone's artifacts are never committed (#1326). // // If .gsd/ IS in .gitignore (the default for external state projects), - // git add -A already skips it and the reset is a harmless no-op. - nativeAddAll(this.basePath); - - const runtimeExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions]; - for (const exclusion of runtimeExclusions) { - try { nativeResetPaths(this.basePath, [exclusion]); } catch { /* path not staged — ignore */ } - } + // git add -A already skips it and the exclusions are harmless no-ops. + const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions]; + nativeAddAllWithExclusions(this.basePath, allExclusions); } /** Tracks whether runtime file cleanup has run this session. */ diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index 22fead5c8..d091da965 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -671,6 +671,43 @@ export function nativeAddAll(basePath: string): void { gitFileExec(basePath, ["add", "-A"]); } +/** + * Stage all files with pathspec exclusions (git add -A -- ':!pattern' ...). + * Excluded paths are never hashed by git, preventing hangs on large + * untracked artifact trees (57GB+, 11K+ files). See #1605. + * + * Falls back to plain `git add -A` when no exclusions are provided. + * Always uses the CLI path (not libgit2) because libgit2's add_all + * does not support pathspec exclusion syntax. + * + * When excluded paths are already covered by .gitignore, git may exit + * with code 1 and an "ignored by .gitignore" warning. This is harmless + * (the staging succeeds for all non-ignored files) and is suppressed. + */ +export function nativeAddAllWithExclusions(basePath: string, exclusions: readonly string[]): void { + if (exclusions.length === 0) { + nativeAddAll(basePath); + return; + } + const pathspecs = exclusions.map(e => `:!${e}`); + try { + execFileSync("git", ["add", "-A", "--", ...pathspecs], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: GIT_NO_PROMPT_ENV, + }); + } catch (err: unknown) { + // 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; + } + throw new GSDError(GSD_GIT_ERROR, `git add -A with exclusions failed in ${basePath}: ${getErrorMessage(err)}`); + } +} + /** * Stage specific files. * Native: libgit2 index add.