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)
This commit is contained in:
parent
e0d130e682
commit
fa0651bfd6
9 changed files with 388 additions and 6 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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/.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue