fix: use pathspec exclusions in smartStage to prevent hanging on large repos (#1613)

smartStage() ran `git add -A` on the entire repo then unstaged exclusions,
causing indefinite hangs on repos with large untracked artifact trees (57GB+).
autoCommitDirtyState() bypassed smartStage() entirely via direct nativeAddAll().

Add nativeAddAllWithExclusions() using `git add -A -- ':!pattern'` syntax so
excluded paths are never hashed. Route autoCommitDirtyState() through it with
RUNTIME_EXCLUSION_PATHS.

Closes #1605

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-20 10:39:34 -06:00 committed by GitHub
parent 2f5323ee97
commit 426e0e839c
3 changed files with 47 additions and 12 deletions

View file

@ -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",

View file

@ -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. */

View file

@ -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.