fix(#3886): git commit timeout reported as commit_timeout; stale lock surfaced; 30s band (#4046)

* test(#3886): a timed-out git commit reports commit_timeout, not commit_failed (failing first)

* fix(#3886): git commit timeout reported as commit_timeout; 30s band; stale-lock surfaced

cmdCommit's git commit invocation did not distinguish a spawnSync
timeout from a real non-zero exit (#2608 fixed this for the staging
loop only): a slow pre-commit hook crossing the 10s cap was
SIGTERM'd mid-hook and reported as reason commit_failed with whatever
partial stderr git had flushed (in the reporter's case an incidental
CRLF warning), while the kill left a stale .git/index.lock blocking
the next attempt.

All three commit sites now check isSpawnTimeout before the
nothing-to-commit/ordinary-failure branches: cmdCommit reports
reason commit_timeout + timed_out:true and names the stale lock's
path (surfaced, not auto-deleted — deleting a lock a live git holds
is destructive; the caller recovers deliberately); the subrepo
counterparts do the same within their per-repo result / rollback
error. The commit calls also move to the 30s band the push call
already uses — husky+lint-staged alone idles ~4s on Windows before
any task runs.

* fix(#3886): review fold-ins — git-path lock resolution, shared band constant, executor contract row, precedence pin

- The stale-lock path is resolved via git rev-parse --git-path
  index.lock, never a literal .git/index.lock join (#3588 row 8's
  class: a linked worktree's .git is a FILE, so the literal path cannot
  exist there while the real lock — under <gitdir>/worktrees/<name>/ —
  blocks the next commit; this repo leans on linked worktrees).
- COMMIT_TIMEOUT_MS hoisted; all three sites and their messages build
  from it (the subrepo variant also regains the stdout fallback the
  primary site had).
- agents/gsd-executor.md's commit-result contract gains the
  commit_timeout row with the OPPOSITE retry advice from
  staging_timeout (remove the stale lock, then retry once) — an
  executor matching the doc previously had no handling for the new
  reason.
- Precedence pin: a timeout whose partial output contains 'nothing to
  commit' must still read as a timeout (branch-reorder mutant).

Emitted-Drift-Ack-Growth: gsd-executor.md — #3886: +commit_timeout row to the commit-result contract with the retry guidance OPPOSITE staging_timeout's (remove the stale lock, then retry once); the executor previously had no handling for the new reason.

* chore(#3886): changeset fragment (pr number backfilled after PR creation)

* chore(#3886): backfill changeset PR number (4046)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-29 13:27:14 -04:00
committed by GitHub
parent 0bf778c352
commit 192eb1dfbd
4 changed files with 197 additions and 3 deletions

View File

@@ -103,6 +103,8 @@ interface CommitToSubrepoRepoResult {
files: string[];
reason?: string;
error?: string;
/** #3886: true when the repo's git commit was timeout-killed (reason commit_timeout). */
timed_out?: boolean;
}
interface EffortSyncChange {
@@ -1820,8 +1822,26 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
if (canScope) {
commitArgs.push('--', ...stagedPaths);
}
const commitResult = execGit(commitArgs, { cwd });
// #3886: `git commit` runs pre-commit hooks (husky/lint-staged routinely
// idles ~4s on Windows before any task) — 10s is too tight, and a timeout
// kill is NOT an ordinary failure. Same band as the push call below.
const commitResult = execGit(commitArgs, { cwd, timeout: COMMIT_TIMEOUT_MS });
if (commitResult.exitCode !== 0) {
// #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
// partial stderr it flushed (often incidental CRLF warnings) is noise,
// and the kill can leave a stale index.lock that blocks the next
// attempt. Report the distinct reason and surface the lock path.
if (isSpawnTimeout(commitResult)) {
const result = {
committed: false,
hash: null,
reason: 'commit_timeout',
timed_out: true,
error: commitTimeoutMessage(cwd, commitResult.stderr, commitResult.stdout),
};
output(result, raw, 'failed');
return;
}
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
output(result, raw, 'nothing');
@@ -1969,8 +1989,21 @@ function cmdCommitToSubrepo(cwd: string, message: string | undefined, files: str
const commitArgs = canScopeSub
? ['commit', '-m', message as string, '--', ...stagedRelPaths]
: ['commit', '-m', message as string];
const commitResult = execGit(commitArgs, { cwd: repoCwd });
const commitResult = execGit(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
if (commitResult.exitCode !== 0) {
if (isSpawnTimeout(commitResult)) {
// #3886 (subrepo counterpart): timeout ≠ error; surface the stale-lock
// path a killed commit can leave in the subrepo.
repos[repo] = {
committed: false,
hash: null,
files: repoFiles,
reason: 'commit_timeout',
timed_out: true,
error: commitTimeoutMessage(repoCwd, commitResult.stderr, commitResult.stdout),
};
continue;
}
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' };
continue;
@@ -2124,9 +2157,17 @@ function cmdPrSubrepo(
const commitArgs = canScopePr
? ['commit', '-m', commitMessage as string, '--', ...changedFiles]
: ['commit', '-m', commitMessage as string];
const commitResult = execGit(commitArgs, { cwd: repoCwd });
const commitResult = execGit(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
if (commitResult.exitCode !== 0) {
rollback();
if (isSpawnTimeout(commitResult)) {
// #3886 (PR-subrepo counterpart): name the timeout and the stale lock
// instead of echoing the killed hook's partial stderr.
error(
`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s in ${repo} (killed mid-hook; ` +
`a stale lock may remain at ${resolveIndexLockPath(repoCwd)} — remove it if no git process is running)`,
);
}
error(`Failed to commit in ${repo}: ${commitResult.stderr}`);
}
@@ -2996,6 +3037,40 @@ interface HooksDirResolution {
reason?: string;
}
/**
* #3886: the timeout band for `git commit` — pre-commit hooks (husky +
* lint-staged idles ~4s on Windows before any task) routinely exceed the 10s
* plumbing default; 30s is the same band the push call uses. Shared by all
* three commit sites AND their timeout messages, so the number and the text
* cannot drift apart.
*/
const COMMIT_TIMEOUT_MS = 30_000;
/**
* #3886: resolve where a killed `git commit` would leave its stale
* index.lock — via `git rev-parse --git-path index.lock`, never a literal
* `.git/index.lock` join (#3588 row 8's class: a linked worktree's `.git` is
* a FILE pointing at `<gitdir>/worktrees/<name>/`, so the literal path
* cannot exist there while the real lock blocks the next commit). Best
* effort: any resolution failure falls back to the literal join, and the
* message already hedges with "may remain".
*/
function resolveIndexLockPath(cwd: string): string {
const result = execGit(['rev-parse', '--git-path', 'index.lock'], { cwd });
if (result.exitCode !== 0) return path.join(cwd, '.git', 'index.lock');
const raw = result.stdout.trim();
return raw ? (path.isAbsolute(raw) ? raw : path.join(cwd, raw)) : path.join(cwd, '.git', 'index.lock');
}
/** #3886: shared timeout message shape for all three commit sites. */
function commitTimeoutMessage(cwd: string, stderr: string, stdout: string): string {
return (
`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s (killed mid-hook; a stale lock may remain at ` +
`${resolveIndexLockPath(cwd)} — remove it if no git process is running). ` +
`Partial stderr: ${stderr || stdout || '(none)'}`
);
}
/**
* Resolve the real git hooks directory for `cwd` via `git rev-parse
* --git-path hooks` — never a literal `.git/hooks` join (#3588 row 8: a