cherry-pick(doctor): self-heal symlinked .sf staging to prevent silent data loss
Cherry-pick of gsd-build/gsd-2 9340f1e9b (#4423) — doctor self-heal detection for symlinked staging directories that can cause silent data loss. Skips native-git-bridge.ts and git-service test (drifted). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7fd4672e55
commit
f1f4b840e1
3 changed files with 55 additions and 2 deletions
|
|
@ -8,7 +8,7 @@ import { deriveState } from "./state.js";
|
|||
import { saveFile } from "./files.js";
|
||||
import { nativeIsRepo, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js";
|
||||
import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js";
|
||||
import { ensureGitignore } from "./gitignore.js";
|
||||
import { ensureGitignore, isGsdGitignored } from "./gitignore.js";
|
||||
import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js";
|
||||
import { recoverFailedMigration } from "./migrate-external.js";
|
||||
|
||||
|
|
@ -383,6 +383,27 @@ export async function checkRuntimeHealth(
|
|||
fixable: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Symlinked .gsd without .gitignore entry (#4423) ──
|
||||
// When `.gsd` is a symlink AND not gitignored, `git add -A -- :!.gsd/...`
|
||||
// pathspecs fail with "beyond a symbolic link". Without self-heal this
|
||||
// silently drops new user files during auto-commit.
|
||||
if (nativeIsRepo(basePath) && !isGsdGitignored(basePath)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "symlinked_gsd_unignored",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: ".gsd is a symlink to external state but is not listed in .gitignore. This causes git pathspec exclusions to fail and can lead to silently dropped new files during auto-commit. Add `.gsd` to .gitignore.",
|
||||
file: ".gitignore",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("symlinked_gsd_unignored")) {
|
||||
const modified = ensureGitignore(basePath);
|
||||
if (modified) fixesApplied.push("added .gsd to .gitignore (symlinked external state)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type DoctorIssueCode =
|
|||
| "state_file_stale"
|
||||
| "state_file_missing"
|
||||
| "gitignore_missing_patterns"
|
||||
| "symlinked_gsd_unignored"
|
||||
| "unresolvable_dependency"
|
||||
| "failed_migration"
|
||||
| "broken_symlink"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
|
|||
* state_file_stale, gitignore_missing_patterns
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, realpathSync, symlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
|
@ -252,6 +252,37 @@ node_modules/
|
|||
} else {
|
||||
}
|
||||
|
||||
// ─── Test 8b: Symlinked .gsd without .gitignore entry (#4423) ─────
|
||||
if (process.platform !== "win32") {
|
||||
test('symlinked_gsd_unignored', async () => {
|
||||
const dir = createGitProject();
|
||||
cleanups.push(dir);
|
||||
|
||||
// Create .gsd as a symlink to an external directory (standard external
|
||||
// state layout), and write a .gitignore that does NOT list .gsd.
|
||||
const externalGsd = mkdtempSync(join(tmpdir(), "gsd-external-doctor-"));
|
||||
cleanups.push(externalGsd);
|
||||
writeFileSync(join(externalGsd, "STATE.md"), "# State\n");
|
||||
symlinkSync(externalGsd, join(dir, ".gsd"));
|
||||
|
||||
writeFileSync(join(dir, ".gitignore"), "node_modules/\n");
|
||||
|
||||
const detect = await runGSDDoctor(dir);
|
||||
const symlinkIssues = detect.issues.filter(i => i.code === "symlinked_gsd_unignored");
|
||||
assert.ok(symlinkIssues.length > 0, "detects symlinked .gsd without gitignore entry");
|
||||
|
||||
const fixed = await runGSDDoctor(dir, { fix: true });
|
||||
assert.ok(
|
||||
fixed.fixesApplied.some(f => f.includes(".gitignore")),
|
||||
"fix updates .gitignore",
|
||||
);
|
||||
|
||||
const content = readFileSync(join(dir, ".gitignore"), "utf-8");
|
||||
assert.ok(/^\.gsd\/?$/m.test(content), "gitignore now has .gsd entry");
|
||||
});
|
||||
} else {
|
||||
}
|
||||
|
||||
// ─── Test 9: Orphaned completed-units detection & fix ─────────────
|
||||
test('orphaned_completed_units', async () => {
|
||||
const dir = createMinimalProject();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue