test(FN-4948): complete Step 3 — add real-git precommit guard coverage
Fusion-Task-Id: FN-4948 Fusion-Task-Lineage: dc622643-4c3e-4217-9219-a6a6e5424427
This commit is contained in:
committed by
gsxdsm
parent
e66f0892f0
commit
925877ba12
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { installTaskWorktreeIdentityGuard } from "../../worktree-hooks.js";
|
||||
|
||||
function git(dir: string, cmd: string): string {
|
||||
return execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim();
|
||||
}
|
||||
|
||||
describe("pre-commit identity guard (real git)", () => {
|
||||
it("blocks misbound task-branch commits while allowing owner and step branches", async () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "fn-4948-precommit-"));
|
||||
const worktreeDir = join(rootDir, "wt-fn-a");
|
||||
const rootFile = join(rootDir, "root.txt");
|
||||
|
||||
try {
|
||||
git(rootDir, "git init -b main");
|
||||
git(rootDir, 'git config user.email "test@example.com"');
|
||||
git(rootDir, 'git config user.name "Test"');
|
||||
writeFileSync(join(rootDir, "README.md"), "init\n");
|
||||
git(rootDir, "git add README.md && git commit -m 'init'");
|
||||
|
||||
git(rootDir, "git worktree add -b fusion/fn-a wt-fn-a HEAD");
|
||||
|
||||
await installTaskWorktreeIdentityGuard({ worktreePath: worktreeDir, taskId: "FN-A" });
|
||||
|
||||
const taskIdPath = git(worktreeDir, "git rev-parse --git-path fusion-task-id");
|
||||
const taskIdFile = readFileSync(isAbsolute(taskIdPath) ? taskIdPath : resolve(worktreeDir, taskIdPath), "utf-8");
|
||||
expect(taskIdFile.trim()).toBe("FN-A");
|
||||
|
||||
const hookRawPath = git(worktreeDir, "git rev-parse --git-path hooks/pre-commit");
|
||||
const hookPath = isAbsolute(hookRawPath) ? hookRawPath : resolve(worktreeDir, hookRawPath);
|
||||
chmodSync(hookPath, 0o755);
|
||||
|
||||
git(worktreeDir, "git checkout -b fusion/fn-b");
|
||||
writeFileSync(join(worktreeDir, "misbound.txt"), "wrong branch\n");
|
||||
git(worktreeDir, "git add misbound.txt");
|
||||
|
||||
const blockedCommit = spawnSync("git", ["commit", "-m", "feat(FN-B): blocked"], { cwd: worktreeDir, encoding: "utf-8" });
|
||||
expect(blockedCommit.status).not.toBe(0);
|
||||
expect(`${blockedCommit.stderr}${blockedCommit.stdout}`).toContain(
|
||||
"fusion: refusing commit — worktree owns FN-A but HEAD is fusion/fn-b",
|
||||
);
|
||||
|
||||
git(worktreeDir, "git checkout fusion/fn-a");
|
||||
writeFileSync(join(worktreeDir, "owned.txt"), "owned branch\n");
|
||||
git(worktreeDir, "git add owned.txt");
|
||||
git(worktreeDir, "git commit -m 'feat(FN-A): allowed owner commit'");
|
||||
|
||||
git(worktreeDir, "git checkout -b fusion/step-1-lemon-lotus");
|
||||
writeFileSync(join(worktreeDir, "step.txt"), "step branch\n");
|
||||
git(worktreeDir, "git add step.txt");
|
||||
git(worktreeDir, "git commit -m 'test(FN-A): step branch commit'");
|
||||
|
||||
writeFileSync(rootFile, "root commit\n");
|
||||
git(rootDir, "git add root.txt");
|
||||
git(rootDir, "git commit -m 'chore: root commit succeeds without task hook'");
|
||||
|
||||
const currentStepSha = git(worktreeDir, "git rev-parse HEAD");
|
||||
git(worktreeDir, `${"git checkout --detach "}${currentStepSha}`);
|
||||
writeFileSync(join(worktreeDir, "detached.txt"), "detached\n");
|
||||
git(worktreeDir, "git add detached.txt");
|
||||
|
||||
const detachedCommit = spawnSync("git", ["commit", "-m", "test(FN-A): detached blocked"], {
|
||||
cwd: worktreeDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(detachedCommit.status).not.toBe(0);
|
||||
expect(`${detachedCommit.stderr}${detachedCommit.stdout}`).toContain(
|
||||
"fusion: refusing commit — worktree owns FN-A but HEAD is detached",
|
||||
);
|
||||
} finally {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { isAbsolute, join, resolve } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildIdentityGuardHook, installTaskWorktreeIdentityGuard } from "../worktree-hooks.js";
|
||||
@@ -10,7 +10,7 @@ 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("TASK_FILE=$(git rev-parse --git-path 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-]*");
|
||||
@@ -28,10 +28,13 @@ describe("worktree-hooks", () => {
|
||||
|
||||
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");
|
||||
const taskIdRaw = execFileSync("git", ["rev-parse", "--git-path", "fusion-task-id"], { cwd: wt, encoding: "utf-8" }).trim();
|
||||
const taskIdPath = isAbsolute(taskIdRaw) ? taskIdRaw : resolve(wt, taskIdRaw);
|
||||
const hookRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks/pre-commit"], {
|
||||
cwd: wt,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
const hookPath = isAbsolute(hookRaw) ? hookRaw : resolve(wt, hookRaw);
|
||||
|
||||
expect((await readFile(taskIdPath, "utf-8")).trim()).toBe("FN-1");
|
||||
await access(hookPath);
|
||||
@@ -50,8 +53,11 @@ describe("worktree-hooks", () => {
|
||||
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 hookRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks/pre-commit"], {
|
||||
cwd: wt,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
const hookPath = isAbsolute(hookRaw) ? hookRaw : resolve(wt, hookRaw);
|
||||
const first = (await stat(hookPath)).mtimeMs;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
@@ -63,7 +69,7 @@ describe("worktree-hooks", () => {
|
||||
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",
|
||||
"Failed to resolve git path",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,7 @@ export function buildIdentityGuardHook(taskId: string, allowedBranchPatterns: re
|
||||
return `#!/bin/sh
|
||||
set -eu
|
||||
|
||||
GIT_DIR=$(git rev-parse --git-dir)
|
||||
TASK_FILE="$GIT_DIR/fusion-task-id"
|
||||
TASK_FILE=$(git rev-parse --git-path fusion-task-id)
|
||||
|
||||
if [ ! -f "$TASK_FILE" ]; then
|
||||
exit 0
|
||||
@@ -52,12 +51,12 @@ exit 1
|
||||
`;
|
||||
}
|
||||
|
||||
async function resolveGitDir(worktreePath: string): Promise<string> {
|
||||
async function resolveGitPath(worktreePath: string, gitPath: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --git-dir", { cwd: worktreePath, encoding: "utf-8" });
|
||||
const { stdout } = await execAsync(`git rev-parse --git-path ${gitPath}`, { 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}`);
|
||||
throw new Error(`Failed to resolve git path '${gitPath}' for worktree ${worktreePath}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +75,9 @@ export async function installTaskWorktreeIdentityGuard(input: {
|
||||
taskId: string;
|
||||
allowedBranchPatterns?: readonly string[];
|
||||
}): Promise<void> {
|
||||
const gitDir = await resolveGitDir(input.worktreePath);
|
||||
const hook = buildIdentityGuardHook(input.taskId, input.allowedBranchPatterns ?? DEFAULT_ALLOWED_BRANCH_PATTERNS);
|
||||
const metadataPath = resolve(gitDir, "fusion-task-id");
|
||||
const hookPath = resolve(gitDir, "hooks", "pre-commit");
|
||||
const metadataPath = await resolveGitPath(input.worktreePath, "fusion-task-id");
|
||||
const hookPath = await resolveGitPath(input.worktreePath, "hooks/pre-commit");
|
||||
|
||||
await writeFileAtomic(metadataPath, `${input.taskId}\n`);
|
||||
await writeFileAtomic(hookPath, hook, 0o755);
|
||||
|
||||
Reference in New Issue
Block a user