Files
fusion/packages/engine/src/branch-conflicts.ts

1112 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { exec } from "node:child_process";
import { existsSync } from "node:fs";
import { promisify } from "node:util";
import { resolveIntegrationBranch } from "./integration-branch.js";
const execAsync = promisify(exec);
const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id";
const GIT_TIMEOUT_MS = 120_000;
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
export interface BranchConflictCommit {
sha: string;
subject: string;
}
export interface BranchCrossContaminationCommit extends BranchConflictCommit {
foreignTaskId: string;
}
export interface BranchConflictDetails {
branchName: string;
conflictingWorktreePath: string;
existingTipSha: string;
strandedCommits: BranchConflictCommit[];
startPoint: string;
recommendedAction: string;
}
export class BranchConflictError extends Error implements BranchConflictDetails {
readonly name = "BranchConflictError";
readonly branchName: string;
readonly conflictingWorktreePath: string;
readonly existingTipSha: string;
readonly strandedCommits: BranchConflictCommit[];
readonly startPoint: string;
readonly recommendedAction: string;
constructor(details: BranchConflictDetails) {
const commitSummary = details.strandedCommits.length > 0
? `${details.strandedCommits.length} stranded commit${details.strandedCommits.length === 1 ? "" : "s"}`
: "no stranded commits";
super(
`Branch ${details.branchName} is already checked out at ${details.conflictingWorktreePath} ` +
`(tip ${details.existingTipSha.slice(0, 12)}, ${commitSummary} since ${details.startPoint}). ` +
details.recommendedAction,
);
this.branchName = details.branchName;
this.conflictingWorktreePath = details.conflictingWorktreePath;
this.existingTipSha = details.existingTipSha;
this.strandedCommits = details.strandedCommits;
this.startPoint = details.startPoint;
this.recommendedAction = details.recommendedAction;
}
}
export function isBranchConflictError(error: unknown): error is BranchConflictError {
return error instanceof BranchConflictError;
}
export interface BranchCrossContaminationDetails {
branchName: string;
baseSha: string;
taskId: string;
foreignCommits: BranchCrossContaminationCommit[];
}
export class BranchCrossContaminationError extends Error implements BranchCrossContaminationDetails {
readonly name = "BranchCrossContaminationError";
readonly branchName: string;
readonly baseSha: string;
readonly taskId: string;
readonly foreignCommits: BranchCrossContaminationCommit[];
constructor(details: BranchCrossContaminationDetails) {
super(
`Branch ${details.branchName} contains ${details.foreignCommits.length} foreign task-attributed commits ` +
`since base ${details.baseSha.slice(0, 12)} for ${details.taskId}`,
);
this.branchName = details.branchName;
this.baseSha = details.baseSha;
this.taskId = details.taskId;
this.foreignCommits = details.foreignCommits;
}
}
export interface InspectBranchConflictInput {
repoDir: string;
branchName: string;
conflictingWorktreePath: string;
requestingTaskId: string;
ownerTaskId?: string;
startPoint?: string;
integrationRef?: string;
}
export type BranchConflictInspectionResult =
| { kind: "stale" }
| { kind: "stale-resolved" }
| { kind: "tip-already-merged"; livePath: string | null; tipSha: string; integrationRef: string }
| { kind: "fully-subsumed"; livePath: string; tipSha: string }
| { kind: "reclaimable"; livePath: string; tipSha: string; taskAttributedCommitCount: number; strandedCommits: BranchConflictCommit[] }
| { kind: "live-foreign"; livePath: string; error: BranchConflictError };
interface UniqueBranchCommitListResult {
commits: BranchConflictCommit[];
mainRef: string;
degraded: boolean;
}
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
async function runGit(repoDir: string, command: string): Promise<string> {
const { stdout } = await execAsync(command, {
cwd: repoDir,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
return stdout.trim();
}
async function revParse(repoDir: string, ref: string): Promise<string> {
return runGit(repoDir, `git rev-parse --verify ${quoteShellArg(`${ref}^{commit}`)}`);
}
async function isAncestor(repoDir: string, sha: string, ref: string): Promise<boolean> {
try {
await execAsync(`git merge-base --is-ancestor ${quoteShellArg(sha)} ${quoteShellArg(ref)}`, {
cwd: repoDir,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
return true;
} catch {
return false;
}
}
async function listStrandedCommits(repoDir: string, startPoint: string, branchName: string): Promise<BranchConflictCommit[]> {
try {
const output = await runGit(
repoDir,
`git log --reverse --format=%H%x09%s ${quoteShellArg(`${startPoint}..${branchName}`)}`,
);
if (!output) return [];
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sha, ...subjectParts] = line.split("\t");
return { sha, subject: subjectParts.join("\t") };
});
} catch {
return [];
}
}
async function resolveBranchComparisonRef(repoDir: string, startPoint: string, branchName: string): Promise<string> {
try {
await revParse(repoDir, startPoint);
await runGit(repoDir, `git merge-base ${quoteShellArg(startPoint)} ${quoteShellArg(branchName)}`);
return startPoint;
} catch {
const resolved = await resolveIntegrationBranch(repoDir, undefined);
return resolved;
}
}
export async function listUniqueBranchCommits(
repoDir: string,
startPoint: string,
branchName: string,
): Promise<UniqueBranchCommitListResult> {
const mainRef = await resolveBranchComparisonRef(repoDir, startPoint, branchName);
try {
const comparisonBase = await runGit(repoDir, `git merge-base ${quoteShellArg(mainRef)} ${quoteShellArg(branchName)}`);
const cherryOutput = await runGit(
repoDir,
`git cherry ${quoteShellArg(mainRef)} ${quoteShellArg(branchName)} ${quoteShellArg(comparisonBase)}`,
);
const plusTokens = cherryOutput
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("+ "))
.map((line) => line.slice(2).trim())
.filter(Boolean);
const commits: BranchConflictCommit[] = [];
for (const token of plusTokens) {
const [sha, subject] = await Promise.all([
runGit(repoDir, `git rev-parse --verify ${quoteShellArg(`${token}^{commit}`)}`).catch(() => token),
runGit(repoDir, `git log -1 --format=%s ${quoteShellArg(token)}`).catch(() => ""),
]);
commits.push({ sha, subject });
}
return {
commits,
mainRef,
degraded: false,
};
} catch {
return {
commits: await listStrandedCommits(repoDir, mainRef, branchName),
mainRef,
degraded: true,
};
}
}
async function getWorktreeBranchMap(repoDir: string): Promise<Map<string, string>> {
const output = await runGit(repoDir, "git worktree list --porcelain");
const map = new Map<string, string>();
let currentWorktree: string | null = null;
for (const line of output.split("\n")) {
if (line.startsWith("worktree ")) {
currentWorktree = line.slice("worktree ".length).trim();
continue;
}
if (line.startsWith("branch refs/heads/") && currentWorktree) {
map.set(line.slice("branch refs/heads/".length).trim(), currentWorktree);
}
if (!line.trim()) {
currentWorktree = null;
}
}
return map;
}
interface TaskAttributionSummary {
ownCount: number;
foreignCount: number;
}
async function summarizeTaskAttributedCommits(repoDir: string, range: string, taskId: string): Promise<TaskAttributionSummary> {
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ownSubjectPattern = new RegExp(`^(feat|fix|test|chore|docs|refactor|perf|build)\\(${escapedTaskId}\\):`);
const ownTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}: ${escapedTaskId}(?:\\n|$)`);
const genericSubjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
const genericTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}:\\s*(FN-\\d+)(?:\\n|$)`, "i");
let output = "";
try {
output = await runGit(repoDir, `git log --format=%H%x00%s%x00%b ${quoteShellArg(range)}`);
} catch {
return { ownCount: 0, foreignCount: 0 };
}
if (!output) return { ownCount: 0, foreignCount: 0 };
const normalizedTaskId = taskId.toUpperCase();
const tokens = output.split("\u0000");
let ownCount = 0;
let foreignCount = 0;
for (let i = 0; i + 2 < tokens.length; i += 3) {
const subject = tokens[i + 1] ?? "";
const body = tokens[i + 2] ?? "";
if (ownSubjectPattern.test(subject) || ownTrailerPattern.test(body)) {
ownCount += 1;
continue;
}
const subjectMatch = subject.match(genericSubjectPattern);
const trailerMatch = body.match(genericTrailerPattern);
const attributedTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
if (attributedTaskId && attributedTaskId !== normalizedTaskId) {
foreignCount += 1;
}
}
return { ownCount, foreignCount };
}
export interface BranchAttributionReport {
/** Commits whose subject matches `<type>(<taskId>):` AND carry the trailer. */
ownTrailed: number;
/** Commits attributed to taskId via subject but missing the Fusion-Task-Id trailer
* (signals: hook didn't fire — worktree was used without identity guards). */
ownUntrailed: { sha: string; subject: string }[];
/** Commits attributed to a different FN-id via subject or trailer (contamination). */
foreign: { sha: string; subject: string; foreignTaskId: string }[];
/** Commits with neither a conventional subject nor any trailer (orphaned writes). */
unattributed: { sha: string; subject: string }[];
}
/**
* Post-session audit of every commit in `base..branch`. Used by the executor
* immediately after a step-session completes to detect three classes of
* contamination early — long before merge time:
*
* 1. ownUntrailed: agent committed legitimately but the commit-msg hook
* didn't fire (missing fusion-task-id, --no-verify, plumbing commit).
* 2. foreign: another task's work landed on this branch (FN-5233 pattern).
* 3. unattributed: commit lacks both subject prefix and trailer (often a
* hand-merged commit or plumbing-driven update).
*
* Returns counts/details rather than throwing so callers can decide whether
* to refuse, warn, or just audit.
*/
export async function reportBranchAttribution(
repoDir: string,
branch: string,
baseSha: string,
taskId: string,
): Promise<BranchAttributionReport> {
const report: BranchAttributionReport = { ownTrailed: 0, ownUntrailed: [], foreign: [], unattributed: [] };
const output = await runGit(repoDir, `git log --format=%H%x1f%s%x1f%b%x1e ${quoteShellArg(`${baseSha}..${branch}`)}`)
.catch(() => "");
if (!output) return report;
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ownSubjectPattern = new RegExp(`^(feat|fix|test|chore|docs|refactor|perf|build)\\(${escapedTaskId}\\):`, "i");
const ownTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}: ${escapedTaskId}\\s*(?:\\n|$)`, "i");
const genericSubjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
const genericTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}:\\s*(FN-\\d+)\\s*(?:\\n|$)`, "i");
const normalizedTaskId = taskId.toUpperCase();
for (const record of output.split("").map((entry) => entry.trim()).filter(Boolean)) {
const [sha = "", subject = "", body = ""] = record.split("");
const subjectMatch = subject.match(genericSubjectPattern);
const trailerMatch = body.match(genericTrailerPattern);
const attributedTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
if (attributedTaskId && attributedTaskId !== normalizedTaskId) {
report.foreign.push({ sha, subject, foreignTaskId: attributedTaskId });
continue;
}
if (!attributedTaskId) {
report.unattributed.push({ sha, subject });
continue;
}
const trailerPresent = ownTrailerPattern.test(body);
const subjectPresent = ownSubjectPattern.test(subject);
if (subjectPresent && trailerPresent) {
report.ownTrailed += 1;
} else if (subjectPresent && !trailerPresent) {
report.ownUntrailed.push({ sha, subject });
} else {
// trailer present, subject not — counts as trailed-own.
report.ownTrailed += 1;
}
}
return report;
}
/**
* True iff `branch`'s tip commit carries a `Fusion-Task-Id: <taskId>` trailer.
* Used as the cheap "is this branch ref authoritative for this task" probe
* at merge handoff so that HEAD drift (detached, wrong branch) can recover
* via a safe re-attach instead of refusing the handoff outright.
*/
export async function branchTipCarriesTaskIdTrailer(
repoDir: string,
branch: string,
taskId: string,
): Promise<boolean> {
try {
const body = await runGit(repoDir, `git log -1 --pretty=%B ${quoteShellArg(branch)}`);
const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}: ${escaped}\\s*(?:\\n|$)`);
return pattern.test(body);
} catch {
return false;
}
}
/**
* Whole-branch authority check: the branch ref exists, its tip carries the
* task's Fusion-Task-Id trailer, and (when a base is supplied) the range
* `base..branch` has no foreign FN-attributed commits.
*
* Returns `{ ok: true }` when safe to treat the branch ref as authoritative
* for `taskId`. On failure, returns `{ ok: false, reason }` so callers can
* log/audit why the gentle recovery was refused.
*/
export async function isBranchAuthoritativeForTask(
repoDir: string,
branch: string,
taskId: string,
baseSha?: string,
): Promise<{ ok: true } | { ok: false; reason: string }> {
try {
await revParse(repoDir, `refs/heads/${branch}`);
} catch {
return { ok: false, reason: "branch-ref-missing" };
}
const tipCarriesTrailer = await branchTipCarriesTaskIdTrailer(repoDir, branch, taskId);
if (!tipCarriesTrailer) {
return { ok: false, reason: "tip-missing-task-trailer" };
}
if (baseSha) {
try {
await assertCleanBranchAtBase(repoDir, branch, baseSha, taskId);
} catch (err) {
const reason = err instanceof BranchCrossContaminationError ? "foreign-contamination" : "clean-branch-check-failed";
return { ok: false, reason };
}
}
return { ok: true };
}
/**
* Cheap ancestry check: is `commitSha` reachable from `ref`?
*
* Used to recognize "promoted" commits during contamination audits: when
* the engine fast-forwards local `main` with a sibling task's commit, that
* commit's `Fusion-Task-Id` trailer still points at the sibling, but the
* commit itself is now integrated. Treating it as foreign contamination
* for downstream tasks branched from the same main tip is incorrect — the
* commit is, by definition, ancestral on the integration target.
*
* Returns `false` on any git error (missing ref, repo unreadable, etc.)
* so the caller falls back to the conservative trailer-only judgement.
*/
async function isAncestorOf(repoDir: string, commitSha: string, ref: string): Promise<boolean> {
try {
await runGit(repoDir, `git merge-base --is-ancestor ${quoteShellArg(commitSha)} ${quoteShellArg(ref)}`);
return true;
} catch {
return false;
}
}
export async function assertCleanBranchAtBase(
repoDir: string,
branchName: string,
baseSha: string,
taskId: string,
): Promise<void> {
const output = await runGit(repoDir, `git log --format=%H%x1f%s%x1f%b ${quoteShellArg(`${baseSha}..${branchName}`)}`)
.catch(() => "");
if (!output) return;
const subjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
const trailerPattern = /(?:^|\n)Fusion-Task-Id:\s*(FN-\d+)\s*(?:\n|$)/i;
const candidateForeign: BranchCrossContaminationCommit[] = [];
for (const line of output.split("\n").map((entry) => entry.trim()).filter(Boolean)) {
const [sha, subject, body] = line.split("\u001f");
const subjectMatch = (subject ?? "").match(subjectPattern);
const trailerMatch = (body ?? "").match(trailerPattern);
const attributedTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
if (attributedTaskId && attributedTaskId !== taskId.toUpperCase()) {
candidateForeign.push({ sha, subject: subject ?? "", foreignTaskId: attributedTaskId });
}
}
if (candidateForeign.length === 0) return;
// FN-5475/FN-219: a commit attributed to another task that's already
// reachable from the integration target was promoted through integration.
// Treat it as ancestral, not contamination. Check both local `main` and
// `origin/main`: long-running dashboards can have stale local main while
// the remote-tracking branch already contains the promoted dependency, and
// using only local main produces false branch-cross-contamination loops.
const integratedRefs = ["main", "origin/main"];
const foreignCommits: BranchCrossContaminationCommit[] = [];
for (const commit of candidateForeign) {
let alreadyIntegrated = false;
for (const ref of integratedRefs) {
if (await isAncestorOf(repoDir, commit.sha, ref)) {
alreadyIntegrated = true;
break;
}
}
if (alreadyIntegrated) continue;
foreignCommits.push(commit);
}
if (foreignCommits.length > 0) {
throw new BranchCrossContaminationError({ branchName, baseSha, taskId, foreignCommits });
}
}
export interface ClassifyBootstrapMisbindingInput {
repoDir: string;
branchName: string;
baseSha: string;
taskId: string;
/**
* Optional and advisory only. The classifier derives the foreign-commit
* count from its own `git log baseSha..branchName` walk because callers
* such as the auto-recovery fallback in `branch-worktree.ts` only have a
* `BranchConflictInspectionResult` (no foreign-commit list) and used to
* pass `[]`, which silently disabled the predicate.
*/
foreignCommits?: BranchCrossContaminationCommit[];
}
export interface ClassifyBootstrapMisbindingResult {
isBootstrapMisbinding: boolean;
ownCommitCount: number;
foreignCommitCount: number;
nonAttributedCount: number;
}
export async function classifyBootstrapMisbinding(
input: ClassifyBootstrapMisbindingInput,
): Promise<ClassifyBootstrapMisbindingResult> {
const { repoDir, branchName, baseSha, taskId } = input;
const output = await runGit(repoDir, `git log --format=%H%x1f%s%x1f%b ${quoteShellArg(`${baseSha}..${branchName}`)}`)
.catch(() => "");
if (!output) {
return {
isBootstrapMisbinding: false,
ownCommitCount: 0,
foreignCommitCount: 0,
nonAttributedCount: 0,
};
}
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ownSubjectPattern = new RegExp(`^(feat|fix|test|chore|docs|refactor|perf|build)\\(${escapedTaskId}\\):`, "i");
const ownTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}:\\s*${escapedTaskId}\\s*(?:\\n|$)`, "i");
const subjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
const trailerPattern = /(?:^|\n)Fusion-Task-Id:\s*(FN-\d+)\s*(?:\n|$)/i;
let ownCommitCount = 0;
let nonAttributedCount = 0;
let foreignCommitCount = 0;
for (const line of output.split("\n").map((entry) => entry.trim()).filter(Boolean)) {
const [, subject = "", body = ""] = line.split("\u001f");
if (ownSubjectPattern.test(subject) || ownTrailerPattern.test(body)) {
ownCommitCount += 1;
continue;
}
const subjectMatch = subject.match(subjectPattern);
const trailerMatch = body.match(trailerPattern);
const attributedTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
if (!attributedTaskId) {
nonAttributedCount += 1;
} else {
foreignCommitCount += 1;
}
}
return {
isBootstrapMisbinding: foreignCommitCount > 0 && ownCommitCount === 0 && nonAttributedCount === 0,
ownCommitCount,
foreignCommitCount,
nonAttributedCount,
};
}
export interface ClassifyForeignCommitsInput {
repoDir: string;
branchName: string;
baseSha: string;
foreignCommits: BranchCrossContaminationCommit[];
mainRef?: string;
}
export interface ClassifyForeignCommitsResult {
/**
* Commits whose patch-id already exists on main and are safe to drop.
*/
alreadyUpstream: BranchCrossContaminationCommit[];
/**
* Commits whose patch-id is unique and require human adjudication.
*/
unique: BranchCrossContaminationCommit[];
}
export type ForeignOnlyContaminationKind =
| "foreign-only-no-own-work"
| "foreign-only-already-upstream"
| "ambiguous"
| "clean";
export interface ClassifyForeignOnlyContaminationInput {
repoDir: string;
branchName: string;
baseSha: string;
taskId: string;
mainRef?: string;
}
export interface ClassifyForeignOnlyContaminationResult {
kind: ForeignOnlyContaminationKind;
ownCommitCount: number;
foreignCommitCount: number;
nonAttributedCount: number;
alreadyUpstreamShas: string[];
uniqueShas: string[];
}
async function classifyForeignCommitsViaPatchId(
repoDir: string,
mainRef: string,
commits: BranchCrossContaminationCommit[],
): Promise<ClassifyForeignCommitsResult> {
const upstreamPatchIdsOutput = await runGit(
repoDir,
`git rev-list ${quoteShellArg(mainRef)} | while read c; do git show "$c" | git patch-id --stable; done`,
).catch(() => "");
const upstreamPatchIds = new Set(
upstreamPatchIdsOutput
.split("\n")
.map((line) => line.trim().split(" ")[0])
.filter(Boolean),
);
const alreadyUpstream: BranchCrossContaminationCommit[] = [];
const unique: BranchCrossContaminationCommit[] = [];
for (const commit of commits) {
const patchIdLine = await runGit(repoDir, `git show ${quoteShellArg(commit.sha)} | git patch-id --stable`).catch(() => "");
const patchId = patchIdLine.trim().split(" ")[0];
if (patchId && upstreamPatchIds.has(patchId)) {
alreadyUpstream.push(commit);
} else {
unique.push(commit);
}
}
return { alreadyUpstream, unique };
}
export async function classifyForeignCommits(
input: ClassifyForeignCommitsInput,
): Promise<ClassifyForeignCommitsResult> {
const resolvedIntegrationBranch = await resolveIntegrationBranch(input.repoDir, undefined);
const { repoDir, branchName, baseSha, foreignCommits } = input;
const mainRef = input.mainRef?.trim() || resolvedIntegrationBranch;
const targetBySha = new Map(foreignCommits.map((commit) => [commit.sha, commit]));
if (targetBySha.size === 0) {
return { alreadyUpstream: [], unique: [] };
}
const classifyFromCherryOutput = async (output: string): Promise<ClassifyForeignCommitsResult> => {
const alreadyUpstreamSha = new Set<string>();
const uniqueSha = new Set<string>();
const resolveFullSha = (token: string): string | null => {
if (targetBySha.has(token)) return token;
const match = foreignCommits.find((commit) => commit.sha.startsWith(token));
return match?.sha ?? null;
};
for (const rawLine of output.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const [marker, token] = line.split(/\s+/, 2);
if (!token) continue;
const sha = resolveFullSha(token);
if (!sha) continue;
if (marker === "-") {
alreadyUpstreamSha.add(sha);
} else if (marker === "+") {
uniqueSha.add(sha);
}
}
const unresolved = foreignCommits.filter((commit) => !alreadyUpstreamSha.has(commit.sha) && !uniqueSha.has(commit.sha));
const unresolvedClassified = unresolved.length > 0
? await classifyForeignCommitsViaPatchId(repoDir, mainRef, unresolved)
: { alreadyUpstream: [], unique: [] };
return {
alreadyUpstream: [
...foreignCommits.filter((commit) => alreadyUpstreamSha.has(commit.sha)),
...unresolvedClassified.alreadyUpstream,
],
unique: [
...foreignCommits.filter((commit) => uniqueSha.has(commit.sha)),
...unresolvedClassified.unique,
],
};
};
try {
const comparisonBase = baseSha || await runGit(repoDir, `git merge-base ${quoteShellArg(mainRef)} ${quoteShellArg(branchName)}`);
const output = await runGit(repoDir, `git cherry ${quoteShellArg(mainRef)} ${quoteShellArg(branchName)} ${quoteShellArg(comparisonBase)}`);
return await classifyFromCherryOutput(output);
} catch {
return classifyForeignCommitsViaPatchId(repoDir, mainRef, foreignCommits);
}
}
export interface ClassifyMisroutedForeignCommitInput {
repoDir: string;
sha: string;
commitSubject: string;
commitBody: string;
currentTaskId: string;
}
export interface ClassifyMisroutedForeignCommitResult {
misrouted: boolean;
foreignTaskId?: string;
paths: string[];
}
export async function classifyMisroutedForeignCommit(
input: ClassifyMisroutedForeignCommitInput,
): Promise<ClassifyMisroutedForeignCommitResult> {
const { repoDir, sha, commitSubject, commitBody, currentTaskId } = input;
const subjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
const trailerPattern = /(?:^|\n)Fusion-Task-Id:\s*(FN-\d+)\s*(?:\n|$)/i;
const subjectMatch = commitSubject.match(subjectPattern);
const trailerMatch = commitBody.match(trailerPattern);
const foreignTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
if (!foreignTaskId || foreignTaskId === currentTaskId.toUpperCase()) {
return { misrouted: false, paths: [] };
}
const pathsOutput = await runGit(
repoDir,
`git diff-tree --root --no-commit-id --name-only -r ${quoteShellArg(sha)}`,
).catch(() => "");
const paths = pathsOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return {
misrouted: paths.length > 0 && paths.every((path) => path.startsWith(".changeset/")),
foreignTaskId,
paths,
};
}
export async function classifyForeignOnlyContamination(
input: ClassifyForeignOnlyContaminationInput,
): Promise<ClassifyForeignOnlyContaminationResult> {
const resolvedIntegrationBranch = await resolveIntegrationBranch(input.repoDir, undefined);
const { repoDir, branchName, baseSha, taskId } = input;
const mainRef = input.mainRef?.trim() || resolvedIntegrationBranch;
// FN-5090 hotfix: stale baseSha (older than the actual fork point with main) caused
// classifyForeignOnlyContamination to see commits that have since been merged into main
// as "foreign", returning kind:"ambiguous" and stranding the task. Prefer the live
// merge-base when it is a descendant of the persisted baseSha.
let effectiveBaseSha = baseSha;
try {
const mergeBaseRaw = await runGit(repoDir, `git merge-base ${quoteShellArg(branchName)} ${quoteShellArg(mainRef)}`);
const liveMergeBase = mergeBaseRaw.trim();
if (liveMergeBase && liveMergeBase !== baseSha) {
// Use live merge-base if it is a descendant of baseSha (newer)
const ancestryCheck = await runGit(
repoDir,
`git merge-base --is-ancestor ${quoteShellArg(baseSha)} ${quoteShellArg(liveMergeBase)} && echo yes || echo no`,
).catch(() => "no");
if (ancestryCheck.trim() === "yes") {
effectiveBaseSha = liveMergeBase;
}
}
} catch {
// fall back to persisted baseSha on any git failure
}
const persistedRangeOutput = await runGit(repoDir, `git log --format=%H%x1f%s%x1f%b ${quoteShellArg(`${baseSha}..${branchName}`)}`)
.catch(() => "");
const subjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
const trailerPattern = /(?:^|\n)Fusion-Task-Id:\s*(FN-\d+)\s*(?:\n|$)/i;
const foreignCommits: BranchCrossContaminationCommit[] = [];
for (const line of persistedRangeOutput.split("\n").map((entry) => entry.trim()).filter(Boolean)) {
const [sha, subject, body] = line.split("\u001f");
const subjectMatch = (subject ?? "").match(subjectPattern);
const trailerMatch = (body ?? "").match(trailerPattern);
const attributedTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
if (attributedTaskId && attributedTaskId !== taskId.toUpperCase()) {
foreignCommits.push({ sha, subject: subject ?? "", foreignTaskId: attributedTaskId });
}
}
const bootstrap = await classifyBootstrapMisbinding({
repoDir,
branchName,
baseSha: effectiveBaseSha,
taskId,
foreignCommits,
});
if (foreignCommits.length === 0) {
return {
kind: "clean",
ownCommitCount: bootstrap.ownCommitCount,
foreignCommitCount: 0,
nonAttributedCount: bootstrap.nonAttributedCount,
alreadyUpstreamShas: [],
uniqueShas: [],
};
}
const foreignClassification = await classifyForeignCommits({
repoDir,
branchName,
baseSha: effectiveBaseSha,
foreignCommits,
mainRef,
});
const result: ClassifyForeignOnlyContaminationResult = {
kind: "ambiguous",
ownCommitCount: bootstrap.ownCommitCount,
foreignCommitCount: foreignCommits.length,
nonAttributedCount: bootstrap.nonAttributedCount,
alreadyUpstreamShas: foreignClassification.alreadyUpstream.map((entry) => entry.sha),
uniqueShas: foreignClassification.unique.map((entry) => entry.sha),
};
if (result.ownCommitCount === 0 && result.nonAttributedCount === 0 && result.foreignCommitCount > 0) {
result.kind = result.uniqueShas.length === 0
? "foreign-only-already-upstream"
: "foreign-only-no-own-work";
return result;
}
if (result.ownCommitCount > 0 || result.nonAttributedCount > 0) {
result.kind = "ambiguous";
return result;
}
result.kind = "clean";
return result;
}
export interface ReanchorBranchToBaseInput {
repoDir: string;
worktreePath: string;
branchName: string;
baseSha: string;
taskId: string;
}
export interface ReanchorBranchToBaseResult {
previousTipSha: string;
newTipSha: string;
}
/**
* Re-anchor a task branch to base while handling already-at-base worktrees.
*
* Fast-path: when both worktree HEAD and branch tip already equal baseSha,
* avoid detach/rebranch churn (`checkout -B` can fail with worktree-binding
* conflicts) and only attempt lightweight branch re-association.
*/
export async function reanchorBranchToBase(
input: ReanchorBranchToBaseInput,
): Promise<ReanchorBranchToBaseResult> {
const { repoDir, worktreePath, branchName, baseSha, taskId } = input;
const previousTipSha = await revParse(repoDir, branchName);
try {
await execAsync("git checkout -- .", {
cwd: worktreePath,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
} catch {
// best-effort: worktree may already be clean
}
await execAsync("git clean -fd", {
cwd: worktreePath,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
const worktreeHeadSha = await revParse(worktreePath, "HEAD");
const branchTipSha = previousTipSha;
const worktreeHeadBranch = await runGit(worktreePath, "git symbolic-ref --quiet --short HEAD").catch(() => "");
if (worktreeHeadSha === baseSha && branchTipSha === baseSha) {
if (worktreeHeadBranch !== branchName) {
try {
await runGit(worktreePath, `git checkout ${quoteShellArg(branchName)}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("already used by worktree")) {
throw error;
}
}
}
await assertCleanBranchAtBase(repoDir, branchName, baseSha, taskId);
return {
previousTipSha,
newTipSha: previousTipSha,
};
}
await runGit(worktreePath, `git checkout --detach ${quoteShellArg(baseSha)}`);
await runGit(worktreePath, `git checkout -B ${quoteShellArg(branchName)} ${quoteShellArg(baseSha)}`);
await assertCleanBranchAtBase(repoDir, branchName, baseSha, taskId);
return {
previousTipSha,
newTipSha: await revParse(repoDir, branchName),
};
}
export interface AutoRecoverCrossContaminationInput {
repoDir: string;
branchName: string;
baseSha: string;
taskId: string;
shasToDrop: string[];
mainRef?: string;
}
export interface AutoRecoverCrossContaminationResult {
newTipSha: string;
droppedShas: string[];
}
export async function autoRecoverCrossContamination(
input: AutoRecoverCrossContaminationInput,
): Promise<AutoRecoverCrossContaminationResult> {
const { repoDir, branchName, baseSha, taskId, shasToDrop } = input;
const dropSet = new Set(shasToDrop);
if (dropSet.size === 0) {
throw new Error("autoRecoverCrossContamination requires at least one SHA to drop");
}
const originalTip = await revParse(repoDir, branchName);
const commitListOutput = await runGit(repoDir, `git rev-list --reverse ${quoteShellArg(`${baseSha}..${branchName}`)}`)
.catch(() => "");
const commits = commitListOutput.split("\n").map((line) => line.trim()).filter(Boolean);
await runGit(repoDir, `git checkout --detach ${quoteShellArg(baseSha)}`);
try {
for (const sha of commits) {
if (dropSet.has(sha)) continue;
await execAsync(`git cherry-pick ${quoteShellArg(sha)}`, {
cwd: repoDir,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
}
const newTip = await revParse(repoDir, "HEAD");
await runGit(repoDir, `git update-ref ${quoteShellArg(`refs/heads/${branchName}`)} ${quoteShellArg(newTip)} ${quoteShellArg(originalTip)}`);
await runGit(repoDir, `git checkout ${quoteShellArg(branchName)}`);
} catch (error) {
await runGit(repoDir, `git cherry-pick --abort`).catch(() => undefined);
await runGit(repoDir, `git checkout ${quoteShellArg(branchName)}`).catch(() => undefined);
throw error;
}
await assertCleanBranchAtBase(repoDir, branchName, baseSha, taskId);
return {
newTipSha: await revParse(repoDir, branchName),
droppedShas: Array.from(dropSet),
};
}
export function deriveTaskIdFromFusionBranch(branchName: string): string | null {
const match = /^fusion\/(fn-\d+)$/i.exec(branchName.trim());
if (!match) return null;
return match[1].toUpperCase();
}
async function isZeroUniqueCommitBranchViaPatchIdFallback(
repoDir: string,
startPoint: string,
branchName: string,
mainRef: string,
): Promise<boolean> {
const range = `${startPoint}..${branchName}`;
const branchCommitsOutput = await runGit(repoDir, `git rev-list ${quoteShellArg(range)}`).catch(() => "");
const branchCommitShas = branchCommitsOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (branchCommitShas.length === 0) {
return true;
}
const upstreamPatchIdsOutput = await runGit(
repoDir,
`git rev-list ${quoteShellArg(mainRef)} | while read c; do git show "$c" | git patch-id --stable; done`,
).catch(() => "");
const upstreamPatchIds = new Set(
upstreamPatchIdsOutput
.split("\n")
.map((line) => line.trim().split(" ")[0])
.filter(Boolean),
);
if (upstreamPatchIds.size === 0) {
return false;
}
for (const sha of branchCommitShas) {
const patchIdLine = await runGit(repoDir, `git show ${quoteShellArg(sha)} | git patch-id --stable`).catch(() => "");
const patchId = patchIdLine.trim().split(" ")[0];
if (!patchId || !upstreamPatchIds.has(patchId)) {
return false;
}
}
return true;
}
export async function inspectBranchConflict(
input: InspectBranchConflictInput,
): Promise<BranchConflictInspectionResult> {
const startPoint = input.startPoint ?? "HEAD";
if (!existsSync(input.conflictingWorktreePath)) {
return { kind: "stale" };
}
try {
await runGit(input.repoDir, "git worktree prune");
} catch {
// best-effort
}
let worktreeMap = await getWorktreeBranchMap(input.repoDir);
let livePath = worktreeMap.get(input.branchName);
try {
await revParse(input.repoDir, `refs/heads/${input.branchName}`);
} catch {
return { kind: "stale-resolved" };
}
if (livePath && !existsSync(livePath)) {
try {
await runGit(input.repoDir, "git worktree prune");
} catch {
// best-effort
}
worktreeMap = await getWorktreeBranchMap(input.repoDir);
const refreshedLivePath = worktreeMap.get(input.branchName);
livePath = refreshedLivePath && existsSync(refreshedLivePath) ? refreshedLivePath : undefined;
}
if (!livePath) {
return { kind: "stale-resolved" };
}
const existingTipSha = await revParse(input.repoDir, input.branchName);
const requestedIntegrationRef = input.integrationRef ?? await resolveIntegrationBranch(input.repoDir, undefined);
const integrationRef = await resolveBranchComparisonRef(input.repoDir, requestedIntegrationRef, input.branchName);
if (await isAncestor(input.repoDir, existingTipSha, integrationRef)) {
return {
kind: "tip-already-merged",
livePath: livePath ?? null,
tipSha: existingTipSha,
integrationRef,
};
}
const uniqueCommitResult = await listUniqueBranchCommits(input.repoDir, startPoint, input.branchName);
const attribution = await summarizeTaskAttributedCommits(
input.repoDir,
`${startPoint}..${input.branchName}`,
input.requestingTaskId,
);
const taskAttributedCommitCount = attribution.ownCount;
if (!uniqueCommitResult.degraded && uniqueCommitResult.commits.length === 0) {
return {
kind: "fully-subsumed",
livePath,
tipSha: existingTipSha,
};
}
if (uniqueCommitResult.degraded && uniqueCommitResult.commits.length === 0) {
const isZeroUnique = await isZeroUniqueCommitBranchViaPatchIdFallback(
input.repoDir,
startPoint,
input.branchName,
uniqueCommitResult.mainRef,
);
if (isZeroUnique) {
return {
kind: "fully-subsumed",
livePath,
tipSha: existingTipSha,
};
}
}
const normalizedOwnerTaskId = (input.ownerTaskId ?? input.requestingTaskId).trim().toUpperCase();
const branchOwnerTaskId = deriveTaskIdFromFusionBranch(input.branchName);
const isSelfOwnedWorktree =
livePath === input.conflictingWorktreePath ||
(branchOwnerTaskId !== null && branchOwnerTaskId === normalizedOwnerTaskId);
if (taskAttributedCommitCount > 0 || (isSelfOwnedWorktree && attribution.foreignCount === 0)) {
return {
kind: "reclaimable",
livePath,
tipSha: existingTipSha,
taskAttributedCommitCount,
strandedCommits: uniqueCommitResult.commits,
};
}
return {
kind: "live-foreign",
livePath,
error: new BranchConflictError({
branchName: input.branchName,
conflictingWorktreePath: livePath,
existingTipSha,
strandedCommits: uniqueCommitResult.commits,
startPoint: uniqueCommitResult.mainRef,
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
};
}