Merge pull request #3138 from jeremymcs/claude/add-stale-commit-check-GIbgw

feat(doctor): stale commit safety check with gsd snapshot and auto-cleanup
This commit is contained in:
Jeremy McSpadden 2026-04-01 14:22:21 -05:00 committed by GitHub
commit b2abff3ce5
9 changed files with 397 additions and 6 deletions

View file

@ -184,11 +184,23 @@ export function buildCategorySummaries(prefs: Record<string, unknown>): Record<s
// Git
const git = prefs.git as Record<string, unknown> | undefined;
const staleThreshold = prefs.stale_commit_threshold_minutes;
const absorbSnapshots = git?.absorb_snapshot_commits;
let gitSummary = "(defaults)";
if (git && Object.keys(git).length > 0) {
const branch = git.main_branch ?? "main";
const push = git.auto_push ? "on" : "off";
gitSummary = `main: ${branch}, push: ${push}`;
{
const parts: string[] = [];
if (git && Object.keys(git).length > 0) {
const branch = git.main_branch ?? "main";
const push = git.auto_push ? "on" : "off";
parts.push(`main: ${branch}, push: ${push}`);
}
if (staleThreshold !== undefined) {
parts.push(`stale: ${staleThreshold === 0 ? "off" : `${staleThreshold}m`}`);
}
if (absorbSnapshots !== undefined) {
parts.push(`absorb: ${absorbSnapshots ? "on" : "off"}`);
}
if (parts.length > 0) gitSummary = parts.join(", ");
}
// Skills
@ -469,9 +481,39 @@ async function configureGit(ctx: ExtensionCommandContext, prefs: Record<string,
git.isolation = isolationChoice;
}
// absorb_snapshot_commits (git sub-key)
const currentAbsorb = git.absorb_snapshot_commits;
const absorbStr = currentAbsorb !== undefined ? String(currentAbsorb) : "";
const absorbChoice = await ctx.ui.select(
`Absorb snapshot commits into real commits${absorbStr ? ` (current: ${absorbStr})` : " (default: true)"}:`,
["true", "false", "(keep current)"],
);
if (absorbChoice && absorbChoice !== "(keep current)") {
git.absorb_snapshot_commits = absorbChoice === "true";
}
if (Object.keys(git).length > 0) {
prefs.git = git;
}
// stale_commit_threshold_minutes (top-level pref, shown in Git section)
const currentThreshold = prefs.stale_commit_threshold_minutes;
const thresholdStr = currentThreshold !== undefined ? String(currentThreshold) : "";
const thresholdInput = await ctx.ui.input(
`Stale commit threshold (minutes, 0 to disable)${thresholdStr ? ` (current: ${thresholdStr})` : " (default: 30)"}:`,
thresholdStr || "30",
);
if (thresholdInput !== null && thresholdInput !== undefined) {
const val = thresholdInput.trim();
const parsed = tryParseInteger(val);
if (val && parsed !== null && parsed >= 0) {
prefs.stale_commit_threshold_minutes = parsed;
} else if (val && parsed === null) {
ctx.ui.notify(`Invalid value "${val}" — must be a whole number. Keeping previous value.`, "warning");
} else if (!val && currentThreshold !== undefined) {
delete prefs.stale_commit_threshold_minutes;
}
}
}
async function configureSkills(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {

View file

@ -10,7 +10,7 @@ import { deriveState, isMilestoneComplete } from "./state.js";
import { listWorktrees, resolveGitDir, worktreesDir } from "./worktree-manager.js";
import { abortAndReset } from "./git-self-heal.js";
import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch, writeIntegrationBranch } from "./git-service.js";
import { nativeIsRepo, nativeWorktreeList, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js";
import { nativeIsRepo, nativeWorktreeList, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached, nativeHasChanges, nativeLastCommitEpoch, nativeGetCurrentBranch, nativeAddTracked, nativeCommit } from "./native-git-bridge.js";
import { getAllWorktreeHealth } from "./worktree-health.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
@ -363,6 +363,54 @@ export async function checkGitHealth(
// Non-fatal — orphaned worktree directory check failed
}
// ── Stale uncommitted changes ────────────────────────────────────────────
// If the working tree has uncommitted changes and the last commit was
// longer ago than the configured threshold, flag it and optionally
// auto-commit a safety snapshot so work isn't lost.
try {
const prefs = loadEffectiveGSDPreferences()?.preferences ?? {};
const thresholdMinutes = prefs.stale_commit_threshold_minutes ?? 30;
if (thresholdMinutes > 0) {
const dirty = nativeHasChanges(basePath);
if (dirty) {
const branch = nativeGetCurrentBranch(basePath);
const lastEpoch = nativeLastCommitEpoch(basePath, branch || "HEAD");
const nowEpoch = Math.floor(Date.now() / 1000);
const minutesSinceCommit = lastEpoch > 0 ? (nowEpoch - lastEpoch) / 60 : Infinity;
if (minutesSinceCommit >= thresholdMinutes) {
const mins = Math.floor(minutesSinceCommit);
issues.push({
severity: "warning",
code: "stale_uncommitted_changes",
scope: "project",
unitId: "project",
message: `Uncommitted changes detected with no commit in ${mins} minute${mins === 1 ? "" : "s"} (threshold: ${thresholdMinutes}m). Snapshotting tracked files.`,
fixable: true,
});
if (shouldFix("stale_uncommitted_changes")) {
try {
nativeAddTracked(basePath);
const commitMsg = `gsd snapshot: uncommitted changes after ${mins}m inactivity`;
const result = nativeCommit(basePath, commitMsg);
if (result) {
fixesApplied.push(`created gsd snapshot after ${mins}m of uncommitted changes`);
} else {
fixesApplied.push("gsd snapshot skipped — nothing to commit after staging tracked files");
}
} catch {
fixesApplied.push("failed to create gsd snapshot commit");
}
}
}
}
}
} catch {
// Non-fatal — stale commit check failed
}
// ── Worktree lifecycle checks ──────────────────────────────────────────
// Check GSD-managed worktrees for: merged branches, stale work, dirty
// state, and unpushed commits. Only worktrees under .gsd/worktrees/.

View file

@ -22,7 +22,7 @@ import { abortAndReset } from "./git-self-heal.js";
import { rebuildState } from "./doctor.js";
import { deriveState } from "./state.js";
import { resolveMilestoneIntegrationBranch } from "./git-service.js";
import { nativeIsRepo } from "./native-git-bridge.js";
import { nativeIsRepo, nativeHasChanges, nativeLastCommitEpoch, nativeGetCurrentBranch, nativeAddTracked, nativeCommit } from "./native-git-bridge.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import { runEnvironmentChecks } from "./doctor-environment.js";
@ -295,6 +295,40 @@ export async function preDispatchHealthGate(basePath: string): Promise<PreDispat
// Non-fatal — dispatch continues if state/branch check fails
}
// ── Stale uncommitted changes — auto-snapshot before dispatch ──
// If the working tree is dirty and no commit has happened recently,
// create a safety snapshot so work isn't lost if the next unit crashes.
try {
if (nativeIsRepo(basePath)) {
const prefs = loadEffectiveGSDPreferences()?.preferences ?? {};
const thresholdMinutes = prefs.stale_commit_threshold_minutes ?? 30;
if (thresholdMinutes > 0 && nativeHasChanges(basePath)) {
const branch = nativeGetCurrentBranch(basePath);
const lastEpoch = nativeLastCommitEpoch(basePath, branch || "HEAD");
const nowEpoch = Math.floor(Date.now() / 1000);
const minutesSinceCommit = lastEpoch > 0 ? (nowEpoch - lastEpoch) / 60 : Infinity;
if (minutesSinceCommit >= thresholdMinutes) {
const mins = Math.floor(minutesSinceCommit);
try {
nativeAddTracked(basePath);
const commitMsg = `gsd snapshot: pre-dispatch, uncommitted changes after ${mins}m inactivity`;
const result = nativeCommit(basePath, commitMsg);
if (result) {
fixesApplied.push(`pre-dispatch: created gsd snapshot after ${mins}m of uncommitted changes`);
}
} catch {
// Non-blocking — snapshot failed but dispatch can continue
fixesApplied.push("pre-dispatch: gsd snapshot failed");
}
}
}
}
} catch {
// Non-fatal
}
// ── Disk space check ──
// Catches low-disk conditions before dispatch rather than letting the unit
// fail mid-execution with ENOSPC (which wastes a full LLM turn).

View file

@ -61,6 +61,8 @@ export type DoctorIssueCode =
| "worktree_stale"
| "worktree_dirty"
| "worktree_unpushed"
// Stale commit safety check
| "stale_uncommitted_changes"
// Snapshot ref bloat
| "snapshot_ref_bloat"
// Runtime data integrity

View file

@ -32,6 +32,8 @@ import {
nativeRmCached,
nativeUpdateRef,
nativeAddPaths,
nativeResetSoft,
nativeCommitSubject,
} from "./native-git-bridge.js";
import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js";
import { getErrorMessage } from "./error-utils.js";
@ -77,6 +79,11 @@ export interface GitPreferences {
* Default: the main branch (from `main_branch` or auto-detected).
*/
pr_target_branch?: string;
/** Whether to squash `gsd snapshot:` commits into the next real autoCommit.
* Enabled by default. Set to false to keep snapshot commits in history
* for forensic inspection.
*/
absorb_snapshot_commits?: boolean;
}
export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
@ -563,9 +570,95 @@ export class GitServiceImpl {
? buildTaskCommitMessage(taskContext)
: `chore: auto-commit after ${unitType}\n\nGSD-Unit: ${unitId}`;
nativeCommit(this.basePath, message, { allowEmpty: false });
// Absorb any preceding gsd snapshot commits into this real commit.
// Walk backwards from HEAD~1 counting consecutive snapshot subjects,
// then soft-reset to before them and re-commit with the same message.
this.absorbSnapshotCommits(message);
return message;
}
/**
* Squash consecutive `gsd snapshot:` commits that sit immediately below
* HEAD into the current HEAD commit. This keeps the git history clean
* after automated snapshot commits are superseded by real work.
*
* Guards:
* - Opt-in via `absorb_snapshot_commits` preference (default: true).
* - Refuses to rewrite commits that have been pushed to the remote
* tracking branch (checks merge-base ancestry).
* - Saves HEAD SHA before reset; restores it if the re-commit fails.
*
* Does nothing if there are no snapshot commits to absorb.
*/
private absorbSnapshotCommits(headMessage: string): void {
try {
// Opt-in guard — users can disable to keep snapshot commits for forensics
if (this.prefs.absorb_snapshot_commits === false) return;
const GSD_SNAPSHOT_PREFIX = "gsd snapshot:";
let count = 0;
// Walk back from HEAD~1 counting consecutive snapshot commits (cap at 10)
for (let i = 1; i <= 10; i++) {
const subject = nativeCommitSubject(this.basePath, `HEAD~${i}`);
if (!subject.startsWith(GSD_SNAPSHOT_PREFIX)) break;
count = i;
}
if (count === 0) return;
// Guard: don't rewrite history that has been pushed to the remote.
// Check whether the newest snapshot commit (HEAD~1) is already
// reachable from the remote tracking branch. If it is, the snapshots
// have been pushed and must not be squashed via local history rewrite.
// (Checking resetTarget instead would false-positive when the remote
// is at the pre-snapshot base but the snapshots themselves are local.)
const resetTarget = `HEAD~${count + 1}`;
try {
const branch = nativeGetCurrentBranch(this.basePath);
if (branch) {
const remoteBranch = `origin/${branch}`;
// merge-base --is-ancestor exits 0 if HEAD~1 is ancestor of remote
execFileSync("git", ["merge-base", "--is-ancestor", "HEAD~1", remoteBranch], {
cwd: this.basePath,
stdio: ["ignore", "pipe", "pipe"],
});
// If we get here, newest snapshot IS reachable from remote — already pushed
return;
}
} catch {
// Not an ancestor or remote doesn't exist — safe to proceed
}
// Save HEAD SHA so we can restore if the re-commit fails
const savedHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: this.basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
nativeResetSoft(this.basePath, resetTarget);
// Re-run smartStage so the same RUNTIME_EXCLUSION_PATHS apply.
// Snapshot commits used nativeAddTracked (git add -u) which stages
// ALL tracked modifications including .gsd/ state files. Without
// re-staging, those .gsd/ changes leak into the absorbed commit.
this.smartStage();
try {
nativeCommit(this.basePath, headMessage, { allowEmpty: false });
} catch {
// Re-commit failed — restore original HEAD to avoid leaving the
// repo in a partially-reset state with no commit
nativeResetSoft(this.basePath, savedHead);
}
} catch {
// Non-fatal — if squash fails, the commits remain unsquashed
}
}
// ─── Branch Queries ────────────────────────────────────────────────────
/**

View file

@ -680,6 +680,16 @@ export function nativeAddAll(basePath: string): void {
gitFileExec(basePath, ["add", "-A"]);
}
/**
* Stage only already-tracked files (git add -u).
* Does NOT add new untracked files only updates modifications and deletions
* for files git already knows about. Safe for automated snapshots where
* pulling in unknown untracked files (secrets, binaries) would be dangerous.
*/
export function nativeAddTracked(basePath: string): void {
gitFileExec(basePath, ["add", "-u"]);
}
/**
* Stage all files with pathspec exclusions (git add -A -- ':!pattern' ...).
* Excluded paths are never hashed by git, preventing hangs on large
@ -931,6 +941,20 @@ export function nativeResetHard(basePath: string): void {
execSync("git reset --hard HEAD", { cwd: basePath, stdio: "pipe" });
}
/**
* Soft reset to a target ref (git reset --soft <ref>).
* Moves HEAD to `target` while keeping all changes staged in the index.
* Used to squash snapshot commits back into a single real commit.
*/
export function nativeResetSoft(basePath: string, target: string): void {
execFileSync("git", ["reset", "--soft", target], {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
env: GIT_NO_PROMPT_ENV,
});
}
/**
* Get the subject line of a commit (git log -1 --format=%s <ref>).
* Returns empty string if the ref doesn't exist.

View file

@ -93,6 +93,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"service_tier",
"forensics_dedup",
"show_token_cost",
"stale_commit_threshold_minutes",
"experimental",
]);
@ -253,6 +254,13 @@ export interface GSDPreferences {
forensics_dedup?: boolean;
/** Opt-in: show per-prompt and cumulative session token cost in the footer. Default: false. */
show_token_cost?: boolean;
/**
* Minutes without a commit before flagging uncommitted changes as stale.
* When the threshold is exceeded and the working tree is dirty, doctor will
* auto-commit a safety snapshot tagged with `[gsd safety]`. Default: 30.
* Set to 0 to disable.
*/
stale_commit_threshold_minutes?: number;
/**
* Opt-in experimental features. All features here are disabled by default.
* See the preferences reference for details on each feature.

View file

@ -645,6 +645,78 @@ describe('doctor-git', async () => {
} else {
}
// ─── Test: stale_uncommitted_changes detection & auto-snapshot ──────
test('stale_uncommitted_changes (detected and auto-committed)', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
// Make the last commit appear old by amending its date to 45 min ago
const pastDate = new Date(Date.now() - 45 * 60 * 1000).toISOString();
run(`git commit --amend --no-edit --date="${pastDate}"`, dir);
// Also set committer date so git log %ct reflects it
execSync(`git commit --amend --no-edit`, {
cwd: dir,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
env: { ...process.env, GIT_COMMITTER_DATE: pastDate },
});
// Modify an already-tracked file (nativeAddTracked uses git add -u,
// which only stages tracked files — new untracked files are not staged)
writeFileSync(join(dir, "README.md"), "# test\nmodified content\n");
const detect = await runGSDDoctor(dir);
const staleIssues = detect.issues.filter(i => i.code === "stale_uncommitted_changes");
assert.ok(staleIssues.length > 0, "detects stale uncommitted changes");
assert.ok(staleIssues[0]?.message.includes("minute"), "message mentions minutes");
assert.ok(staleIssues[0]?.fixable === true, "stale uncommitted changes is fixable");
// Fix should create a gsd snapshot commit
const fixed = await runGSDDoctor(dir, { fix: true });
assert.ok(
fixed.fixesApplied.some(f => f.includes("gsd snapshot")),
"fix creates a gsd snapshot commit",
);
// Verify the snapshot commit was created with the gsd snapshot tag
const log = run("git log -1 --oneline", dir);
assert.ok(log.includes("gsd snapshot"), "commit is tagged with gsd snapshot");
});
// ─── Test: stale_uncommitted_changes NOT flagged when recent commit ──
test('stale_uncommitted_changes (no false positive on recent commit)', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
// Create uncommitted changes (but last commit is fresh — just created)
writeFileSync(join(dir, "fresh-dirty.txt"), "recent changes\n");
const detect = await runGSDDoctor(dir);
const staleIssues = detect.issues.filter(i => i.code === "stale_uncommitted_changes");
assert.deepStrictEqual(staleIssues.length, 0, "recent commit with dirty tree NOT flagged as stale");
});
// ─── Test: stale_uncommitted_changes NOT flagged when tree is clean ──
test('stale_uncommitted_changes (no false positive on clean tree)', async () => {
const dir = createRepoWithActiveMilestone();
cleanups.push(dir);
// Make the last commit appear old
const pastDate = new Date(Date.now() - 45 * 60 * 1000).toISOString();
run(`git commit --amend --no-edit --date="${pastDate}"`, dir);
execSync(`git commit --amend --no-edit`, {
cwd: dir,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
env: { ...process.env, GIT_COMMITTER_DATE: pastDate },
});
// No uncommitted changes — tree is clean
const detect = await runGSDDoctor(dir);
const staleIssues = detect.issues.filter(i => i.code === "stale_uncommitted_changes");
assert.deepStrictEqual(staleIssues.length, 0, "old commit with clean tree NOT flagged as stale");
});
} finally {
for (const dir of cleanups) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }

View file

@ -1455,4 +1455,72 @@ describe('git-service', async () => {
try { rmSync(repo, { recursive: true, force: true }); } catch {}
try { rmSync(externalGsd, { recursive: true, force: true }); } catch {}
});
// ─── autoCommit: absorbs preceding gsd snapshot commits ─────────────────
test('autoCommit: absorbs preceding gsd snapshot commits', () => {
const repo = initTempRepo();
// Simulate 2 gsd snapshot commits
createFile(repo, "file1.ts", "v1");
run("git add -A", repo);
run('git commit -m "gsd snapshot: uncommitted changes after 35m inactivity"', repo);
createFile(repo, "file2.ts", "v2");
run("git add -A", repo);
run('git commit -m "gsd snapshot: pre-dispatch, uncommitted changes after 40m inactivity"', repo);
// Verify we have 3 commits (init + 2 snapshots)
const countBefore = run("git rev-list --count HEAD", repo);
assert.deepStrictEqual(countBefore, "3", "precondition: 3 commits before autoCommit");
// Now make a real change and autoCommit
createFile(repo, "feature.ts", "real work");
const svc = new GitServiceImpl(repo);
const msg = svc.autoCommit("execute-task", "S01/T01");
assert.ok(msg !== null, "autoCommit succeeds");
// Should be 2 commits: init + squashed real commit (snapshots absorbed)
const countAfter = run("git rev-list --count HEAD", repo);
assert.deepStrictEqual(countAfter, "2", "snapshot commits absorbed into real commit");
// All files should be present
const files = run("git show --name-only HEAD", repo);
assert.ok(files.includes("file1.ts"), "file1.ts from snapshot 1 preserved");
assert.ok(files.includes("file2.ts"), "file2.ts from snapshot 2 preserved");
assert.ok(files.includes("feature.ts"), "feature.ts from real commit preserved");
// No gsd snapshot commits in log
const log = run("git log --oneline", repo);
assert.ok(!log.includes("gsd snapshot"), "no gsd snapshot commits remain in history");
rmSync(repo, { recursive: true, force: true });
});
// ─── autoCommit: does not absorb non-snapshot commits ───────────────────
test('autoCommit: does not absorb non-snapshot commits', () => {
const repo = initTempRepo();
// Create a normal (non-snapshot) commit
createFile(repo, "earlier.ts", "earlier work");
run("git add -A", repo);
run('git commit -m "feat: earlier work"', repo);
const countBefore = run("git rev-list --count HEAD", repo);
assert.deepStrictEqual(countBefore, "2", "precondition: 2 commits before autoCommit");
// Make a real change and autoCommit
createFile(repo, "feature.ts", "new work");
const svc = new GitServiceImpl(repo);
svc.autoCommit("execute-task", "S01/T02");
// Should be 3 commits — earlier commit not absorbed
const countAfter = run("git rev-list --count HEAD", repo);
assert.deepStrictEqual(countAfter, "3", "non-snapshot commits NOT absorbed");
rmSync(repo, { recursive: true, force: true });
});
});