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:
committed by
gsxdsm
parent
96eb610800
commit
ac9c83c106
@@ -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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user