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:
@@ -1,5 +1,5 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
|
||||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
export type { MergeStrategy, MergeConflictStrategy, Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
export {
|
export {
|
||||||
BUILTIN_AGENT_PROMPTS,
|
BUILTIN_AGENT_PROMPTS,
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
smartConflictResolution: true,
|
smartConflictResolution: true,
|
||||||
worktreeRebaseBeforeMerge: true,
|
worktreeRebaseBeforeMerge: true,
|
||||||
worktreeRebaseRemote: "",
|
worktreeRebaseRemote: "",
|
||||||
|
worktreeRebaseLocalBase: true,
|
||||||
|
mergeConflictStrategy: "smart",
|
||||||
workflowStepTimeoutMs: 360_000,
|
workflowStepTimeoutMs: 360_000,
|
||||||
strictScopeEnforcement: false,
|
strictScopeEnforcement: false,
|
||||||
buildRetryCount: 0,
|
buildRetryCount: 0,
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ export type ColorTheme = (typeof COLOR_THEMES)[number];
|
|||||||
|
|
||||||
export type PrStatus = "open" | "closed" | "merged";
|
export type PrStatus = "open" | "closed" | "merged";
|
||||||
export type MergeStrategy = "direct" | "pull-request";
|
export type MergeStrategy = "direct" | "pull-request";
|
||||||
|
/** How merge conflicts are resolved when the AI agent can't (or shouldn't) decide.
|
||||||
|
* - "smart" (default): try AI, then auto-resolve lock/generated/trivial files,
|
||||||
|
* then fall back to `-X theirs` (task branch wins). Backwards-compatible.
|
||||||
|
* - "ai-only": run AI on every attempt; never silently prefer one side.
|
||||||
|
* - "prefer-main": after AI/auto-resolve, fall back to `-X ours` so main's
|
||||||
|
* state wins. Best when concurrent tasks frequently regress just-merged
|
||||||
|
* sibling work.
|
||||||
|
* - "abort": run AI once; if conflict remains, fail the merge so a human
|
||||||
|
* can resolve it. */
|
||||||
|
export type MergeConflictStrategy = "smart" | "ai-only" | "prefer-main" | "abort";
|
||||||
/** Policy for handling task execution when the selected node is unavailable/unhealthy. */
|
/** Policy for handling task execution when the selected node is unavailable/unhealthy. */
|
||||||
export type UnavailableNodePolicy = "block" | "fallback-local";
|
export type UnavailableNodePolicy = "block" | "fallback-local";
|
||||||
|
|
||||||
@@ -685,8 +695,8 @@ export interface MergeDetails {
|
|||||||
mergedAt?: string;
|
mergedAt?: string;
|
||||||
mergeConfirmed?: boolean;
|
mergeConfirmed?: boolean;
|
||||||
prNumber?: number;
|
prNumber?: number;
|
||||||
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
|
resolutionStrategy?: "ai" | "auto-resolve" | "theirs" | "ours" | "abort";
|
||||||
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs";
|
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs" | "ours" | "abort";
|
||||||
attemptsMade?: 1 | 2 | 3;
|
attemptsMade?: 1 | 2 | 3;
|
||||||
autoResolvedCount?: number;
|
autoResolvedCount?: number;
|
||||||
}
|
}
|
||||||
@@ -1500,6 +1510,17 @@ export interface ProjectSettings {
|
|||||||
* (typically `origin`). Exposed as a dropdown in the dashboard's
|
* (typically `origin`). Exposed as a dropdown in the dashboard's
|
||||||
* Worktrees settings. */
|
* Worktrees settings. */
|
||||||
worktreeRebaseRemote?: string;
|
worktreeRebaseRemote?: string;
|
||||||
|
/** When true, the worktree is also rebased onto the local default-branch
|
||||||
|
* HEAD (in addition to the remote rebase). Catches sibling tasks that
|
||||||
|
* merged into local main *after* this task's worktree was created but
|
||||||
|
* *before* its merge — including merges that haven't been pushed yet.
|
||||||
|
* Without this, concurrent task branches based on stale main can silently
|
||||||
|
* re-introduce code that an earlier sibling task already deleted.
|
||||||
|
* Default: true. */
|
||||||
|
worktreeRebaseLocalBase?: boolean;
|
||||||
|
/** Strategy used when a merge conflict can't be resolved by AI. See
|
||||||
|
* {@link MergeConflictStrategy}. Default: "smart". */
|
||||||
|
mergeConflictStrategy?: MergeConflictStrategy;
|
||||||
/** Wall-clock timeout (ms) for a single pre-merge workflow step's AI call.
|
/** Wall-clock timeout (ms) for a single pre-merge workflow step's AI call.
|
||||||
* When a step exceeds this, the session is aborted and the executor is
|
* When a step exceeds this, the session is aborted and the executor is
|
||||||
* given one shot to retry with the configured fallback model before the
|
* given one shot to retry with the configured fallback model before the
|
||||||
|
|||||||
@@ -2770,6 +2770,22 @@ export function SettingsModal({
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="worktreeRebaseLocalBase" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="worktreeRebaseLocalBase"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.worktreeRebaseLocalBase !== false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, worktreeRebaseLocalBase: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Also rebase onto local default-branch HEAD
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet — without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "commands":
|
case "commands":
|
||||||
@@ -2937,6 +2953,24 @@ export function SettingsModal({
|
|||||||
</label>
|
</label>
|
||||||
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.</small>
|
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="mergeConflictStrategy">Conflict Fallback Strategy</label>
|
||||||
|
<select
|
||||||
|
id="mergeConflictStrategy"
|
||||||
|
value={form.mergeConflictStrategy ?? "smart"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart" | "ai-only" | "prefer-main" | "abort" }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="smart">Smart — AI, then auto-resolve, then prefer task branch (default)</option>
|
||||||
|
<option value="ai-only">AI only — never silently pick a side</option>
|
||||||
|
<option value="prefer-main">Prefer main — fall back to keeping main's version</option>
|
||||||
|
<option value="abort">Abort — fail merge if AI can't resolve (manual review)</option>
|
||||||
|
</select>
|
||||||
|
<small>
|
||||||
|
What to do on the final merge attempt when AI and auto-resolve haven't fully resolved the conflict. <strong>Smart</strong> matches the historical behavior — the task branch wins on hard conflicts, which is fast but can resurrect code an earlier sibling task deleted. <strong>Prefer main</strong> sides with main instead, protecting just-merged sibling work. <strong>AI only</strong> retries the AI agent rather than silently choosing a side. <strong>Abort</strong> requires manual resolution for any conflict the AI can't handle.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="pushAfterMerge" className="checkbox-label">
|
<label htmlFor="pushAfterMerge" className="checkbox-label">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1702,31 +1702,6 @@ export function TaskDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MergeDetails task={task} />
|
<MergeDetails task={task} />
|
||||||
<div className="detail-section">
|
|
||||||
<h4>Node Routing</h4>
|
|
||||||
<dl className="detail-source-grid">
|
|
||||||
<div>
|
|
||||||
<dt>Task Override</dt>
|
|
||||||
<dd>{task.nodeId ?? <span className="detail-source-empty">(none)</span>}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Effective Node</dt>
|
|
||||||
<dd>{(task as Task & { effectiveNodeId?: string }).effectiveNodeId ?? "local execution"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Routing Source</dt>
|
|
||||||
<dd>{(task as Task & { effectiveNodeSource?: string }).effectiveNodeSource ?? "local"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Unavailable Node Policy</dt>
|
|
||||||
<dd>{(settings as Settings & { unavailableNodePolicy?: string } | undefined)?.unavailableNodePolicy ?? "block"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Blocking Reason</dt>
|
|
||||||
<dd>{((task as Task & { blockedReason?: string; statusReason?: string }).blockedReason || (task as Task & { statusReason?: string }).statusReason) ?? <span className="detail-source-empty">(not blocked)</span>}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
{task.sourceIssue && (
|
{task.sourceIssue && (
|
||||||
<div className="detail-section detail-source-section">
|
<div className="detail-section detail-source-section">
|
||||||
<h4>Source Issue</h4>
|
<h4>Source Issue</h4>
|
||||||
|
|||||||
@@ -4161,11 +4161,11 @@ and show an appropriate message to the user.\`
|
|||||||
const result = await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
|
const result = await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
|
||||||
// Mirror the merge-time rebase behavior: when worktreeRebaseBeforeMerge
|
// Mirror the merge-time rebase behavior: when worktreeRebaseBeforeMerge
|
||||||
// is enabled, fetch the remote and rebase the just-created task branch
|
// is enabled, fetch the remote and rebase the just-created task branch
|
||||||
// onto the latest <remote>/<defaultBranch>. This makes the worktree
|
// onto the latest <remote>/<defaultBranch>. When worktreeRebaseLocalBase
|
||||||
// start from origin/main + local main both, so divergence only matters
|
// is also enabled (default), additionally rebase onto rootDir's HEAD —
|
||||||
// if the user actively skips this setting. Best-effort: failures here
|
// catches sibling tasks that merged into local main but haven't been
|
||||||
// don't abort task setup.
|
// pushed yet. Best-effort: failures don't abort task setup.
|
||||||
await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId).catch((err: unknown) => {
|
await this.rebaseNewWorktreeOntoBase(result.path, result.branch, taskId).catch((err: unknown) => {
|
||||||
executorLog.warn(
|
executorLog.warn(
|
||||||
`Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`,
|
`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
|
* After creating a fresh task worktree, rebase the task branch onto the
|
||||||
* rebase the task branch onto `<remote>/<defaultBranch>`. The result is a
|
* freshest available base.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* No-op when `worktreeRebaseBeforeMerge` is disabled, no remote is
|
* Two stages, each independent and best-effort:
|
||||||
* configured/resolvable, or the rebase produces conflicts (we abort and
|
* 1. Fetch the configured remote and rebase onto `<remote>/<defaultBranch>`
|
||||||
* leave the worktree as-is so the executor can still run).
|
* (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,
|
worktreePath: string,
|
||||||
branch: string,
|
branch: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
@@ -4223,69 +4231,105 @@ and show an appropriate message to the user.\`
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (settings.worktreeRebaseBeforeMerge === false) return;
|
|
||||||
|
|
||||||
let remote = settings.worktreeRebaseRemote?.trim() || "";
|
const remoteRebaseEnabled = settings.worktreeRebaseBeforeMerge !== false;
|
||||||
if (!remote) {
|
const localRebaseEnabled = settings.worktreeRebaseLocalBase !== false;
|
||||||
try {
|
if (!remoteRebaseEnabled && !localRebaseEnabled) return;
|
||||||
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;
|
|
||||||
|
|
||||||
let defaultBranch = "";
|
let defaultBranch = "";
|
||||||
try {
|
let remote = "";
|
||||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir });
|
|
||||||
defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), "");
|
if (remoteRebaseEnabled) {
|
||||||
} catch {
|
remote = settings.worktreeRebaseRemote?.trim() || "";
|
||||||
// origin/HEAD not set — fall back to current branch in rootDir.
|
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) {
|
if (!defaultBranch) {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir });
|
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir });
|
||||||
defaultBranch = stdout.trim();
|
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 {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (!localHead) return;
|
||||||
if (!defaultBranch || defaultBranch === "HEAD") return;
|
|
||||||
|
|
||||||
const remoteRef = `${remote}/${defaultBranch}`;
|
// Skip if the worktree branch already contains the local HEAD — nothing to do.
|
||||||
|
|
||||||
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}`,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
await execAsync(`git merge-base --is-ancestor ${this.quoteShellArg(localHead)} HEAD`, { cwd: worktreePath });
|
||||||
|
return;
|
||||||
} catch {
|
} 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 { existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
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 { resolveAgentPrompt } from "@fusion/core";
|
||||||
import { describeModel, promptWithFallback } from "./pi.js";
|
import { describeModel, promptWithFallback } from "./pi.js";
|
||||||
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||||
@@ -2170,6 +2170,7 @@ export async function aiMergeTask(
|
|||||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||||
// Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy)
|
// Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy)
|
||||||
const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
|
const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
|
||||||
|
const mergeConflictStrategy: NonNullable<MergeConflictStrategy> = settings.mergeConflictStrategy ?? "smart";
|
||||||
|
|
||||||
// 3. Check branch exists
|
// 3. Check branch exists
|
||||||
try {
|
try {
|
||||||
@@ -2320,6 +2321,45 @@ export async function aiMergeTask(
|
|||||||
throwIfAborted(options.signal, taskId);
|
throwIfAborted(options.signal, taskId);
|
||||||
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
|
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
|
||||||
mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
|
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 {
|
} else {
|
||||||
mergerLog.warn(`${taskId}: no worktreePath — skipping task branch rebase`);
|
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);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
mergerLog.warn(`${taskId}: pre-merge rebase pipeline failed (${msg}) — proceeding without rebase`);
|
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)
|
// 4. Gather context for the agent (used in all attempts)
|
||||||
@@ -2436,6 +2510,7 @@ export async function aiMergeTask(
|
|||||||
diffStat,
|
diffStat,
|
||||||
includeTaskId,
|
includeTaskId,
|
||||||
smartConflictResolution,
|
smartConflictResolution,
|
||||||
|
mergeConflictStrategy,
|
||||||
attemptNum,
|
attemptNum,
|
||||||
options,
|
options,
|
||||||
result,
|
result,
|
||||||
@@ -2448,7 +2523,7 @@ export async function aiMergeTask(
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
result.attemptsMade = attemptNum;
|
result.attemptsMade = attemptNum;
|
||||||
result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution);
|
result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution, mergeConflictStrategy);
|
||||||
result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
|
result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
|
||||||
result.merged = true;
|
result.merged = true;
|
||||||
return true;
|
return true;
|
||||||
@@ -2698,13 +2773,20 @@ export async function aiMergeTask(
|
|||||||
// Attempt 1: Standard AI merge
|
// Attempt 1: Standard AI merge
|
||||||
merged = await mergeAttempt(1);
|
merged = await mergeAttempt(1);
|
||||||
|
|
||||||
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled)
|
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled).
|
||||||
if (!merged && smartConflictResolution) {
|
// Skipped for "abort" — that strategy gives the user one AI shot, no more.
|
||||||
|
if (!merged && smartConflictResolution && mergeConflictStrategy !== "abort") {
|
||||||
merged = await mergeAttempt(2);
|
merged = await mergeAttempt(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt 3: Use -X theirs merge strategy (if enabled)
|
// Attempt 3: -X theirs (smart) or -X ours (prefer-main) fallback.
|
||||||
if (!merged && smartConflictResolution) {
|
// 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);
|
merged = await mergeAttempt(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2717,6 +2799,10 @@ export async function aiMergeTask(
|
|||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
mergerLog.warn(`${taskId}: git reset --merge cleanup failed: ${errorMessage}`);
|
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`);
|
throw new Error(`AI merge failed for ${taskId}: all 3 attempts exhausted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2926,10 +3012,14 @@ export async function aiMergeTask(
|
|||||||
return result;
|
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(
|
function getResolutionStrategy(
|
||||||
attemptNum: 1 | 2 | 3,
|
attemptNum: 1 | 2 | 3,
|
||||||
smartConflictResolution: boolean,
|
smartConflictResolution: boolean,
|
||||||
|
mergeConflictStrategy: NonNullable<MergeConflictStrategy> = "smart",
|
||||||
): MergeResult["resolutionStrategy"] {
|
): MergeResult["resolutionStrategy"] {
|
||||||
if (!smartConflictResolution || attemptNum === 1) {
|
if (!smartConflictResolution || attemptNum === 1) {
|
||||||
return "ai";
|
return "ai";
|
||||||
@@ -2937,7 +3027,18 @@ function getResolutionStrategy(
|
|||||||
if (attemptNum === 2) {
|
if (attemptNum === 2) {
|
||||||
return "auto-resolve";
|
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 */
|
/** Map resolutionStrategy and autoResolvedCount to resolutionMethod for metrics/debugging */
|
||||||
@@ -2948,6 +3049,8 @@ function getResolutionMethod(
|
|||||||
): MergeResult["resolutionMethod"] {
|
): MergeResult["resolutionMethod"] {
|
||||||
if (strategy === "ai") return "ai";
|
if (strategy === "ai") return "ai";
|
||||||
if (strategy === "theirs") return "theirs";
|
if (strategy === "theirs") return "theirs";
|
||||||
|
if (strategy === "ours") return "ours";
|
||||||
|
if (strategy === "abort") return "abort";
|
||||||
if (strategy === "auto-resolve") {
|
if (strategy === "auto-resolve") {
|
||||||
// auto-resolve strategy: determine if pure auto or mixed with AI
|
// auto-resolve strategy: determine if pure auto or mixed with AI
|
||||||
if (autoResolvedCount && autoResolvedCount > 0) {
|
if (autoResolvedCount && autoResolvedCount > 0) {
|
||||||
@@ -2968,6 +3071,7 @@ interface MergeAttemptParams {
|
|||||||
diffStat: string;
|
diffStat: string;
|
||||||
includeTaskId: boolean;
|
includeTaskId: boolean;
|
||||||
smartConflictResolution: boolean;
|
smartConflictResolution: boolean;
|
||||||
|
mergeConflictStrategy: NonNullable<MergeConflictStrategy>;
|
||||||
attemptNum: 1 | 2 | 3;
|
attemptNum: 1 | 2 | 3;
|
||||||
options: MergerOptions;
|
options: MergerOptions;
|
||||||
result: MergeResult;
|
result: MergeResult;
|
||||||
@@ -3017,9 +3121,15 @@ async function executeMergeAttempt(
|
|||||||
buildSource,
|
buildSource,
|
||||||
} = params;
|
} = 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) {
|
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
|
// 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;
|
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 {
|
try {
|
||||||
// Use -X theirs to auto-resolve conflicts favoring the incoming branch
|
|
||||||
throwIfAborted(params.options.signal, taskId);
|
throwIfAborted(params.options.signal, taskId);
|
||||||
await execAsync(`git merge -X theirs --squash "${branch}"`, {
|
await execAsync(`git merge -X ${side} --squash "${branch}"`, {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3313,8 +3428,8 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
|
|||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
if (conflictedOutput.length > 0) {
|
if (conflictedOutput.length > 0) {
|
||||||
mergerLog.warn(`${taskId}: -X theirs left unresolved conflicts: ${conflictedOutput}`);
|
mergerLog.warn(`${taskId}: -X ${side} left unresolved conflicts: ${conflictedOutput}`);
|
||||||
return false; // Still has conflicts after -X theirs
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there's anything staged
|
// 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}`,
|
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${authorArg}`,
|
||||||
{ cwd: rootDir },
|
{ 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
|
// Run deterministic verification after committing
|
||||||
if (testCommand || buildCommand) {
|
if (testCommand || buildCommand) {
|
||||||
@@ -3373,7 +3488,7 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
|
|||||||
if (error instanceof Error && error.name === "MergeAbortedError") {
|
if (error instanceof Error && error.name === "MergeAbortedError") {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
|
mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user