feat(merger): rebase task branch onto remote before merge
Adds a new worktree setting that fetches the configured remote and rebases the task branch onto the latest default-branch tip before the merger attempts to merge it back. Catches concurrent pushes from other collaborators or fusion workers on other hosts before they surface as merge conflicts — anything the rebase can't fast-forward flows into the existing smart/AI resolve pipeline (attempts 1–3) rather than needing new handling. - `settings.worktreeRebaseBeforeMerge` (bool, default true) — gates the step. - `settings.worktreeRebaseRemote` (string, default "") — which remote to fetch; empty falls back to git's configured remote for the default branch, then to the sole remote if there's only one, then to "origin". - Rebase runs inside the task's worktree; failure aborts and falls through to the merge cascade. Rebase errors are warn-logged but never throw. - Dashboard SettingsModal Worktrees section now has a toggle for the setting plus a remote dropdown populated from `/api/git/remotes/detailed`. The dropdown defaults to "Use git default" so no explicit selection is needed on first configure. Also aligns the Last/Next heartbeat spans on the agent list card — both now share the `.agent-heartbeat-last, .agent-heartbeat-next, .agent-heartbeat-saving` font-size rule with a consistent line-height and inline-flex alignment so the labels don't drift vertically when they share a row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1823,6 +1823,98 @@ export async function aiMergeTask(
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Pre-merge remote rebase.
|
||||
//
|
||||
// When another collaborator (or another fusion worker on a different
|
||||
// machine) pushes to the remote while our task branch is in flight, the
|
||||
// merge would otherwise surface as a conflict. Rebasing the task branch
|
||||
// onto the latest remote tip beforehand turns most of those into trivial
|
||||
// fast-forwards. When conflicts do appear the existing smart/AI resolve
|
||||
// flow (Attempts 1–3 below) picks them up just like normal merge
|
||||
// conflicts — the caller doesn't need to distinguish.
|
||||
//
|
||||
// Controlled by `settings.worktreeRebaseBeforeMerge` (default true) and
|
||||
// `settings.worktreeRebaseRemote` (empty → use repo's default remote).
|
||||
if (settings.worktreeRebaseBeforeMerge !== false) {
|
||||
try {
|
||||
// Resolve which remote to fetch. An explicit setting wins; otherwise
|
||||
// the repo's configured default (branch.<main>.remote) or the sole
|
||||
// remote if there's exactly one.
|
||||
let remote = settings.worktreeRebaseRemote?.trim();
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout: mainBranchOut } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
const mainBranch = mainBranchOut.trim();
|
||||
const { stdout: configuredRemote } = await execAsync(
|
||||
`git config --get branch.${mainBranch}.remote`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
).catch(() => ({ stdout: "" }));
|
||||
remote = configuredRemote.trim();
|
||||
} catch {
|
||||
// Fall through to listing remotes below.
|
||||
}
|
||||
}
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout: remotesOut } = await execAsync("git remote", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const remotes = remotesOut.trim().split(/\s+/).filter(Boolean);
|
||||
if (remotes.length === 1) {
|
||||
remote = remotes[0];
|
||||
} else if (remotes.includes("origin")) {
|
||||
remote = "origin";
|
||||
}
|
||||
} catch {
|
||||
// Ignore — we'll skip the rebase if no remote is resolvable.
|
||||
}
|
||||
}
|
||||
|
||||
if (!remote) {
|
||||
mergerLog.log(`${taskId}: no remote resolvable — skipping pre-merge rebase`);
|
||||
} else {
|
||||
mergerLog.log(`${taskId}: fetching ${remote} before merge`);
|
||||
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 {
|
||||
const { stdout: mainBranchOut } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
const mainBranch = mainBranchOut.trim();
|
||||
const remoteRef = `${remote}/${mainBranch}`;
|
||||
|
||||
// Rebase in the task's worktree (so the rootDir's HEAD isn't
|
||||
// disturbed). This is the same worktree the executor used.
|
||||
if (worktreePath) {
|
||||
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
|
||||
mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
|
||||
} else {
|
||||
mergerLog.warn(`${taskId}: no worktreePath — skipping task branch rebase`);
|
||||
}
|
||||
} catch (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`);
|
||||
if (worktreePath) {
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`${taskId}: pre-merge rebase pipeline failed (${msg}) — proceeding without rebase`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Gather context for the agent (used in all attempts)
|
||||
let commitLog = "";
|
||||
let diffStat = "";
|
||||
|
||||
Reference in New Issue
Block a user