test(FN-5089): add commit-msg hook coverage and wiring

Fusion-Task-Id: FN-5089
Fusion-Task-Lineage: ad7ea32a-c024-45bb-b68e-b65665b9b7c6
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 02:10:45 -07:00
committed by gsxdsm
parent 96eb610800
commit ac9c83c106
9 changed files with 180 additions and 18 deletions

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } 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("commit-msg trailer hook (real git)", () => {
it("appends and preserves Fusion-Task-Id trailer in fusion worktrees", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "fn-5089-commit-msg-"));
const worktreeDir = join(rootDir, "wt-kb");
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/kb-7 wt-kb HEAD");
await installTaskWorktreeIdentityGuard({
worktreePath: worktreeDir,
taskId: "KB-7",
taskPrefix: "KB",
taskAttributionTrailerName: "Fusion-Task-Id",
});
git(worktreeDir, "git commit --allow-empty -m 'feat(KB-7): first'");
const firstBody = git(worktreeDir, "git log -1 --format=%B");
expect(firstBody).toContain("Fusion-Task-Id: KB-7");
const firstTrailers = git(worktreeDir, "git log -1 --format=%B | git interpret-trailers --parse");
expect(firstTrailers).toContain("Fusion-Task-Id: KB-7");
git(worktreeDir, "git commit --amend --no-edit --allow-empty");
const amendNoEditBody = git(worktreeDir, "git log -1 --format=%B");
expect((amendNoEditBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1);
git(worktreeDir, "git commit --amend -m 'feat(KB-7): rewritten' --allow-empty");
const rewrittenBody = git(worktreeDir, "git log -1 --format=%B");
expect(rewrittenBody).toContain("feat(KB-7): rewritten");
expect((rewrittenBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1);
writeFileSync(join(rootDir, "outside.txt"), "outside\n");
git(rootDir, "git add outside.txt && git commit -m 'chore: root commit'");
const rootBody = git(rootDir, "git log -1 --format=%B");
expect(rootBody).not.toContain("Fusion-Task-Id:");
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
}, 30_000);
});

View File

@@ -1,10 +1,10 @@
import { mkdtempSync } from "node:fs";
import { access, readFile, stat } from "node:fs/promises";
import { access, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
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";
import { describe, expect, it, vi } from "vitest";
import { buildCommitMsgTrailerHook, buildIdentityGuardHook, installTaskWorktreeIdentityGuard } from "../worktree-hooks.js";
describe("worktree-hooks", () => {
it("builds a hook with expected guard lines", () => {
@@ -16,7 +16,27 @@ describe("worktree-hooks", () => {
expect(hook).toContain("fusion/step-[0-9]*-[a-z0-9-]*");
});
it("installs metadata and pre-commit hook in linked worktree", async () => {
it("builds commit-msg trailer hook with expected lines", () => {
const hook = buildCommitMsgTrailerHook("FN-42");
expect(hook).toContain("#!/bin/sh");
expect(hook).toContain("TASK_FILE=$(git rev-parse --git-path fusion-task-id)");
expect(hook).toContain("[ -f \"$TASK_FILE\" ] || exit 0");
expect(hook).toContain("[ -n \"$TASK_ID\" ] || exit 0");
expect(hook).toContain("git interpret-trailers");
expect(hook).toContain("--in-place");
expect(hook).toContain("--if-exists doNothing");
expect(hook).toContain("--trailer \"$TRAILER_NAME: $TASK_ID\"");
expect(hook).toContain("s/^FN-//i");
});
it("parameterizes commit-msg hook for custom prefix and trailer name", () => {
const hook = buildCommitMsgTrailerHook("KB-9", { taskPrefix: "KB", trailerName: "Task-Id" });
expect(hook).toContain('PREFIX="KB"');
expect(hook).toContain('TRAILER_NAME="Task-Id"');
expect(hook).toContain("s/^KB-//i");
});
it("installs metadata and pre-commit + commit-msg hooks 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 });
@@ -30,16 +50,23 @@ describe("worktree-hooks", () => {
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"], {
const preCommitRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks/pre-commit"], {
cwd: wt,
encoding: "utf-8",
}).trim();
const hookPath = isAbsolute(hookRaw) ? hookRaw : resolve(wt, hookRaw);
const preCommitPath = isAbsolute(preCommitRaw) ? preCommitRaw : resolve(wt, preCommitRaw);
const commitMsgRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks/commit-msg"], {
cwd: wt,
encoding: "utf-8",
}).trim();
const commitMsgPath = isAbsolute(commitMsgRaw) ? commitMsgRaw : resolve(wt, commitMsgRaw);
expect((await readFile(taskIdPath, "utf-8")).trim()).toBe("FN-1");
await access(hookPath);
const mode = (await stat(hookPath)).mode & 0o777;
expect(mode).toBe(0o755);
await access(preCommitPath);
await access(commitMsgPath);
expect((await stat(preCommitPath)).mode & 0o777).toBe(0o755);
expect((await stat(commitMsgPath)).mode & 0o777).toBe(0o755);
expect(await readFile(commitMsgPath, "utf-8")).toContain('git interpret-trailers');
});
it("is idempotent when run twice", async () => {
@@ -53,17 +80,69 @@ describe("worktree-hooks", () => {
execFileSync("git", ["worktree", "add", "-b", "fusion/fn-2", wt], { cwd: root });
await installTaskWorktreeIdentityGuard({ worktreePath: wt, taskId: "FN-2" });
const hookRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks/pre-commit"], {
const preCommitRaw = 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;
const preCommitPath = isAbsolute(preCommitRaw) ? preCommitRaw : resolve(wt, preCommitRaw);
const commitMsgRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks/commit-msg"], {
cwd: wt,
encoding: "utf-8",
}).trim();
const commitMsgPath = isAbsolute(commitMsgRaw) ? commitMsgRaw : resolve(wt, commitMsgRaw);
const firstPreCommit = (await stat(preCommitPath)).mtimeMs;
const firstCommitMsg = (await stat(commitMsgPath)).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);
const secondPreCommit = (await stat(preCommitPath)).mtimeMs;
const secondCommitMsg = (await stat(commitMsgPath)).mtimeMs;
expect(secondPreCommit).toBe(firstPreCommit);
expect(secondCommitMsg).toBe(firstCommitMsg);
});
it("skips commit-msg install when disabled", async () => {
const root = mkdtempSync(join(tmpdir(), "wt-hook-disabled-"));
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-3", wt], { cwd: root });
await installTaskWorktreeIdentityGuard({ worktreePath: wt, taskId: "FN-3", commitMsgHookEnabled: false });
const commitMsgRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks/commit-msg"], {
cwd: wt,
encoding: "utf-8",
}).trim();
const commitMsgPath = isAbsolute(commitMsgRaw) ? commitMsgRaw : resolve(wt, commitMsgRaw);
await expect(access(commitMsgPath)).rejects.toBeDefined();
});
it("refuses to overwrite existing commit-msg hook", async () => {
const root = mkdtempSync(join(tmpdir(), "wt-hook-existing-"));
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-4", wt], { cwd: root });
const commitMsgRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks/commit-msg"], {
cwd: wt,
encoding: "utf-8",
}).trim();
const commitMsgPath = isAbsolute(commitMsgRaw) ? commitMsgRaw : resolve(wt, commitMsgRaw);
await writeFile(commitMsgPath, "#!/bin/sh\necho custom\n", "utf-8");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await installTaskWorktreeIdentityGuard({ worktreePath: wt, taskId: "FN-4" });
expect(await readFile(commitMsgPath, "utf-8")).toContain("echo custom");
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it("throws when not in git worktree", async () => {

View File

@@ -8610,7 +8610,13 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
const installGuardOrCleanup = async () => {
try {
await installTaskWorktreeIdentityGuard({ worktreePath: path, taskId });
await installTaskWorktreeIdentityGuard({
worktreePath: path,
taskId,
commitMsgHookEnabled: settings.commitMsgHookEnabled,
taskPrefix: settings.taskPrefix,
taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0],
});
} catch (error) {
try {
await execAsync(`rm -rf "${path}"`, { cwd: this.rootDir });

View File

@@ -1357,6 +1357,9 @@ Follow instructions precisely and avoid unrelated changes.`,
await installTaskWorktreeIdentityGuard({
worktreePath,
taskId: this.options.taskDetail.id,
commitMsgHookEnabled: settings.commitMsgHookEnabled,
taskPrefix: settings.taskPrefix,
taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0],
});
} catch (err) {
try {

View File

@@ -172,7 +172,7 @@ export class NativeWorktreeBackend implements WorktreeBackend {
constructor(
private readonly deps: {
logger?: { log: (m: string) => void; warn: (m: string) => void };
settings?: Pick<Settings, "worktreesDir">;
settings?: Pick<Settings, "worktreesDir" | "commitMsgHookEnabled" | "taskPrefix" | "taskAttributionTrailerNames">;
audit?: Pick<RunAuditor, "git">;
} = {},
) {}
@@ -181,7 +181,13 @@ export class NativeWorktreeBackend implements WorktreeBackend {
const startArg = input.startPoint ? ` ${quoteShellArg(input.startPoint)}` : "";
const installGuardOrCleanup = async (worktreePath: string) => {
try {
await installTaskWorktreeIdentityGuard({ worktreePath, taskId: input.taskId });
await installTaskWorktreeIdentityGuard({
worktreePath,
taskId: input.taskId,
commitMsgHookEnabled: this.deps.settings?.commitMsgHookEnabled,
taskPrefix: this.deps.settings?.taskPrefix,
taskAttributionTrailerName: this.deps.settings?.taskAttributionTrailerNames?.[0],
});
} catch (error) {
await execAsync(`rm -rf ${quoteShellArg(worktreePath)}`, {
cwd: input.rootDir,
@@ -543,7 +549,13 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
}
try {
await installTaskWorktreeIdentityGuard({ worktreePath: resolvedPath, taskId: input.taskId });
await installTaskWorktreeIdentityGuard({
worktreePath: resolvedPath,
taskId: input.taskId,
commitMsgHookEnabled: this.deps.settings?.commitMsgHookEnabled,
taskPrefix: this.deps.settings?.taskPrefix,
taskAttributionTrailerName: this.deps.settings?.taskAttributionTrailerNames?.[0],
});
} catch (error) {
await execAsync(`rm -rf ${quoteShellArg(resolvedPath)}`, {
cwd: input.rootDir,