Merge pull request #3864 from mastertyko/fix/3839-snapshot-stage-untracked-files

fix(gsd): snapshot new untracked files before dispatch
This commit is contained in:
Jeremy McSpadden 2026-04-09 09:23:51 -05:00 committed by GitHub
commit f18d8e9f30
4 changed files with 49 additions and 9 deletions

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, 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");

View file

@ -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<PreDispat
if (minutesSinceCommit >= 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) {

View file

@ -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 ──

View file

@ -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);