fix(headless): repair missing sf project symlink

This commit is contained in:
Mikael Hugo 2026-04-29 14:43:30 +02:00
parent 3b6cbcd79f
commit 9b718f8e36
3 changed files with 100 additions and 0 deletions

View file

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

View file

@ -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.
*

View file

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