Accepted (at 3ca03a7a1820ee89b2b3e4bc3e902fb2c098b4e8)
Phase 13.4a wired the three-way tree walk but threw
MERGE_HAS_CONFLICTS when conflicts were detected. The throw was a
placeholder for Phase 13.4b. Now that we persist merge state on
disk (markers, stage-1/2/3 index, MERGE_HEAD, MERGE_MSG, ORIG_HEAD),
we need to decide how callers learn about the conflict:
- A — keep throwing.
MERGE_HAS_CONFLICTSis augmented with paths/count and carries no merge-state metadata. - B — throw a richer error. Carry the conflict list AND the merge-state references in the error data.
- C — return a
kind: 'conflict'variant. Extend the existingMergeResultdiscriminated union.
We adopt option C. The MergeResult type grows a fourth variant:
| {
readonly kind: 'conflict';
readonly conflicts: ReadonlyArray<{
readonly path: FilePath;
readonly type: ConflictType;
}>;
readonly mergeHead: ObjectId;
readonly origHead: ObjectId;
}Callers that pattern-match on kind add a case 'conflict': branch.
The discriminated union's exhaustiveness check catches missing
branches at compile time.
- Symmetric with existing
MergeResultkinds.up-to-date,fast-forward, andmergealready return rich metadata; the conflict case follows the same shape. - A conflicting merge is a SUCCESSFUL library call. The state on disk is persisted intentionally — the user can resolve and commit. Throwing would imply a programming error or unrecoverable failure.
- TypeScript exhaustiveness catches missing cases. A caller
that switches on
kindwithout aconflictarm fails to compile, preventing silent fall-through. - No try/catch noise around the call site. Callers inspect the return value with familiar pattern-matching idioms.
- Breaking change at the type level. Existing callers that switched exhaustively on the three pre-existing kinds will fail to compile after this PR. Mitigated by the MIGRATION.md update and the small caller surface (this is a brand-new library).
- The CLI
git mergereturns a non-zero exit code on conflict. Callers that mirror CLI behaviour have to translatekind === 'conflict'into their preferred error surface. Trivial but worth noting. MERGE_HAS_CONFLICTSbecomes vestigial inmerge.ts. It stays in the domain for callers (primitive layer) that genuinely want to throw — e.g., a futuremergePathFileprimitive that doesn't write merge state. Removing the error from the union would be a wider refactor with no upside.
- The
conflictsarray in the return value duplicates information already in the index's stage-1/2/3 entries on disk. We include it for callers that don't want to re-read the index. - Forward-compatible with
--abortand other v2 surfaces: the conflict-result kind doesn't constrain future shape.
- Option A — keep throwing. Rejected. Throwing makes a successful state look like a failure; callers must catch-and- inspect; doesn't compose with TypeScript exhaustiveness.
- Option B — richer thrown error. Rejected. Same problem as A plus an ad-hoc error shape that diverges from every other command's contract.
- Option D — TWO return paths:
MergeResultfor clean cases,MergeConflictResultfor conflicts. Rejected. Two return types force callers to handle the dispatch externally; the whole point of a discriminated union is to keep dispatch internal to the type system.