fix(engine): split pre-merge rebase into independent remote + local-base stages
The pre-merge rebase had the local-base rebase nested inside the remote-rebase success path, so when no remote resolved (or worktreePath was missing), the entire block exited without running local-base. That left smart-prefer-main exposed: with no rebase, the -X ours fallback would silently re-introduce code main had recently deleted, which is exactly the case the strategy is meant to prevent. Restructure so remote rebase (Stage 1) and local-base rebase (Stage 2) run as independent gates. Local-base rebase still picks up sibling-task merges that landed locally even when the remote stage was skipped or disabled, so prefer-main always gets at least one defense. Also relax the semantic-incompatibility guard: prefer-main now requires EITHER stage to remain enabled (was: required worktreeRebaseBeforeMerge). Extracted runLocalBaseRebase() helper to remove duplication between the two entry points (after Stage 1 vs. standalone) and centralize the ancestor-check + abort handling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -677,7 +677,11 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
|
|||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hard-fails when prefer-main is paired with worktreeRebaseBeforeMerge=false", async () => {
|
it("hard-fails when prefer-main is paired with both rebase stages disabled", async () => {
|
||||||
|
// After the rebase block was split into two independent stages
|
||||||
|
// (remote + local-base), prefer-main is satisfied by either stage running
|
||||||
|
// successfully. Only when BOTH are explicitly disabled is the
|
||||||
|
// configuration incoherent.
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
@@ -686,6 +690,7 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
|
|||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
mergeConflictStrategy: "smart-prefer-main",
|
mergeConflictStrategy: "smart-prefer-main",
|
||||||
worktreeRebaseBeforeMerge: false,
|
worktreeRebaseBeforeMerge: false,
|
||||||
|
worktreeRebaseLocalBase: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
mockedExecSync.mockImplementation((cmd: any) => {
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
@@ -696,10 +701,58 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
||||||
/Incompatible settings.*smart-prefer-main.*worktreeRebaseBeforeMerge/i,
|
/Incompatible settings.*smart-prefer-main.*worktreeRebase/i,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("local-base rebase still runs when remote is unresolvable (independent stages)", async () => {
|
||||||
|
// Regression: previously the local-base rebase was nested inside the
|
||||||
|
// remote-rebase success path, so a missing remote would skip BOTH. Now
|
||||||
|
// the stages are independent — local-base runs even when remote can't
|
||||||
|
// resolve, providing prefer-main with a usable safety net.
|
||||||
|
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,
|
||||||
|
mergeConflictStrategy: "smart-prefer-main",
|
||||||
|
});
|
||||||
|
|
||||||
|
let localBaseRebaseRan = false;
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||||
|
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
|
||||||
|
if (cmdStr === "git rev-parse --abbrev-ref HEAD") return "main" as any;
|
||||||
|
// No remote configured: empty config + empty remote list
|
||||||
|
if (cmdStr.includes("git config --get branch.")) return "" as any;
|
||||||
|
if (cmdStr === "git remote") return "" as any;
|
||||||
|
// Local HEAD lookup for Stage 2
|
||||||
|
if (cmdStr === "git rev-parse HEAD") return "localhead123";
|
||||||
|
// Stage 2 ancestor check fails (not contained), so rebase runs
|
||||||
|
if (cmdStr.includes("merge-base --is-ancestor")) {
|
||||||
|
const err: any = new Error("not an ancestor");
|
||||||
|
err.status = 1;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Stage 2 rebase command
|
||||||
|
if (cmdStr.includes('git rebase "localhead123"')) {
|
||||||
|
localBaseRebaseRan = true;
|
||||||
|
return Buffer.from("");
|
||||||
|
}
|
||||||
|
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||||
|
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
|
||||||
|
if (cmdStr.includes("diff --cached --quiet")) return "1";
|
||||||
|
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+), 0 deletions(-)";
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
|
||||||
|
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||||
|
|
||||||
|
expect(localBaseRebaseRan).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("hard-fails when smart-prefer-main rebase aborts (no silent fall-through)", async () => {
|
it("hard-fails when smart-prefer-main rebase aborts (no silent fall-through)", async () => {
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
@@ -4938,6 +4991,14 @@ describe("aiMergeTask — merge details collection", () => {
|
|||||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
|
// Use prefer-branch: this test simulates `git rev-parse HEAD` failing to
|
||||||
|
// exercise merge-details fallback. The local-base rebase stage hits the
|
||||||
|
// same command first; under prefer-main its failure would hard-fail. This
|
||||||
|
// test isn't about prefer-main semantics, so opt out.
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
mergeConflictStrategy: "smart-prefer-branch",
|
||||||
|
});
|
||||||
|
|
||||||
let revParseHeadCalled = false;
|
let revParseHeadCalled = false;
|
||||||
mockedExecSync.mockImplementation((cmd: any) => {
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
|||||||
@@ -2272,26 +2272,86 @@ export async function aiMergeTask(
|
|||||||
// recently deleted (the merge sees branch additions vs main deletions as
|
// recently deleted (the merge sees branch additions vs main deletions as
|
||||||
// non-conflicting). Track here and throw outside the catch wrapper.
|
// non-conflicting). Track here and throw outside the catch wrapper.
|
||||||
//
|
//
|
||||||
// We also surface silent-skip cases (no remote, no worktreePath) as
|
// The block runs as TWO INDEPENDENT STAGES so that prefer-main always gets
|
||||||
// warnings so they're observable in logs even when prefer-main can't
|
// the strongest available rebase coverage:
|
||||||
// strictly enforce its semantics.
|
// Stage 1 (remote): rebase onto remote/main when enabled + remote resolves.
|
||||||
|
// Picks up upstream pushes from collaborators / other
|
||||||
|
// workers.
|
||||||
|
// Stage 2 (local-base): rebase onto rootDir's HEAD when enabled. Picks up
|
||||||
|
// sibling-task merges that landed locally but
|
||||||
|
// haven't pushed yet, AND covers the no-remote case
|
||||||
|
// where Stage 1 silently skipped.
|
||||||
|
// Either stage failing under prefer-main is a hard error.
|
||||||
let rebaseHappened = false;
|
let rebaseHappened = false;
|
||||||
let preferMainRebaseFailureMessage: string | undefined;
|
let preferMainRebaseFailureMessage: string | undefined;
|
||||||
// Semantic guard: prefer-main + rebase explicitly disabled is incoherent —
|
|
||||||
// the strategy depends on the rebase to honor main's deletions. Fail fast
|
// Semantic guards: prefer-main with no rebase available is incoherent —
|
||||||
|
// the strategy depends on rebase to honor main's deletions. Fail fast
|
||||||
// before we waste work attempting a merge that can't deliver its promise.
|
// before we waste work attempting a merge that can't deliver its promise.
|
||||||
if (
|
if (
|
||||||
settings.worktreeRebaseBeforeMerge === false
|
settings.worktreeRebaseBeforeMerge === false
|
||||||
|
&& settings.worktreeRebaseLocalBase === false
|
||||||
&& mergeConflictStrategy === "smart-prefer-main"
|
&& mergeConflictStrategy === "smart-prefer-main"
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Incompatible settings for ${taskId}: mergeConflictStrategy="smart-prefer-main" ` +
|
`Incompatible settings for ${taskId}: mergeConflictStrategy="smart-prefer-main" ` +
|
||||||
`requires worktreeRebaseBeforeMerge to remain enabled. The strategy relies on ` +
|
`requires at least one of worktreeRebaseBeforeMerge or worktreeRebaseLocalBase ` +
|
||||||
`rebasing the branch onto current main to preserve main's deletions; with rebase ` +
|
`to remain enabled. The strategy relies on rebasing the branch onto current main ` +
|
||||||
`disabled it can silently re-introduce branch-only content. Re-enable ` +
|
`to preserve main's deletions; with both disabled it can silently re-introduce ` +
|
||||||
`worktreeRebaseBeforeMerge or switch to "smart-prefer-branch" / "ai-only".`,
|
`branch-only content. Re-enable a rebase stage or switch to "smart-prefer-branch" ` +
|
||||||
|
`/ "ai-only".`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: run the local-base rebase (Stage 2). Centralized so both
|
||||||
|
// entry points (after Stage 1, or standalone when Stage 1 is disabled)
|
||||||
|
// share the same logic for ancestor check, rebase, and abort handling.
|
||||||
|
async function runLocalBaseRebase(label: string): Promise<void> {
|
||||||
|
if (!worktreePath) return;
|
||||||
|
try {
|
||||||
|
const { stdout: localHeadOut } = await execAsync("git rev-parse HEAD", {
|
||||||
|
cwd: rootDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
const localHead = localHeadOut.trim();
|
||||||
|
if (!localHead) return;
|
||||||
|
|
||||||
|
// Skip if worktree branch already contains local HEAD.
|
||||||
|
let alreadyContains = false;
|
||||||
|
try {
|
||||||
|
await execAsync(`git merge-base --is-ancestor "${localHead}" HEAD`, { cwd: worktreePath });
|
||||||
|
alreadyContains = true;
|
||||||
|
} catch {
|
||||||
|
// not an ancestor — rebase needed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alreadyContains) {
|
||||||
|
// Branch is already up-to-date with current main; prefer-main is
|
||||||
|
// satisfied without re-running git rebase.
|
||||||
|
rebaseHappened = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throwIfAborted(options.signal, taskId);
|
||||||
|
await execAsync(`git rebase "${localHead}"`, { cwd: worktreePath });
|
||||||
|
rebaseHappened = true;
|
||||||
|
mergerLog.log(`${taskId}: rebased ${branch} onto local HEAD ${localHead.slice(0, 8)}${label ? ` (${label})` : ""}`);
|
||||||
|
} catch (localRebaseErr) {
|
||||||
|
rethrowIfMergeAborted(localRebaseErr);
|
||||||
|
const lmsg = localRebaseErr instanceof Error ? localRebaseErr.message : String(localRebaseErr);
|
||||||
|
mergerLog.warn(`${taskId}: pre-merge rebase onto local HEAD failed (${lmsg}) — aborting and falling through`);
|
||||||
|
try {
|
||||||
|
await execAsync("git rebase --abort", { cwd: worktreePath });
|
||||||
|
} catch (abortError: unknown) {
|
||||||
|
mergerLog.warn(`${taskId}: failed to abort local-HEAD rebase: ${getCommandErrorMessage(abortError)}`);
|
||||||
|
}
|
||||||
|
if (mergeConflictStrategy === "smart-prefer-main" && !preferMainRebaseFailureMessage) {
|
||||||
|
preferMainRebaseFailureMessage = `Pre-merge rebase onto local HEAD aborted (${lmsg})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 1: remote rebase ────────────────────────────────────────────
|
||||||
if (settings.worktreeRebaseBeforeMerge !== false) {
|
if (settings.worktreeRebaseBeforeMerge !== false) {
|
||||||
try {
|
try {
|
||||||
// Resolve which remote to fetch. An explicit setting wins; otherwise
|
// Resolve which remote to fetch. An explicit setting wins; otherwise
|
||||||
@@ -2332,17 +2392,14 @@ export async function aiMergeTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
mergerLog.log(`${taskId}: no remote resolvable — skipping pre-merge rebase`);
|
mergerLog.log(`${taskId}: no remote resolvable — skipping remote rebase stage (local-base stage may still run)`);
|
||||||
|
} else if (!worktreePath) {
|
||||||
|
mergerLog.warn(`${taskId}: no worktreePath — skipping remote rebase stage`);
|
||||||
} else {
|
} else {
|
||||||
throwIfAborted(options.signal, taskId);
|
throwIfAborted(options.signal, taskId);
|
||||||
mergerLog.log(`${taskId}: fetching ${remote} before merge`);
|
mergerLog.log(`${taskId}: fetching ${remote} before merge`);
|
||||||
await execAsync(`git fetch "${remote}"`, { cwd: rootDir });
|
await execAsync(`git fetch "${remote}"`, { cwd: rootDir });
|
||||||
|
|
||||||
// Rebase the task branch onto the freshly-fetched remote main.
|
|
||||||
// Use a worktree-scoped checkout of the task branch, rebase, then
|
|
||||||
// return rootDir to the main branch so the subsequent merge starts
|
|
||||||
// from the expected state. If rebase fails we log and fall through
|
|
||||||
// — aiMergeTask's attempt cascade still has a chance to succeed.
|
|
||||||
try {
|
try {
|
||||||
const { stdout: mainBranchOut } = await execAsync(
|
const { stdout: mainBranchOut } = await execAsync(
|
||||||
"git rev-parse --abbrev-ref HEAD",
|
"git rev-parse --abbrev-ref HEAD",
|
||||||
@@ -2350,73 +2407,14 @@ export async function aiMergeTask(
|
|||||||
);
|
);
|
||||||
const mainBranch = mainBranchOut.trim();
|
const mainBranch = mainBranchOut.trim();
|
||||||
const remoteRef = `${remote}/${mainBranch}`;
|
const remoteRef = `${remote}/${mainBranch}`;
|
||||||
|
throwIfAborted(options.signal, taskId);
|
||||||
// Rebase in the task's worktree (so the rootDir's HEAD isn't
|
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
|
||||||
// disturbed). This is the same worktree the executor used.
|
rebaseHappened = true;
|
||||||
if (worktreePath) {
|
mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
|
||||||
throwIfAborted(options.signal, taskId);
|
|
||||||
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
|
|
||||||
rebaseHappened = true;
|
|
||||||
mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
|
|
||||||
|
|
||||||
// Stage 2: also rebase onto rootDir's local HEAD when enabled.
|
|
||||||
// Catches sibling-task merges that landed locally but haven't
|
|
||||||
// been pushed to remote yet. Without this, two tasks based on a
|
|
||||||
// common ancestor where one deletes code can have the other
|
|
||||||
// silently resurrect that code via a -X theirs fallback.
|
|
||||||
if (settings.worktreeRebaseLocalBase !== false) {
|
|
||||||
try {
|
|
||||||
const { stdout: localHeadOut } = await execAsync("git rev-parse HEAD", {
|
|
||||||
cwd: rootDir,
|
|
||||||
encoding: "utf-8",
|
|
||||||
});
|
|
||||||
const localHead = localHeadOut.trim();
|
|
||||||
if (localHead) {
|
|
||||||
// Skip if worktree branch already contains local HEAD.
|
|
||||||
let alreadyContains = false;
|
|
||||||
try {
|
|
||||||
await execAsync(`git merge-base --is-ancestor "${localHead}" HEAD`, { cwd: worktreePath });
|
|
||||||
alreadyContains = true;
|
|
||||||
} catch {
|
|
||||||
// not an ancestor — rebase needed
|
|
||||||
}
|
|
||||||
if (!alreadyContains) {
|
|
||||||
throwIfAborted(options.signal, taskId);
|
|
||||||
await execAsync(`git rebase "${localHead}"`, { cwd: worktreePath });
|
|
||||||
rebaseHappened = true;
|
|
||||||
mergerLog.log(`${taskId}: rebased ${branch} onto local HEAD ${localHead.slice(0, 8)}`);
|
|
||||||
} else {
|
|
||||||
// Already contains current main — branch is up-to-date,
|
|
||||||
// so prefer-main semantics are satisfied even without a
|
|
||||||
// fresh rebase command running.
|
|
||||||
rebaseHappened = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (localRebaseErr) {
|
|
||||||
rethrowIfMergeAborted(localRebaseErr);
|
|
||||||
const lmsg = localRebaseErr instanceof Error ? localRebaseErr.message : String(localRebaseErr);
|
|
||||||
mergerLog.warn(`${taskId}: pre-merge rebase onto local HEAD failed (${lmsg}) — aborting and falling through`);
|
|
||||||
try {
|
|
||||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
|
||||||
} catch (abortError: unknown) {
|
|
||||||
mergerLog.warn(`${taskId}: failed to abort local-HEAD rebase: ${getCommandErrorMessage(abortError)}`);
|
|
||||||
}
|
|
||||||
// Strict prefer-main semantics: a stale branch base means main's
|
|
||||||
// deletions can be silently re-added by the -X ours fallback.
|
|
||||||
// Record so we can throw after the outer catch.
|
|
||||||
if (mergeConflictStrategy === "smart-prefer-main") {
|
|
||||||
preferMainRebaseFailureMessage =
|
|
||||||
`Pre-merge rebase onto local HEAD aborted (${lmsg})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mergerLog.warn(`${taskId}: no worktreePath — skipping task branch rebase`);
|
|
||||||
}
|
|
||||||
} catch (rebaseErr) {
|
} catch (rebaseErr) {
|
||||||
rethrowIfMergeAborted(rebaseErr);
|
rethrowIfMergeAborted(rebaseErr);
|
||||||
const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr);
|
const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr);
|
||||||
mergerLog.warn(`${taskId}: pre-merge rebase failed (${msg}) — aborting rebase and falling through to smart/AI merge`);
|
mergerLog.warn(`${taskId}: pre-merge rebase failed (${msg}) — aborting rebase and falling through`);
|
||||||
if (worktreePath) {
|
if (worktreePath) {
|
||||||
try {
|
try {
|
||||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
await execAsync("git rebase --abort", { cwd: worktreePath });
|
||||||
@@ -2424,61 +2422,31 @@ export async function aiMergeTask(
|
|||||||
mergerLog.warn(`${taskId}: failed to abort pre-merge rebase: ${getCommandErrorMessage(abortError)}`);
|
mergerLog.warn(`${taskId}: failed to abort pre-merge rebase: ${getCommandErrorMessage(abortError)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// See above: prefer-main semantics require a successful rebase.
|
|
||||||
if (mergeConflictStrategy === "smart-prefer-main") {
|
if (mergeConflictStrategy === "smart-prefer-main") {
|
||||||
preferMainRebaseFailureMessage =
|
preferMainRebaseFailureMessage = `Pre-merge rebase onto remote main aborted (${msg})`;
|
||||||
`Pre-merge rebase onto remote main aborted (${msg})`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
rethrowIfMergeAborted(err);
|
rethrowIfMergeAborted(err);
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
mergerLog.warn(`${taskId}: pre-merge rebase pipeline failed (${msg}) — proceeding without rebase`);
|
mergerLog.warn(`${taskId}: pre-merge remote rebase pipeline failed (${msg}) — proceeding without remote rebase`);
|
||||||
}
|
|
||||||
} else if (settings.worktreeRebaseLocalBase !== false && worktreePath) {
|
|
||||||
// Remote rebase is disabled but the user still wants the local-base
|
|
||||||
// rebase. This catches sibling-task merges that landed locally even when
|
|
||||||
// the project doesn't have a remote configured.
|
|
||||||
try {
|
|
||||||
const { stdout: localHeadOut } = await execAsync("git rev-parse HEAD", {
|
|
||||||
cwd: rootDir,
|
|
||||||
encoding: "utf-8",
|
|
||||||
});
|
|
||||||
const localHead = localHeadOut.trim();
|
|
||||||
if (localHead) {
|
|
||||||
let alreadyContains = false;
|
|
||||||
try {
|
|
||||||
await execAsync(`git merge-base --is-ancestor "${localHead}" HEAD`, { cwd: worktreePath });
|
|
||||||
alreadyContains = true;
|
|
||||||
} catch {
|
|
||||||
// not an ancestor — rebase needed
|
|
||||||
}
|
|
||||||
if (!alreadyContains) {
|
|
||||||
throwIfAborted(options.signal, taskId);
|
|
||||||
await execAsync(`git rebase "${localHead}"`, { cwd: worktreePath });
|
|
||||||
rebaseHappened = true;
|
|
||||||
mergerLog.log(`${taskId}: rebased ${branch} onto local HEAD ${localHead.slice(0, 8)} (remote rebase disabled)`);
|
|
||||||
} else {
|
|
||||||
rebaseHappened = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (localOnlyErr) {
|
|
||||||
rethrowIfMergeAborted(localOnlyErr);
|
|
||||||
const msg = localOnlyErr instanceof Error ? localOnlyErr.message : String(localOnlyErr);
|
|
||||||
mergerLog.warn(`${taskId}: pre-merge local-HEAD rebase failed (${msg}) — falling through to smart/AI merge`);
|
|
||||||
try {
|
|
||||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
|
||||||
} catch (abortError: unknown) {
|
|
||||||
mergerLog.warn(`${taskId}: failed to abort local-HEAD rebase: ${getCommandErrorMessage(abortError)}`);
|
|
||||||
}
|
|
||||||
if (mergeConflictStrategy === "smart-prefer-main") {
|
|
||||||
preferMainRebaseFailureMessage =
|
|
||||||
`Pre-merge local-HEAD rebase aborted (${msg})`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stage 2: local-base rebase ─────────────────────────────────────────
|
||||||
|
// Runs independently of Stage 1, so the no-remote case still gets coverage.
|
||||||
|
// Skipped if Stage 1 already aborted under prefer-main (we'll throw below
|
||||||
|
// anyway, and a second attempt just adds noise).
|
||||||
|
if (
|
||||||
|
settings.worktreeRebaseLocalBase !== false
|
||||||
|
&& !preferMainRebaseFailureMessage
|
||||||
|
) {
|
||||||
|
await runLocalBaseRebase(
|
||||||
|
settings.worktreeRebaseBeforeMerge === false ? "remote rebase disabled" : "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Hard-fail prefer-main when a rebase started and aborted: a stale branch
|
// Hard-fail prefer-main when a rebase started and aborted: a stale branch
|
||||||
// base means the -X ours fallback can silently re-introduce branch-only
|
// base means the -X ours fallback can silently re-introduce branch-only
|
||||||
// content that main recently deleted.
|
// content that main recently deleted.
|
||||||
|
|||||||
Reference in New Issue
Block a user