From fa0651bfd65518c2c9d8b623801a4e8689e26c77 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 29 Mar 2026 12:40:47 -0500 Subject: [PATCH 1/4] feat(doctor): stale commit safety check with gsd snapshot and auto-cleanup Adds a safety mechanism that detects uncommitted changes idle past a configurable threshold (default: 30 min), auto-snapshots tracked files using `git add -u`, and cleans up snapshot commits when real work lands. - New `stale_uncommitted_changes` doctor issue with auto-snapshot fix - Detection in health widget (60s), pre-dispatch gate, and /gsd doctor - `nativeAddTracked()` stages only tracked files (no secrets/binaries) - `absorbSnapshotCommits()` squashes `gsd snapshot:` commits into next real autoCommit via soft reset + re-commit - Configurable via `stale_commit_threshold_minutes` preference (0=off) --- .../extensions/gsd/commands-prefs-wizard.ts | 50 ++++++++++- .../extensions/gsd/doctor-git-checks.ts | 50 ++++++++++- .../extensions/gsd/doctor-proactive.ts | 36 +++++++- src/resources/extensions/gsd/doctor-types.ts | 2 + src/resources/extensions/gsd/git-service.ts | 84 +++++++++++++++++++ .../extensions/gsd/native-git-bridge.ts | 24 ++++++ .../extensions/gsd/preferences-types.ts | 8 ++ .../gsd/tests/integration/doctor-git.test.ts | 72 ++++++++++++++++ .../gsd/tests/integration/git-service.test.ts | 68 +++++++++++++++ 9 files changed, 388 insertions(+), 6 deletions(-) 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 }); + }); }); From 36b03890dafb7364c41a74ebca4d5284953dd76e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 30 Mar 2026 10:43:06 -0500 Subject: [PATCH 2/4] fix(git-service): fix merge-base ancestry check and .gsd/ leakage in snapshot absorption - Check HEAD~1 (newest snapshot) instead of resetTarget (pre-snapshot base) for remote ancestry. The old check false-positived when the remote was at the pre-snapshot base but snapshots were local-only. - Re-run smartStage() after soft reset so RUNTIME_EXCLUSION_PATHS apply to the absorbed commit. Without this, .gsd/ state files from snapshot commits leaked into the real commit. --- src/resources/extensions/gsd/git-service.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 4769bd3d6..ae73a0e94 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -610,19 +610,22 @@ export class GitServiceImpl { 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. + // 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 resetTarget is ancestor of remote - execFileSync("git", ["merge-base", "--is-ancestor", resetTarget, remoteBranch], { + // 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, resetTarget IS an ancestor of remote — snapshots are pushed + // If we get here, newest snapshot IS reachable from remote — already pushed return; } } catch { @@ -638,6 +641,12 @@ export class GitServiceImpl { 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 { From 0e978d4565e8e3e0a3c4eff47af86ac06baa0582 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 31 Mar 2026 17:34:05 -0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(state):=20always=20run=20disk=E2=86=92D?= =?UTF-8?q?B=20reconciliation=20when=20DB=20is=20available=20(#2631)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When DB was available but empty, deriveState skipped deriveStateFromDb entirely, bypassing the disk→DB sync logic. Milestones created outside the DB write path were never discovered. --- src/resources/extensions/gsd/state.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 9e1b3f311..7f9a0504c 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -228,19 +228,15 @@ export async function deriveState(basePath: string): Promise { const stopTimer = debugTime("derive-state-impl"); let result: GSDState; - // Dual-path: try DB-backed derivation first when hierarchy tables are populated + // Dual-path: try DB-backed derivation when DB is available. + // Always go through deriveStateFromDb when DB is open — even if hierarchy + // tables are empty — because it contains disk→DB reconciliation logic that + // discovers milestones created outside the DB write path (#2631). if (isDbAvailable()) { - const dbMilestones = getAllMilestones(); - if (dbMilestones.length > 0) { - const stopDbTimer = debugTime("derive-state-db"); - result = await deriveStateFromDb(basePath); - stopDbTimer({ phase: result.phase, milestone: result.activeMilestone?.id }); - _telemetry.dbDeriveCount++; - } else { - // DB open but empty hierarchy tables — pre-migration project, use filesystem - result = await _deriveStateImpl(basePath); - _telemetry.markdownDeriveCount++; - } + const stopDbTimer = debugTime("derive-state-db"); + result = await deriveStateFromDb(basePath); + stopDbTimer({ phase: result.phase, milestone: result.activeMilestone?.id }); + _telemetry.dbDeriveCount++; } else { result = await _deriveStateImpl(basePath); _telemetry.markdownDeriveCount++; From 2cc01c11ee5cef92e063052f0ca78ccc49b8a6f3 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 31 Mar 2026 17:48:45 -0500 Subject: [PATCH 4/4] fix(merge): clean stale MERGE_HEAD before squash merge (#2912) A pre-existing MERGE_HEAD (from failed prior merge, libgit2 native path, or external tooling) blocks git merge --squash. Remove stale merge state files before starting the squash merge, not just after. --- src/resources/extensions/gsd/auto-worktree.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 27a70af84..c6eca1004 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -1566,6 +1566,18 @@ export function mergeMilestoneToMain( // Non-fatal — proceed with merge; untracked files may block it } + // 7b. Clean up stale merge state files before starting the squash merge (#2912). + // A previous failed merge, libgit2 native path, or external tooling may leave + // MERGE_HEAD on disk. git refuses to start a new merge when MERGE_HEAD exists, + // causing `git merge --squash` to fail with "You have not concluded your merge". + try { + const gitDir_ = resolveGitDir(originalBasePath_); + for (const f of ["MERGE_HEAD", "MERGE_MSG", "SQUASH_MSG"]) { + const p = join(gitDir_, f); + if (existsSync(p)) unlinkSync(p); + } + } catch { /* best-effort — proceed and let the merge report the error if it fails */ } + // 8. Squash merge — auto-resolve .gsd/ state file conflicts (#530) const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch);