feat(FN-5083): complete Step 3 — canonicalize fusion branch naming
This commit is contained in:
committed by
gsxdsm
parent
13d7195642
commit
c56e0cf814
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalFusionBranchName } from "../worktree-names.js";
|
||||
|
||||
describe("executor branch canonicalization", () => {
|
||||
it("canonicalizes mixed-case task IDs to lowercase fusion branches", () => {
|
||||
expect(canonicalFusionBranchName("FN-5083")).toBe("fusion/fn-5083");
|
||||
expect(canonicalFusionBranchName("Fn-ABC-123")).toBe("fusion/fn-abc-123");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { exec, execSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { canonicalFusionBranchName } from "./worktree-names.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id" | "tree-equal";
|
||||
@@ -82,7 +84,7 @@ export async function findAlreadyMergedTaskCommit(
|
||||
}
|
||||
|
||||
let branchTip: string | null = null;
|
||||
const branchName = taskBranch || `fusion/${taskId.toLowerCase()}`;
|
||||
const branchName = taskBranch || canonicalFusionBranchName(taskId);
|
||||
try {
|
||||
branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, {
|
||||
cwd: repoDir,
|
||||
@@ -182,7 +184,7 @@ export async function findAlreadyMergedTaskCommit(
|
||||
}
|
||||
|
||||
try {
|
||||
const treeBranchName = taskBranch || `fusion/${taskId.toLowerCase()}`;
|
||||
const treeBranchName = taskBranch || canonicalFusionBranchName(taskId);
|
||||
execSync(`git rev-parse --verify ${shellQuote(treeBranchName)}`, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
VERIFICATION_LOG_MAX_CHARS,
|
||||
type VerificationResult,
|
||||
} from "./verification-utils.js";
|
||||
import { generateWorktreeName } from "./worktree-names.js";
|
||||
import { canonicalFusionBranchName, generateWorktreeName } from "./worktree-names.js";
|
||||
import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||
@@ -5291,7 +5291,7 @@ export class TaskExecutor {
|
||||
worktreePathOverride?: string,
|
||||
): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> {
|
||||
const settings = await this.store.getSettings();
|
||||
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
const branchName = task.branch || canonicalFusionBranchName(task.id);
|
||||
const worktreePath = worktreePathOverride ?? task.worktree ?? this.activeWorktrees.get(task.id) ?? null;
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -6065,7 +6065,7 @@ export class TaskExecutor {
|
||||
|
||||
// Delete the branch — use stored branch name if available, fall back to convention
|
||||
const task = await this.store.getTask(taskId);
|
||||
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
|
||||
const branch = task.branch || canonicalFusionBranchName(taskId);
|
||||
let branchDeleted = false;
|
||||
try {
|
||||
await execAsync(`git branch -D "${branch}"`, { cwd: this.rootDir });
|
||||
@@ -9502,7 +9502,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
);
|
||||
if (completedSteps.length === 0) return;
|
||||
|
||||
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
const branchName = task.branch || canonicalFusionBranchName(task.id);
|
||||
|
||||
try {
|
||||
// Check if the branch has any unique commits vs main
|
||||
|
||||
@@ -34,6 +34,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { resolveTaskWorktreePath } from "./worktree-paths.js";
|
||||
import { canonicalFusionBranchName } from "./worktree-names.js";
|
||||
import { hostname } from "node:os";
|
||||
import {
|
||||
buildTaskLineageTrailer,
|
||||
@@ -523,7 +524,7 @@ export async function classifyOwnedLandedEvidence(
|
||||
task: Task,
|
||||
opts: { mergeTargetBranch: string },
|
||||
): Promise<OwnedLandedClassification> {
|
||||
const branch = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
const branch = task.branch || canonicalFusionBranchName(task.id);
|
||||
const mergeTargetBranch = opts.mergeTargetBranch;
|
||||
|
||||
const ownedCommit = await findOwnedLandedCommitForTask(rootDir, task);
|
||||
@@ -2572,7 +2573,7 @@ async function tryRecoverHardFailApply(params: {
|
||||
task,
|
||||
taskId,
|
||||
rootDir,
|
||||
branch: task.branch || `fusion/${taskId.toLowerCase()}`,
|
||||
branch: task.branch || canonicalFusionBranchName(taskId),
|
||||
conflictFiles: threeWayConflicted,
|
||||
auditor: undefined,
|
||||
});
|
||||
@@ -3001,7 +3002,7 @@ async function restoreUnrelatedRootDirChanges(
|
||||
task,
|
||||
taskId,
|
||||
rootDir,
|
||||
branch: task.branch || `fusion/${taskId.toLowerCase()}`,
|
||||
branch: task.branch || canonicalFusionBranchName(taskId),
|
||||
conflictFiles: conflictedFiles,
|
||||
auditor: undefined,
|
||||
});
|
||||
@@ -6172,7 +6173,7 @@ export async function aiMergeTask(
|
||||
});
|
||||
return {
|
||||
task,
|
||||
branch: task.branch || `fusion/${taskId.toLowerCase()}`,
|
||||
branch: task.branch || canonicalFusionBranchName(taskId),
|
||||
merged: false,
|
||||
noOp: true,
|
||||
ok: true,
|
||||
@@ -6186,7 +6187,7 @@ export async function aiMergeTask(
|
||||
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
|
||||
}
|
||||
|
||||
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
|
||||
const branch = task.branch || canonicalFusionBranchName(taskId);
|
||||
const requestedBaseRef = task.mergeDetails?.mergeTargetBranch || "main";
|
||||
const resolveAheadCount = async (): Promise<{ aheadCount: number; baseRef: string } | null> => {
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { AgentSemaphore } from "./concurrency.js";
|
||||
import { planTaskWorktreePath } from "./worktree-names.js";
|
||||
import { canonicalFusionBranchName, planTaskWorktreePath } from "./worktree-names.js";
|
||||
import { schedulerLog } from "./logger.js";
|
||||
import { type PrMonitor, type PrComment } from "./pr-monitor.js";
|
||||
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
|
||||
@@ -770,7 +770,7 @@ export class Scheduler {
|
||||
for (const depId of task.dependencies) {
|
||||
const dep = allTasks.find((t) => t.id === depId);
|
||||
if (dep && dep.column === "in-review" && dep.worktree) {
|
||||
return dep.branch || `fusion/${dep.id.toLowerCase()}`;
|
||||
return dep.branch || canonicalFusionBranchName(dep.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@ export class Scheduler {
|
||||
if (task.blockedBy) {
|
||||
const blocker = allTasks.find((t) => t.id === task.blockedBy);
|
||||
if (blocker && blocker.column === "in-review" && blocker.worktree) {
|
||||
return blocker.branch || `fusion/${blocker.id.toLowerCase()}`;
|
||||
return blocker.branch || canonicalFusionBranchName(blocker.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import { AutoRecoveryDispatcher } from "./auto-recovery.js";
|
||||
import { activeSessionRegistry } from "./active-session-registry.js";
|
||||
import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js";
|
||||
import { resolveWorktreesDir } from "./worktree-paths.js";
|
||||
import { canonicalFusionBranchName } from "./worktree-names.js";
|
||||
import type { OwnedLandedClassification } from "./merger.js";
|
||||
import { recoverForeignOnlyContamination } from "./recovery/foreign-only-contamination.js";
|
||||
import {
|
||||
@@ -438,7 +439,7 @@ export async function isBranchAheadOfBase(
|
||||
rootDir: string,
|
||||
preferredBaseRef?: string,
|
||||
): Promise<{ aheadCount: number; baseRef: string } | null> {
|
||||
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
const branchName = task.branch || canonicalFusionBranchName(task.id);
|
||||
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${shellQuote(branchName)}`, {
|
||||
@@ -868,7 +869,7 @@ export class SelfHealingManager {
|
||||
);
|
||||
if (completedSteps.length === 0) return;
|
||||
|
||||
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
const branchName = task.branch || canonicalFusionBranchName(task.id);
|
||||
|
||||
try {
|
||||
const { stdout: mergeBaseOut } = await execAsync(
|
||||
@@ -1111,7 +1112,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
const branch = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
const branch = task.branch || canonicalFusionBranchName(task.id);
|
||||
try {
|
||||
await execAsync(`git branch -D ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
@@ -2410,7 +2411,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
const branchName = task?.branch || `fusion/${taskId.toLowerCase()}`;
|
||||
const branchName = task?.branch || canonicalFusionBranchName(taskId);
|
||||
const hintedWorktreePath = options?.worktreeHint;
|
||||
let worktreePath = hintedWorktreePath;
|
||||
if (!worktreePath || !existsSync(worktreePath)) {
|
||||
@@ -2530,7 +2531,7 @@ export class SelfHealingManager {
|
||||
phase: "in-review-branch-rebind",
|
||||
});
|
||||
await auditor.database({
|
||||
type: input.mutationType,
|
||||
type: input.mutationType as unknown as Parameters<typeof auditor.database>[0]["type"],
|
||||
target: input.taskId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
@@ -2574,7 +2575,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
const normalizedId = task.id.toLowerCase();
|
||||
const candidates = new Set<string>([`fusion/${normalizedId}`, `fusion/${task.id}`]);
|
||||
const candidates = new Set<string>([canonicalFusionBranchName(task.id), `fusion/`.concat(task.id)]);
|
||||
for (const branch of fusionBranches) {
|
||||
const stem = branch.startsWith("fusion/") ? branch.slice("fusion/".length) : "";
|
||||
if (stem.toLowerCase() === normalizedId) candidates.add(branch);
|
||||
@@ -2617,7 +2618,7 @@ export class SelfHealingManager {
|
||||
const aheadCount = Number.parseInt(aheadCountRaw.stdout.trim(), 10);
|
||||
const normalizedBranchRef = branch.toLowerCase();
|
||||
const existingCandidate = existingCandidatesByRef.get(normalizedBranchRef);
|
||||
const normalizedCandidate = `fusion/${normalizedId}`;
|
||||
const normalizedCandidate = canonicalFusionBranchName(task.id);
|
||||
if (!existingCandidate || branch === normalizedCandidate) {
|
||||
existingCandidatesByRef.set(normalizedBranchRef, {
|
||||
branch,
|
||||
@@ -2641,12 +2642,15 @@ export class SelfHealingManager {
|
||||
const withUniqueWork = existingCandidates.filter((candidate) => candidate.aheadCount > 0);
|
||||
if (withUniqueWork.length === 1) {
|
||||
const selected = withUniqueWork[0];
|
||||
const patch: Partial<Task> = { branch: selected.branch, worktree: null };
|
||||
const patch: Partial<Task> = { branch: selected.branch, worktree: null as unknown as string };
|
||||
if (!task.baseCommitSha) {
|
||||
patch.baseCommitSha = (await execAsync(
|
||||
const derivedBaseCommit = (await execAsync(
|
||||
`git merge-base ${shellQuote(integrationBase)} ${shellQuote(selected.branch)}`,
|
||||
{ cwd: this.options.rootDir, timeout: 30_000 },
|
||||
)).stdout.trim() || null;
|
||||
)).stdout.trim();
|
||||
if (derivedBaseCommit) {
|
||||
patch.baseCommitSha = derivedBaseCommit;
|
||||
}
|
||||
}
|
||||
// TODO(FN-5066): tighten composition once helper API is final.
|
||||
try {
|
||||
@@ -2728,7 +2732,7 @@ export class SelfHealingManager {
|
||||
if (executingIds.has(task.id)) continue;
|
||||
if (activeSessionRegistry.isPathActive(task.worktree)) continue;
|
||||
|
||||
const normalizedBranch = `fusion/${task.id.toLowerCase()}`;
|
||||
const normalizedBranch = canonicalFusionBranchName(task.id);
|
||||
const canonicalTaskWorktree = resolve(task.worktree);
|
||||
const stale = !existsSync(task.worktree) || !registeredPaths.has(canonicalTaskWorktree);
|
||||
if (!stale) continue;
|
||||
@@ -5933,7 +5937,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
const branchName = task.branch || canonicalFusionBranchName(task.id);
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify "${branchName}"`, {
|
||||
cwd: this.options.rootDir,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { RunMutationContext, Settings, Task, TaskStore, SecretsStore } from "@fusion/core";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { canonicalFusionBranchName, generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { resolveTaskWorktreePathForBackend } from "./worktree-paths.js";
|
||||
import { hydrateWorktreeDb } from "./worktree-db-hydrate.js";
|
||||
import { formatError } from "./logger.js";
|
||||
@@ -202,7 +202,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
|
||||
const branchName = task.branch || canonicalFusionBranchName(task.id);
|
||||
const naming = settings.worktreeNaming || "random";
|
||||
const allowSiblingBranchRename = settings.executorAllowSiblingBranchRename === true;
|
||||
const baseBranch = task.executionStartBranch || null;
|
||||
|
||||
@@ -28,6 +28,7 @@ if [ ! -f "$TASK_FILE" ]; then
|
||||
fi
|
||||
|
||||
WORKTREE_TASK_ID=$(cat "$TASK_FILE")
|
||||
# Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId)
|
||||
EXPECTED_BRANCH="fusion/${taskId.toLowerCase()}"
|
||||
|
||||
if ! HEAD_BRANCH=$(git symbolic-ref --quiet --short HEAD 2>/dev/null); then
|
||||
|
||||
@@ -29,6 +29,10 @@ export const NOUNS = [
|
||||
"thorn", "tiger", "trail", "trout", "wren",
|
||||
];
|
||||
|
||||
export function canonicalFusionBranchName(taskId: string): string {
|
||||
return `fusion/${taskId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to a URL-friendly slug.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Column, SecretsStore, Settings, TaskStore, WorktrunkSettings } fro
|
||||
import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js";
|
||||
import { worktreePoolLog } from "./logger.js";
|
||||
import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js";
|
||||
import { canonicalFusionBranchName } from "./worktree-names.js";
|
||||
import {
|
||||
resolveWorktrunkBinary,
|
||||
} from "./worktrunk-installer.js";
|
||||
@@ -838,7 +839,7 @@ const MERGER_MANAGED_COLUMNS: ReadonlySet<Column> = new Set(["in-review", "done"
|
||||
*
|
||||
* Lists all local branches matching the `fusion/*` pattern, then compares
|
||||
* against branches stored on tasks (via `task.branch` or derived as
|
||||
* `fusion/${taskId.toLowerCase()}`). Branches belonging to tasks in the
|
||||
* canonicalFusionBranchName(taskId)). Branches belonging to tasks in the
|
||||
* `in-review` or `done` columns are excluded because the merger is
|
||||
* responsible for cleaning those up.
|
||||
*
|
||||
@@ -881,7 +882,7 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P
|
||||
activeBranches.add(task.branch);
|
||||
}
|
||||
// Always add the derived name too — the task may not have `branch` set yet
|
||||
activeBranches.add(`fusion/${task.id.toLowerCase()}`);
|
||||
activeBranches.add(canonicalFusionBranchName(task.id));
|
||||
}
|
||||
|
||||
// Return branches not associated with any active task
|
||||
|
||||
Reference in New Issue
Block a user