feat(FN-5479): fix identity-guard merger bypass on detached HEAD and gate l
The merge delivers the FN-5483 identity-guard bypass for merger-driven commits on detached HEAD, plus Steps 2 and 6 of FN-5479 which gate the limbo counter by enqueue acceptance and document the associated invariant. It also restores the dashboard's PWA and theme-boot contract in index.html. New reg Fusion-Task-Id: FN-5479
This commit is contained in:
committed by
gsxdsm
parent
4b484fd818
commit
1bffa22ca9
9
.changeset/fix-fn-5483-identity-guard-merger-bypass.md
Normal file
9
.changeset/fix-fn-5483-identity-guard-merger-bypass.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
fix(FN-5483): allow merger-driven commits past the identity-guard pre-commit hook (detached HEAD false-positive).
|
||||
|
||||
The reuse-task-worktree merge path intentionally detaches HEAD at the integration target before running squash and verification-fix ceremonies. The identity-guard hook (`buildIdentityGuardHook`) refused every such commit because `HEAD_BRANCH=detached` never matches the owning task branch, surfacing as `merge-deadlock-detected: requires manual intervention — verified content not on main` on FN-5441 and FN-5446.
|
||||
|
||||
The hook now honors a `FUSION_MERGER_BYPASS_IDENTITY_GUARD=1` env-var bypass (gated to the exact value `"1"`), set only on merger-driven `git commit` calls. The marker is placed after the `TASK_FILE` check so non-fusion worktrees stay no-op, and before `EXPECTED_BRANCH` so detached HEAD never reaches the refusal printf. Agent commits never set this env, so the guard still catches executor/reviewer misuse. `buildCommitMsgTrailerHook` and `buildPrepareCommitMsgEmptyGuardHook` are unchanged and continue to run on every merger commit, preserving FN-5089 trailer attribution and FN-5345/FN-5377 empty-commit refusal.
|
||||
@@ -146,6 +146,10 @@ Per-task opt-out exists: `task.scopeOverride = true` (log the reason).
|
||||
|
||||
When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a human. Lifecycle-mutating self-healing must not move these tasks backward, pause/fail them, or re-enqueue them for execution.
|
||||
|
||||
### Completion-handoff limbo budget invariant (FN-5479)
|
||||
|
||||
`recoverCompletionHandoffLimbo` may only increment `completionHandoffLimboRecoveryCount` after merge requeue is **accepted** by the in-memory merge scheduler. Queue-row writes alone are insufficient. If enqueue is not accepted, skip budget consumption so tasks do not hit generic `Completion handoff limbo recovery exhausted` without a real merge attempt. Backstop: `packages/engine/src/__tests__/reliability-interactions/completion-handoff-limbo.test.ts` (`FN-5479` case).
|
||||
|
||||
### Mock provider (test mode)
|
||||
|
||||
`testMode?: boolean` is now available in both project and global settings. If project `testMode === true` (or the resolved default provider is `"mock"` at any tier), every AI lane is forced to `mock/scripted`, overriding per-task and per-lane model selections. The dashboard exposes this via the Settings Modal "Enable test mode" toggle and a persistent "Test mode — no real AI calls" banner.
|
||||
|
||||
@@ -122,6 +122,7 @@ vi.mock("../worktree-pool.js", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("../worktree-hooks.js", () => ({
|
||||
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
|
||||
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
||||
}));
|
||||
|
||||
vi.mock("../worktree-stale-lock.js", async () => {
|
||||
|
||||
@@ -123,4 +123,29 @@ describe("FN-4999 reliability interactions: completion-handoff-limbo", () => {
|
||||
.filter((value: unknown) => typeof value === "number");
|
||||
expect(increments).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("FN-5479: does not consume limbo recovery budget when merge requeue is not accepted", async () => {
|
||||
const task = makeTask({
|
||||
status: undefined,
|
||||
review: undefined,
|
||||
reviewState: undefined,
|
||||
mergeDetails: undefined,
|
||||
completionHandoffLimboRecoveryCount: 2,
|
||||
log: [{ action: "Task marked done by agent", timestamp: new Date(Date.now() - 6 * 60_000).toISOString() } as any],
|
||||
});
|
||||
const store = createStore(task);
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/repo",
|
||||
enqueueMerge: vi.fn(() => false),
|
||||
requeueForAutoMerge: vi.fn(() => false),
|
||||
});
|
||||
|
||||
await manager.recoverCompletionHandoffLimbo();
|
||||
|
||||
expect(store.enqueueMergeQueue).toHaveBeenCalledWith("FN-4999-T");
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-4999-T", expect.objectContaining({ completionHandoffLimboRecoveryCount: 3 }));
|
||||
expect(store.recordRunAuditEvent).not.toHaveBeenCalledWith(expect.objectContaining({ mutationType: "task:auto-recover-completion-handoff-limbo" }));
|
||||
expect(store.logEntry).not.toHaveBeenCalledWith("FN-4999-T", expect.stringMatching(/Auto-recovered \(FN-4999\)/));
|
||||
expect(store._get().completionHandoffLimboRecoveryCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../worktree-hooks.js", () => ({
|
||||
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
|
||||
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
||||
}));
|
||||
import { AgentSemaphore } from "../concurrency.js";
|
||||
|
||||
|
||||
@@ -13,10 +13,12 @@ import { installTaskWorktreeIdentityGuard } from "../worktree-hooks.js";
|
||||
|
||||
vi.mock("../worktree-hooks.js", () => ({
|
||||
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
|
||||
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
||||
}));
|
||||
|
||||
vi.mock("../worktree-hooks.js", () => ({
|
||||
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
|
||||
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
||||
}));
|
||||
|
||||
// ── Shared test fixtures ──────────────────────────────────────────────
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../worktree-hooks.js", () => ({
|
||||
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
|
||||
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
||||
}));
|
||||
import { acquireTaskWorktree } from "../worktree-acquisition.js";
|
||||
import type { WorktreeBackend } from "../worktree-backend.js";
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock("node:child_process", () => ({ exec: execMock }));
|
||||
vi.mock("node:fs", () => ({ existsSync: existsSyncMock }));
|
||||
vi.mock("../worktree-hooks.js", () => ({
|
||||
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
|
||||
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
||||
}));
|
||||
vi.mock("../worktree-pool.js", async () => {
|
||||
const actual = await vi.importActual<any>("../worktree-pool.js");
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("../branch-conflicts.js", () => ({
|
||||
}));
|
||||
vi.mock("../worktree-hooks.js", () => ({
|
||||
installTaskWorktreeIdentityGuard: installGuardMock,
|
||||
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
|
||||
}));
|
||||
vi.mock("../worktree-stale-lock.js", () => ({
|
||||
StaleWorktreeIndexLockError: class StaleWorktreeIndexLockError extends Error {
|
||||
|
||||
@@ -32,6 +32,23 @@ describe("worktree-hooks", () => {
|
||||
expect(hook).toContain(`EXPECTED_BRANCH=\"${expectedBranch}\"`);
|
||||
});
|
||||
|
||||
it("honors the merger bypass marker on detached HEAD before computing EXPECTED_BRANCH", () => {
|
||||
const hook = buildIdentityGuardHook("FN-5483");
|
||||
const bypassIndex = hook.indexOf('FUSION_MERGER_BYPASS_IDENTITY_GUARD:-');
|
||||
const expectedBranchIndex = hook.indexOf('EXPECTED_BRANCH=');
|
||||
const refuseIndex = hook.indexOf('refusing commit');
|
||||
|
||||
expect(bypassIndex).toBeGreaterThan(-1);
|
||||
// bypass check must come before the branch comparison and before the refusal printf
|
||||
expect(bypassIndex).toBeLessThan(expectedBranchIndex);
|
||||
expect(bypassIndex).toBeLessThan(refuseIndex);
|
||||
// bypass must require the exact value "1" — not a non-empty truthy check
|
||||
expect(hook).toContain('"${FUSION_MERGER_BYPASS_IDENTITY_GUARD:-}" = "1"');
|
||||
// bypass arm must short-circuit with exit 0 before reaching the refusal path
|
||||
const bypassBlock = hook.slice(bypassIndex, expectedBranchIndex);
|
||||
expect(bypassBlock).toContain("exit 0");
|
||||
});
|
||||
|
||||
it("builds commit-msg trailer hook with expected lines", () => {
|
||||
const hook = buildCommitMsgTrailerHook("FN-42");
|
||||
expect(hook).toContain("#!/bin/sh");
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { execSync, exec, execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { IDENTITY_GUARD_BYPASS_ENV } from "./worktree-hooks.js";
|
||||
|
||||
// Internal git plumbing intentionally bypasses sandbox backends.
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Env for merger-driven `git commit` calls so the identity-guard pre-commit
|
||||
* hook accepts commits made on a detached HEAD (intentional in
|
||||
* reuse-task-worktree squash/verification-fix ceremonies). Scope is narrow —
|
||||
* commit calls only, never plumbing like checkout/reset — and the guard
|
||||
* checks for the exact value "1" so a leaked/empty var cannot accidentally
|
||||
* bypass agent commits.
|
||||
*/
|
||||
function mergerCommitEnv(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, [IDENTITY_GUARD_BYPASS_ENV]: "1" };
|
||||
}
|
||||
import {
|
||||
detectMissingWorkspaceEntry,
|
||||
runVerificationCommand as runVerificationCommandShared,
|
||||
@@ -3897,7 +3910,7 @@ export async function commitOrAmendMergeWithFixes(
|
||||
});
|
||||
await execAsync(
|
||||
`git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
{ cwd: rootDir, env: mergerCommitEnv() },
|
||||
);
|
||||
if (store && lineageId) {
|
||||
const sha = (await execAsync("git rev-parse HEAD", { cwd: rootDir })).stdout.trim();
|
||||
@@ -3940,7 +3953,7 @@ export async function commitOrAmendMergeWithFixes(
|
||||
});
|
||||
await execAsync(
|
||||
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
{ cwd: rootDir, env: mergerCommitEnv() },
|
||||
);
|
||||
if (store && lineageId) {
|
||||
const sha = (await execAsync("git rev-parse HEAD", { cwd: rootDir })).stdout.trim();
|
||||
@@ -4839,7 +4852,7 @@ async function ensureTaskTrailersOnHead(rootDir: string, task: Pick<Task, "id">
|
||||
for (const trailer of trailersToAdd) {
|
||||
amendCommand += ` --trailer "${trailer}"`;
|
||||
}
|
||||
await execAsync(amendCommand, { cwd: rootDir });
|
||||
await execAsync(amendCommand, { cwd: rootDir, env: mergerCommitEnv() });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`${task.id}: failed to add merge trailers to HEAD (${msg}) — relying on fallback ownership signals`);
|
||||
@@ -9766,7 +9779,7 @@ export async function executeMergeAttempt(
|
||||
});
|
||||
await execAsync(
|
||||
`git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
{ cwd: rootDir, env: mergerCommitEnv() },
|
||||
);
|
||||
mergerLog.log(`${taskId}: committed after auto-resolving all conflicts`);
|
||||
} else {
|
||||
@@ -9995,7 +10008,7 @@ export async function executeMergeAttempt(
|
||||
const trailerArg = buildTaskTrailerArgs(taskId);
|
||||
await execAsync(
|
||||
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
{ cwd: rootDir, env: mergerCommitEnv() },
|
||||
);
|
||||
mergerLog.log(`${taskId}: rewrote AI-authored merge commit message with deterministic body`);
|
||||
} catch (err: unknown) {
|
||||
@@ -10203,7 +10216,7 @@ async function finalizeSideStrategyAttempt(
|
||||
});
|
||||
await execAsync(
|
||||
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
{ cwd: rootDir, env: mergerCommitEnv() },
|
||||
);
|
||||
mergerLog.log(`${taskId}: committed with -X ${side} auto-resolution`);
|
||||
|
||||
@@ -10570,7 +10583,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
});
|
||||
await execAsync(
|
||||
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
{ cwd: rootDir, env: mergerCommitEnv() },
|
||||
);
|
||||
} else {
|
||||
// Build command was configured but agent didn't commit and didn't report failure
|
||||
|
||||
@@ -694,7 +694,7 @@ export class InProcessRuntime
|
||||
getPlanningTaskIds: () => this.triageProcessor?.getProcessingTaskIds() ?? new Set<string>(),
|
||||
evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? new Set<string>(),
|
||||
enqueueMerge: this.mergeEnqueuer ? (taskId: string) => this.mergeEnqueuer?.(taskId) ?? false : undefined,
|
||||
requeueForAutoMerge: this.mergeEnqueuer ? (taskId: string) => { this.mergeEnqueuer?.(taskId); } : undefined,
|
||||
requeueForAutoMerge: this.mergeEnqueuer ? (taskId: string) => this.mergeEnqueuer?.(taskId) ?? false : undefined,
|
||||
isTaskActive: (taskId: string) => this.executor.isTaskActive(taskId),
|
||||
clearMergeActive: this.clearMergeActive ? (taskId: string) => this.clearMergeActive?.(taskId) : undefined,
|
||||
getActiveMergeTaskId: () => this.activeMergeTaskIdProvider?.() ?? null,
|
||||
|
||||
@@ -226,7 +226,7 @@ export interface SelfHealingOptions {
|
||||
* the polling sweep's enqueue to silently no-op).
|
||||
*/
|
||||
enqueueMerge?: (taskId: string) => boolean;
|
||||
requeueForAutoMerge?: (taskId: string) => void | Promise<void>;
|
||||
requeueForAutoMerge?: (taskId: string) => boolean | void | Promise<boolean | void>;
|
||||
isTaskActive?: (taskId: string) => boolean;
|
||||
clearMergeActive?: (taskId: string) => void;
|
||||
/**
|
||||
@@ -1907,21 +1907,6 @@ export class SelfHealingManager {
|
||||
if (!task.branch || !task.worktree) return false;
|
||||
try {
|
||||
const integrationBranch = await resolveIntegrationBranch(this.options.rootDir, undefined);
|
||||
const baseSha = task.baseCommitSha ?? task.baseBranch ?? task.executionStartBranch ?? integrationBranch;
|
||||
if (!baseSha) return false;
|
||||
const classification = await classifyForeignOnlyContamination({
|
||||
repoDir: this.options.rootDir,
|
||||
branchName: task.branch,
|
||||
baseSha,
|
||||
taskId: task.id,
|
||||
}).catch(() => null);
|
||||
if (!classification) return false;
|
||||
if (
|
||||
classification.kind !== "foreign-only-no-own-work" &&
|
||||
classification.kind !== "foreign-only-already-upstream"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-heal", task.id),
|
||||
agentId: "self-healing",
|
||||
@@ -1929,6 +1914,8 @@ export class SelfHealingManager {
|
||||
taskLineageId: task.lineageId,
|
||||
phase: "reanchor-foreign-only-contamination",
|
||||
});
|
||||
// recoverForeignOnlyContamination internally classifies and bails on
|
||||
// non-matching kinds, so we do not pre-classify here.
|
||||
const recovered = await recoverForeignOnlyContamination(task, {
|
||||
repoDir: this.options.rootDir,
|
||||
taskStore: this.store,
|
||||
@@ -1938,7 +1925,7 @@ export class SelfHealingManager {
|
||||
if (!recovered.recovered) return false;
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-reanchored ${task.branch} to base (${classification.kind}, ${classification.foreignCommitCount} foreign commit(s) discarded — no own work to preserve)`,
|
||||
`Auto-reanchored ${task.branch} to base (foreign-only contamination, subtype=${recovered.subtype ?? "unknown"})`,
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -5794,6 +5781,33 @@ export class SelfHealingManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
let accepted = false;
|
||||
if (this.options.requeueForAutoMerge || this.options.enqueueMerge) {
|
||||
try {
|
||||
// FN-5353: strict targetTaskId leasing in reuse handoff requires an
|
||||
// explicit queue row before re-emitting auto-merge.
|
||||
await this.store.enqueueMergeQueue(task.id);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`recoverCompletionHandoffLimbo: enqueue failed for ${task.id}: ${errorMessage}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.options.enqueueMerge) {
|
||||
accepted = this.options.enqueueMerge(task.id);
|
||||
} else if (this.options.requeueForAutoMerge) {
|
||||
const enqueueResult = await this.options.requeueForAutoMerge(task.id);
|
||||
accepted = enqueueResult !== false;
|
||||
}
|
||||
} else {
|
||||
log.warn(`recoverCompletionHandoffLimbo: requeueForAutoMerge callback missing for ${task.id}`);
|
||||
}
|
||||
|
||||
if (!accepted) {
|
||||
log.warn(`recoverCompletionHandoffLimbo: merge requeue not accepted for ${task.id}; skipping limbo recovery budget increment`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
completionHandoffLimboRecoveryCount: currentCount + 1,
|
||||
});
|
||||
@@ -5812,20 +5826,6 @@ export class SelfHealingManager {
|
||||
});
|
||||
|
||||
await this.store.logEntry(task.id, "Auto-recovered (FN-4999): task in 'in-review' past handoff grace with no merge fan-out — re-emitting auto-merge handoff");
|
||||
if (this.options.requeueForAutoMerge) {
|
||||
try {
|
||||
// FN-5353: strict targetTaskId leasing in reuse handoff requires an
|
||||
// explicit queue row before re-emitting auto-merge.
|
||||
await this.store.enqueueMergeQueue(task.id);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`recoverCompletionHandoffLimbo: enqueue failed for ${task.id}: ${errorMessage}`);
|
||||
continue;
|
||||
}
|
||||
await this.options.requeueForAutoMerge(task.id);
|
||||
} else {
|
||||
log.warn(`recoverCompletionHandoffLimbo: requeueForAutoMerge callback missing for ${task.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@ 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";
|
||||
|
||||
@@ -37,6 +44,21 @@ 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' /
|
||||
|
||||
@@ -460,7 +460,8 @@ export class WorktreePool {
|
||||
// freshly-created fn-5432/fn-5255 branches because the recycled worktree
|
||||
// was still pointing at the previous occupant's commit and base silently
|
||||
// collapsed onto HEAD.
|
||||
if (!base || !base.trim() || base.trim().toUpperCase() === "HEAD") {
|
||||
const trimmedBase = base?.trim() ?? "";
|
||||
if (!trimmedBase || trimmedBase.toUpperCase() === "HEAD") {
|
||||
throw new Error(
|
||||
`prepareForTask: refusing to create branch ${branchName} from base ${JSON.stringify(base)} (worktree=${worktreePath}, startPoint=${String(startPoint)})`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user