diff --git a/src/resources/extensions/gsd/commands-prefs-wizard.ts b/src/resources/extensions/gsd/commands-prefs-wizard.ts index 98d12be78..5ca8faf79 100644 --- a/src/resources/extensions/gsd/commands-prefs-wizard.ts +++ b/src/resources/extensions/gsd/commands-prefs-wizard.ts @@ -184,11 +184,23 @@ export function buildCategorySummaries(prefs: Record): Record | 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 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): Promise { diff --git a/src/resources/extensions/gsd/doctor-git-checks.ts b/src/resources/extensions/gsd/doctor-git-checks.ts index 78754fc8f..2ceffe97e 100644 --- a/src/resources/extensions/gsd/doctor-git-checks.ts +++ b/src/resources/extensions/gsd/doctor-git-checks.ts @@ -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/. diff --git a/src/resources/extensions/gsd/doctor-proactive.ts b/src/resources/extensions/gsd/doctor-proactive.ts index 0eb3b016f..eb7c11a7f 100644 --- a/src/resources/extensions/gsd/doctor-proactive.ts +++ b/src/resources/extensions/gsd/doctor-proactive.ts @@ -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 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). diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts index 864e8f8fa..8c804b3b8 100644 --- a/src/resources/extensions/gsd/doctor-types.ts +++ b/src/resources/extensions/gsd/doctor-types.ts @@ -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 diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 53c8d85a2..4769bd3d6 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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,86 @@ 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. + // If the reset target is an ancestor of the remote tracking branch, + // those commits are published and must not be squashed. + const resetTarget = `HEAD~${count + 1}`; + try { + const branch = nativeGetCurrentBranch(this.basePath); + if (branch) { + const remoteBranch = `origin/${branch}`; + // merge-base --is-ancestor exits 0 if resetTarget is ancestor of remote + execFileSync("git", ["merge-base", "--is-ancestor", resetTarget, remoteBranch], { + cwd: this.basePath, + stdio: ["ignore", "pipe", "pipe"], + }); + // If we get here, resetTarget IS an ancestor of remote — snapshots are 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); + + 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 ──────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index 48426dd14..76c140251 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -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 ). + * 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 ). * Returns empty string if the ref doesn't exist. diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 443dc4920..7ae8c9bda 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -93,6 +93,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "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. diff --git a/src/resources/extensions/gsd/tests/integration/doctor-git.test.ts b/src/resources/extensions/gsd/tests/integration/doctor-git.test.ts index d307627a3..4d58083d1 100644 --- a/src/resources/extensions/gsd/tests/integration/doctor-git.test.ts +++ b/src/resources/extensions/gsd/tests/integration/doctor-git.test.ts @@ -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 */ } diff --git a/src/resources/extensions/gsd/tests/integration/git-service.test.ts b/src/resources/extensions/gsd/tests/integration/git-service.test.ts index d1ba7a7ff..2c7d23fc8 100644 --- a/src/resources/extensions/gsd/tests/integration/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/integration/git-service.test.ts @@ -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 }); + }); });