feat(FN-5266): add metadata-driven pre-commit identity guard
Implements metadata-driven pre-commit identity guard hooks in the worktree layer, replacing static hook bodies with configurable metadata; adds regression tests covering shared-hook worktrees and the metadata-driven lock behavior. Fusion-Task-Id: FN-5266
This commit is contained in:
committed by
gsxdsm
parent
35112e6070
commit
17eb85ed81
5
.changeset/FN-5266-precommit-stale-taskid.md
Normal file
5
.changeset/FN-5266-precommit-stale-taskid.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix Fusion pre-commit identity-guard hook leaking install-time task ID across shared git hooks dir; hook is now driven entirely by per-worktree fusion-task-id metadata (lowercased to match canonicalFusionBranchName), so a stale install no longer refuses valid sibling-worktree commits.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { isAbsolute, join, resolve } from "node:path";
|
import { isAbsolute, join, resolve } from "node:path";
|
||||||
import { execSync, spawnSync } from "node:child_process";
|
import { execSync, spawnSync } from "node:child_process";
|
||||||
@@ -10,6 +11,61 @@ function git(dir: string, cmd: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("pre-commit identity guard (real git)", () => {
|
describe("pre-commit identity guard (real git)", () => {
|
||||||
|
it("uses per-worktree metadata so a shared stale hook still allows sibling owner commits", async () => {
|
||||||
|
const rootDir = mkdtempSync(join(tmpdir(), "fn-5266-precommit-"));
|
||||||
|
const staleDir = join(rootDir, "wt-stale");
|
||||||
|
const activeDir = join(rootDir, "wt-active");
|
||||||
|
|
||||||
|
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-stale wt-stale HEAD");
|
||||||
|
await installTaskWorktreeIdentityGuard({ worktreePath: staleDir, taskId: "FN-STALE" });
|
||||||
|
|
||||||
|
git(rootDir, "git worktree add -b fusion/fn-active wt-active HEAD");
|
||||||
|
await installTaskWorktreeIdentityGuard({ worktreePath: activeDir, taskId: "FN-ACTIVE" });
|
||||||
|
|
||||||
|
const staleHookRawPath = git(staleDir, "git rev-parse --git-path hooks/pre-commit");
|
||||||
|
const staleHookPath = isAbsolute(staleHookRawPath) ? staleHookRawPath : resolve(staleDir, staleHookRawPath);
|
||||||
|
const activeHookRawPath = git(activeDir, "git rev-parse --git-path hooks/pre-commit");
|
||||||
|
const activeHookPath = isAbsolute(activeHookRawPath) ? activeHookRawPath : resolve(activeDir, activeHookRawPath);
|
||||||
|
expect(activeHookPath).toBe(staleHookPath);
|
||||||
|
|
||||||
|
const activeTaskIdPathRaw = git(activeDir, "git rev-parse --git-path fusion-task-id");
|
||||||
|
const activeTaskIdPath = isAbsolute(activeTaskIdPathRaw) ? activeTaskIdPathRaw : resolve(activeDir, activeTaskIdPathRaw);
|
||||||
|
// Mirror the common on-disk state where fusion-task-id preserves the original uppercase task id.
|
||||||
|
await writeFile(activeTaskIdPath, "FN-ACTIVE\n", "utf-8");
|
||||||
|
expect(readFileSync(activeTaskIdPath, "utf-8")).toBe("FN-ACTIVE\n");
|
||||||
|
|
||||||
|
writeFileSync(join(activeDir, "active.txt"), "active branch\n");
|
||||||
|
git(activeDir, "git add active.txt");
|
||||||
|
git(activeDir, "git commit -m 'feat(FN-ACTIVE): owner commit'");
|
||||||
|
|
||||||
|
writeFileSync(join(staleDir, "stale.txt"), "stale branch\n");
|
||||||
|
git(staleDir, "git add stale.txt");
|
||||||
|
git(staleDir, "git commit -m 'feat(FN-STALE): owner commit'");
|
||||||
|
|
||||||
|
git(activeDir, "git checkout -b fusion/fn-other");
|
||||||
|
writeFileSync(join(activeDir, "other.txt"), "other branch\n");
|
||||||
|
git(activeDir, "git add other.txt");
|
||||||
|
|
||||||
|
const blockedCommit = spawnSync("git", ["commit", "-m", "feat(FN-ACTIVE): blocked"], {
|
||||||
|
cwd: activeDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
expect(blockedCommit.status).not.toBe(0);
|
||||||
|
expect(`${blockedCommit.stderr}${blockedCommit.stdout}`).toContain(
|
||||||
|
"fusion: refusing commit — worktree owns FN-ACTIVE but HEAD is fusion/fn-other",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(rootDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
it("blocks misbound task-branch commits while allowing owner and step branches", async () => {
|
it("blocks misbound task-branch commits while allowing owner and step branches", async () => {
|
||||||
const rootDir = mkdtempSync(join(tmpdir(), "fn-4948-precommit-"));
|
const rootDir = mkdtempSync(join(tmpdir(), "fn-4948-precommit-"));
|
||||||
const worktreeDir = join(rootDir, "wt-fn-a");
|
const worktreeDir = join(rootDir, "wt-fn-a");
|
||||||
|
|||||||
@@ -11,9 +11,23 @@ describe("worktree-hooks", () => {
|
|||||||
const hook = buildIdentityGuardHook("FN-1");
|
const hook = buildIdentityGuardHook("FN-1");
|
||||||
expect(hook).toContain("#!/bin/sh");
|
expect(hook).toContain("#!/bin/sh");
|
||||||
expect(hook).toContain("TASK_FILE=$(git rev-parse --git-path 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("tr '[:upper:]' '[:lower:]'");
|
||||||
|
expect(hook).toContain(`EXPECTED_BRANCH="fusion/$(printf '%s' "$WORKTREE_TASK_ID" | tr '[:upper:]' '[:lower:]')"`);
|
||||||
expect(hook).toContain("fusion: refusing commit — worktree owns");
|
expect(hook).toContain("fusion: refusing commit — worktree owns");
|
||||||
expect(hook).toContain("fusion/step-[0-9]*-[a-z0-9-]*");
|
expect(hook).toContain("fusion/step-[0-9]*-[a-z0-9-]*");
|
||||||
|
expect(hook).not.toContain("fusion/fn-1");
|
||||||
|
expect(hook).not.toContain("FN-1");
|
||||||
|
expect(hook).not.toContain("fn-1");
|
||||||
|
expect(hook).not.toMatch(/if \[ "\$WORKTREE_TASK_ID" !=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not vary by install-time task id", () => {
|
||||||
|
const firstHook = buildIdentityGuardHook("FN-1");
|
||||||
|
const secondHook = buildIdentityGuardHook("FN-9999");
|
||||||
|
|
||||||
|
expect(firstHook).toBe(secondHook);
|
||||||
|
expect(firstHook).not.toContain("FN-1");
|
||||||
|
expect(firstHook).not.toContain("FN-9999");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds commit-msg trailer hook with expected lines", () => {
|
it("builds commit-msg trailer hook with expected lines", () => {
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ function toShellCasePattern(pattern: string): string {
|
|||||||
.replace(/\[a-z0-9-\]\+/g, "[a-z0-9-]*");
|
.replace(/\[a-z0-9-\]\+/g, "[a-z0-9-]*");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildIdentityGuardHook(taskId: string, allowedBranchPatterns: readonly string[] = DEFAULT_ALLOWED_BRANCH_PATTERNS): string {
|
/**
|
||||||
|
* Build the shared pre-commit identity-guard hook.
|
||||||
|
*
|
||||||
|
* The emitted script must stay metadata-driven because linked git worktrees share
|
||||||
|
* the common hooks directory. The install-time taskId is intentionally unused in
|
||||||
|
* the hook body; each commit resolves its owning task from `fusion-task-id` and
|
||||||
|
* lowercases it to stay aligned with canonicalFusionBranchName(taskId).
|
||||||
|
*/
|
||||||
|
export function buildIdentityGuardHook(_taskId: string, allowedBranchPatterns: readonly string[] = DEFAULT_ALLOWED_BRANCH_PATTERNS): string {
|
||||||
const allowChecks = allowedBranchPatterns.map((pattern) => ` ${toShellCasePattern(pattern)}) exit 0 ;;`).join("\n");
|
const allowChecks = allowedBranchPatterns.map((pattern) => ` ${toShellCasePattern(pattern)}) exit 0 ;;`).join("\n");
|
||||||
|
|
||||||
return `#!/bin/sh
|
return `#!/bin/sh
|
||||||
@@ -30,16 +38,12 @@ fi
|
|||||||
|
|
||||||
WORKTREE_TASK_ID=$(cat "$TASK_FILE")
|
WORKTREE_TASK_ID=$(cat "$TASK_FILE")
|
||||||
# Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId)
|
# Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId)
|
||||||
EXPECTED_BRANCH="fusion/${taskId.toLowerCase()}"
|
EXPECTED_BRANCH="fusion/$(printf '%s' "$WORKTREE_TASK_ID" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
if ! HEAD_BRANCH=$(git symbolic-ref --quiet --short HEAD 2>/dev/null); then
|
if ! HEAD_BRANCH=$(git symbolic-ref --quiet --short HEAD 2>/dev/null); then
|
||||||
HEAD_BRANCH="detached"
|
HEAD_BRANCH="detached"
|
||||||
fi
|
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
|
if [ "$HEAD_BRANCH" = "$EXPECTED_BRANCH" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user