feat(FN-5271): add precommit identity guard for worktree hooks
Adds pre-commit identity guard handling for branch case normalization (FN-5271), updating the worktree hooks implementation and adding a real-git integration test alongside a changeset for `@runfusion/fusion`. Fusion-Task-Id: FN-5271
This commit is contained in:
committed by
gsxdsm
parent
e33f84fb0a
commit
9efcf9375a
5
.changeset/fn-5271-precommit-branch-case.md
Normal file
5
.changeset/fn-5271-precommit-branch-case.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix Fusion worktree pre-commit identity-guard hook to accept canonical lowercase `fusion/<id>` branches when the on-disk `fusion-task-id` metadata stores an uppercase id, eliminating spurious "refusing commit" rejections that previously required `--no-verify`.
|
||||
@@ -66,6 +66,51 @@ describe("pre-commit identity guard (real git)", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("FN-5271 accepts canonical lowercase task branches when fusion-task-id casing drifts, while still blocking other branches", async () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "fn-5271-precommit-"));
|
||||
const worktreeDir = join(rootDir, "wt-fn-5271");
|
||||
|
||||
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-5271 wt-fn-5271 HEAD");
|
||||
await installTaskWorktreeIdentityGuard({ worktreePath: worktreeDir, taskId: "FN-5271" });
|
||||
|
||||
const taskIdPathRaw = git(worktreeDir, "git rev-parse --git-path fusion-task-id");
|
||||
const taskIdPath = isAbsolute(taskIdPathRaw) ? taskIdPathRaw : resolve(worktreeDir, taskIdPathRaw);
|
||||
await writeFile(taskIdPath, "FN-5210\n", "utf-8");
|
||||
|
||||
git(worktreeDir, "git checkout -B fusion/fn-5210");
|
||||
writeFileSync(join(worktreeDir, "owner.txt"), "owner branch\n");
|
||||
git(worktreeDir, "git add owner.txt");
|
||||
|
||||
const allowedCommit = spawnSync("git", ["commit", "-m", "fix(FN-5210): allow canonical lowercase branch"], {
|
||||
cwd: worktreeDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(allowedCommit.status).toBe(0);
|
||||
|
||||
git(worktreeDir, "git checkout -B fusion/fn-other");
|
||||
writeFileSync(join(worktreeDir, "other.txt"), "other branch\n");
|
||||
git(worktreeDir, "git add other.txt");
|
||||
|
||||
const blockedCommit = spawnSync("git", ["commit", "-m", "fix(FN-5210): blocked other branch"], {
|
||||
cwd: worktreeDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(blockedCommit.status).not.toBe(0);
|
||||
expect(`${blockedCommit.stderr}${blockedCommit.stdout}`).toContain(
|
||||
"fusion: refusing commit — worktree owns FN-5210 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 () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "fn-4948-precommit-"));
|
||||
const worktreeDir = join(rootDir, "wt-fn-a");
|
||||
|
||||
@@ -8,26 +8,28 @@ import { buildCommitMsgTrailerHook, buildIdentityGuardHook, installTaskWorktreeI
|
||||
|
||||
describe("worktree-hooks", () => {
|
||||
it("builds a hook with expected guard lines", () => {
|
||||
const hook = buildIdentityGuardHook("FN-1");
|
||||
const hook = buildIdentityGuardHook("FN-5210");
|
||||
expect(hook).toContain("#!/bin/sh");
|
||||
expect(hook).toContain("TASK_FILE=$(git rev-parse --git-path fusion-task-id)");
|
||||
expect(hook).toContain('EXPECTED_BRANCH="fusion/fn-5210"');
|
||||
expect(hook).toContain("tr '[:upper:]' '[:lower:]'");
|
||||
expect(hook).toContain(`EXPECTED_BRANCH="fusion/$(printf '%s' "$WORKTREE_TASK_ID" | tr '[:upper:]' '[:lower:]')"`);
|
||||
expect(hook).toContain('EXPECTED_BRANCH="fusion/$(printf \'%s\' \"$WORKTREE_TASK_ID\" | tr \'[:upper:]\' \'[:lower:]\')"');
|
||||
expect(hook).toContain('HEAD_BRANCH_CANONICAL=$(printf \'%s\' "$HEAD_BRANCH" | tr \'[:upper:]\' \'[:lower:]\')');
|
||||
expect(hook).toContain('EXPECTED_BRANCH_CANONICAL=$(printf \'%s\' "$EXPECTED_BRANCH" | tr \'[:upper:]\' \'[:lower:]\')');
|
||||
expect(hook).toContain("fusion: refusing commit — worktree owns");
|
||||
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" !=/);
|
||||
expect(hook).toContain('!= "fn-5210"');
|
||||
expect(hook).toContain("# Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId)");
|
||||
expect(hook).not.toContain("FN-5210");
|
||||
});
|
||||
|
||||
it("does not vary by install-time task id", () => {
|
||||
const firstHook = buildIdentityGuardHook("FN-1");
|
||||
const secondHook = buildIdentityGuardHook("FN-9999");
|
||||
it.each([
|
||||
["FN-1", "fusion/fn-1"],
|
||||
["FN-9999", "fusion/fn-9999"],
|
||||
])("uses the install-time task id as the canonical default branch for %s", (taskId, expectedBranch) => {
|
||||
const hook = buildIdentityGuardHook(taskId);
|
||||
|
||||
expect(firstHook).toBe(secondHook);
|
||||
expect(firstHook).not.toContain("FN-1");
|
||||
expect(firstHook).not.toContain("FN-9999");
|
||||
expect(hook).toContain(`EXPECTED_BRANCH=\"${expectedBranch}\"`);
|
||||
});
|
||||
|
||||
it("builds commit-msg trailer hook with expected lines", () => {
|
||||
|
||||
@@ -19,12 +19,12 @@ function toShellCasePattern(pattern: string): 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).
|
||||
* The emitted script must stay metadata-aware because linked git worktrees share
|
||||
* the common hooks directory. It bakes in the install-time taskId as the default
|
||||
* expected branch, then falls back to `fusion-task-id` when runtime metadata
|
||||
* drifts so the shared hook still follows the current owning task.
|
||||
*/
|
||||
export function buildIdentityGuardHook(_taskId: string, allowedBranchPatterns: readonly string[] = DEFAULT_ALLOWED_BRANCH_PATTERNS): string {
|
||||
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
|
||||
@@ -38,13 +38,20 @@ fi
|
||||
|
||||
WORKTREE_TASK_ID=$(cat "$TASK_FILE")
|
||||
# Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId)
|
||||
EXPECTED_BRANCH="fusion/$(printf '%s' "$WORKTREE_TASK_ID" | tr '[:upper:]' '[:lower:]')"
|
||||
EXPECTED_BRANCH=${JSON.stringify(`fusion/${taskId.toLowerCase()}`)}
|
||||
|
||||
if [ "$(printf '%s' "$WORKTREE_TASK_ID" | tr '[:upper:]' '[:lower:]')" != ${JSON.stringify(taskId.toLowerCase())} ]; then
|
||||
EXPECTED_BRANCH="fusion/$(printf '%s' "$WORKTREE_TASK_ID" | tr '[:upper:]' '[:lower:]')"
|
||||
fi
|
||||
|
||||
if ! HEAD_BRANCH=$(git symbolic-ref --quiet --short HEAD 2>/dev/null); then
|
||||
HEAD_BRANCH="detached"
|
||||
fi
|
||||
|
||||
if [ "$HEAD_BRANCH" = "$EXPECTED_BRANCH" ]; then
|
||||
HEAD_BRANCH_CANONICAL=$(printf '%s' "$HEAD_BRANCH" | tr '[:upper:]' '[:lower:]')
|
||||
EXPECTED_BRANCH_CANONICAL=$(printf '%s' "$EXPECTED_BRANCH" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if [ "$HEAD_BRANCH_CANONICAL" = "$EXPECTED_BRANCH_CANONICAL" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user