Morph merges the same working tree Git merges, but it also reconciles the structured objects Git can't see — pipelines, evaluation suites, and the certified metrics that decide whether a merge is behaviorally safe. This document explains how the merge engine works end-to-end, from object-level reconciliation up through the user-facing morph merge flow and the textual-conflict fallback.
If you only need to use the feature, jump to the user flow. If you want to understand or extend the engine, read the whole thing top to bottom.
A Morph commit is more than a tree hash. It points at:
- a Tree (
tree) — the file-tree snapshot, like Git - a Pipeline (
pipeline) — the DAG of operators that produced it (prompt calls, tool calls, transforms) - an EvalSuite via
eval_contract.suite— the cases and metrics the commit claims to satisfy - observed metric scores in
eval_contract.observed_metrics - optional
evidence_refs— Run / Trace hashes that back up the claim - optional
env_constraints,morph_version,morph_instance, …
Two commits that diverged from a common ancestor differ in all of these. A naive "git merge" of just the file tree would silently throw away the rest. Morph's merge engine reconciles each of them on its own terms:
| Object | Reconciled by | Lives in |
|---|---|---|
Two Trees + ancestor |
3-way structural tree merge with textual fallback | morph-core/src/treemerge.rs |
Two Pipelines + ancestor |
Node/edge structural merge | morph-core/src/pipemerge.rs |
Two EvalSuites + ancestor |
Case/metric structural merge | morph-core/src/objmerge.rs |
| Two metric vectors | Behavioral dominance check | morph-core/src/merge.rs (check_dominance) |
Two evidence_refs lists |
Deduped sorted union | morph-core/src/merge.rs (union_evidence_refs) |
| Two trees with file conflicts | git merge-file line-level |
morph-core/src/treemerge.rs |
The common ancestor used everywhere is the lowest common ancestor (LCA) of the two commits in the parent DAG. It's computed once by merge_base in morph-core/src/objmerge.rs (re-exported from the crate root) and threaded through every layer below.
Two suites can disagree on cases or metrics. objmerge::merge_eval_suites(base, ours, theirs):
- Cases: keyed by
id. Identical edits on both sides take that edit. Disjoint edits compose. Conflicting edits (both sides changed the same case differently) are an error the user must resolve manually before retrying. - Metrics: keyed by
name. Same rules; aggregation, threshold, and direction must agree on both sides for a non-conflicting metric. - Adds: a case present on one side and absent on the ancestor and the other side is added to the merged suite.
- Deletes: a case present on the ancestor and absent on one side is removed.
pipemerge::merge_pipelines(base, ours, theirs) walks the pipeline DAG:
- Nodes: keyed by
id. Same composition rules as eval cases. Identical edits compose; divergent edits on the same node are a conflict. - Edges: edge sets are unioned. Two sides that both add the same edge end up with one copy.
- Prompts (
prompts: Vec<Hash>): set union, sorted by hash for determinism. - Provenance: rolled forward from whichever side touched it; if both sides set provenance differently it's a conflict.
This is the heaviest piece of the engine and lives in morph-core/src/treemerge.rs. The output is a MergePlan with three things:
- The merged
Treeobject hash (when there are no conflicts). - A list of
WorkdirOps —Write { path, bytes }andDelete { path }operations to apply to the working tree. - A list of conflicting paths — files where Morph couldn't reconcile structurally and needs the user to resolve text first.
The structural rules:
- Disjoint adds / deletes / edits compose.
- Both sides identical → take it.
- One side edited, the other untouched → take the edit.
- Both sides edited the same path differently → write a conflict marker (
<<<<<<<…=======…>>>>>>>) by shelling out togit merge-file, mark the path unmerged in the staging index, and add the path toMergePlan.conflicts. - Both sides deleted → delete.
- One side deleted while the other edited → conflict; user picks.
Textual fallback uses Git's own git merge-file binary: write base.txt, ours.txt, theirs.txt into a tempdir, invoke the tool, capture the merged buffer with conflict markers, and write that to the workdir. We trust Git's diff3 implementation rather than reimplementing it.
The unmerged paths are recorded in index.unmerged_entries: BTreeMap<Path, UnmergedEntry { base_blob, ours_blob, theirs_blob }> so morph status can list them and morph merge --continue can drop them once the user runs morph add on a resolved file.
A clean structural merge is necessary but not sufficient. Morph also requires the merged commit to dominate both parents on every declared metric.
check_dominance(parent_a_metrics, parent_b_metrics, candidate_metrics, retired) enforces:
- For each metric
mnot inretired:- if direction is
maximize:candidate[m] >= max(parent_a[m], parent_b[m]) - if direction is
minimize:candidate[m] <= min(parent_a[m], parent_b[m])
- if direction is
- If a metric is missing from
candidate, dominance fails for that metric (you can't merge code that drops a measured behavior unless you explicitly retire the metric). - If
retiredlists a metric, that metric is not checked. Retiring is how you signal "this metric is no longer relevant — the pipeline changed enough that we shouldn't compare on it anymore."
Behavioral dominance only fires if RepoPolicy.merge_policy is "dominance" (the default). Setting it to "none" lets you merge purely on the structural result, which is occasionally what you want during rapid prototyping.
The CLI surfaces dominance failures with the exact metric, parent values, and candidate value so you know what regressed.
The merge commit's evidence_refs is the deduped, sorted union of parent_a.evidence_refs and parent_b.evidence_refs. The implementation is merge::union_evidence_refs. Two consequences:
- Run/Trace provenance is preserved across merge boundaries —
morph logandmorph showcan still walk back to either parent's evidence. - Bare-server fetches that traverse
evidence_refs(PR 5) work transparently after a merge: the union is exactly the closure both parents needed.
If both parents lack evidence the field stays None (rather than Some(vec![])) so legacy commits round-trip through serde unchanged.
A merge in progress is recorded as files inside .morph/. They are managed by morph-core/src/merge_state.rs:
| File | Means |
|---|---|
MERGE_HEAD |
hash of "their" commit being merged in |
ORIG_HEAD |
the HEAD commit before the merge started; consulted by --abort to restore the working tree |
MERGE_MSG |
proposed commit message; the user can edit before --continue |
MERGE_PIPELINE.json |
merged pipeline object (only when start_merge produced one with no node-level conflicts, or every node-level conflict was resolved by morph merge resolve-node) |
MERGE_SUITE |
hash of the merged eval suite (only when start_merge produced one) |
Plus the staging index.json carries unmerged_entries listing the conflicting paths.
merge_flow.rs orchestrates the lifecycle:
┌─────────────────┐
│ no merge yet │
└─────────────────┘
│
morph merge <branch>
│
▼
┌──────────────────────────────────────┐
│ MERGE_HEAD / ORIG_HEAD / MERGE_MSG │
│ (+ MERGE_PIPELINE / MERGE_SUITE) │
│ index.unmerged_entries populated │
│ workdir has conflict markers │
└──────────────────────────────────────┘
│ │
morph merge --abort morph merge --continue
│ │
│ (after the user runs `morph add`
│ on every resolved file; the
│ `unmerged_entries` map must now
│ be empty)
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ no merge yet│ │ merge commit │
└─────────────┘ │ with both │
│ parents, │
│ evidence_refs │
│ unioned │
└─────────────────┘
morph merge <branch> (start_merge) returns a StartMergeOutcome:
- Read HEAD and
<branch>tips. - Compute the LCA via
merge_baseand callobjmerge::merge_commits. - If the relationship is
TrivialOutcome::AlreadyMergedorAlreadyAhead, the CLI prints "Already up to date." and exits. - If the relationship is
TrivialOutcome::FastForward, the CLI moves the local branch ref tootherand updates the working tree viacheckout_tree. - Otherwise (
TrivialOutcome::Diverged), apply the engine's planned working-tree writes viatreemerge::apply_workdir_ops(which writes diff3 conflict markers into the workdir for textual conflicts) and build aMergePlan. - If
outcome.needs_resolutionisfalse, the CLI immediately callscontinue_mergeto finalize the merge commit. - If
outcome.needs_resolutionistrue, writeMERGE_HEAD/ORIG_HEAD/MERGE_MSG(andMERGE_PIPELINE/MERGE_SUITEwhen relevant), recordunmerged_entries, and exit non-zero so the user can resolve the listed conflicts and re-runmorph merge --continue.
morph merge resolve-node <id> --pick ours|theirs|base (resolve_node):
- Refuse when no merge is in progress.
- Look up the named pipeline-node conflict and replace its entry in
MERGE_PIPELINE.jsonwith the chosen side. - Drop the node from the in-progress conflict list. When the list is empty,
--continueis unblocked.
morph merge --continue (continue_merge):
- Read state. Refuse if no merge is in progress.
- Refuse if
unmerged_entriesis still non-empty (the user has not staged every resolution). - Refuse if the working tree has uncommitted changes to tracked files (
working_tree_clean). - Build the merge commit with
parents = [HEAD, MERGE_HEAD], the merged tree/pipeline/suite, the recorded observed metrics, andevidence_refs = union_evidence_refs(parents). - Apply behavioral dominance (
check_dominance) unless the repo'smerge_policyis"none"— fail loudly if the merged metrics regress. - Update the active branch ref, clear all
MERGE_*state files, dropunmerged_entries, and return the new commit hash.
morph merge --abort (abort_merge):
- Look up
ORIG_HEAD. Refuse when no merge is in progress so users get a clear signal. - Restore the workdir to
ORIG_HEADviacheckout_tree. - Clear all
MERGE_*files and dropunmerged_entriesfrom the index.
morph status reads this state and prints the same kind of "Unmerged paths" / "All conflicts fixed but you are still merging" hints Git users expect.
The simplest possible session, no conflicts:
morph merge feature
# 8d8b287a4c… ← the new merge commit hashA conflicting session:
morph merge feature
# Auto-merging failed for 1 path; conflict markers written to disk.
# CONFLICT (content): src/lib.rs
# Run `morph status` for details, then `morph merge --continue`.
morph status
# Unmerged paths:
# both modified: src/lib.rs
# Edit src/lib.rs, remove the <<<<<<<, =======, >>>>>>> markers.
morph add src/lib.rs
morph merge --continue
# 8d8b287a4c…If your divergence touches a pipeline node, you'll instead see a node-level conflict:
morph merge feature
# Pipeline has 1 node-level conflict:
# CONFLICT (pipeline node): generate
# resolve with: morph merge resolve-node <id> --pick ours|theirs|base
morph merge resolve-node generate --pick theirs
morph merge --continueIf you change your mind:
morph merge --abort
# Merge aborted; working tree restored to ORIG_HEAD.If a textual merge isn't enough — say cargo test fails on the merged tree — you can keep iterating:
# fix things in src/, run cargo test until clean
morph add src/lib.rs
morph merge --continueIf the merge succeeds structurally but dominance fails:
merge rejected: merged metrics do not dominate both parents
pass_rate: candidate 0.91 < required 0.95
mean_latency_ms: candidate 320 > required 280
The fix is one of:
- re-run your eval suite on the merged tree until the metrics dominate;
- explicitly retire an obsolete metric with
morph merge … --retire metric_name(single-shot form) so the merge contract drops it; - set
merge_policy = "none"in.morph/config.jsonto opt out of dominance gating during prototyping (see the policy block in v0-spec.md §11.1); - decide this is the wrong merge and run
morph merge --abort.
morph pull is fast-forward only. When the local branch has diverged from its remote-tracking ref, pull raises Diverged with the local and remote tips. The same orchestrator is reachable via morph pull --merge, which:
- Fetches the remote.
- Calls
start_mergeagainst the remote-tracking ref. - Surfaces the same conflicts / fast-forward / merged outcomes.
In other words, the same merge engine handles local merges, cross-branch merges, and post-fetch merges. All three flow through merge_flow.rs, all three respect dominance, all three union evidence. The transport layer (PR 5) is invisible to the merge layer.
| File | Role |
|---|---|
morph-core/src/merge.rs |
prepare_merge, execute_merge, check_dominance, union_evidence_refs |
morph-core/src/objmerge.rs |
merge_base (LCA), commit-level merge_commits, TrivialOutcome (AlreadyMerged / AlreadyAhead / FastForward / Diverged), EvalSuite case/metric merge |
morph-core/src/merge_flow.rs |
start_merge, continue_merge, abort_merge, resolve_node, MergeProgress |
morph-core/src/merge_state.rs |
read_* / write_* / clear_merge_state for the .morph/MERGE_* files |
morph-core/src/treemerge.rs |
3-way Tree merge, WorkdirOp, apply_workdir_ops, textual fallback |
morph-core/src/text3way.rs |
Wrapper around git merge-file (the diff3 textual fallback) |
morph-core/src/pipemerge.rs |
Pipeline DAG merge |
morph-core/src/index.rs |
StagingIndex.unmerged_entries, UnmergedEntry |
morph-core/src/workdir.rs |
working_tree_clean, checkout_tree (used by --abort and fast-forward) |
morph-cli/src/main.rs |
run_merge dispatch (start / single-shot / --continue / --abort / resolve-node) |
morph-cli/tests/specs/merge*.yaml |
End-to-end spec tests for every branch above |
The closest thing to a "single big test" is merge_flow::tests::continue_merge_writes_evidence_union_from_parents, which sets up two parents with disjoint evidence and proves the merged commit unions them.