diff --git a/src/headless.ts b/src/headless.ts index 5ae297075..672ec9725 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -54,6 +54,11 @@ import { startSupervisedStdinReader, } from "./headless-ui.js"; import { getProjectSessionsDir } from "./project-sessions.js"; +import { + ensureGsdSymlink, + externalGsdRoot, + hasExternalProjectState, +} from "./resources/extensions/sf/repo-identity.js"; import { completeSpan, flushTrace, @@ -92,6 +97,19 @@ export interface HeadlessOptions { bare?: boolean; // --bare: suppress CLAUDE.md/AGENTS.md, user skills, project preferences } +export function repairMissingSfSymlinkForHeadless( + basePath: string, +): string | null { + const sfDir = join(basePath, ".sf"); + if (existsSync(sfDir)) return sfDir; + + const externalPath = externalGsdRoot(basePath); + if (!hasExternalProjectState(externalPath)) return null; + + const linkedPath = ensureGsdSymlink(basePath); + return existsSync(sfDir) ? linkedPath : null; +} + interface TrackedEvent { type: string; timestamp: number; @@ -408,6 +426,12 @@ async function runHeadlessOnce( process.stderr.write( "[headless] Migrated .gsd/ → .sf/ (legacy GSD2 project detected)\n", ); + } else if (repairMissingSfSymlinkForHeadless(process.cwd())) { + if (!options.json) { + process.stderr.write( + "[headless] Re-linked .sf to existing external project state\n", + ); + } } else { process.stderr.write( "[headless] Error: No .sf/ directory found in current directory.\n", diff --git a/src/resources/extensions/sf/repo-identity.ts b/src/resources/extensions/sf/repo-identity.ts index c73aa1050..1e92d4e16 100644 --- a/src/resources/extensions/sf/repo-identity.ts +++ b/src/resources/extensions/sf/repo-identity.ts @@ -448,6 +448,10 @@ function hasProjectState(externalPath: string): boolean { } } +export function hasExternalProjectState(externalPath: string): boolean { + return hasProjectState(externalPath); +} + /** * Resolve the external state directory, with recovery for relocated projects. * diff --git a/src/resources/extensions/sf/tests/headless-project-repair.test.ts b/src/resources/extensions/sf/tests/headless-project-repair.test.ts new file mode 100644 index 000000000..065d7aa05 --- /dev/null +++ b/src/resources/extensions/sf/tests/headless-project-repair.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { execSync } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "node:test"; + +import { repairMissingSfSymlinkForHeadless } from "../../../../headless.ts"; +import { externalGsdRoot } from "../repo-identity.ts"; + +function run(command: string, cwd: string): string { + return execSync(command, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); +} + +describe("headless project repair", () => { + let base: string; + let stateDir: string; + let previousStateDir: string | undefined; + + beforeEach(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "sf-headless-repair-"))); + stateDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-state-"))); + previousStateDir = process.env.SF_STATE_DIR; + process.env.SF_STATE_DIR = stateDir; + + run("git init -b main", base); + run('git config user.name "SF Test"', base); + run('git config user.email "sf@example.com"', base); + run("git remote add origin git@github.com:example/repaired.git", base); + writeFileSync(join(base, "README.md"), "# Test Repo\n", "utf-8"); + run("git add README.md", base); + run('git commit -m "chore: init"', base); + }); + + afterEach(() => { + if (previousStateDir === undefined) delete process.env.SF_STATE_DIR; + else process.env.SF_STATE_DIR = previousStateDir; + rmSync(base, { recursive: true, force: true }); + rmSync(stateDir, { recursive: true, force: true }); + }); + + test("re-links .sf when matching external project state already exists", () => { + const externalPath = externalGsdRoot(base); + mkdirSync(join(externalPath, "milestones"), { recursive: true }); + + const repairedPath = repairMissingSfSymlinkForHeadless(base); + + assert.equal(repairedPath, externalPath); + assert.ok(existsSync(join(base, ".sf")), ".sf exists after repair"); + assert.ok(lstatSync(join(base, ".sf")).isSymbolicLink(), ".sf is a symlink"); + assert.equal(realpathSync(join(base, ".sf")), realpathSync(externalPath)); + }); + + test("does not initialize a blank project for read-only headless commands", () => { + const repairedPath = repairMissingSfSymlinkForHeadless(base); + + assert.equal(repairedPath, null); + assert.equal(existsSync(join(base, ".sf")), false); + }); +});