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:
Mikael Hugo 2026-04-28 05:25:56 +02:00
parent 7fd4672e55
commit f1f4b840e1
3 changed files with 55 additions and 2 deletions

View file

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

View file

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

View file

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