@gitkraken/conflict-tools
v0.2.1
Published
AI-powered git conflict resolution.
Downloads
1,679
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), and writes results back to the working tree.
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, returns aResolutionwith resolved content, confidence, and metrics. The single-file workhorse.resolveConflicts() + applyResolutions()— batch-resolve every currently unmerged file in the working tree, 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-toolsPorts
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) => simpleGit.raw(...args),
};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 |
| 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.
ResolutionVerifier (optional)
A pluggable post-resolution check. The library ships a defaultVerifier that scans the assembled content for residual conflict markers. During the AI loop, a failed verification triggers a retry with feedback — the model gets a chance to self-correct.
import { defaultVerifier } from '@gitkraken/conflict-tools';
import type { ResolutionVerifier } from '@gitkraken/conflict-tools';
// Use the built-in (checks for residual <<<<<<< / ======= / >>>>>>>)
const verifier = defaultVerifier;
// Or provide your own (e.g. run a linter, compile check)
const custom: ResolutionVerifier = {
verify: async (filePath, content) => {
const errors = await lint(content);
return errors.length === 0
? { valid: true }
: { valid: false, feedback: errors.join('\n') };
},
};Pass the verifier via deps.verifier 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,
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'
// resolution.confidence — 0..1, from the AI's own self-assessment
// resolution.description — one-or-two sentence rationale, from the AI
// resolution.chunks? — per-marker decisions (for observability and validation)
// resolution.metrics? — { inputTokens, outputTokens, stepCount, toolCallCount, ... }ResolutionContext carries optional hints the AI can use:
interface ResolutionContext {
commitMessage?: string;
prDescription?: string;
previousResolutions?: Resolution[];
fileNeighbors?: string[];
refs?: { ours: string; theirs: string; base?: string };
threeWayDiff?: { oursDiff: string; theirsDiff: 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.
Phase 2 refine pass
After Phase 1 resolves conflict markers, the AI may return followUpInstructions: string[] on the resolution — a list of targeted edits it could not express as a whole-chunk decision (for example, re-applying a partial change from the incoming branch). When followUpInstructions is non-empty, resolveConflict automatically runs a second agentic loop (Phase 2) that applies those edits via the edit_file_lines tool.
ResolverConfig.refineMaxSteps?: number controls how many model turns Phase 2 is allowed. Defaults to 5.
Resolution.edits?: IntraFileEditOp[] carries the list of edit operations that were successfully applied during Phase 2. Absent when Phase 2 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 } from '@gitkraken/conflict-tools';
const step = await resolveConflicts(
{
model,
git,
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 } for files that failed (no fallback or AI exhausted)
// step.skipped?[] — { filePath, reason } for files that were excluded or had no markers
await applyResolutions(step.resolutions, { git });
// Resolutions are written via writeFile, checkoutFile, or removeFile and staged.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'.fallbackStrategy— when AI fails for a file, fall back to'take-ours'or'take-theirs'instead of recording an error.
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, and returns. It does not call git rebase --continue or any other operation command — that is the consumer's job.
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, onProgress: webview.post });
const approved = await reviewDialog(step.resolutions);
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,
config: {
rules: [{ match: '*.lock', strategy: 'take-theirs' }],
fallbackStrategy: 'take-ours',
},
onProgress: emit,
});
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 |
| conflict:excluded | A file was skipped by a rule with strategy: 'skip' |
| conflict:skipped | A file had no conflict markers and no delete-modify reason |
| resolution:applied | A resolution was produced for a file |
| resolution:fallback | AI failed; fallback strategy applied |
| resolution:failed | AI failed and no fallback configured |
| resolver:tool-call | The AI invoked a tool. Includes reason (why the AI called it) |
| resolver:step-usage | Token usage for one model call |
| resolver:completed | The resolver finished a single file |
| resolver:response | Phase 1 produced its final response |
| resolver:tool-result | A Phase 1 tool returned content |
| refine:started | Phase 2 has begun |
| refine:skipped | Phase 2 was not run (no follow-ups) |
| refine:tool-call | Phase 2 model invoked edit_file_lines |
| refine:tool-result | Phase 2 tool returned feedback |
| refine:final-text | Phase 2 model emitted final description |
| refine:completed | Phase 2 finished successfully (includes appliedCount) |
| refine:failed | Phase 2 threw before completing |
| edit:discarded | An edit batch was rejected (introduced markers) |
Note: refine:tool-result.appliedCount reports edits applied to an in-progress buffer per turn. The final disposition is signaled by refine:completed / edit: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 |
| AIError | VALIDATION_EXHAUSTED, INCOMPLETE_RESOLUTION | 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 that have a configured fallback do not throw — they are downgraded to resolution:fallback events, and the resulting resolutions appear in step.resolutions. Errors without a fallback land in step.errors and the loop continues with the next file.
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