From 9a8ae40b251dced1af5e981e302eaeff09e0cd57 Mon Sep 17 00:00:00 2001 From: mastertyko <11311479+mastertyko@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:58:06 +0200 Subject: [PATCH] fix(gsd): snapshot new untracked files before dispatch --- .../extensions/gsd/doctor-git-checks.ts | 8 ++--- .../extensions/gsd/doctor-proactive.ts | 6 ++-- .../gsd/tests/integration/doctor-git.test.ts | 11 +++++-- .../integration/doctor-proactive.test.ts | 33 +++++++++++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/resources/extensions/gsd/doctor-git-checks.ts b/src/resources/extensions/gsd/doctor-git-checks.ts index 2ceffe97e..36b2eb5eb 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, nativeHasChanges, nativeLastCommitEpoch, nativeGetCurrentBranch, nativeAddTracked, nativeCommit } from "./native-git-bridge.js"; +import { nativeIsRepo, nativeWorktreeList, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached, nativeHasChanges, nativeLastCommitEpoch, nativeGetCurrentBranch, nativeAddAllWithExclusions, nativeCommit } from "./native-git-bridge.js"; import { getAllWorktreeHealth } from "./worktree-health.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; @@ -386,19 +386,19 @@ export async function checkGitHealth( 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.`, + message: `Uncommitted changes detected with no commit in ${mins} minute${mins === 1 ? "" : "s"} (threshold: ${thresholdMinutes}m). Snapshotting uncommitted changes.`, fixable: true, }); if (shouldFix("stale_uncommitted_changes")) { try { - nativeAddTracked(basePath); + nativeAddAllWithExclusions(basePath, RUNTIME_EXCLUSION_PATHS); 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"); + fixesApplied.push("gsd snapshot skipped — nothing to commit after staging changes"); } } catch { fixesApplied.push("failed to create gsd snapshot commit"); diff --git a/src/resources/extensions/gsd/doctor-proactive.ts b/src/resources/extensions/gsd/doctor-proactive.ts index eb7c11a7f..20beae148 100644 --- a/src/resources/extensions/gsd/doctor-proactive.ts +++ b/src/resources/extensions/gsd/doctor-proactive.ts @@ -21,8 +21,8 @@ import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.j 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, nativeHasChanges, nativeLastCommitEpoch, nativeGetCurrentBranch, nativeAddTracked, nativeCommit } from "./native-git-bridge.js"; +import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch } from "./git-service.js"; +import { nativeIsRepo, nativeHasChanges, nativeLastCommitEpoch, nativeGetCurrentBranch, nativeAddAllWithExclusions, nativeCommit } from "./native-git-bridge.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { runEnvironmentChecks } from "./doctor-environment.js"; @@ -312,7 +312,7 @@ export async function preDispatchHealthGate(basePath: string): Promise= thresholdMinutes) { const mins = Math.floor(minutesSinceCommit); try { - nativeAddTracked(basePath); + nativeAddAllWithExclusions(basePath, RUNTIME_EXCLUSION_PATHS); const commitMsg = `gsd snapshot: pre-dispatch, uncommitted changes after ${mins}m inactivity`; const result = nativeCommit(basePath, commitMsg); if (result) { 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 4d58083d1..135f3040b 100644 --- a/src/resources/extensions/gsd/tests/integration/doctor-git.test.ts +++ b/src/resources/extensions/gsd/tests/integration/doctor-git.test.ts @@ -661,9 +661,10 @@ describe('doctor-git', async () => { 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) + // Modify a tracked file and create a new untracked file. The snapshot + // must preserve both, not just tracked changes. writeFileSync(join(dir, "README.md"), "# test\nmodified content\n"); + writeFileSync(join(dir, "new-untracked.ts"), "export const preserved = true;\n"); const detect = await runGSDDoctor(dir); const staleIssues = detect.issues.filter(i => i.code === "stale_uncommitted_changes"); @@ -681,6 +682,12 @@ describe('doctor-git', async () => { // 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"); + + const files = run("git show --name-only --format= HEAD", dir); + assert.ok(files.includes("README.md"), "snapshot keeps tracked modifications"); + assert.ok(files.includes("new-untracked.ts"), "snapshot also includes new untracked files"); + const status = run("git status --short", dir); + assert.ok(!status.includes("new-untracked.ts"), "snapshot does not leave the new source file untracked"); }); // ─── Test: stale_uncommitted_changes NOT flagged when recent commit ── diff --git a/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts b/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts index af04680ca..f7a21ca1f 100644 --- a/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +++ b/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts @@ -219,6 +219,39 @@ describe('doctor-proactive', async () => { assert.ok(result.fixesApplied.some((f: string) => f.includes("STATE.md")), "reports STATE.md status as info"); }); + test('health gate: pre-dispatch snapshot includes new untracked files', async () => { + const dir = createRepoWithActiveMilestone(); + cleanups.push(dir); + + 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 }, + }); + + writeFileSync(join(dir, "README.md"), "# test\nmodified content\n"); + writeFileSync(join(dir, "new-untracked.ts"), "export const preserved = true;\n"); + + const result = await preDispatchHealthGate(dir); + assert.ok(result.proceed, "dispatch still proceeds after snapshotting"); + assert.ok( + result.fixesApplied.some((f: string) => f.includes("gsd snapshot")), + "pre-dispatch gate creates a snapshot commit", + ); + + const log = run("git log -1 --oneline", dir); + assert.ok(log.includes("gsd snapshot"), "snapshot commit is created"); + + const files = run("git show --name-only --format= HEAD", dir); + assert.ok(files.includes("README.md"), "snapshot keeps tracked modifications"); + assert.ok(files.includes("new-untracked.ts"), "snapshot also includes new untracked files"); + const status = run("git status --short", dir); + assert.ok(!status.includes("new-untracked.ts"), "snapshot does not leave the new source file untracked"); + }); + test('health gate: stale crash lock auto-cleared', async () => { const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-"))); cleanups.push(dir);