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:
@@ -96,6 +96,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
defaultPresetBySize: {},
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
worktreeRebaseBeforeMerge: true,
|
||||
worktreeRebaseRemote: "",
|
||||
strictScopeEnforcement: false,
|
||||
buildRetryCount: 0,
|
||||
verificationFixRetries: 1,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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