diff --git a/src/resources/extensions/sf/doctor-runtime-checks.ts b/src/resources/extensions/sf/doctor-runtime-checks.ts index b9b9df53d..d1cce28be 100644 --- a/src/resources/extensions/sf/doctor-runtime-checks.ts +++ b/src/resources/extensions/sf/doctor-runtime-checks.ts @@ -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 { diff --git a/src/resources/extensions/sf/doctor-types.ts b/src/resources/extensions/sf/doctor-types.ts index 8929a6e7e..41a5cc976 100644 --- a/src/resources/extensions/sf/doctor-types.ts +++ b/src/resources/extensions/sf/doctor-types.ts @@ -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" diff --git a/src/resources/extensions/sf/tests/integration/doctor-runtime.test.ts b/src/resources/extensions/sf/tests/integration/doctor-runtime.test.ts index d62544f81..d9a1821aa 100644 --- a/src/resources/extensions/sf/tests/integration/doctor-runtime.test.ts +++ b/src/resources/extensions/sf/tests/integration/doctor-runtime.test.ts @@ -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();