fix(dashboard): hand off cleanly from setup wizard to model onboarding
On a fresh install useAuthOnboarding's effect ran at mount before the setup wizard's 500ms auto-open timer fired. The one-shot ref locked, and the resolved fetch could either stack model onboarding on top of the wizard or never re-trigger after the wizard closed. - Gate the trigger on projectId being set so the wizard owns the bootstrap phase; the auth check only fires once a project exists. - Re-check setupWizardOpen via a ref when the auth fetch resolves to avoid stacking onboarding on top of a wizard opened mid-fetch. - Release the one-shot in that suppressed branch so the effect retries when the wizard closes. Adds two regression tests: fresh-install handoff and mid-fetch wizard suppression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4161,11 +4161,11 @@ and show an appropriate message to the user.\`
|
||||
const result = await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
|
||||
// Mirror the merge-time rebase behavior: when worktreeRebaseBeforeMerge
|
||||
// is enabled, fetch the remote and rebase the just-created task branch
|
||||
// onto the latest <remote>/<defaultBranch>. This makes the worktree
|
||||
// start from origin/main + local main both, so divergence only matters
|
||||
// if the user actively skips this setting. Best-effort: failures here
|
||||
// don't abort task setup.
|
||||
await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId).catch((err: unknown) => {
|
||||
// onto the latest <remote>/<defaultBranch>. When worktreeRebaseLocalBase
|
||||
// is also enabled (default), additionally rebase onto rootDir's HEAD —
|
||||
// catches sibling tasks that merged into local main but haven't been
|
||||
// pushed yet. Best-effort: failures don't abort task setup.
|
||||
await this.rebaseNewWorktreeOntoBase(result.path, result.branch, taskId).catch((err: unknown) => {
|
||||
executorLog.warn(
|
||||
`Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
@@ -4202,17 +4202,25 @@ and show an appropriate message to the user.\`
|
||||
}
|
||||
|
||||
/**
|
||||
* After creating a fresh task worktree, fetch the configured remote and
|
||||
* rebase the task branch onto `<remote>/<defaultBranch>`. The result is a
|
||||
* branch that contains origin's tip plus any local main commits, so the
|
||||
* eventual merge has fewer surprises and the executor sees the freshest
|
||||
* code its peers/CI may have published.
|
||||
* After creating a fresh task worktree, rebase the task branch onto the
|
||||
* freshest available base.
|
||||
*
|
||||
* No-op when `worktreeRebaseBeforeMerge` is disabled, no remote is
|
||||
* configured/resolvable, or the rebase produces conflicts (we abort and
|
||||
* leave the worktree as-is so the executor can still run).
|
||||
* Two stages, each independent and best-effort:
|
||||
* 1. Fetch the configured remote and rebase onto `<remote>/<defaultBranch>`
|
||||
* (controlled by `worktreeRebaseBeforeMerge`).
|
||||
* 2. Rebase onto rootDir's HEAD — i.e., the local default branch with any
|
||||
* sibling-task merges that haven't been pushed yet (controlled by
|
||||
* `worktreeRebaseLocalBase`).
|
||||
*
|
||||
* Without stage 2, a task that branches before a sibling merge will silently
|
||||
* resurrect deletions that the sibling already shipped (the FN-2887 class of
|
||||
* regression). With stage 2, even unpushed local merges are picked up.
|
||||
*
|
||||
* No-op when both rebases are disabled. Conflicts at either stage abort that
|
||||
* stage cleanly and leave the worktree usable; the merge-time rebase remains
|
||||
* the backstop.
|
||||
*/
|
||||
private async rebaseNewWorktreeOntoRemote(
|
||||
private async rebaseNewWorktreeOntoBase(
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
taskId: string,
|
||||
@@ -4223,69 +4231,105 @@ and show an appropriate message to the user.\`
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (settings.worktreeRebaseBeforeMerge === false) return;
|
||||
|
||||
let remote = settings.worktreeRebaseRemote?.trim() || "";
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git remote", { cwd: this.rootDir });
|
||||
const remotes = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (remotes.includes("origin")) remote = "origin";
|
||||
else if (remotes.length === 1) remote = remotes[0];
|
||||
} catch {
|
||||
// No remote resolvable — nothing to rebase against.
|
||||
}
|
||||
}
|
||||
if (!remote) return;
|
||||
const remoteRebaseEnabled = settings.worktreeRebaseBeforeMerge !== false;
|
||||
const localRebaseEnabled = settings.worktreeRebaseLocalBase !== false;
|
||||
if (!remoteRebaseEnabled && !localRebaseEnabled) return;
|
||||
|
||||
let defaultBranch = "";
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir });
|
||||
defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), "");
|
||||
} catch {
|
||||
// origin/HEAD not set — fall back to current branch in rootDir.
|
||||
let remote = "";
|
||||
|
||||
if (remoteRebaseEnabled) {
|
||||
remote = settings.worktreeRebaseRemote?.trim() || "";
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git remote", { cwd: this.rootDir });
|
||||
const remotes = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (remotes.includes("origin")) remote = "origin";
|
||||
else if (remotes.length === 1) remote = remotes[0];
|
||||
} catch {
|
||||
// No remote resolvable — fall through; the local-base stage may still apply.
|
||||
}
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir });
|
||||
defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), "");
|
||||
} catch {
|
||||
// origin/HEAD not set — fall through.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!defaultBranch) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir });
|
||||
defaultBranch = stdout.trim();
|
||||
} catch {
|
||||
// No HEAD branch — nothing to rebase onto.
|
||||
}
|
||||
}
|
||||
if (defaultBranch === "HEAD") defaultBranch = "";
|
||||
|
||||
// Stage 1: rebase onto remote tip.
|
||||
if (remoteRebaseEnabled && remote && defaultBranch) {
|
||||
const remoteRef = `${remote}/${defaultBranch}`;
|
||||
try {
|
||||
await execAsync(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`, { cwd: this.rootDir });
|
||||
await execAsync(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath });
|
||||
await this.store.logEntry(taskId, `Rebased new worktree branch ${branch} onto ${remoteRef}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
executorLog.warn(
|
||||
`Worktree rebase: stage 1 (onto ${remoteRef}) failed for ${taskId}: ${msg}`,
|
||||
);
|
||||
try {
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: rebase onto local rootDir HEAD. Picks up sibling merges that
|
||||
// landed locally after this worktree was created but haven't been pushed.
|
||||
if (localRebaseEnabled) {
|
||||
let localHead = "";
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse HEAD", { cwd: this.rootDir });
|
||||
localHead = stdout.trim();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!defaultBranch || defaultBranch === "HEAD") return;
|
||||
if (!localHead) return;
|
||||
|
||||
const remoteRef = `${remote}/${defaultBranch}`;
|
||||
|
||||
try {
|
||||
await execAsync(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`, { cwd: this.rootDir });
|
||||
} catch (err) {
|
||||
executorLog.warn(
|
||||
`Worktree rebase: fetch ${remote} ${defaultBranch} failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath });
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Rebased new worktree branch ${branch} onto ${remoteRef}`,
|
||||
);
|
||||
} catch (rebaseErr) {
|
||||
const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr);
|
||||
executorLog.warn(
|
||||
`Worktree rebase: rebase onto ${remoteRef} failed for ${taskId} — aborting and leaving local base intact: ${msg}`,
|
||||
);
|
||||
// Skip if the worktree branch already contains the local HEAD — nothing to do.
|
||||
try {
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
||||
await execAsync(`git merge-base --is-ancestor ${this.quoteShellArg(localHead)} HEAD`, { cwd: worktreePath });
|
||||
return;
|
||||
} catch {
|
||||
// best-effort
|
||||
// not an ancestor — proceed to rebase.
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git rebase ${this.quoteShellArg(localHead)}`, { cwd: worktreePath });
|
||||
await this.store.logEntry(taskId, `Rebased new worktree branch ${branch} onto local ${defaultBranch || "HEAD"} (${localHead.slice(0, 8)})`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
executorLog.warn(
|
||||
`Worktree rebase: stage 2 (onto local HEAD ${localHead.slice(0, 8)}) failed for ${taskId} — aborting: ${msg}`,
|
||||
);
|
||||
try {
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Could not rebase new worktree onto local ${defaultBranch || "HEAD"} — merge-time rebase will retry with conflict resolution.`,
|
||||
);
|
||||
}
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Could not rebase new worktree onto ${remoteRef} — kept local base. The merge-time rebase will retry with conflict resolution.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ async function execWithProcessGroup(
|
||||
}
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
|
||||
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig, type MergeConflictStrategy } from "@fusion/core";
|
||||
import { resolveAgentPrompt } from "@fusion/core";
|
||||
import { describeModel, promptWithFallback } from "./pi.js";
|
||||
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||
@@ -2170,6 +2170,7 @@ export async function aiMergeTask(
|
||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||
// Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy)
|
||||
const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
|
||||
const mergeConflictStrategy: NonNullable<MergeConflictStrategy> = settings.mergeConflictStrategy ?? "smart";
|
||||
|
||||
// 3. Check branch exists
|
||||
try {
|
||||
@@ -2320,6 +2321,45 @@ export async function aiMergeTask(
|
||||
throwIfAborted(options.signal, taskId);
|
||||
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
|
||||
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 });
|
||||
mergerLog.log(`${taskId}: rebased ${branch} onto local HEAD ${localHead.slice(0, 8)}`);
|
||||
}
|
||||
}
|
||||
} 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mergerLog.warn(`${taskId}: no worktreePath — skipping task branch rebase`);
|
||||
}
|
||||
@@ -2341,6 +2381,40 @@ export async function aiMergeTask(
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`${taskId}: pre-merge rebase pipeline failed (${msg}) — proceeding without 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 });
|
||||
mergerLog.log(`${taskId}: rebased ${branch} onto local HEAD ${localHead.slice(0, 8)} (remote rebase disabled)`);
|
||||
}
|
||||
}
|
||||
} 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Gather context for the agent (used in all attempts)
|
||||
@@ -2436,6 +2510,7 @@ export async function aiMergeTask(
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
smartConflictResolution,
|
||||
mergeConflictStrategy,
|
||||
attemptNum,
|
||||
options,
|
||||
result,
|
||||
@@ -2448,7 +2523,7 @@ export async function aiMergeTask(
|
||||
|
||||
if (success) {
|
||||
result.attemptsMade = attemptNum;
|
||||
result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution);
|
||||
result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution, mergeConflictStrategy);
|
||||
result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
|
||||
result.merged = true;
|
||||
return true;
|
||||
@@ -2698,13 +2773,20 @@ export async function aiMergeTask(
|
||||
// Attempt 1: Standard AI merge
|
||||
merged = await mergeAttempt(1);
|
||||
|
||||
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled)
|
||||
if (!merged && smartConflictResolution) {
|
||||
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled).
|
||||
// Skipped for "abort" — that strategy gives the user one AI shot, no more.
|
||||
if (!merged && smartConflictResolution && mergeConflictStrategy !== "abort") {
|
||||
merged = await mergeAttempt(2);
|
||||
}
|
||||
|
||||
// Attempt 3: Use -X theirs merge strategy (if enabled)
|
||||
if (!merged && smartConflictResolution) {
|
||||
// Attempt 3: -X theirs (smart) or -X ours (prefer-main) fallback.
|
||||
// Skipped for "ai-only" (no silent side-pick) and "abort" (one shot only).
|
||||
if (
|
||||
!merged
|
||||
&& smartConflictResolution
|
||||
&& mergeConflictStrategy !== "ai-only"
|
||||
&& mergeConflictStrategy !== "abort"
|
||||
) {
|
||||
merged = await mergeAttempt(3);
|
||||
}
|
||||
|
||||
@@ -2717,6 +2799,10 @@ export async function aiMergeTask(
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`${taskId}: git reset --merge cleanup failed: ${errorMessage}`);
|
||||
}
|
||||
if (mergeConflictStrategy === "abort") {
|
||||
result.resolutionStrategy = "abort";
|
||||
throw new Error(`Merge conflict for ${taskId}: aborted per mergeConflictStrategy="abort" — manual resolution required`);
|
||||
}
|
||||
throw new Error(`AI merge failed for ${taskId}: all 3 attempts exhausted`);
|
||||
}
|
||||
|
||||
@@ -2926,10 +3012,14 @@ export async function aiMergeTask(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get the resolution strategy based on attempt number and settings */
|
||||
/** Get the resolution strategy based on attempt number and settings.
|
||||
* `mergeConflictStrategy` controls the FALLBACK on attempt 3 (and gates the
|
||||
* whole cascade on "abort"); attempts 1–2 always try AI then auto-resolve so
|
||||
* trivial conflicts don't pay an unnecessary price. */
|
||||
function getResolutionStrategy(
|
||||
attemptNum: 1 | 2 | 3,
|
||||
smartConflictResolution: boolean,
|
||||
mergeConflictStrategy: NonNullable<MergeConflictStrategy> = "smart",
|
||||
): MergeResult["resolutionStrategy"] {
|
||||
if (!smartConflictResolution || attemptNum === 1) {
|
||||
return "ai";
|
||||
@@ -2937,7 +3027,18 @@ function getResolutionStrategy(
|
||||
if (attemptNum === 2) {
|
||||
return "auto-resolve";
|
||||
}
|
||||
return "theirs";
|
||||
// Attempt 3: fallback strategy
|
||||
switch (mergeConflictStrategy) {
|
||||
case "ai-only":
|
||||
return "ai";
|
||||
case "prefer-main":
|
||||
return "ours";
|
||||
case "abort":
|
||||
return "abort";
|
||||
case "smart":
|
||||
default:
|
||||
return "theirs";
|
||||
}
|
||||
}
|
||||
|
||||
/** Map resolutionStrategy and autoResolvedCount to resolutionMethod for metrics/debugging */
|
||||
@@ -2948,6 +3049,8 @@ function getResolutionMethod(
|
||||
): MergeResult["resolutionMethod"] {
|
||||
if (strategy === "ai") return "ai";
|
||||
if (strategy === "theirs") return "theirs";
|
||||
if (strategy === "ours") return "ours";
|
||||
if (strategy === "abort") return "abort";
|
||||
if (strategy === "auto-resolve") {
|
||||
// auto-resolve strategy: determine if pure auto or mixed with AI
|
||||
if (autoResolvedCount && autoResolvedCount > 0) {
|
||||
@@ -2968,6 +3071,7 @@ interface MergeAttemptParams {
|
||||
diffStat: string;
|
||||
includeTaskId: boolean;
|
||||
smartConflictResolution: boolean;
|
||||
mergeConflictStrategy: NonNullable<MergeConflictStrategy>;
|
||||
attemptNum: 1 | 2 | 3;
|
||||
options: MergerOptions;
|
||||
result: MergeResult;
|
||||
@@ -3017,9 +3121,15 @@ async function executeMergeAttempt(
|
||||
buildSource,
|
||||
} = params;
|
||||
|
||||
// Attempt 3: Use -X theirs strategy
|
||||
// Attempt 3: dispatch on the configured fallback strategy.
|
||||
// Note: "ai-only" and "abort" are filtered out by the mergeAttempt cascade
|
||||
// before reaching here — only "smart" (theirs) and "prefer-main" (ours)
|
||||
// legitimately run attempt 3.
|
||||
if (attemptNum === 3) {
|
||||
return attemptWithTheirsStrategy(params);
|
||||
if (params.mergeConflictStrategy === "prefer-main") {
|
||||
return attemptWithSideStrategy(params, "ours");
|
||||
}
|
||||
return attemptWithSideStrategy(params, "theirs");
|
||||
}
|
||||
|
||||
// Attempt 1 & 2: Standard squash merge
|
||||
@@ -3292,17 +3402,22 @@ async function executeMergeAttempt(
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt 3: Use git merge -X theirs --squash strategy
|
||||
* Attempt 3: Use git merge -X{theirs,ours} --squash strategy.
|
||||
* Side controls which version wins on conflicts:
|
||||
* - "theirs" — the task branch wins (default fallback)
|
||||
* - "ours" — the main branch wins (used by mergeConflictStrategy="prefer-main")
|
||||
*/
|
||||
async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<boolean> {
|
||||
async function attemptWithSideStrategy(
|
||||
params: MergeAttemptParams,
|
||||
side: "theirs" | "ours" = "theirs",
|
||||
): Promise<boolean> {
|
||||
const { rootDir, branch, commitLog, includeTaskId, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params;
|
||||
|
||||
mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`);
|
||||
mergerLog.log(`${taskId}: attempting merge with -X ${side} strategy`);
|
||||
|
||||
try {
|
||||
// Use -X theirs to auto-resolve conflicts favoring the incoming branch
|
||||
throwIfAborted(params.options.signal, taskId);
|
||||
await execAsync(`git merge -X theirs --squash "${branch}"`, {
|
||||
await execAsync(`git merge -X ${side} --squash "${branch}"`, {
|
||||
cwd: rootDir,
|
||||
});
|
||||
|
||||
@@ -3313,8 +3428,8 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
|
||||
}).trim();
|
||||
|
||||
if (conflictedOutput.length > 0) {
|
||||
mergerLog.warn(`${taskId}: -X theirs left unresolved conflicts: ${conflictedOutput}`);
|
||||
return false; // Still has conflicts after -X theirs
|
||||
mergerLog.warn(`${taskId}: -X ${side} left unresolved conflicts: ${conflictedOutput}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there's anything staged
|
||||
@@ -3351,7 +3466,7 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${authorArg}`,
|
||||
{ cwd: rootDir },
|
||||
);
|
||||
mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`);
|
||||
mergerLog.log(`${taskId}: committed with -X ${side} auto-resolution`);
|
||||
|
||||
// Run deterministic verification after committing
|
||||
if (testCommand || buildCommand) {
|
||||
@@ -3373,7 +3488,7 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
|
||||
if (error instanceof Error && error.name === "MergeAbortedError") {
|
||||
throw error;
|
||||
}
|
||||
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
|
||||
mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user