feat(FN-5349): add integration branch resolver with auto-recovery fallback
FN-5349 adds a dedicated integration branch resolution module (`packages/engine/src/integration-branch.ts`) replacing ad-hoc dynamic fallbacks, routes merger branch conflict resolution through it, wires auto-recovery handlers (branch-worktree, contamination) to use integration branch fallback, and w Fusion-Task-Id: FN-5349
This commit is contained in:
committed by
gsxdsm
parent
e58530aa37
commit
79850b233f
@@ -101,6 +101,43 @@ describe("branch-conflicts", () => {
|
||||
expect(result).toEqual({ kind: "stale-resolved" });
|
||||
});
|
||||
|
||||
it("prefers explicit integrationRef when inspecting branch conflicts", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git worktree prune") return Buffer.from("");
|
||||
if (command === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /tmp/existing-wt", "HEAD 2222222", "branch refs/heads/fusion/fn-4068", ""].join("\n"));
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'refs/heads/fusion/fn-4068^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'master^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (command.includes("git merge-base 'master' 'fusion/fn-4068'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (command.includes("git merge-base --is-ancestor 'abc123def456' 'master'")) {
|
||||
return Buffer.from("");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir: "/tmp/repo",
|
||||
branchName: "fusion/fn-4068",
|
||||
conflictingWorktreePath: "/tmp/existing-wt",
|
||||
requestingTaskId: "FN-4068",
|
||||
startPoint: "master",
|
||||
integrationRef: "master",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ kind: "tip-already-merged", integrationRef: "master" });
|
||||
});
|
||||
|
||||
it("FN-4476/FN-4471: classifies live branch at main tip as tip-already-merged even with stale-base churn", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
|
||||
115
packages/engine/src/__tests__/integration-branch.test.ts
Normal file
115
packages/engine/src/__tests__/integration-branch.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { execMock, execSyncMock } = vi.hoisted(() => ({
|
||||
execMock: vi.fn(),
|
||||
execSyncMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
exec: execMock,
|
||||
execSync: execSyncMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
__resetIntegrationBranchCacheForTests,
|
||||
INTEGRATION_BRANCH_FALLBACK,
|
||||
resolveIntegrationBranch,
|
||||
resolveIntegrationBranchSync,
|
||||
} from "../integration-branch.js";
|
||||
|
||||
describe("integration-branch resolver", () => {
|
||||
beforeEach(() => {
|
||||
__resetIntegrationBranchCacheForTests();
|
||||
execMock.mockReset();
|
||||
execSyncMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetIntegrationBranchCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("integrationBranch override wins over baseBranch and origin/HEAD", async () => {
|
||||
const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: " trunk ", baseBranch: "develop" } as any);
|
||||
|
||||
expect(resolved).toBe("trunk");
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("baseBranch wins over origin/HEAD", async () => {
|
||||
const resolved = await resolveIntegrationBranch("/repo", { baseBranch: " develop " } as any);
|
||||
|
||||
expect(resolved).toBe("develop");
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips refs/remotes/origin and origin prefixes", async () => {
|
||||
execMock.mockImplementationOnce((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
|
||||
cb(null, { stdout: "refs/remotes/origin/master\n" });
|
||||
return {};
|
||||
});
|
||||
execMock.mockImplementationOnce((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
|
||||
cb(null, { stdout: "origin/develop\n" });
|
||||
return {};
|
||||
});
|
||||
|
||||
const first = await resolveIntegrationBranch("/repo-a", {} as any);
|
||||
const second = await resolveIntegrationBranch("/repo-b", {} as any);
|
||||
|
||||
expect(first).toBe("master");
|
||||
expect(second).toBe("develop");
|
||||
});
|
||||
|
||||
it("treats whitespace and empty settings as unset", async () => {
|
||||
execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
|
||||
cb(null, { stdout: "origin/master\n" });
|
||||
return {};
|
||||
});
|
||||
|
||||
const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: " ", baseBranch: "" } as any);
|
||||
|
||||
expect(resolved).toBe("master");
|
||||
});
|
||||
|
||||
it("falls back to main and warns once per rootDir", async () => {
|
||||
execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
|
||||
cb(new Error("no symbolic ref"), { stdout: "" });
|
||||
return {};
|
||||
});
|
||||
const warn = vi.fn();
|
||||
|
||||
const first = await resolveIntegrationBranch("/repo", undefined, { logger: { warn } });
|
||||
const second = await resolveIntegrationBranch("/repo", undefined, { logger: { warn } });
|
||||
|
||||
expect(first).toBe(INTEGRATION_BRANCH_FALLBACK);
|
||||
expect(second).toBe(INTEGRATION_BRANCH_FALLBACK);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sync and async variants match", async () => {
|
||||
execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
|
||||
cb(null, { stdout: "refs/remotes/origin/master\n" });
|
||||
return {};
|
||||
});
|
||||
execSyncMock.mockReturnValue("origin/master\n");
|
||||
|
||||
const asyncResolved = await resolveIntegrationBranch("/repo", undefined);
|
||||
const syncResolved = resolveIntegrationBranchSync("/repo", undefined);
|
||||
|
||||
expect(syncResolved).toEqual(asyncResolved);
|
||||
expect(syncResolved).toBe("master");
|
||||
});
|
||||
|
||||
it("swallows git failures and does not throw", async () => {
|
||||
execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
|
||||
cb(new Error("git failed"), { stdout: "" });
|
||||
return {};
|
||||
});
|
||||
execSyncMock.mockImplementation(() => {
|
||||
throw new Error("git failed");
|
||||
});
|
||||
|
||||
await expect(resolveIntegrationBranch("/repo", undefined)).resolves.toBe(INTEGRATION_BRANCH_FALLBACK);
|
||||
expect(() => resolveIntegrationBranchSync("/repo", undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,7 @@ import {
|
||||
type ConflictCategory,
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { __resetIntegrationBranchCacheForTests } from "../integration-branch.js";
|
||||
import { createFnAgent } from "../pi.js";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import * as core from "@fusion/core";
|
||||
@@ -384,6 +385,38 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("aiMergeTask — integration branch resolution", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetIntegrationBranchCacheForTests();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
setupHappyPathExecSync();
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("threads settings.baseBranch into merge target branch", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
mergeIntegrationWorktree: "cwd-main" as const,
|
||||
baseBranch: "trunk",
|
||||
});
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
|
||||
expect(commands.some((cmd) => cmd.includes("checkout \"trunk\""))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aiMergeTask — model settings threading", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1084,19 +1084,24 @@ describe("aiMergeTask — merge-target branch resolution", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults merge-target context to main when task.baseBranch is missing", async () => {
|
||||
it("defaults merge-target context to integrationBranch when task.baseBranch is missing", async () => {
|
||||
const store = createMockStore({
|
||||
id: "FN-050",
|
||||
branch: "feature/fn-050-work",
|
||||
baseBranch: undefined,
|
||||
worktree: "/tmp/root",
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
mergeIntegrationWorktree: "cwd-main" as const,
|
||||
integrationBranch: "master",
|
||||
});
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(
|
||||
mockedExecSync.mock.calls.some(([cmd]) =>
|
||||
String(cmd).includes('git merge-base "feature/fn-050-work" "main"'),
|
||||
String(cmd).includes('git merge-base "feature/fn-050-work" "master"'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { inspectBranchConflict } from "../../branch-conflicts.js";
|
||||
import { resolveIntegrationBranch } from "../../integration-branch.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function git(repo: string, command: string): string {
|
||||
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
describeIfGit("integration branch resolution (real git, master)", () => {
|
||||
const repos: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function setupRepo(): string {
|
||||
const repo = mkdtempSync(path.join(os.tmpdir(), "fn-5349-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b master");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test"');
|
||||
git(repo, "git commit --allow-empty -m 'init'");
|
||||
git(repo, "git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/master");
|
||||
return repo;
|
||||
}
|
||||
|
||||
it("resolves origin/HEAD and respects explicit override", async () => {
|
||||
const repo = setupRepo();
|
||||
await expect(resolveIntegrationBranch(repo, {})).resolves.toBe("master");
|
||||
await expect(resolveIntegrationBranch(repo, { integrationBranch: "trunk" })).resolves.toBe("trunk");
|
||||
});
|
||||
|
||||
it("inspects branch conflicts against master without disturbing dirty root worktree", async () => {
|
||||
const repo = setupRepo();
|
||||
git(repo, "git checkout -b fusion/fn-5349-check");
|
||||
mkdirSync(path.join(repo, "src"), { recursive: true });
|
||||
writeFileSync(path.join(repo, "src", "task.txt"), "task\n", "utf-8");
|
||||
git(repo, "git add src/task.txt && git commit -m 'task change'");
|
||||
git(repo, "git checkout master");
|
||||
|
||||
const conflictWorktree = path.join(repo, ".worktrees", "fn-5349-check");
|
||||
mkdirSync(path.dirname(conflictWorktree), { recursive: true });
|
||||
git(repo, `git worktree add ${JSON.stringify(conflictWorktree)} fusion/fn-5349-check`);
|
||||
|
||||
writeFileSync(path.join(repo, "dirty.txt"), "dirty\n", "utf-8");
|
||||
writeFileSync(path.join(repo, "untracked.txt"), "untracked\n", "utf-8");
|
||||
const preStatus = git(repo, "git status --short");
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir: repo,
|
||||
branchName: "fusion/fn-5349-check",
|
||||
conflictingWorktreePath: conflictWorktree,
|
||||
requestingTaskId: "FN-5349",
|
||||
ownerTaskId: "FN-5349",
|
||||
startPoint: "master",
|
||||
integrationRef: "master",
|
||||
});
|
||||
|
||||
expect(["reclaimable", "live-foreign", "fully-subsumed", "tip-already-merged"]).toContain(result.kind);
|
||||
expect(git(repo, "git symbolic-ref --short HEAD")).toBe("master");
|
||||
expect(readFileSync(path.join(repo, "dirty.txt"), "utf-8")).toBe("dirty\n");
|
||||
expect(readFileSync(path.join(repo, "untracked.txt"), "utf-8")).toBe("untracked\n");
|
||||
expect(git(repo, "git status --short")).toBe(preStatus);
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ describe("reliability interaction: foreign-only contamination recovery", () => {
|
||||
baseCommitSha: baseSha,
|
||||
baseBranch: "main",
|
||||
executionStartBranch: "fusion/fn-y",
|
||||
} as any, { repoDir, taskStore: store, runAudit });
|
||||
} as any, { repoDir, taskStore: store, runAudit, integrationBranch: "main" });
|
||||
|
||||
expect(result.recovered).toBe(true);
|
||||
expect(["reanchor", "branch-discard"]).toContain(result.subtype);
|
||||
@@ -90,7 +90,7 @@ describe("reliability interaction: foreign-only contamination recovery", () => {
|
||||
baseCommitSha: baseSha,
|
||||
baseBranch: "main",
|
||||
executionStartBranch: "fusion/fn-y",
|
||||
} as any, { repoDir, taskStore: store, runAudit });
|
||||
} as any, { repoDir, taskStore: store, runAudit, integrationBranch: "main" });
|
||||
|
||||
expect(result.recovered).toBe(false);
|
||||
expect(result.reason).toBe("active-session");
|
||||
|
||||
@@ -666,8 +666,8 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
).rejects.toMatchObject({ code: "worktrunk_timeout" });
|
||||
});
|
||||
|
||||
it("syncs by fetching then rebasing branch", async () => {
|
||||
execMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
it("syncs by fetching then rebasing resolved integration branch", async () => {
|
||||
execMock.mockResolvedValue({ stdout: "origin/main\n", stderr: "" });
|
||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||
|
||||
await expect(
|
||||
@@ -676,11 +676,16 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"git symbolic-ref --short refs/remotes/origin/HEAD",
|
||||
expect.objectContaining({ cwd: "/repo", timeout: 5000, maxBuffer: 1048576 }),
|
||||
);
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'git fetch origin "main"',
|
||||
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 180000, maxBuffer: 10485760 }),
|
||||
);
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
'git rebase "main"',
|
||||
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 180000, maxBuffer: 10485760 }),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../branch-conflicts.js";
|
||||
import type { AutoRecoveryContext, AutoRecoveryDecision, AutoRecoveryFailure } from "../auto-recovery.js";
|
||||
import { activeSessionRegistry } from "../active-session-registry.js";
|
||||
import { resolveIntegrationBranch } from "../integration-branch.js";
|
||||
import { createLogger, type Logger } from "../logger.js";
|
||||
import type { RunAuditor } from "../run-audit.js";
|
||||
|
||||
@@ -149,13 +150,18 @@ export class BranchWorktreeAutoRecoveryHandler {
|
||||
: "")).trim();
|
||||
if (!branchName || !conflictingWorktreePath) return;
|
||||
|
||||
const integrationBranch = await resolveIntegrationBranch(
|
||||
repoDir,
|
||||
ctx.settings as { integrationBranch?: string; baseBranch?: unknown },
|
||||
);
|
||||
const inspection = await inspectBranchConflict({
|
||||
repoDir,
|
||||
branchName,
|
||||
conflictingWorktreePath,
|
||||
requestingTaskId: ctx.task.id,
|
||||
ownerTaskId: ctx.task.id,
|
||||
startPoint: ctx.task.baseCommitSha ?? "main",
|
||||
startPoint: ctx.task.baseCommitSha ?? integrationBranch,
|
||||
integrationRef: integrationBranch,
|
||||
});
|
||||
|
||||
if (inspection.kind === "stale-resolved" || inspection.kind === "fully-subsumed" || inspection.kind === "tip-already-merged") {
|
||||
@@ -196,7 +202,7 @@ export class BranchWorktreeAutoRecoveryHandler {
|
||||
const bootstrap = await classifyBootstrapMisbinding({
|
||||
repoDir,
|
||||
branchName,
|
||||
baseSha: ctx.task.baseCommitSha ?? "main",
|
||||
baseSha: ctx.task.baseCommitSha ?? integrationBranch,
|
||||
taskId: ctx.task.id,
|
||||
foreignCommits: [],
|
||||
}).catch(() => ({ isBootstrapMisbinding: false, ownCommitCount: 0, nonAttributedCount: 0 }));
|
||||
@@ -206,7 +212,7 @@ export class BranchWorktreeAutoRecoveryHandler {
|
||||
repoDir,
|
||||
worktreePath: inspection.livePath,
|
||||
branchName,
|
||||
baseSha: ctx.task.baseCommitSha ?? "main",
|
||||
baseSha: ctx.task.baseCommitSha ?? integrationBranch,
|
||||
taskId: ctx.task.id,
|
||||
}).catch(() => null);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { classifyForeignOnlyContamination } from "../branch-conflicts.js";
|
||||
import type { AutoRecoveryContext, AutoRecoveryDecision, AutoRecoveryFailure, AutoRecoveryHandlers } from "../auto-recovery.js";
|
||||
import { createLogger, type Logger } from "../logger.js";
|
||||
import { recoverForeignOnlyContamination } from "../recovery/foreign-only-contamination.js";
|
||||
import { resolveIntegrationBranch } from "../integration-branch.js";
|
||||
import type { RunAuditor } from "../run-audit.js";
|
||||
|
||||
const baseLog = createLogger("auto-recovery:contamination");
|
||||
@@ -53,7 +54,11 @@ export class ContaminationAutoRecoveryHandler implements Pick<AutoRecoveryHandle
|
||||
let subtype: "reanchor" | "branch-discard" | undefined;
|
||||
|
||||
if (ownCommits === 0 && foreignAttributedCommits > 0 && task.branch && task.worktree) {
|
||||
const baseSha = task.baseCommitSha ?? task.baseBranch ?? task.executionStartBranch ?? "main";
|
||||
const integrationBranch = await resolveIntegrationBranch(
|
||||
this.deps.repoDir,
|
||||
ctx.settings as { integrationBranch?: string; baseBranch?: unknown },
|
||||
);
|
||||
const baseSha = task.baseCommitSha ?? task.baseBranch ?? task.executionStartBranch ?? integrationBranch;
|
||||
const classification = await classifyForeignOnlyContamination({
|
||||
repoDir: this.deps.repoDir,
|
||||
branchName: task.branch,
|
||||
@@ -66,6 +71,7 @@ export class ContaminationAutoRecoveryHandler implements Pick<AutoRecoveryHandle
|
||||
repoDir: this.deps.repoDir,
|
||||
taskStore: this.deps.taskStore,
|
||||
runAudit: this.deps.runAudit,
|
||||
integrationBranch,
|
||||
});
|
||||
if (recovered.recovered) {
|
||||
recoveryKind = "foreign-only";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -89,6 +90,7 @@ export interface InspectBranchConflictInput {
|
||||
requestingTaskId: string;
|
||||
ownerTaskId?: string;
|
||||
startPoint?: string;
|
||||
integrationRef?: string;
|
||||
}
|
||||
|
||||
export type BranchConflictInspectionResult =
|
||||
@@ -163,7 +165,8 @@ async function resolveBranchComparisonRef(repoDir: string, startPoint: string, b
|
||||
await runGit(repoDir, `git merge-base ${quoteShellArg(startPoint)} ${quoteShellArg(branchName)}`);
|
||||
return startPoint;
|
||||
} catch {
|
||||
return "main";
|
||||
const resolved = await resolveIntegrationBranch(repoDir, undefined);
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +438,9 @@ async function classifyForeignCommitsViaPatchId(
|
||||
export async function classifyForeignCommits(
|
||||
input: ClassifyForeignCommitsInput,
|
||||
): Promise<ClassifyForeignCommitsResult> {
|
||||
const { repoDir, branchName, baseSha, foreignCommits, mainRef = "main" } = input;
|
||||
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: [] };
|
||||
@@ -536,7 +541,9 @@ export async function classifyMisroutedForeignCommit(
|
||||
export async function classifyForeignOnlyContamination(
|
||||
input: ClassifyForeignOnlyContaminationInput,
|
||||
): Promise<ClassifyForeignOnlyContaminationResult> {
|
||||
const { repoDir, branchName, baseSha, taskId, mainRef = "main" } = input;
|
||||
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
|
||||
@@ -848,7 +855,8 @@ export async function inspectBranchConflict(
|
||||
}
|
||||
|
||||
const existingTipSha = await revParse(input.repoDir, input.branchName);
|
||||
const integrationRef = await resolveBranchComparisonRef(input.repoDir, "main", 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",
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
inspectBranchConflict,
|
||||
} from "./branch-conflicts.js";
|
||||
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "./branch-attribution.js";
|
||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { createLogger, executorLog, reviewerLog, formatError } from "./logger.js";
|
||||
import { TokenCapDetector } from "./token-cap-detector.js";
|
||||
@@ -8101,6 +8102,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
return "sticky";
|
||||
}
|
||||
|
||||
const integrationRef = task.mergeDetails?.mergeTargetBranch ?? task.baseBranch ?? task.executionStartBranch ?? await resolveIntegrationBranch(this.rootDir, undefined);
|
||||
const inspection = await inspectBranchConflict({
|
||||
repoDir: this.rootDir,
|
||||
branchName: error.branchName,
|
||||
@@ -8108,6 +8110,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
requestingTaskId: task.id,
|
||||
ownerTaskId: task.id,
|
||||
startPoint: error.startPoint,
|
||||
integrationRef,
|
||||
});
|
||||
|
||||
if (inspection.kind === "stale-resolved") {
|
||||
@@ -9101,6 +9104,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
requestingTaskId: taskId,
|
||||
ownerTaskId: taskId,
|
||||
startPoint,
|
||||
integrationRef: await resolveIntegrationBranch(this.rootDir, settings),
|
||||
});
|
||||
|
||||
if (inspection.kind === "stale" || inspection.kind === "stale-resolved" || inspection.kind === "tip-already-merged") {
|
||||
|
||||
@@ -34,6 +34,11 @@ export {
|
||||
type MergerOptions,
|
||||
type AutostashOrphanRecord,
|
||||
} from "./merger.js";
|
||||
export {
|
||||
resolveIntegrationBranch,
|
||||
resolveIntegrationBranchSync,
|
||||
__resetIntegrationBranchCacheForTests,
|
||||
} from "./integration-branch.js";
|
||||
export {
|
||||
resolveMergeIntegrationRoot,
|
||||
resolveIntegrationRemote,
|
||||
|
||||
118
packages/engine/src/integration-branch.ts
Normal file
118
packages/engine/src/integration-branch.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { exec, execSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { ProjectSettings } from "@fusion/core";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export type IntegrationBranchSettings =
|
||||
| ProjectSettings
|
||||
| (Pick<ProjectSettings, "integrationBranch"> & { baseBranch?: unknown })
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
export const INTEGRATION_BRANCH_FALLBACK = "main";
|
||||
const warnedFallbackRootDirs = new Set<string>();
|
||||
|
||||
function normalize(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, "")
|
||||
.replace(/^refs\/remotes\/origin\//, "")
|
||||
.replace(/^origin\//, "");
|
||||
}
|
||||
|
||||
function warnFallback(rootDir: string, logger: Pick<Console, "warn">): void {
|
||||
if (warnedFallbackRootDirs.has(rootDir)) {
|
||||
return;
|
||||
}
|
||||
warnedFallbackRootDirs.add(rootDir);
|
||||
logger.warn("[integration-branch] falling back to 'main' — origin/HEAD unset and no project override");
|
||||
}
|
||||
|
||||
function resolveFromSettings(settings: IntegrationBranchSettings): string {
|
||||
const fromIntegration = normalize(settings?.integrationBranch);
|
||||
if (fromIntegration.length > 0) {
|
||||
return fromIntegration;
|
||||
}
|
||||
|
||||
return normalize((settings as { baseBranch?: unknown } | null | undefined)?.baseBranch);
|
||||
}
|
||||
|
||||
async function resolveFromOriginHead(rootDir: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git symbolic-ref --short refs/remotes/origin/HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return normalize(stdout);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFromOriginHeadSync(rootDir: string): string {
|
||||
try {
|
||||
const stdout = execSync("git symbolic-ref --short refs/remotes/origin/HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
return normalize(stdout);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveIntegrationBranch(
|
||||
rootDir: string,
|
||||
settings: IntegrationBranchSettings,
|
||||
opts: { logger?: Pick<Console, "warn"> } = {},
|
||||
): Promise<string> {
|
||||
const logger = opts.logger ?? console;
|
||||
|
||||
const fromSettings = resolveFromSettings(settings);
|
||||
if (fromSettings.length > 0) {
|
||||
return fromSettings;
|
||||
}
|
||||
|
||||
const fromOrigin = await resolveFromOriginHead(rootDir);
|
||||
if (fromOrigin.length > 0) {
|
||||
return fromOrigin;
|
||||
}
|
||||
|
||||
warnFallback(rootDir, logger);
|
||||
return INTEGRATION_BRANCH_FALLBACK;
|
||||
}
|
||||
|
||||
export function resolveIntegrationBranchSync(
|
||||
rootDir: string,
|
||||
settings: IntegrationBranchSettings,
|
||||
opts: { logger?: Pick<Console, "warn"> } = {},
|
||||
): string {
|
||||
const logger = opts.logger ?? console;
|
||||
|
||||
const fromSettings = resolveFromSettings(settings);
|
||||
if (fromSettings.length > 0) {
|
||||
return fromSettings;
|
||||
}
|
||||
|
||||
const fromOrigin = resolveFromOriginHeadSync(rootDir);
|
||||
if (fromOrigin.length > 0) {
|
||||
return fromOrigin;
|
||||
}
|
||||
|
||||
warnFallback(rootDir, logger);
|
||||
return INTEGRATION_BRANCH_FALLBACK;
|
||||
}
|
||||
|
||||
export function __resetIntegrationBranchCacheForTests(): void {
|
||||
warnedFallbackRootDirs.clear();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Branch-name resolution: callers must pass the resolved integration branch via Step 3 plumbing; never hardcode "main". See FN-5349.
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { ProjectSettings } from "@fusion/core";
|
||||
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
type HandoffResult,
|
||||
} from "./merger-integration-worktree.js";
|
||||
import { acquireTaskWorktree } from "./worktree-acquisition.js";
|
||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||
|
||||
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||
|
||||
@@ -6663,9 +6664,9 @@ export async function aiMergeTask(
|
||||
|
||||
const projectRootDir = rootDir;
|
||||
const settings = await store.getSettings();
|
||||
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
|
||||
const resolvedIntegrationBranch = await resolveIntegrationBranch(projectRootDir, settings);
|
||||
const mergeTarget = resolveTaskMergeTarget(task, {
|
||||
projectDefaultBranch,
|
||||
projectDefaultBranch: resolvedIntegrationBranch,
|
||||
});
|
||||
let branch = task.branch || canonicalFusionBranchName(taskId);
|
||||
|
||||
@@ -7677,13 +7678,13 @@ export async function aiMergeTask(
|
||||
}
|
||||
|
||||
// Layer 1: surgical drop of declared-dependency commits.
|
||||
// When `task.executionStartBranch` is a non-main branch (a sibling task's branch),
|
||||
// When `task.executionStartBranch` is a non-integration branch (a sibling task's branch),
|
||||
// the dependent worktree was forked off it and inherited its commits.
|
||||
// If the dep was later squash-merged to main, those raw commits are now
|
||||
// If the dep was later squash-merged to the integration branch, those raw commits are now
|
||||
// orphans whose content already exists in main. Re-rebase the task
|
||||
// branch onto main using `git rebase --onto <target> <dep-tip> <branch>`,
|
||||
// branch onto the integration branch using `git rebase --onto <target> <dep-tip> <branch>`,
|
||||
// which peels off the dep's commits cleanly.
|
||||
if (rebaseTarget && task.executionStartBranch && task.executionStartBranch !== "main") {
|
||||
if (rebaseTarget && task.executionStartBranch && task.executionStartBranch !== mergeTarget.branch) {
|
||||
// Resolve the dep's tip — prefer the live branch ref, fall back to
|
||||
// the recorded baseCommitSha if the branch was already deleted.
|
||||
let depTip: string | undefined;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface RecoverForeignOnlyContaminationDeps {
|
||||
repoDir: string;
|
||||
taskStore: TaskStore;
|
||||
runAudit: RunAuditor;
|
||||
integrationBranch: string;
|
||||
}
|
||||
|
||||
export interface RecoverForeignOnlyContaminationResult {
|
||||
@@ -36,7 +37,7 @@ export async function recoverForeignOnlyContamination(
|
||||
): Promise<RecoverForeignOnlyContaminationResult> {
|
||||
if (!task.branch || !task.worktree) return { recovered: false, reason: "missing-branch-or-worktree" };
|
||||
|
||||
const baseSha = task.baseCommitSha ?? task.baseBranch ?? task.executionStartBranch ?? "main";
|
||||
const baseSha = task.baseCommitSha ?? task.baseBranch ?? task.executionStartBranch ?? deps.integrationBranch;
|
||||
if (!baseSha) {
|
||||
await deps.runAudit.database({
|
||||
type: "task:auto-recover-foreign-only-contamination-skipped",
|
||||
|
||||
@@ -46,6 +46,7 @@ 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 { resolveIntegrationBranch, resolveIntegrationBranchSync } from "./integration-branch.js";
|
||||
import type { OwnedLandedClassification } from "./merger.js";
|
||||
import { recoverForeignOnlyContamination } from "./recovery/foreign-only-contamination.js";
|
||||
import {
|
||||
@@ -443,7 +444,7 @@ export async function isBranchAheadOfBase(
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestedBaseRef = preferredBaseRef || task.mergeDetails?.mergeTargetBranch || "main";
|
||||
const requestedBaseRef = preferredBaseRef || task.mergeDetails?.mergeTargetBranch || await resolveIntegrationBranch(rootDir, undefined);
|
||||
let resolvedBaseRef = requestedBaseRef;
|
||||
|
||||
try {
|
||||
@@ -1645,13 +1646,15 @@ export class SelfHealingManager {
|
||||
if (!await isUsableTaskWorktree(this.options.rootDir, task.worktree)) return withPerPr({ outcome: "skipped", reason: "unusable-worktree" });
|
||||
|
||||
try {
|
||||
const integrationBranch = await resolveIntegrationBranch(this.options.rootDir, settings);
|
||||
const inspection = await inspectBranchConflict({
|
||||
repoDir: this.options.rootDir,
|
||||
branchName: task.branch,
|
||||
conflictingWorktreePath: task.worktree,
|
||||
requestingTaskId: task.id,
|
||||
ownerTaskId: task.id,
|
||||
startPoint: task.baseCommitSha ?? task.mergeDetails?.mergeTargetBranch ?? "main",
|
||||
startPoint: task.baseCommitSha ?? task.mergeDetails?.mergeTargetBranch ?? integrationBranch,
|
||||
integrationRef: integrationBranch,
|
||||
});
|
||||
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
@@ -1805,6 +1808,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
let recovered = 0;
|
||||
const integrationBranch = await resolveIntegrationBranch(this.options.rootDir, settings);
|
||||
for (const task of candidates) {
|
||||
if (task.checkedOutBy || activeTaskIds.has(task.id.toUpperCase()) || !task.branch || !task.worktree) continue;
|
||||
if (task.userPaused) continue;
|
||||
@@ -1833,7 +1837,8 @@ export class SelfHealingManager {
|
||||
conflictingWorktreePath: task.worktree,
|
||||
requestingTaskId: task.id,
|
||||
ownerTaskId: task.id,
|
||||
startPoint: task.baseCommitSha ?? task.mergeDetails?.mergeTargetBranch ?? "main",
|
||||
startPoint: task.baseCommitSha ?? task.mergeDetails?.mergeTargetBranch ?? integrationBranch,
|
||||
integrationRef: integrationBranch,
|
||||
});
|
||||
|
||||
if (inspection.kind === "stale") {
|
||||
@@ -2408,7 +2413,7 @@ export class SelfHealingManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseBranch = task.baseBranch || "main";
|
||||
const baseBranch = task.baseBranch || await resolveIntegrationBranch(this.options.rootDir, undefined);
|
||||
const comparison = await listUniqueBranchCommits(this.options.rootDir, baseBranch, branchName);
|
||||
if (comparison.commits.length > 0) {
|
||||
log.warn(
|
||||
@@ -2676,7 +2681,7 @@ export class SelfHealingManager {
|
||||
if (stem.toLowerCase() === normalizedId) candidates.add(branch);
|
||||
}
|
||||
|
||||
const integrationBase = task.baseBranch || "main";
|
||||
const integrationBase = task.baseBranch || await resolveIntegrationBranch(this.options.rootDir, undefined);
|
||||
const existingCandidatesByRef = new Map<string, { branch: string; aheadCount: number }>();
|
||||
for (const branch of candidates) {
|
||||
try {
|
||||
@@ -3054,7 +3059,7 @@ export class SelfHealingManager {
|
||||
const reasons: string[] = [];
|
||||
|
||||
try {
|
||||
const ahead = await isBranchAheadOfBase(task, this.options.rootDir, task.baseBranch ?? task.mergeDetails?.mergeTargetBranch ?? "main");
|
||||
const ahead = await isBranchAheadOfBase(task, this.options.rootDir, task.baseBranch ?? task.mergeDetails?.mergeTargetBranch ?? await resolveIntegrationBranch(this.options.rootDir, undefined));
|
||||
if (ahead && ahead.aheadCount > 0) reasons.push("branch-has-unique-commits");
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Meta auto-archive branch probe failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -3764,9 +3769,7 @@ export class SelfHealingManager {
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
let recovered = 0;
|
||||
const mergeTargetBranch = typeof settings.baseBranch === "string" && settings.baseBranch.trim().length > 0
|
||||
? settings.baseBranch
|
||||
: "main";
|
||||
const mergeTargetBranch = await resolveIntegrationBranch(this.options.rootDir, settings);
|
||||
for (const task of candidates) {
|
||||
const ahead = await this.isBranchAheadOfBase(task, task.mergeDetails?.mergeTargetBranch || mergeTargetBranch);
|
||||
if (!ahead || ahead.aheadCount !== 0) continue;
|
||||
@@ -3875,9 +3878,7 @@ export class SelfHealingManager {
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
const settings = await this.store.getSettings();
|
||||
const mergeTargetBranch = typeof settings.baseBranch === "string" && settings.baseBranch.trim().length > 0
|
||||
? settings.baseBranch
|
||||
: "main";
|
||||
const mergeTargetBranch = await resolveIntegrationBranch(this.options.rootDir, settings);
|
||||
|
||||
let reconciled = 0;
|
||||
for (const task of candidates) {
|
||||
@@ -5000,7 +5001,7 @@ export class SelfHealingManager {
|
||||
target: task.id,
|
||||
metadata: {
|
||||
mergeSha: task.mergeDetails?.commitSha ?? null,
|
||||
baseBranch: task.baseBranch || task.executionStartBranch || "main",
|
||||
baseBranch: task.baseBranch || task.executionStartBranch || await resolveIntegrationBranch(this.options.rootDir, undefined),
|
||||
clearedFlags,
|
||||
},
|
||||
});
|
||||
@@ -5230,7 +5231,7 @@ export class SelfHealingManager {
|
||||
const hasDeclaredOverlap = orphanFiles.some((file) => matchesScope(file, declaredScope));
|
||||
if (hasDeclaredOverlap) continue;
|
||||
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || await resolveIntegrationBranch(this.options.rootDir, undefined);
|
||||
const landed = await this.findAlreadyMergedTaskCommit({
|
||||
taskId: task.id,
|
||||
lineageId: task.lineageId,
|
||||
@@ -5367,7 +5368,7 @@ export class SelfHealingManager {
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
try {
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || await resolveIntegrationBranch(this.options.rootDir, undefined);
|
||||
if (!baseBranch) continue;
|
||||
|
||||
const landed = await this.findAlreadyMergedTaskCommit({
|
||||
@@ -5594,7 +5595,7 @@ export class SelfHealingManager {
|
||||
try {
|
||||
const branch = task.branch;
|
||||
if (!branch) continue;
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || await resolveIntegrationBranch(this.options.rootDir, undefined);
|
||||
const check = await this.isBranchTipMisboundToTask({
|
||||
branch,
|
||||
taskId: task.id,
|
||||
@@ -5716,9 +5717,10 @@ export class SelfHealingManager {
|
||||
];
|
||||
|
||||
let recovered = 0;
|
||||
const integrationBranch = await resolveIntegrationBranch(this.options.rootDir, settings);
|
||||
for (const task of candidates) {
|
||||
if (!task.branch || !task.worktree) continue;
|
||||
const baseSha = task.baseCommitSha ?? task.baseBranch ?? task.executionStartBranch ?? "main";
|
||||
const baseSha = task.baseCommitSha ?? task.baseBranch ?? task.executionStartBranch ?? integrationBranch;
|
||||
try {
|
||||
const classification = await classifyForeignOnlyContamination({
|
||||
repoDir: this.options.rootDir,
|
||||
@@ -5745,6 +5747,7 @@ export class SelfHealingManager {
|
||||
const result = await recoverForeignOnlyContamination(task, {
|
||||
repoDir: this.options.rootDir,
|
||||
taskStore: this.store,
|
||||
integrationBranch,
|
||||
runAudit: createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-heal", task.id),
|
||||
agentId: "self-healing",
|
||||
@@ -5846,7 +5849,7 @@ export class SelfHealingManager {
|
||||
|
||||
const taskIds: string[] = [];
|
||||
for (const task of candidates) {
|
||||
const ahead = await isBranchAheadOfBase(task, this.options.rootDir, task.baseBranch || "main");
|
||||
const ahead = await isBranchAheadOfBase(task, this.options.rootDir, task.baseBranch || await resolveIntegrationBranch(this.options.rootDir, undefined));
|
||||
if (ahead && ahead.aheadCount === 0) {
|
||||
taskIds.push(task.id);
|
||||
}
|
||||
@@ -7143,6 +7146,7 @@ export class SelfHealingManager {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve orphaned `fusion/*` branches.
|
||||
* Subsumed branches are pruned. Unique-commit branches are left untouched (operator-managed).
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { RunAuditor } from "./run-audit.js";
|
||||
import { resolveTaskWorktreePath } from "./worktree-paths.js";
|
||||
import { inspectBranchConflict } from "./branch-conflicts.js";
|
||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||
import { formatError } from "./logger.js";
|
||||
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
|
||||
import { pruneWorktreeAdminEntries } from "./worktree-prune.js";
|
||||
@@ -383,6 +384,7 @@ export class NativeWorktreeBackend implements WorktreeBackend {
|
||||
conflictingWorktreePath: input.worktreePath,
|
||||
requestingTaskId: input.taskId,
|
||||
startPoint: input.startPoint,
|
||||
integrationRef: await resolveIntegrationBranch(input.rootDir, undefined),
|
||||
});
|
||||
} catch (inspectError) {
|
||||
this.deps.logger?.warn?.(
|
||||
@@ -648,7 +650,7 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
|
||||
|
||||
async sync(input: WorktreeSyncInput): Promise<{ skipped: boolean }> {
|
||||
try {
|
||||
const trunk = input.trunk ?? "main";
|
||||
const trunk = input.trunk ?? await resolveIntegrationBranch(input.rootDir, undefined);
|
||||
await execAsync(`git fetch origin ${quoteShellArg(trunk)}`, {
|
||||
cwd: input.worktreePath,
|
||||
encoding: "utf-8",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./worktree-backend.js";
|
||||
import { cleanupSecretsEnvFile } from "./secrets-env-writer.js";
|
||||
import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js";
|
||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||
import type { RunAuditor } from "./run-audit.js";
|
||||
import { pruneWorktreeAdminEntries } from "./worktree-prune.js";
|
||||
|
||||
@@ -452,7 +453,7 @@ export class WorktreePool {
|
||||
await execAsync("git clean -fd", { cwd: worktreePath });
|
||||
await removeDesktopBuildArtifacts(worktreePath, worktreePoolLog);
|
||||
|
||||
const base = startPoint || "main";
|
||||
const base = startPoint || await resolveIntegrationBranch(options?.repoDir ?? worktreePath, undefined);
|
||||
await execAsync(`git checkout --detach ${base}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
@@ -482,13 +483,15 @@ export class WorktreePool {
|
||||
// conflict or, when explicitly enabled, fall back to the legacy sibling
|
||||
// suffix flow.
|
||||
const conflictingPath = match[1];
|
||||
const repoDir = options?.repoDir ?? worktreePath;
|
||||
const inspection = await inspectBranchConflict({
|
||||
repoDir: options?.repoDir ?? worktreePath,
|
||||
repoDir,
|
||||
branchName,
|
||||
conflictingWorktreePath: conflictingPath,
|
||||
requestingTaskId: options?.requestingTaskId ?? taskId,
|
||||
ownerTaskId: taskId,
|
||||
startPoint: base,
|
||||
integrationRef: await resolveIntegrationBranch(repoDir, undefined),
|
||||
});
|
||||
if (inspection.kind === "stale" || inspection.kind === "stale-resolved" || inspection.kind === "tip-already-merged") {
|
||||
const backend = resolveWorktreeBackendViaSettings({}, { logger: worktreePoolLog });
|
||||
|
||||
Reference in New Issue
Block a user