@gitkraken/conflict-tools
v0.5.0
Published
AI-powered git conflict resolution.
Readme
@gitkraken/conflict-tools
AI-powered git conflict resolution. Parses conflict markers (including delete-modify conflicts), resolves them with an agentic AI loop that has access to git context (blame, grep, diff, log, show, file content), refines the result with targeted intra-file edits, then runs deterministic reference-integrity detection over the whole touched set and produces an honest verdict. Results are written back to the working tree by the consumer.
What it does
Three entry points, each serving a different consumer need:
extractConflict()— parses conflict markers from a single file and returns a structured representation. Handles both text conflicts (with markers) and delete-modify conflicts (one side deleted, the other modified). No AI, no resolution.resolveConflict()— takes oneConflictand an optionalResolutionContext, runs the AI tool-use loop and per-file refine, returns aResolutionwith resolved content, confidence, and metrics. The single-file workhorse.resolveConflicts() + applyResolutions()— batch-resolve every currently unmerged file in the working tree, run set-level detection, then write and stage results. Supports pattern-based routing: skip files, apply take-ours/take-theirs, or route to AI per glob pattern. The library does NOT orchestrategit rebase/merge/cherry-pick— the consumer drives the operation lifecycle.
The library never calls git or an AI provider directly. Consumers supply adapters via ports.
Install
pnpm add @gitkraken/conflict-toolsPipeline
An operation runs in three stages. Report-only is the default, and the stage shape is identical whether or not the opt-in fix phase is enabled — the fix phase only adds edit attempts, it does not change the flow.
- Resolve — the agentic AI tool-use loop replaces conflict markers, self-assessing a confidence per marker. The only blocking gate is residual conflict markers (checked built-in, language-agnostic): a candidate that still contains markers is rejected and retried. No other check gates this stage — parser, validator, and detection are all downstream and advisory.
- Refine (per file) — when the resolver returns
followUpInstructions(targeted edits it could not express as a whole-chunk decision), a second agentic loop applies them via theedit_file_linestool. A refine that reintroduces markers is discarded; a refined file's confidence is capped at 0.9. - Detection (set level) — after every file is resolved, deterministic reference-integrity detection runs over the whole touched set: per-file structural checks (syntax errors, duplicate definitions, intra-file broken references) on the resolved and the auto-merged/incoming files, plus cross-file broken-reference checks. Report-only — it never mutates state or fails the operation. It produces the
DetectionReportattached toStepResult.detection, which drives the verdict.
The opt-in fix phase (config.fix: true) sits between refine and the final detection report: it re-detects, routes the error-severity findings back through refine (per-file convergence, then cross-file routing), and only then emits the final report. What it cannot fix stays in the findings, so the verdict always reflects the post-fix state. See Fix phase.
Ports
ConflictGitPort
A bag of optional high-level git ops, plus an optional raw exec fallback. Provide exec only (the library's dispatchers will build the git commands themselves), provide individual ops (your adapter's native API is used directly), or mix both.
import type { ConflictGitPort } from '@gitkraken/conflict-tools';
const git: ConflictGitPort = {
exec: async (args, options) => {
const result = await yourGitBackend.run(args, {
cwd: '/path/to/repo',
env: options?.env,
stdin: options?.stdin,
signal: options?.signal,
});
return result.stdout;
},
};Forward every option, not just args. signal is how the library cancels an in-flight operation, and stdin is how it feeds a path list to a --stdin command — an adapter that drops it leaves git waiting on a pipe that never closes.
High-level ops the library dispatches through:
| Op | Purpose |
|---|---|
| readFile(path) | Read working-tree file content (used during conflict extraction) |
| showFile(ref, path, opts?) | Read file content at a specific ref. Supports startLine/endLine |
| unmergedEntries() | List files git considers unresolved |
| mergeBase(a, b) | Common ancestor between two refs |
| changedFiles(from, to) | Paths changed between two refs (name-only). Used to discover the merge's incoming/auto-merged set for detection |
| blame(path, opts?) | Line-level authorship. Supports startLine/endLine (maps to git blame -L) |
| grep(pattern, opts?) | Content search across the tree. Supports maxResults |
| diff(from, to, opts?) | Unified diff between refs, optionally scoped to a path |
| log(opts?) | Commit history. Supports ref, path, maxCount |
| show(sha, opts?) | Commit details (git show). Supports path to scope output |
| writeFile(path, content) | Write resolved content to the working tree |
| stageFiles(paths) | Stage resolved files (git add) |
| checkoutFile(path, 'ours' \| 'theirs') | Apply a side directly via git |
| removeFile(path) | Remove a file from the working tree (git rm) |
Outputs from showFile and blame are capped at 1000 lines; grep and log at 100 results; show at 500 lines. When the cap fires, the library prepends an actionable header (e.g. [Output capped at 1000 lines. Use startLine/endLine to read other regions of the file.]) so the AI can re-query with a narrower range.
If a flow needs an op that the consumer didn't supply and exec is unavailable, the library throws ConflictGitPortMissingOpError.
ConflictModelPort
A single-method port for AI inference. The library owns the tool-use loop — it calls generate, dispatches any toolCalls it sees against ConflictGitPort, feeds the results back, and repeats until the model returns a final structured answer.
import type { ConflictModelPort } from '@gitkraken/conflict-tools';
const model: ConflictModelPort = {
generate: async ({ system, messages, tools, temperature, signal }) => {
// Map to your AI SDK (Vercel AI SDK, Anthropic, OpenAI, VS Code LLM API, ...)
// Return { text?, toolCalls?, usage? }
},
};The shape of tools, toolCalls, and toolResults follows abstract types (ToolDefinition, ToolCall, ToolResult) so consumers can adapt any SDK. The library does not require streaming or any provider-specific feature.
ParserPort (optional — enables detection)
A facts-in port. Given a content overlay, it returns per-file parse facts — definitions, references (each with a resolved flag), optional bindings, and syntax errors. The package derives findings from those facts; the port itself never classifies anything as a conflict or a break.
import type { ParserPort, SourceOverlay, ParsedFile } from '@gitkraken/conflict-tools';
const parser: ParserPort = {
async analyze(sources: SourceOverlay): Promise<ReadonlyMap<string, ParsedFile>> {
// tree-sitter (merge-mate) or a language server (GitLens/GKD), overlaying `sources`.
// Return one ParsedFile per input path.
},
};Four properties define the contract:
- Content-in overlay, no FS access.
analyze(sources)sees exactly theSourceOverlaycontents (aReadonlyMap<path, content>) and never reads the filesystem itself. This is what lets the package hand it pre-merge parent snapshots and the post-merge result and diff the two. A tree-sitter backend sees only these files; a language server may resolve against its workspace with these contents overlaid on top. - Language-agnostic. A file type the backend has no grammar for comes back
supported: falsewith empty facts; the detector skips it (and reportspartial-unsupported-language) rather than emitting false findings. A file it does handle but cannot parse issupported: truewith the failure insyntaxErrors— that is a finding, not a coverage gap, and conflating the two would let a broken merge result read as clean. - Precision-declaring. A tree-sitter backend and a language-server backend differ in how completely they resolve references, and the port asks them to say so via
capabilities. A backend withoutcrossFileResolutionreports every import as unresolved on both parents and the result, so no cross-file break can ever surface — detection reportspartial-no-cross-file-resolutioninstead of letting that empty result pass as a clean cross-file check. - Never called concurrently. The package awaits each
analyzebefore issuing the next, so a single-instance backend is never asked to hold two versions of the same path at once. Implementations need no internal locking.
ParsedFile:
| Field | Meaning |
|---|---|
| supported | false when the backend has no grammar for this file type at all — the detector skips it |
| language | Backend's language id; meaningful only when supported |
| definitions | SymbolDefinition[] — top-level defs (name, kind?, 1-indexed line) |
| references | SymbolReference[] — name, line, column?, resolved (did it resolve within the overlay), target? |
| bindings? | Optional names bound at any scope (module-level and nested). Populating it lets detection catch a reference broken by removing a nested binding. A backend that only surfaces top-level definitions may omit it |
| syntaxErrors | SyntaxIssue[] — line, message |
Pass via deps.parser. When absent, detection is skipped and the report says so (skipped-no-parser) — never a silent clean.
Validator (optional)
The consumer's validation port. Plug in any checks — syntax, real import resolution, types, lint, project rules. It runs post-refine over an overlay and returns ValidatorFinding[].
The overlay is currently always a single file: the package validates one file at a time, right after that file's refine. SourceOverlay is the parameter type so a whole-set call can be added later without a breaking change, but no such call site exists yet — do not write a validator that only works across files.
import type { Validator, ValidatorFinding } from '@gitkraken/conflict-tools';
const validator: Validator = {
async validate(sources) {
const findings: ValidatorFinding[] = [];
for (const [file, content] of sources) {
for (const err of await typecheck(file, content)) {
findings.push({ category: 'type-error', severity: 'error', file, line: err.line, message: err.message });
}
}
return findings;
},
};Advisory, never blocking. Its findings fold into the operation's detection findings and the verdict; a validator finding never triggers a re-resolve, and a throwing validator never destroys the resolution — it is kept intact and the failure is reported. On the resolve/report path that report is a validation:failed event; inside the opt-in fix phase a validator throw folds into the phase's per-file isolation and surfaces as fix:failed (like any per-file throw there), so telemetry that must catch every validator failure should watch both. ValidatorFinding.category is an open string (e.g. 'type-error', 'lint:no-console') so consumer categories flow through alongside the package's own FindingCategory values.
The one blocking gate — residual conflict markers — is not this port's job. It is checked built-in and language-agnostic inside the resolve loop. (This was the old defaultVerifier; it is now internal. defaultVerifier, ResolutionVerifier, and VerificationResult are no longer exported.)
Pass via deps.validator to resolveConflict or resolveConflicts.
Entry points
extractConflict(filePath, deps) — parse only
import { extractConflict } from '@gitkraken/conflict-tools';
// Text conflict (has markers)
const conflict = await extractConflict('src/auth.ts', { git });
// conflict.filePath — 'src/auth.ts'
// conflict.markers[] — position, sides, and surrounding context per marker block
// conflict.type — 'text'
// conflict.rawContent — original file content with markers preserved
// Delete-modify conflict (pass reason from unmergedEntries)
const dm = await extractConflict('old-module.ts', { git }, 'deleted-by-them');
// dm.type — 'delete-modify'
// dm.deletedBy — 'theirs'
// dm.markers — [] (no conflict markers for delete-modify)Returns null for files without conflict markers and no delete-modify reason.
resolveConflict(conflict, context, deps) — single file
import { resolveConflict } from '@gitkraken/conflict-tools';
const resolution = await resolveConflict(conflict, context, {
model,
git,
validator, // optional — advisory per-file checks
config: { maxSteps: 15, temperature: 0 },
onProgress: (event) => console.log(event),
});
// resolution.content — resolved file content (markers replaced)
// resolution.strategy — 'ai' | 'take-ours' | 'take-theirs' | 'deleted' | 'skipped' | 'mechanical'
// resolution.confidence — 0..1, from the AI's own self-assessment (capped at 0.9 if refined)
// resolution.description — one-or-two sentence rationale, from the AI
// resolution.findings? — advisory per-file findings from the validator port (post-refine)
// resolution.chunks? — per-marker decisions (for observability and validation)
// resolution.edits? — IntraFileEditOp[] applied during refine (absent if refine did not run)
// resolution.metrics? — { inputTokens, outputTokens, stepCount, toolCallCount, ... }
// resolution.rawSignals? — policy-free measurements for the consumer's own assessmentresolveConflict runs stages 1–2 (resolve + refine) for a single file. Set-level detection (stage 3) is only run by the batch entry point, since it needs the whole touched set; use detect() directly if you want detection for a single-file flow.
ResolutionContext carries optional hints the AI can use, plus the refs detection compares against:
interface ResolutionContext {
commitMessage?: string;
prDescription?: string;
previousResolutions?: Resolution[];
fileNeighbors?: string[];
/** Auto-merged files beyond the conflict set. Usually unset — detection discovers this from refs. */
touchedFiles?: string[];
refs?: { ours: string; theirs: string; base?: string };
/** Parent tips for detection's before/after comparison, when they differ from `refs`. */
detectionRefs?: { ours: string; theirs: string; base?: string };
threeWayDiff?: { oursDiff: string; theirsDiff: string };
/** Whole-file free-text guidance, rendered as a top-level <user-guidance> block. */
userGuidance?: string;
metadata?: Record<string, unknown>;
}When refs is provided, the library computes git diff base..ours -- file and git diff base..theirs -- file automatically and includes both in the prompt. This typically lets the AI resolve without any tool calls. Consumers that already have these diffs cached can pass threeWayDiff directly to skip the computation.
refs drives the resolver's three-way diff; detectionRefs (falling back to refs) drives detection's before/after comparison. They differ in a rebase — where refs is the in-flight per-step state while detection needs the two stable pre-merge branch tips — and coincide in a plain merge, so detectionRefs is usually left unset.
Refine edits
The refine pass (stage 2) applies edits the resolver could not express as whole-chunk decisions — for example, re-applying a partial change from the incoming branch. ResolverConfig.refineMaxSteps (default 5) bounds its model turns. Resolution.edits?: IntraFileEditOp[] lists the operations that survived (absent when refine did not run or all edits were discarded).
IntraFileEditOp is a discriminated union on kind:
insert— insertscontentafter the line matched byanchoratstartLine.replace— replaces linesstartLine–endLine(matched byanchor) withcontent.delete— removes linesstartLine–endLinematched byanchor. Nocontent.verify— confirms lines atstartLine(optionally throughendLine) matchanchorwithout modifying content.
resolveConflicts(deps, context?) + applyResolutions(resolutions, deps) — batch
import { resolveConflicts, applyResolutions, summarizeOperation } from '@gitkraken/conflict-tools';
const step = await resolveConflicts(
{
model,
git,
parser, // optional — supply to enable set-level detection (stage 3)
validator, // optional — advisory per-file checks
config: {
rules: [
{ match: ['*.lock', 'pnpm-lock.yaml'], strategy: 'take-theirs' },
{ match: '*.generated.ts', strategy: 'skip' },
{ match: 'dist/**', strategy: 'skip' },
],
defaultStrategy: 'ai',
fallbackStrategy: 'take-theirs',
maxSteps: 15,
},
onProgress: (event) => updateUI(event),
},
{ commitMessage, refs },
);
// step.resolutions[] — successful resolutions
// step.errors[] — { filePath, error, reason, causeReason? } for files that failed (see Error handling)
// step.skipped?[] — { filePath, reason, entryReason?, stages? } for files that were excluded,
// had no markers, or were left to a human (see the skip reasons below)
// step.detection — DetectionReport over the whole touched set; ALWAYS present. Read `checks`
// (or digest.detectionComplete) to tell whether detection actually ran —
// without a parser it is a present, empty report with `skipped-no-parser`.
// Gate on the verdict, not on raw confidence — see below.
const { digest } = await summarizeOperation(step);
if (digest.verdict === 'clean') {
await applyResolutions(step.resolutions, { git });
}
// Resolutions are written via writeFile, checkoutFile, or removeFile and staged.Skip reasons
StepResult.skipped holds the unmerged entries the resolver produced no resolution for. reason stays a plain string so a new value never breaks a consumer's compile; entryReason (git's own unmerged reason) and stages (which index stages hold the path, and at what mode) carry the git-level detail.
| reason | Meaning |
|---|---|
| 'excluded' | A rules entry with strategy: 'skip' matched the path |
| 'no-markers' | The file carries no conflict markers and is not a delete-modify, and nothing explains why |
| 'unmergeable' | No markers, but both index stages hold content and git did not merge them — the content is binary, or a merge gitattribute told git not to merge the path; a side must be chosen |
| 'submodule' | A gitlink stage: no content to read or write |
| 'rename-choice' | A rename/rename set the model was asked about and could not settle — tried and failed, as opposed to never attempted |
Rename/rename and retiredPaths
When both sides rename the same file to different names, git leaves three unmerged paths — the base name at stage 1 alone, and each destination at its own side's stage — and nothing in the index says which name should survive. Both destinations carry the identical blob, so only the name is undecided. resolveConflicts asks the model to choose, then expresses the whole set as one resolution on the surviving path:
{
filePath: 'src/settings.ts', // the surviving name
strategy: 'ai', // 'mechanical' when neither side edited the content
retiredPaths: ['src/options.ts', 'src/config.ts'], // discarded destination, then the base path
// ...
}applyResolutions removes every retiredPaths entry with removeFile and never stages it, so the index is left with no unmerged entry for the set and the operation can be committed. strategy stays orthogonal to the name decision: 'ai' when the model merged conflict markers into the survivor, 'mechanical' when git had already settled the content and the file is staged as it lies (so a symlink's target is never rewritten).
A consumer that calls applyResolutions needs no change. A consumer with its own apply loop must honour retiredPaths — ignoring it leaves those paths unmerged, which git then refuses to commit.
The group is only formed from an unambiguous set: exactly one stage-1-only entry, exactly two lone-side entries at matching modes, neither a gitlink, both destinations holding identical content, and the effective strategy 'ai' for all three paths. The content check is not a second reading of the stage signature — a stage-1-only entry is equally git's both-deleted signature, so the stages can only say a pairing is possible. A genuine rename/rename always passes it (git records one blob on stages 2 and 3, so both working-tree files match down to the marker labels), while an unrelated base beside two unrelated one-sided adds does not — and retiring a path is destructive. Caller instructions win: a rule with a non-ai strategy, or a non-ai defaultStrategy, on any of the three paths withholds the grouping and leaves the per-path handling in place. An explicit { strategy: 'ai' } rule does not withhold it — asking for AI resolution is asking for the name decision too, and it matches how an 'ai' rule already defers to the stage signature for every other path. When the model fails to name a survivor, no resolution is produced and all three paths land in skipped with reason: 'rename-choice'.
StepConfig extends ResolverConfig with batch-only fields:
rules— array ofFileRuleobjects. Each rule has amatch(glob pattern or array of patterns) and astrategy. First matching rule wins. Patterns without/match on basename; patterns with/match on full path.defaultStrategy— strategy for files not matching any rule. Defaults to'ai'.fix,fixLoopMaxIterations,crossFileMaxIterations,maxModelCalls— the fix phase and its bounds. See Fix phase. All four are batch-only because the fix phase is what enforces them.fallbackStrategy— when AI fails for a file, fall back to'take-ours'or'take-theirs'instead of recording an error. Guarded against code loss, and the guard stops every text conflict. The fallback is applied withgit checkout --ours/--theirs, which replaces the whole file with one stage, so it discards the other side plus anything git auto-merged outside the conflict markers. On rebase "ours" is everything integrated so far, so a blind take-theirs can wipe it. The file is recorded as aCODE_LOSS_RISKerror with aresolution:failedevent instead of completing silently. The one case that still applies is a delete-modify conflict resolved toward the side that keeps content. SetallowLossyFallbackto apply the fallback regardless.allowLossyFallback— opt back into the pre-guard behavior: applyfallbackStrategywholesale even when it would discard integrated work. Off by default.
Available strategies for rules and defaultStrategy:
| Strategy | Effect |
|---|---|
| 'ai' | Run AI resolver (default) |
| 'take-ours' | Accept current branch version, no AI |
| 'take-theirs' | Accept incoming branch version, no AI |
| 'deleted' | Remove the file entirely |
| 'skip' | Skip the file (no resolution produced) |
The function inspects the working tree once (via unmergedEntries), processes each conflict sequentially, runs detection over the touched set, and returns. It does not call git rebase --continue or any other operation command — that is the consumer's job.
Detection & verdict
detect(resolutions, context, deps)
Deterministic, report-only post-resolution detection. It never mutates state or fails the operation. resolveConflicts calls it for you and attaches the result to StepResult.detection; call it directly for a single-file or custom flow.
import { detect } from '@gitkraken/conflict-tools';
const report = await detect(step.resolutions, { refs }, { git, parser });DetectDeps is { git; parser?; signal?; excludePaths? }. excludePaths (a ReadonlySet<string>) keeps paths out of scope — pass the files you skipped or failed to resolve, since those still carry conflict markers on disk and the parser would report the marker text as syntax errors. It flags:
- references that resolved in a pre-merge parent (ours/theirs) but no longer resolve in the merged result (
broken-reference, withprovenance), - duplicate definitions and syntax errors across the whole analyzed scope — the resolved files and the auto-merged/incoming ones (
duplicate-definition,syntax-error). Duplicates are diffed against the parents, so a legal same-name declaration that already existed (a TS overload,interface/namespacemerging) is not reported; only a duplicate the merge introduced is, - the resolver's emergency fallback taking one side wholesale and discarding a substantial diff (
dropped-side).
The scope includes the resolved files and the merge's auto-merged/incoming set (discovered from refs via changedFiles/mergeBase, or overridden by context.touchedFiles), so a dangling reference in a cleanly-merged file is still caught.
DetectionReport:
interface DetectionReport {
findings: Finding[];
checks: { referenceIntegrity: CheckStatus; structural: CheckStatus };
/** Honest coverage — present only when the reference check ran. */
scope?: { inScope: number; readable: number; truncated: boolean; incomingDiscovered: boolean };
}Finding:
| Field | Meaning |
|---|---|
| category | Open string. The package emits FindingCategory; a consumer validator may add its own |
| severity | 'error' | 'warning' |
| tier | 'file' (intra-file) or 'project' (cross-file) — the fix phase routes each tier differently |
| location | { file; line; symbol? } |
| provenance? | For broken-reference: the pre-merge side ('ours'/'theirs') that still resolved it, and where |
| message | Human-readable description |
FindingCategory (the well-known values the package itself emits):
| Category | Meaning |
|---|---|
| broken-reference | A symbol resolved in a pre-merge parent but no longer resolves in the merged result |
| duplicate-definition | The same symbol is defined twice in a resolved file (advisory warning — the detector cannot tell a genuine redefinition from legal overloads / declaration merging) |
| syntax-error | The parser reported a syntax error in a resolved file |
| dropped-side | The resolver's emergency fallback took one side wholesale, discarding a substantial diff (a deliberate rule/default take is not flagged) |
CheckStatus tells "ran, clean" apart from "did not run" — so an empty findings array is never mistaken for a clean result:
| Status | Meaning |
|---|---|
| ran | The check ran to completion |
| skipped-no-parser | No ParserPort supplied |
| skipped-no-refs | No refs, so the before/after comparison was impossible (structural checks still ran) |
| skipped-unreadable-source | Refs were present but the parent snapshots came back empty (unreadable source) |
| partial-unsupported-language | The check ran, but some files in scope were a file type the backend has no grammar for |
| partial-no-cross-file-resolution | The backend declared it does not resolve references across files, so cross-file breaks were structurally out of reach |
| errored | The parser was present but threw |
summarizeOperation / buildOperationDigest
buildOperationDigest(result, reviewThreshold?) is a pure, model-free rollup of a StepResult that folds in detection. summarizeOperation(result, opts?) returns { digest, prose?, usage? } — always the digest, plus a prose narrative when opts.model is supplied.
import { buildOperationDigest } from '@gitkraken/conflict-tools';
const digest = buildOperationDigest(step);
// digest.verdict — 'clean' | 'needs-review' | 'blocked'
// digest.detectionComplete — did every detection check run to completion
// digest.findingCount — total detection findings
// digest.needsReview — ReviewItem[] (findings first, then lowest confidence first)
// digest.minConfidence — lowest file confidence, or null
// digest.byStrategy — resolution count per strategyOperationVerdict:
blocked— at least one error-severity finding exists (the build is known-broken).needs-review— confidence is low, detection did not fully run, a resolution errored, or a warning finding exists.clean— none of the above.
Gate auto-apply on the verdict, not on confidence. A refined or fixed file has its confidence capped at 0.9 — so a "confidence must be 1.0" gate would reject exactly the files the tool improved. verdict === 'clean' is the honest signal. And an absent verdict must NOT be read as clean: if you never ran detection (no parser) or never summarized, detectionComplete is false and empty findings mean "we didn't look," not "nothing's wrong." Only verdict === 'clean' is clean.
A ReviewItem distinguishes a low-confidence flag from a finding: reason: 'low-confidence' | 'finding' | 'unresolved' ('unresolved' marks an unmerged entry the tool left untouched — any skip but a deliberate 'excluded' one, an 'unmergeable' entry say — which keeps the operation off clean), with confidence? (absent for a file flagged purely by a finding or as unresolved, e.g. an auto-merged file with no resolution of its own) and findingCategories? (present when reason is 'finding').
Fix phase (opt-in)
By default the pipeline is report-only: detection findings land in StepResult.detection and the verdict, and the consumer decides what to do. Set config.fix: true to have the library attempt to fix error-severity findings before emitting the final report. The pipeline shape is identical in both modes — the fix phase only inserts edit attempts between refine and the final detection.
When enabled:
- Per-file convergence — an AI-resolved (
strategy: 'ai') text file carrying error-severity per-file findings is routed back through refine; the file is re-analyzed after each edit and the loop repeats until it is clean, a round makes no progress, orfixLoopMaxIterations(default 3) is reached. - Cross-file routing — a
tier: 'project'broken reference (an import/cross-file use whose target stopped resolving) is routed to the refine of its use-site file, and the whole set is re-detected, up tocrossFileMaxIterations(default 2) rounds. The refine is seeded with the defining file's current definitions (names, kind, line — a token-cheap list, not its text) so it can spot a renamed or moved symbol behind the broken use, even though the refine tool cannot read other files. A file is routed when it has a write channel: an AI resolution, or an AI-unanimous whole-side take (relabelled toaiwhen the fix edit lands). A deliberate rule/default take and an emergency fallback take are not routed, and a break landing on an untouched auto-merged file (no resolution to carry the edit) is reported but not auto-fixed.
Whatever the fix phase cannot fix stays in the findings, so the verdict always reflects the post-fix state. Two invariants hold throughout: a fix that reintroduces conflict markers is discarded rather than written, and a round that leaves more error findings than it started with is discarded too — the better content is kept, so the phase can never ship a file worse than the one it was handed.
Failures are isolated per file: a parser, validator, or model throw on one file leaves that file at its pre-fix resolution, emits fix:failed with its filePath, and the phase continues with the rest. Only a user abort propagates.
Without ResolutionContext.refs (or detectionRefs) the per-file stage still runs on what needs no parent content — syntax errors and validator findings — while cross-file routing is skipped, since it is driven by the whole-set before/after report.
StepConfig fix-phase fields:
| Field | Default | Effect |
|---|---|---|
| fix | false | Opt in to the fix phase. Off → report-only |
| fixLoopMaxIterations | 3 | Max per-file fix iterations (refine → re-detect the file) |
| crossFileMaxIterations | 2 | Max cross-file fix rounds (route project-level breaks → re-detect the set) |
| maxModelCalls | unset | Global model-call budget for the whole operation. When set and the budget is spent, the fix phase stops attempting further fixes and leaves the remainder in the findings, emitting fix:budget-exhausted. When unset, the fix phase is not budget-limited |
The fix phase is fault-isolated at two levels. A throw on one file (parser, validator, or model) leaves that file at its pre-fix resolution, emits fix:failed with its filePath, and the phase moves on — detection still runs over the set. A throw that escapes the whole phase does not reject the operation either: the resolutions computed so far (possibly partially fixed, since the phase mutates them as it goes) are kept, both detection checks are reported as errored, and fix:failed is emitted without a filePath. A user abort always propagates and cancels the operation.
Consumer examples
GitLens — single-file editor command
const git = createVSCodeConflictGit(vscodeGitApi);
const model = createVSCodeAiModel(vscodeLlmApi);
const conflict = await extractConflict(activeFilePath, { git });
if (conflict) {
const resolution = await resolveConflict(conflict, { commitMessage }, { model, git });
showInDiffEditor(resolution);
}GitLens / GKD — batch "resolve all" (user-driven)
const step = await resolveConflicts({ git, model, parser, onProgress: webview.post }, { refs });
const { digest } = await summarizeOperation(step);
const approved = await reviewDialog(step.resolutions, step.detection, digest.verdict);
await applyResolutions(approved, { git });
// User clicks "continue rebase" themselves.CLI / GitHub Action — automated rebase loop
// The rebase loop lives in the consumer (e.g. @merge-mate/core), not in conflict-tools.
for (const commit of commits) {
const result = await git.exec(['rebase', '--onto', target, `${commit}^`, commit]);
if (result.exitCode === 0) continue;
const step = await resolveConflicts({
git, model, parser,
config: {
rules: [{ match: '*.lock', strategy: 'take-theirs' }],
fallbackStrategy: 'take-ours',
fix: true, // attempt to fix detected breaks before reporting
},
onProgress: emit,
}, { refs });
const { digest } = await summarizeOperation(step);
if (digest.verdict === 'blocked') { await escalateToHuman(step); break; }
await applyResolutions(step.resolutions, { git });
await git.exec(['rebase', '--continue']);
}Eval harness
const conflict = await extractConflict(fixturePath, { git });
const resolution = await resolveConflict(conflict!, context, { model, git });
score(resolution, goldenFile);Observability
resolveConflicts and resolveConflict accept an onProgress callback that receives a discriminated ConflictProgressEvent:
| Event | When |
|---|---|
| conflict:found | A conflict file has been parsed (conflictType, markerCount) |
| conflict:excluded | A file was skipped by a rule with strategy: 'skip' |
| conflict:skipped | The resolver produced no resolution for an unmerged path — see Skip reasons (reason, entryReason?) |
| resolution:applied | A resolution was produced for a file (strategy, confidence?, description?) |
| resolution:fallback | AI failed; fallback strategy applied (error, cause?) |
| resolution:failed | AI failed and no fallback configured (error) |
| resolver:tool-call | The AI invoked a tool during resolve (tool, args, stepNumber, reason?) |
| resolver:step-usage | Token usage for one resolve model call (stepNumber, usage) |
| resolver:completed | The resolver finished a single file (stepCount) |
| resolver:response | The resolve stage produced its final structured response (chunks, confidence, description, strategy?, followUpInstructions?) |
| resolver:tool-result | A resolve-stage tool returned content (tool, stepNumber, content) |
| refine:started | The refine pass has begun |
| refine:skipped | Refine was not run (reason, e.g. no follow-ups) |
| refine:tool-call | Refine model invoked edit_file_lines (stepNumber, ops) |
| refine:tool-result | A refine tool returned feedback (stepNumber, appliedCount, isError, feedback) |
| refine:final-text | Refine model emitted its final description (text) |
| refine:completed | Refine finished successfully (appliedCount) |
| refine:failed | Refine threw before completing (reason) |
| refine:bailed | Refine bailed early after consecutive apply errors, keeping edits that landed (reason) |
| refine:discarded | A refine/fix edit batch was rejected — it reintroduced conflict markers, or (in the fix phase) it introduced an error the file did not have (reason says which) |
| refine:touched-user-region | Refine modified a user-resolved marker region (markerIndexes) |
| fix:started | The fix loop began fixing a file (findingCount) |
| fix:completed | The fix loop finished evaluating a file (iterations, remainingFindings) — emitted even for a clean file (iterations 0, remainingFindings 0). iterations: 0 means "evaluated, spent no model call" |
| fix:failed | A non-abort error in the opt-in fix phase (filePath?, reason). With filePath: that one file threw and was left at its pre-fix resolution while the phase carried on — detection still runs. Without filePath: the whole phase threw before it could report, so both checks are forced to errored |
| fix:budget-exhausted | The global maxModelCalls budget was spent (spent, limit); the fix phase stopped and left the remainder in findings |
| validation:failed | The consumer validator threw; reported and swallowed, resolution kept (reason) |
Note: refine:tool-result.appliedCount reports edits applied to an in-progress buffer per turn. The final disposition is signaled by refine:completed / refine:discarded / refine:failed — only edits that survive residual-marker validation appear in Resolution.edits.
Operation-level events (rebase started, step completed, etc.) are the consumer's responsibility — the library has no opinion about the surrounding workflow.
Error handling
| Error | Codes | When |
|---|---|---|
| ConflictError | PARSE_FAILED | Conflict extraction failed |
| ConflictError | CODE_LOSS_RISK | A wholesale fallbackStrategy would have discarded integrated work; the fallback was not applied |
| AIError | VALIDATION_EXHAUSTED | Model could not produce a valid resolution after retries |
| ConflictGitPortMissingOpError | — | A required git op is missing and no exec fallback was provided |
import { resolveConflict, AIError, ConflictError } from '@gitkraken/conflict-tools';
try {
await resolveConflict(conflict, context, { model, git });
} catch (error) {
if (error instanceof ConflictError) {
// Conflict extraction failed (e.g. binary file or corrupt markers).
} else if (error instanceof AIError) {
// Model exhausted retries. Configure StepConfig.fallbackStrategy or handle here.
}
}In the batch entry point, errors do not throw; the loop continues with the next file. Where they land depends on the fallback:
- No
fallbackStrategy: the error goes tostep.errors. fallbackStrategyset, text conflict: the code-loss guard stops it, so aCODE_LOSS_RISKerror goes tostep.errorswith aresolution:failedevent. This is the common path, not the exception.fallbackStrategyset, delete-modify resolved toward the side that keeps content: downgraded to aresolution:fallbackevent, with the resolution instep.resolutions.allowLossyFallback: true: the guard is off, so every fallback takes theresolution:fallbackpath.
Each step.errors entry carries a typed reason next to the original error, so a consumer does not need instanceof checks to tell failures apart:
| reason | Meaning |
|---|---|
| read-failed | The file could not be read from the working tree |
| parse-failed | The conflict markers could not be parsed (PARSE_FAILED) |
| model-error | The model call threw: a provider error, a timeout, a rejected request |
| validation-exhausted | No model answer passed validation within maxSteps (VALIDATION_EXHAUSTED) |
| code-loss-risk | The fallback was withheld (CODE_LOSS_RISK). causeReason holds why the resolver failed, the error's cause holds that error, and its message ends with the cause's message |
| aborted | The signal was aborted while the file was being read |
| unknown | Any other error |
The advisory stages (refine, validator, detection, fix phase) are fault-isolated — a non-abort throw there is reported and swallowed rather than destroying a good resolution. A user abort (signal.aborted) always propagates and cancels the operation.
Semver policy
| Change | Bump |
|---|---|
| New optional field on an exported type | patch |
| New variant in ConflictProgressEvent | minor |
| New optional method on ConflictGitOps | patch |
| New required method on a port | major |
| Remove or rename an exported field | major |
| Change the signature of a port method | major |
| Bundled prompt change (no API change) | patch |
| New required field on a config | major |
Contributing
This package follows the monorepo's standard tooling — see the root CONTRIBUTING.md. Local commands:
pnpm --filter @gitkraken/conflict-tools build
pnpm --filter @gitkraken/conflict-tools typecheck
pnpm --filter @gitkraken/conflict-tools test