Files
fusion/packages/engine/src/worktree-hooks.ts
2026-05-22 22:43:26 -07:00

314 lines
11 KiB
TypeScript

import { exec } from "node:child_process";
import * as fs from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { promisify } from "node:util";
const execAsync = promisify(exec);
export const DEFAULT_ALLOWED_BRANCH_PATTERNS = ["^fusion/step-\\d+-[a-z0-9-]+$"] as const;
/**
* Env-var marker the merger sets around its own `git commit` calls to bypass
* the identity-guard hook on detached HEAD. Kept in sync with the literal in
* `buildIdentityGuardHook` below; the hook gates strictly to the value "1".
*/
export const IDENTITY_GUARD_BYPASS_ENV = "FUSION_MERGER_BYPASS_IDENTITY_GUARD";
const COMMIT_MSG_HOOK_MARKER = "# fusion-managed-commit-msg-hook";
const PREPARE_COMMIT_MSG_HOOK_MARKER = "# fusion-managed-prepare-commit-msg-hook";
function toShellCasePattern(pattern: string): string {
return pattern
.replace(/^\^/, "")
.replace(/\$$/, "")
.replace(/\\d\+/g, "[0-9]*")
.replace(/\[a-z0-9-\]\+/g, "[a-z0-9-]*");
}
/**
* Build the shared pre-commit identity-guard hook.
*
* 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 {
const allowChecks = allowedBranchPatterns.map((pattern) => ` ${toShellCasePattern(pattern)}) exit 0 ;;`).join("\n");
return `#!/bin/sh
set -eu
TASK_FILE=$(git rev-parse --git-path fusion-task-id)
if [ ! -f "$TASK_FILE" ]; then
exit 0
fi
# Merger bypass: the merger commits on a detached HEAD during
# reuse-task-worktree squash and verification-fix ceremonies. Gated to the
# exact value "1" so a leaked/empty var cannot accidentally bypass agent
# commits. Placed after the TASK_FILE check (non-fusion worktrees stay
# no-op) and before EXPECTED_BRANCH (detached HEAD never reaches refusal).
if [ "\${${IDENTITY_GUARD_BYPASS_ENV}:-}" = "1" ]; then
if HEAD_BRANCH_DIAG=$(git symbolic-ref --quiet --short HEAD 2>/dev/null); then
:
else
HEAD_BRANCH_DIAG="detached"
fi
printf '%s\n' "fusion: identity-guard bypass honored for merger commit on $HEAD_BRANCH_DIAG" >&2
exit 0
fi
# Note: empty-commit refusal (FN-5345/FN-5377) lives in the
# prepare-commit-msg hook (installed by installTaskWorktreeIdentityGuard).
# That hook gets the commit-source argument and can distinguish 'amend' /
# 'merge' / 'squash' (which legitimately may produce empty commits) from
# new commits with --allow-empty.
WORKTREE_TASK_ID=$(cat "$TASK_FILE")
# Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId)
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
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
case "$HEAD_BRANCH" in
${allowChecks}
esac
printf '%s\n' "fusion: refusing commit — worktree owns $WORKTREE_TASK_ID but HEAD is $HEAD_BRANCH" >&2
exit 1
`;
}
async function resolveGitPath(worktreePath: string, gitPath: string): Promise<string> {
try {
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 path '${gitPath}' for worktree ${worktreePath}: ${(error as Error).message}`);
}
}
/**
* Build the shared prepare-commit-msg empty-commit guard hook.
*
* Refuses to author empty commits in fusion-managed task worktrees. A
* verification-only task that finds no repro should leave own-commits at 0
* and let the merger's empty-own-diff / proven-no-op classifier finalize it.
* Manufacturing an empty handoff commit (e.g. `git commit --allow-empty`)
* defeats that classifier and wedges the task in the merge path (FN-5345).
*
* prepare-commit-msg gets the commit-source argument so we can correctly
* allow legitimate empty commits during amend / merge / squash / template
* paths and only refuse new --allow-empty commits.
*/
export function buildPrepareCommitMsgEmptyGuardHook(taskId: string): string {
return `#!/bin/sh
set -eu
${PREPARE_COMMIT_MSG_HOOK_MARKER}
# fusion-task-id-seed: ${taskId}
# Only enforce in fusion-managed task worktrees.
TASK_FILE=$(git rev-parse --git-path fusion-task-id)
[ -f "$TASK_FILE" ] || exit 0
COMMIT_SOURCE="\${2:-}"
# Allow legitimate paths that may produce empty commits:
# - commit (amend via --amend with no -m)
# - merge (merge commit)
# - squash (squash merge)
# - cherry-pick / revert / rebase ceremonies (detected via git-dir markers)
case "$COMMIT_SOURCE" in
commit|merge|squash) exit 0 ;;
esac
# 'git commit --amend -m "..."' reports source=message (not commit), so the
# source arg alone cannot distinguish amend-with-new-message from
# --allow-empty -m. Inspect the parent process command line as a tiebreaker.
#
# Sourcing:
# - 'ps -o args= -p $PPID' is POSIX (macOS, BSD, glibc Linux).
# - Alpine/busybox 'ps' may not support '-o args='; fall back to
# /proc/$PPID/cmdline (Linux including busybox).
#
# Matching: tokenize PARENT_CMD by whitespace and require an EXACT '--amend'
# token APPEARING BEFORE the first message-supplying flag ('-m', '-F',
# '--message', '--file', '--message=...', '--file=...'). 'ps -o args=' joins
# argv with spaces, so a commit message containing the substring '--amend'
# (e.g. -m 'fix --amend handling') re-tokenizes into a standalone '--amend'
# token — we must not be fooled by message content. Since '--amend' is a
# positional flag that always appears before the message args, stopping at
# the first message flag is reliable on both macOS ps and Linux
# /proc/$PPID/cmdline (which preserves argv boundaries with NUL separators).
PARENT_CMD=$(ps -o args= -p "$PPID" 2>/dev/null || echo "")
if [ -z "$PARENT_CMD" ] && [ -r "/proc/$PPID/cmdline" ]; then
PARENT_CMD=$(tr '\0' ' ' < "/proc/$PPID/cmdline" 2>/dev/null || echo "")
fi
for tok in $PARENT_CMD; do
case "$tok" in
-m|-F|--message|--file|--message=*|--file=*)
# Long-form message args; everything after this is user-controlled.
break
;;
-[!-]*[mF]*)
# Combined short flag containing 'm' or 'F' (e.g. -am, -vm, -sm, -aF).
# First char is '-', second is NOT '-' (so '--amend' does not match),
# and the cluster contains a message-supplying letter. Everything after
# this token is user-controlled message text.
break
;;
--amend)
exit 0
;;
esac
done
GIT_DIR=$(git rev-parse --git-dir)
if [ -f "$GIT_DIR/MERGE_HEAD" ] \\
|| [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] \\
|| [ -f "$GIT_DIR/REVERT_HEAD" ] \\
|| [ -d "$GIT_DIR/rebase-merge" ] \\
|| [ -d "$GIT_DIR/rebase-apply" ]; then
exit 0
fi
if git diff --cached --quiet --no-ext-diff 2>/dev/null; then
printf '%s\\n' "fusion: refusing empty commit \u2014 staged diff is empty." >&2
printf '%s\\n' " Use fn_task_document_write for narrative output, not git commits." >&2
printf '%s\\n' " (FN-5345/FN-5377 empty-commit guard)" >&2
exit 1
fi
`;
}
export function buildCommitMsgTrailerHook(
taskId: string,
options: {
taskPrefix?: string;
trailerName?: string;
} = {}
): string {
const taskPrefix = (options.taskPrefix ?? "FN").trim() || "FN";
const trailerName = (options.trailerName ?? "Fusion-Task-Id").trim() || "Fusion-Task-Id";
return `#!/bin/sh
set -eu
${COMMIT_MSG_HOOK_MARKER}
# fusion-task-id-seed: ${taskId}
TASK_FILE=$(git rev-parse --git-path fusion-task-id)
[ -f "$TASK_FILE" ] || exit 0
TASK_ID=$(cat "$TASK_FILE")
[ -n "$TASK_ID" ] || exit 0
PREFIX=${JSON.stringify(taskPrefix)}
case "$TASK_ID" in
${taskPrefix}-*) ;;
*) TASK_ID="$PREFIX-$(printf '%s' "$TASK_ID" | sed -E "s/^${taskPrefix}-//i")" ;;
esac
TRAILER_NAME=${JSON.stringify(trailerName)}
git interpret-trailers \
--in-place \
--if-exists doNothing \
--trailer "$TRAILER_NAME: $TASK_ID" \
"$1"
`;
}
export async function writeFileAtomic(targetPath: string, content: string, mode?: number): Promise<void> {
await fs.mkdir(dirname(targetPath), { recursive: true });
const tmpPath = `${targetPath}.tmp`;
const current = await fs.readFile(targetPath, "utf-8").catch(() => null);
if (current === content) return;
await fs.writeFile(tmpPath, content, { encoding: "utf-8", mode });
if (mode != null) await fs.chmod(tmpPath, mode);
await fs.rename(tmpPath, targetPath);
}
async function installCommitMsgHook(input: {
worktreePath: string;
taskId: string;
taskPrefix: string;
trailerName: string;
}): Promise<void> {
const hookPath = await resolveGitPath(input.worktreePath, "hooks/commit-msg");
const existing = await fs.readFile(hookPath, "utf-8").catch(() => null);
if (existing && !existing.includes(COMMIT_MSG_HOOK_MARKER)) {
console.warn(
`[worktree-hooks] commit-msg hook already exists at ${hookPath}; skipping Fusion trailer hook install for ${input.taskId}`
);
return;
}
const hook = buildCommitMsgTrailerHook(input.taskId, {
taskPrefix: input.taskPrefix,
trailerName: input.trailerName,
});
await writeFileAtomic(hookPath, hook, 0o755);
}
async function installPrepareCommitMsgEmptyGuard(input: {
worktreePath: string;
taskId: string;
}): Promise<void> {
const hookPath = await resolveGitPath(input.worktreePath, "hooks/prepare-commit-msg");
const existing = await fs.readFile(hookPath, "utf-8").catch(() => null);
if (existing && !existing.includes(PREPARE_COMMIT_MSG_HOOK_MARKER)) {
console.warn(
`[worktree-hooks] prepare-commit-msg hook already exists at ${hookPath}; skipping Fusion empty-commit guard install for ${input.taskId}`
);
return;
}
const hook = buildPrepareCommitMsgEmptyGuardHook(input.taskId);
await writeFileAtomic(hookPath, hook, 0o755);
}
export async function installTaskWorktreeIdentityGuard(input: {
worktreePath: string;
taskId: string;
allowedBranchPatterns?: readonly string[];
commitMsgHookEnabled?: boolean;
taskPrefix?: string;
taskAttributionTrailerName?: string;
}): Promise<void> {
const hook = buildIdentityGuardHook(input.taskId, input.allowedBranchPatterns ?? DEFAULT_ALLOWED_BRANCH_PATTERNS);
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);
if (input.commitMsgHookEnabled !== false) {
await installCommitMsgHook({
worktreePath: input.worktreePath,
taskId: input.taskId,
taskPrefix: input.taskPrefix ?? "FN",
trailerName: input.taskAttributionTrailerName ?? "Fusion-Task-Id",
});
}
// FN-5345/FN-5377: install the empty-commit guard alongside the trailer
// hook. Skipped automatically if a non-fusion hook already exists.
await installPrepareCommitMsgEmptyGuard({
worktreePath: input.worktreePath,
taskId: input.taskId,
});
}