fix(headless): repair missing sf project symlink
This commit is contained in:
parent
3b6cbcd79f
commit
9b718f8e36
3 changed files with 100 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue