feat(FN-4948): complete Step 1 — add task worktree identity guard installer

Fusion-Task-Id: FN-4948
Fusion-Task-Lineage: dc622643-4c3e-4217-9219-a6a6e5424427
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 13:14:28 -07:00
committed by gsxdsm
parent ddc37a8857
commit f42a149fe3
2 changed files with 173 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import { mkdtempSync } from "node:fs";
import { access, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import { buildIdentityGuardHook, installTaskWorktreeIdentityGuard } from "../worktree-hooks.js";
describe("worktree-hooks", () => {
it("builds a hook with expected guard lines", () => {
const hook = buildIdentityGuardHook("FN-1");
expect(hook).toContain("#!/bin/sh");
expect(hook).toContain('TASK_FILE="$GIT_DIR/fusion-task-id"');
expect(hook).toContain('EXPECTED_BRANCH="fusion/fn-1"');
expect(hook).toContain("fusion: refusing commit — worktree owns");
expect(hook).toContain("fusion/step-[0-9]*-[a-z0-9-]*");
});
it("installs metadata and pre-commit hook in linked worktree", async () => {
const root = mkdtempSync(join(tmpdir(), "wt-hook-root-"));
execFileSync("git", ["init"], { cwd: root });
execFileSync("git", ["config", "user.name", "Test"], { cwd: root });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root });
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: root });
const wt = join(root, "wt");
execFileSync("git", ["worktree", "add", "-b", "fusion/fn-1", wt], { cwd: root });
await installTaskWorktreeIdentityGuard({ worktreePath: wt, taskId: "FN-1" });
const gitDir = execFileSync("git", ["rev-parse", "--git-dir"], { cwd: wt, encoding: "utf-8" }).trim();
const absGitDir = resolve(wt, gitDir);
const taskIdPath = join(absGitDir, "fusion-task-id");
const hookPath = join(absGitDir, "hooks", "pre-commit");
expect((await readFile(taskIdPath, "utf-8")).trim()).toBe("FN-1");
await access(hookPath);
const mode = (await stat(hookPath)).mode & 0o777;
expect(mode).toBe(0o755);
});
it("is idempotent when run twice", async () => {
const root = mkdtempSync(join(tmpdir(), "wt-hook-idem-"));
execFileSync("git", ["init"], { cwd: root });
execFileSync("git", ["config", "user.name", "Test"], { cwd: root });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root });
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: root });
const wt = join(root, "wt");
execFileSync("git", ["worktree", "add", "-b", "fusion/fn-2", wt], { cwd: root });
await installTaskWorktreeIdentityGuard({ worktreePath: wt, taskId: "FN-2" });
const gitDir = resolve(wt, execFileSync("git", ["rev-parse", "--git-dir"], { cwd: wt, encoding: "utf-8" }).trim());
const hookPath = join(gitDir, "hooks", "pre-commit");
const first = (await stat(hookPath)).mtimeMs;
await new Promise((r) => setTimeout(r, 20));
await installTaskWorktreeIdentityGuard({ worktreePath: wt, taskId: "FN-2" });
const second = (await stat(hookPath)).mtimeMs;
expect(second).toBe(first);
});
it("throws when not in git worktree", async () => {
const dir = mkdtempSync(join(tmpdir(), "wt-hook-bad-"));
await expect(installTaskWorktreeIdentityGuard({ worktreePath: dir, taskId: "FN-3" })).rejects.toThrow(
"Failed to resolve git dir",
);
});
});

View File

@@ -0,0 +1,104 @@
import { execFile } from "node:child_process";
import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export const DEFAULT_ALLOWED_BRANCH_PATTERNS = ["^fusion/step-\\d+-[a-z0-9-]+$"] as const;
function toShellCasePattern(pattern: string): string {
return pattern
.replace(/^\^/, "")
.replace(/\$$/, "")
.replace(/\\d\+/g, "[0-9]*")
.replace(/\[a-z0-9-\]\+/g, "[a-z0-9-]*");
}
export function buildIdentityGuardHook(taskId: string, allowedBranchPatterns: readonly string[] = DEFAULT_ALLOWED_BRANCH_PATTERNS): string {
const allowChecks = allowedBranchPatterns
.map((pattern) => ` ${toShellCasePattern(pattern)}) exit 0 ;;`)
.join("\n");
return `#!/bin/sh
set -eu
GIT_DIR=$(git rev-parse --git-dir)
TASK_FILE="$GIT_DIR/fusion-task-id"
if [ ! -f "$TASK_FILE" ]; then
exit 0
fi
WORKTREE_TASK_ID=$(cat "$TASK_FILE")
EXPECTED_BRANCH="fusion/${taskId.toLowerCase()}"
if ! HEAD_BRANCH=$(git symbolic-ref --quiet --short HEAD 2>/dev/null); then
HEAD_BRANCH="detached"
fi
if [ "$WORKTREE_TASK_ID" != "${taskId}" ] && [ "$WORKTREE_TASK_ID" != "${taskId.toLowerCase()}" ]; then
EXPECTED_BRANCH="fusion/$WORKTREE_TASK_ID"
fi
if [ "$HEAD_BRANCH" = "$EXPECTED_BRANCH" ]; then
exit 0
fi
case "$HEAD_BRANCH" in
${allowChecks}
esac
printf '%s\n' "fusion: refusing commit — worktree owns $WORKTREE_TASK_ID but HEAD is $HEAD_BRANCH" >&2
exit 1
`;
}
async function resolveGitDir(worktreePath: string): Promise<string> {
try {
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
cwd: worktreePath,
encoding: "utf-8",
});
return resolve(worktreePath, stdout.trim());
} catch (error) {
throw new Error(`Failed to resolve git dir for worktree ${worktreePath}: ${(error as Error).message}`);
}
}
async function writeFileAtomic(targetPath: string, content: string, mode?: number): Promise<void> {
await mkdir(dirname(targetPath), { recursive: true });
const tmpPath = `${targetPath}.tmp`;
const current = await readFile(targetPath, "utf-8").catch(() => null);
if (current === content) {
if (mode != null) {
await chmod(targetPath, mode);
}
return;
}
await writeFile(tmpPath, content, { encoding: "utf-8", mode });
if (mode != null) {
await chmod(tmpPath, mode);
}
await rename(tmpPath, targetPath);
}
export async function installTaskWorktreeIdentityGuard(input: {
worktreePath: string;
taskId: string;
allowedBranchPatterns?: readonly string[];
}): Promise<void> {
const gitDir = await resolveGitDir(input.worktreePath);
const guard = buildIdentityGuardHook(input.taskId, input.allowedBranchPatterns ?? DEFAULT_ALLOWED_BRANCH_PATTERNS);
const metadataPath = resolve(gitDir, "fusion-task-id");
const hookPath = resolve(gitDir, "hooks", "pre-commit");
await writeFileAtomic(metadataPath, `${input.taskId}\n`);
await writeFileAtomic(hookPath, guard, 0o755);
const hookStat = await stat(hookPath);
if ((hookStat.mode & 0o111) === 0) {
await chmod(hookPath, 0o755);
}
}