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:
committed by
gsxdsm
parent
ddc37a8857
commit
f42a149fe3
69
packages/engine/src/__tests__/worktree-hooks.test.ts
Normal file
69
packages/engine/src/__tests__/worktree-hooks.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
104
packages/engine/src/worktree-hooks.ts
Normal file
104
packages/engine/src/worktree-hooks.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user