Pre-flight conflict checking, one-shot merge simulation, AI
resolution proposals, and external CLI resolver runs all live
alongside detection in
apps/desktop/src/main/services/conflicts/conflictService.ts.
simulateMerge({ laneAId, laneBId? }) runs a single on-demand
merge simulation and returns a MergeSimulationResult with
rendered conflict markers:
type MergeSimulationResult = {
outcome: "clean" | "conflict" | "error";
mergedFiles: string[];
conflictingFiles: Array<{ path: string; conflictMarkers: string }>;
diffStat: { insertions: number; deletions: number; filesChanged: number };
error?: string;
};Steps inside simulateMerge:
- Look up
laneAin the active lanes list; 404 if missing. - Read
laneAHead = git rev-parse HEADfrom the lane worktree. - If
laneBIdis provided: readlaneBHeadfrom lane B's worktree. Else: read the base branch head from the project root (readHeadSha(projectRoot, laneA.baseRef)). - Compute
mergeBase = git merge-base <laneAHead> <laneBHead>. - Run
runGitMergeTree({ cwd: projectRoot, mergeBase, branchA, branchB, timeoutMs: 60_000 }). - Read diff numstats for both sides and compute per-side insertions / deletions / files-changed sets.
- Compute
mergedFilesastouchedA ∪ touchedBandoverlapFilesas their intersection. - Build
conflictFilesviabuildConflictFiles(conflicts, overlapFiles)— merge-tree conflicts first, overlapping-but-not-conflicting paths second withconflictType: "content". - Derive
outcomefrom(conflictFiles.length, merge.exitCode):- any conflicts →
"conflict" - zero conflicts + exit 0 →
"clean" - otherwise →
"error"withmerge.stderr.trim()
- any conflicts →
Consumers:
- Graph edge click → inline conflict panel →
simulateMerge. - Lane detail → merge simulation panel.
- Integration tab → per-pair simulation (via
prService.simulateIntegration, notconflictService.simulateMerge).
simulateChainedMerge covers multi-lane sequential merges (the
merge-plan flow). For each step in stack-depth order:
- Simulate the merge of the current source onto the running chain state.
- If clean, apply it to an in-memory chain tree and continue.
- If conflicted, stop and report the blocked step.
This feeds the Integration tab's merge plan view where the user sees "lanes 1-3 merge cleanly; lane 4 conflicts with lane 2."
Two phases: prepare (build bounded context, surface a preview) and request (dispatch to the provider).
- Compose the context envelope:
LaneExportLitefor the laneLaneExportLitefor the peer (when pairwise)ConflictExportStandardfor the specific conflict packconflictJobContextwith freshness metadata and stale policy
- Validate that required file contexts are present
(
relevantFilesForConflict[]+fileContexts[]). If gaps are found, markinsufficientContext: trueand includeinsufficientReasons[]. - Redact secrets through
redactSecretsDeep. - Return a
ConflictProposalPreviewwith preview files, target branch (for apply), confidence estimate, and provider metadata.
The preview is cached in memory under a sha256 digest of its
serialized envelope with a 20-minute TTL (PREPARED_TTL_MS).
cleanupPreparedContexts() runs opportunistically to expire stale
entries.
- Re-fetch the prepared context from the cache (or rebuild if missing).
- Short-circuit if
insufficientContext: record afailedproposal with explicit data-gap messaging, do not dispatch. - Route through
aiIntegrationService.requestConflictProposalwhich callsAgentExecutor.execute()with the Claude CLI by default (sonnet, read-only permissions, 60 s timeout). - Persist the result as a
conflict_proposalsrow with:source: 'subscription'or'local'confidence: number | null(0.0–1.0)explanation,diff_patch,status: 'pending'job_id,artifact_id,metadata_json
- Emit a
proposal-readyevent.
Logged to ai_usage_log for usage tracking.
Conflict prompts enforce an exact output structure:
ResolutionStrategyRelevantEvidenceScopePatchConfidenceAssumptionsUnknownsInsufficientContext
If InsufficientContext=true, the Patch section must be empty.
parseStructuredObject(text) + extractDiffPatchFromText(text)
walk the response and guard against malformed payloads.
applyProposal({ proposalId, mode, message? }):
mode: "unstaged"—git apply <patch>, leave unstagedmode: "staged"—git apply --index <patch>, stage but don't commitmode: "commit"—git apply --index <patch>, thengit commitwith the supplied (or AI-generated) message
All paths use git apply --3way so partial conflicts surface as
merge markers rather than a hard failure. Each apply records an
operation with the proposal id and the pre/post HEAD SHAs so the
undo path can reverse cleanly.
undoProposal({ proposalId }):
- Look up the applied operation.
- Run
git apply -R <patch>against the stored patch file. - Record a new operation with reason
conflict-proposal-undo. - Mark the proposal
status: 'rejected'.
Patch files live at
<worktree>/.ade/tmp/conflict-proposals/proposal-<uuid>.patch and
are cleaned up on proposal rejection/application.
runExternalResolver(args) runs a Codex / Claude CLI session in the
target lane's worktree (single-source) or a dedicated integration
lane (multi-source). Resolution happens interactively; the resolver
runs with full repo access and produces changes that ADE then
commits via the explicit commitExternalResolverRun step.
- Single source lane (
sourceLaneIds.length === 1): cwd = source lane's worktree. - Multi-source integration merge (
sourceLaneIds.length > 1): cwd = integration lane's worktree. The integration lane is created automatically viaensureIntegrationLaneif not supplied. - Explicit override: caller may pass
cwdLaneIdto force a specific worktree.
Scenario is recorded as ResolverSessionScenario:
"single-merge" | "integration-merge".
prepareResolverSession(...)— builds prompt, conflict context, picks model/reasoning effort/permission mode, writes the initialExternalResolverRunRecordto<packsRoot>/external-resolver-runs/<runId>/run.json.- Command template resolution via
resolveExternalResolverCommand(provider). Templates support{{promptFile}},{{projectRoot}},{{targetLaneId}},{{sourceLaneIds}},{{runDir}}placeholders. - Spawn the CLI with:
cwd: cwdLane.worktreePathstdio: ["ignore", "pipe", "pipe"]timeout: 8 * 60_000(8 min)- 8 MB stdout/stderr caps per stream.
- Write
output.logwith combined stdout + stderr. - Capture changes via
git diff --binary(32 MB cap). Write tochanges.patchif non-empty. - Update the run record with
status,completedAt,command,changedFiles,summary,patchPath,logPath,warnings,error.
If context validation fails at prepare time, the run is recorded
as status: "blocked" with warnings like
missing_context:<repoRelativePath>. The runner short-circuits
without spawning the CLI. This prevents speculative patches in
situations where ADE knows it's missing required file content.
commitExternalResolverRun({ runId, message? }):
- Re-read the run record; reject if not
completed, if already committed, or if no patch artifact exists. - Read the patch body and extract touched paths via
extractCommitPathsFromUnifiedDiff. - Normalize paths to repo-relative via
ensureRelativeRepoPath. git add -- <paths>+git commit -m <message> -- <paths>.- Read the resulting
commitShaand persistcommittedAt/commitSha/commitMessageon the run record.
Commit message defaults to
"Resolve conflicts via ADE <provider> external resolver".
When sessionService is wired in, the resolver can surface inside
the Sessions surface and the terminal modal
(renderer/components/shared/conflictResolver/ResolverTerminalModal.tsx).
attachResolverSession, finalizeResolverSession, and
cancelResolverSession bridge between the run record and session
state.
suggestResolverTarget(args) picks a reasonable default target
lane for the UI given the scenario:
- Single-source: the source lane.
- Multi-source: heuristic prefers existing integration lanes over creating a new one; ties break by most recently touched.
Users can persist resolution preferences under
ai.conflict_resolution:
ai:
conflict_resolution:
change_target: "ai_decides" # target | source | ai_decides
post_resolution: "staged" # unstaged | staged | commit
pr_behavior: "do_nothing" # do_nothing | open_pr | add_to_existing
auto_apply_threshold: 0.85 # 0.0-1.0
autonomy: "propose_only" # propose_only | auto_applyThe conflict resolution dialog reads and writes these values via
projectConfigService.
- Context completeness is strict. Both
relevantFilesForConflict[]andfileContexts[]must be present and non-empty to dispatch. Partial context blocks the run. - External resolver runs are not transactional. The CLI may
complete partial work even when reporting
status !== 0. The commit workflow is a separate explicit step so the user can review before committing. - Patch files accumulate on disk under
<worktree>/.ade/tmp/conflict-proposals/if proposals are neither applied nor rejected.deletePatchFileruns after apply/undo. Consider adding a sweep for orphaned patches in long-running projects. git diff --binarycan produce huge output. The 32 MB cap is a hard stop; warningsgit_diff_stdout_truncated/git_diff_stderr_truncatedflag truncation so the UI can warn.- Provider-specific command templates come from
projectConfigServiceand may be empty. When missing, the service recordsresolver_command_missing_in_configand returns a failed run rather than throwing. - Process signals (SIGTERM, etc) are recorded as
process_signal:<signal>warnings so postmortem debugging can distinguish "CLI crashed" from "CLI exited non-zero cleanly." - Integration lane auto-creation happens only for multi-source
runs. If you manually create an integration lane first and pass
it via
cwdLaneId, the service does not try to create a second one — it respects the override. - TTL for prepared previews (20 min) may force re-prepare if the user takes too long between preview and dispatch. The dispatch path re-prepares transparently.