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 22d31c4cac
commit b72ff64a67
5 changed files with 163 additions and 3 deletions

View File

@@ -96,6 +96,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
defaultPresetBySize: {},
autoResolveConflicts: true,
smartConflictResolution: true,
worktreeRebaseBeforeMerge: true,
worktreeRebaseRemote: "",
strictScopeEnforcement: false,
buildRetryCount: 0,
verificationFixRetries: 1,

View File

@@ -1203,6 +1203,19 @@ export interface ProjectSettings {
* lock files (ours), generated files (theirs), and trivial whitespace conflicts
* without spawning an AI agent. Default: true. */
smartConflictResolution?: boolean;
/** When true, the merger fetches the remote and rebases the task branch
* onto the latest `<remote>/<defaultBranch>` before attempting to merge
* it back into the main branch. This catches upstream changes from
* other collaborators (or from a running fusion worker on another host)
* before they become a merge conflict. Auto-resolve still runs on any
* conflicts the rebase surfaces, so most of the time this is invisible.
* Default: true. */
worktreeRebaseBeforeMerge?: boolean;
/** Git remote to fetch from for the pre-merge rebase. When unset or empty,
* the merger resolves the default remote from the repo's configuration
* (typically `origin`). Exposed as a dropdown in the dashboard's
* Worktrees settings. */
worktreeRebaseRemote?: string;
/** When true, out-of-scope file changes block merge instead of just logging warnings.
* Useful for teams that want strict enforcement of declared File Scope.
* Default: false (soft guardrail — warnings only). */

View File

@@ -2,8 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { Globe, Folder } from "lucide-react";
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, PromptKey, AgentPromptsConfig } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities, MemoryFileInfo, MemoryRetrievalTestResult } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchGitRemotesDetailed } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
@@ -188,6 +188,9 @@ export function SettingsModal({
const [memoryContent, setMemoryContent] = useState("");
const [memoryLoading, setMemoryLoading] = useState(false);
const [memoryDirty, setMemoryDirty] = useState(false);
// Git remotes for the worktree rebase dropdown. Loaded lazily; empty list
// is a valid state (fresh repo, no remotes configured yet).
const [gitRemotes, setGitRemotes] = useState<GitRemoteDetailed[]>([]);
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
const [selectedMemoryPath, setSelectedMemoryPath] = useState(DEFAULT_MEMORY_EDITOR_PATH);
const [memoryTestQuery, setMemoryTestQuery] = useState("");
@@ -303,6 +306,16 @@ export function SettingsModal({
}
}, [activeSection, projectId]);
// Lazy-load git remotes for the rebase-remote dropdown when the Worktrees
// section becomes visible. Failure is non-fatal: the dropdown falls back
// to just "Use git default".
useEffect(() => {
if (activeSection !== "worktrees") return;
fetchGitRemotesDetailed(projectId)
.then((remotes) => setGitRemotes(remotes))
.catch(() => setGitRemotes([]));
}, [activeSection, projectId]);
useEffect(() => {
if (activeSection !== "memory" || memoryDirty) {
return;
@@ -2053,6 +2066,42 @@ export function SettingsModal({
: "How to name fresh worktree directories. Only applies when recycling is off."}
</small>
</div>
<div className="form-group">
<label htmlFor="worktreeRebaseBeforeMerge" className="checkbox-label">
<input
id="worktreeRebaseBeforeMerge"
type="checkbox"
checked={form.worktreeRebaseBeforeMerge !== false}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeRebaseBeforeMerge: e.target.checked }))
}
/>
Rebase from remote before merge
</label>
<small>When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline.</small>
</div>
{form.worktreeRebaseBeforeMerge !== false && (
<div className="form-group">
<label htmlFor="worktreeRebaseRemote">Rebase Remote</label>
<select
id="worktreeRebaseRemote"
value={form.worktreeRebaseRemote ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeRebaseRemote: e.target.value || undefined }))
}
>
<option value="">Use git default</option>
{gitRemotes.map((remote) => (
<option key={remote.name} value={remote.name}>
{remote.name} ({remote.fetchUrl})
</option>
))}
</select>
<small>
Which remote to fetch for the pre-merge rebase. "Use git default" falls back to the remote configured for the default branch (typically <code>origin</code>).
</small>
</div>
)}
</>
);
case "commands":

View File

@@ -21409,7 +21409,7 @@ html .column.drag-over * {
flex-direction: column;
max-width: 540px;
width: 100%;
max-height: min(720px, calc(100dvh - (var(--space-2xl) * 2)));
max-height: min(720px, calc(100dvh - var(--overlay-padding-top, 10vh) - var(--space-lg)));
animation: slideUp var(--transition-normal) ease-out;
overflow: hidden;
}
@@ -26623,8 +26623,12 @@ html .column.drag-over * {
}
.agent-heartbeat-last,
.agent-heartbeat-next,
.agent-heartbeat-saving {
font-size: var(--space-md);
line-height: 1;
display: inline-flex;
align-items: center;
}
.agent-card-actions {

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