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:
gsxdsm
2026-04-22 22:17:33 -07:00
parent 21d6703b22
commit eb5393dbdb
5 changed files with 163 additions and 3 deletions

View File

@@ -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 13 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 = "";