Skip to main content

lex_vcs/
merge_session.rs

1//! Stateful merge sessions for programmatic conflict resolution (#134).
2//!
3//! Today's `lex_vcs::merge` returns a list of `MergeOutcome`s — auto-
4//! merged sigs *and* conflicts — and exits. To act on conflicts an
5//! agent has to:
6//!
7//! 1. Run `lex store-merge`.
8//! 2. Parse the JSON output.
9//! 3. Decide a resolution per conflict.
10//! 4. Manually edit source files.
11//! 5. Run `lex check`.
12//! 6. Run `lex publish`.
13//! 7. Loop on failure.
14//!
15//! Six round-trips for what should be one transaction. Worse, the
16//! agent edits *text* between steps 4 and 6 — the typed conflict
17//! the merge engine produced gets re-derived from the new text. The
18//! information loss is what the issue calls out.
19//!
20//! [`MergeSession`] gives the engine layer needed to expose merging
21//! as a state machine: `start` collects conflicts, `resolve` accepts
22//! batched [`Resolution`]s, `commit` finalizes when no conflicts
23//! remain. The HTTP wrapper (`POST /v1/merge/start` etc.) and the
24//! CLI mirror (`lex merge resolve`) compose on top of this.
25//!
26//! # Why a stateful session
27//!
28//! Merging conflicts iteratively is the natural agent loop:
29//! "submit 50 resolutions, see which were accepted, fix the ones
30//! that broke type-checking, retry." The session holds the
31//! in-progress state so the merge cost (LCA computation, op
32//! grouping, conflict classification) is paid once per merge,
33//! not once per resolution batch.
34//!
35//! # What's in the foundation slice
36//!
37//! The state machine: types, transitions, validation hook for
38//! resolved candidates, commit path that produces a fresh head op.
39//! Persistence (so a session survives a process restart) and the
40//! HTTP / CLI surfaces are subsequent slices.
41
42use std::collections::BTreeMap;
43
44use serde::{Deserialize, Serialize};
45
46use crate::merge::{ConflictKind, MergeOutcome, MergeOutput};
47use crate::op_log::OpLog;
48use crate::operation::{BlobId, OpId, Operation, SigId, StageId};
49
50/// Stable id for a merge in flight. Caller-supplied so the HTTP
51/// surface can map URLs to sessions without leaking session ids
52/// from the engine. Production callers will likely use UUIDs;
53/// tests use short strings.
54pub type MergeSessionId = String;
55
56/// Stable id for a conflict within a session. We use the SigId as
57/// the conflict id since conflicts are 1:1 with the sigs that have
58/// `MergeOutcome::Conflict`. If a future merge ever produces
59/// multiple conflicts on the same sig, this becomes a tuple.
60pub type ConflictId = SigId;
61
62/// Snapshot of one conflict the agent needs to resolve.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ConflictRecord {
65    pub conflict_id: ConflictId,
66    pub sig_id: SigId,
67    pub kind: ConflictKind,
68    /// Stage on the LCA. `None` for `AddAdd` (no shared base) and
69    /// for sigs that didn't exist on the LCA.
70    pub base: Option<StageId>,
71    /// Stage on the dst (ours) side of the merge. `None` if dst
72    /// removed it.
73    pub ours: Option<StageId>,
74    /// Stage on the src (theirs) side of the merge. `None` if src
75    /// removed it.
76    pub theirs: Option<StageId>,
77}
78
79// ---- Files (#1007 PR 7): the manifest side of a merge ----
80//
81// `lex-vcs` doesn't know about `Manifest` or blob storage (that's
82// `lex-store`'s layer — see `crate::merge_session`'s module docs on
83// `ResolutionChecker` for the same layering reason). So a file conflict
84// here is described purely in terms of the blob triple a path resolved
85// to on each side — everything a caller needs to render or resolve it,
86// without this crate depending on `lex-store::files::{Entry, Manifest}`.
87// The caller (lex-store, via `Store::manifest_merge`) computes the 3-way
88// diff and hands the resulting conflicts to
89// [`MergeSession::attach_file_conflicts`]; this crate then tracks
90// resolutions the same way it tracks sig conflicts.
91
92/// A path in a files manifest (#1007). Distinct type alias from
93/// [`ConflictId`] even though both are `String` — a sig id and a file
94/// path are never interchangeable, and the alias documents which one a
95/// signature expects.
96pub type FilePath = String;
97
98/// One side of a [`FileConflict`]: the blob (and its metadata) a path
99/// resolved to in a manifest. Mirrors `lex_store::files::Entry` field
100/// for field, without this crate depending on `lex-store`.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct FileEntry {
103    pub blob: BlobId,
104    pub mode: String,
105    pub size: u64,
106}
107
108/// A path whose manifest entry differs on both sides of a merge, and
109/// differs from the base too — a real content conflict, not a one-sided
110/// edit (those auto-resolve before this is ever surfaced; see
111/// `Store::manifest_merge`'s doc comment for the auto-resolve rule).
112///
113/// Blobs are opaque bytes (binary allowed, per #1007 §2) so there is no
114/// meaningful 3-way *content* merge the way there is for text lines —
115/// resolving a `FileConflict` means picking a side
116/// ([`FileResolution::TakeOurs`] / [`FileResolution::TakeTheirs`]), not
117/// splicing bytes. See the module docs on why real content merging is
118/// out of scope for #1007 PR 7.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct FileConflict {
121    pub path: FilePath,
122    /// The path's entry on the merge base (the LCA's manifest). `None`
123    /// if the path didn't exist there (both sides added it
124    /// differently — the file-level analogue of `ConflictKind::AddAdd`).
125    pub base: Option<FileEntry>,
126    /// The path's entry on dst's side. `None` if dst doesn't have it
127    /// (removed, or never added).
128    pub ours: Option<FileEntry>,
129    /// The path's entry on src's side. `None` if src doesn't have it.
130    pub theirs: Option<FileEntry>,
131}
132
133/// Choice for a single file conflict. No `Custom` variant (unlike
134/// [`Resolution`]): a file conflict has no "brand-new op" analogue — the
135/// only two things you can do with two divergent versions of an opaque
136/// blob are keep one or the other. See the module docs above.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(tag = "kind", rename_all = "snake_case")]
139pub enum FileResolution {
140    /// Keep dst's (ours) entry for this path; discard src's.
141    TakeOurs,
142    /// Keep src's (theirs) entry for this path; discard dst's.
143    TakeTheirs,
144    /// Punt to a human reviewer, same as [`Resolution::Defer`].
145    Defer,
146}
147
148/// Why a file resolution was rejected. Only the structural case applies
149/// today — a file resolution never fails a type-check the way a sig
150/// resolution can, since `SetFiles` carries no program semantics.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(tag = "kind", rename_all = "snake_case")]
153pub enum FileResolutionRejection {
154    /// `path` doesn't refer to any pending file conflict in the
155    /// session — invented, or already resolved and pruned.
156    UnknownConflict { path: FilePath },
157}
158
159/// Per-path outcome of a `resolve_files` call. Mirrors [`ResolveVerdict`].
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct FileResolveVerdict {
162    pub path: FilePath,
163    pub accepted: bool,
164    pub rejection: Option<FileResolutionRejection>,
165}
166
167/// Choice for a single conflict.
168// `Operation` is the only payload-carrying variant and grew with
169// #280's typed transforms. Clippy flags the size disparity, but
170// boxing the field would churn callers (HTTP handler, CLI, tests)
171// for a heuristic warning — the heap allocation cost vs. the
172// occasional empty variant is not actually a hot path here.
173#[allow(clippy::large_enum_variant)]
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(tag = "kind", rename_all = "snake_case")]
176pub enum Resolution {
177    /// Keep dst's stage; discard src's.
178    TakeOurs,
179    /// Keep src's stage; discard dst's.
180    TakeTheirs,
181    /// Submit a brand-new op that supersedes both sides. The op's
182    /// parents must include both ours and theirs (the merge engine
183    /// validates this; see [`MergeSession::validate_resolution`]).
184    Custom { op: Operation },
185    /// Punt to a human reviewer. Surfaces as
186    /// [`CommitError::ConflictsRemaining`] on commit until removed.
187    Defer,
188}
189
190/// Why a resolution was rejected. Distinct from [`CommitError`]
191/// because a resolve call returns *per-conflict* verdicts; commit
192/// returns a single overall verdict.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(tag = "kind", rename_all = "snake_case")]
195pub enum ResolutionRejection {
196    /// The conflict_id doesn't refer to any pending conflict in
197    /// the session. Either the agent invented one, or it was
198    /// already resolved and the session pruned it.
199    UnknownConflict { conflict_id: ConflictId },
200    /// The custom op's parents don't include both `ours` and
201    /// `theirs`. A custom resolution that doesn't acknowledge
202    /// both sides isn't a merge — it's a fork.
203    CustomOpMissingParents {
204        conflict_id: ConflictId,
205        expected: Vec<OpId>,
206        got: Vec<OpId>,
207    },
208    /// The resolution is structurally valid but the program it
209    /// produces — dst's head with this resolution (and every
210    /// resolution accepted so far) overlaid — does not type-check.
211    /// Only returned by [`MergeSession::resolve_checked`]; the
212    /// structural [`MergeSession::resolve`] never composes a program
213    /// and so never emits this. `errors` are the composed program's
214    /// type errors, rendered by the injected [`ResolutionChecker`].
215    TypeError {
216        conflict_id: ConflictId,
217        errors: Vec<String>,
218    },
219}
220
221/// Injected composer + type-checker for merge resolutions.
222///
223/// `lex-vcs` deliberately does not depend on `lex-store`, so a merge
224/// session cannot compose a program from stage ids on its own — it
225/// only knows the *shape* of the merge (which sig resolves to which
226/// stage). The caller, which holds the store, supplies a checker so
227/// [`MergeSession::resolve_checked`] can type-check a resolution the
228/// moment it is submitted rather than only at commit. This mirrors
229/// [`crate::IntentResolver`], the same dependency-injection seam the
230/// predicate engine uses.
231///
232/// Implementors receive the full projected post-merge **delta against
233/// dst's head** — `sig_id -> Some(stage)` to set that sig to `stage`,
234/// `sig_id -> None` to remove it. The implementor overlays the delta
235/// onto dst's current head, composes the stages, and type-checks:
236/// return the (possibly empty) list of type errors as strings. An
237/// empty vec means the resolution composes.
238pub trait ResolutionChecker {
239    fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String>;
240}
241
242/// Per-conflict outcome of a resolve call.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct ResolveVerdict {
245    pub conflict_id: ConflictId,
246    pub accepted: bool,
247    pub rejection: Option<ResolutionRejection>,
248}
249
250/// Why a commit failed. Conflicts-remaining is the most common
251/// case — agents are expected to iterate via resolve until this
252/// goes away.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum CommitError {
255    /// At least one sig conflict has no resolution or has
256    /// [`Resolution::Defer`]. The session is still alive; submit
257    /// resolutions and retry. Checked before file conflicts, so a
258    /// session with both kinds pending reports this first.
259    ConflictsRemaining(Vec<ConflictId>),
260    /// At least one file conflict (#1007 PR 7) has no resolution or
261    /// has [`FileResolution::Defer`]. Only reachable once
262    /// `ConflictsRemaining` is empty.
263    FileConflictsRemaining(Vec<FilePath>),
264}
265
266/// What [`MergeSession::commit`] hands the caller to land: the resolved
267/// sig conflicts (as before #1007) plus the resolved file conflicts and
268/// whether the merge needs a `SetFiles` op at all (#1007 PR 7 — see
269/// [`MergeSession::attach_file_conflicts`]).
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct MergeCommitOutput {
272    pub resolved: Vec<(ConflictId, Resolution)>,
273    pub resolved_files: Vec<(FilePath, FileResolution)>,
274    /// True when dst's and src's files manifests disagreed at all (even
275    /// if every path auto-resolved with no [`FileConflict`]) — the
276    /// merge commit must append a `SetFiles` recording the merged
277    /// manifest, per #1007 §1. False when the session was never told
278    /// about a files dimension, or the manifests already agreed.
279    pub needs_setfiles: bool,
280}
281
282/// Stateful merge in flight. Hold one per active merge between
283/// `start` and `commit`. Sessions are not thread-safe; the HTTP
284/// wrapper is expected to wrap them in a `Mutex` keyed by
285/// [`MergeSessionId`].
286#[derive(Debug, Serialize, Deserialize)]
287pub struct MergeSession {
288    pub merge_id: MergeSessionId,
289    pub src_head: Option<OpId>,
290    pub dst_head: Option<OpId>,
291    pub lca: Option<OpId>,
292    /// Outcomes the engine resolved unilaterally — `Both` (both
293    /// sides agreed) and one-sided (`Src` / `Dst`). The agent sees
294    /// these for audit but doesn't need to act on them.
295    pub auto_resolved: Vec<MergeOutcome>,
296    /// Conflicts indexed by id. Removed as resolutions land.
297    conflicts: BTreeMap<ConflictId, ConflictRecord>,
298    /// Resolutions accumulated across resolve calls. Validated
299    /// against `conflicts` when applied.
300    resolutions: BTreeMap<ConflictId, Resolution>,
301    /// File conflicts (#1007 PR 7), attached separately from `start`
302    /// via [`Self::attach_file_conflicts`] — `lex-vcs` doesn't compute
303    /// these itself (see the module docs above `FilePath`). Empty for
304    /// a session whose merge has no files dimension at all.
305    #[serde(default)]
306    file_conflicts: BTreeMap<FilePath, FileConflict>,
307    /// File resolutions accumulated across `resolve_files` calls.
308    #[serde(default)]
309    file_resolutions: BTreeMap<FilePath, FileResolution>,
310    /// Whether dst's and src's files manifests disagree at all — see
311    /// [`MergeCommitOutput::needs_setfiles`].
312    #[serde(default)]
313    needs_setfiles: bool,
314}
315
316impl MergeSession {
317    /// Start a merge session. Runs the engine in [`crate::merge`]
318    /// and partitions the outcomes into auto-resolved and
319    /// conflicts-needing-attention.
320    pub fn start(
321        merge_id: impl Into<MergeSessionId>,
322        op_log: &OpLog,
323        src_head: Option<&OpId>,
324        dst_head: Option<&OpId>,
325    ) -> std::io::Result<Self> {
326        let MergeOutput { lca, outcomes } = crate::merge::merge(op_log, src_head, dst_head)?;
327        let mut auto_resolved = Vec::new();
328        let mut conflicts: BTreeMap<ConflictId, ConflictRecord> = BTreeMap::new();
329        for outcome in outcomes {
330            match outcome {
331                MergeOutcome::Conflict {
332                    sig_id,
333                    kind,
334                    base,
335                    src,
336                    dst,
337                } => {
338                    let conflict_id = sig_id.clone();
339                    conflicts.insert(
340                        conflict_id.clone(),
341                        ConflictRecord {
342                            conflict_id,
343                            sig_id,
344                            kind,
345                            base,
346                            // The merge engine returns `src` and
347                            // `dst` from src's and dst's perspective
348                            // respectively. We map dst→ours and
349                            // src→theirs, matching the canonical
350                            // git terminology and the issue text.
351                            ours: dst,
352                            theirs: src,
353                        },
354                    );
355                }
356                other => auto_resolved.push(other),
357            }
358        }
359        Ok(Self {
360            merge_id: merge_id.into(),
361            src_head: src_head.cloned(),
362            dst_head: dst_head.cloned(),
363            lca,
364            auto_resolved,
365            conflicts,
366            resolutions: BTreeMap::new(),
367            file_conflicts: BTreeMap::new(),
368            file_resolutions: BTreeMap::new(),
369            needs_setfiles: false,
370        })
371    }
372
373    /// Attach the files dimension of the merge (#1007 PR 7): the
374    /// conflicts a 3-way manifest diff surfaced (paths edited
375    /// differently on both sides — see [`FileConflict`]) and whether
376    /// the merge needs a `SetFiles` op at all. Called once, right
377    /// after [`Self::start`], by the caller that holds the store (the
378    /// same layering [`ResolutionChecker`] uses: this crate tracks the
379    /// session's state machine, the caller computes the domain-specific
380    /// diff). A no-op call with an empty `conflicts` and
381    /// `needs_setfiles: false` — the default — leaves a session with
382    /// no files dimension, exactly as before this feature existed.
383    pub fn attach_file_conflicts(&mut self, conflicts: Vec<FileConflict>, needs_setfiles: bool) {
384        self.file_conflicts = conflicts.into_iter().map(|c| (c.path.clone(), c)).collect();
385        self.needs_setfiles = needs_setfiles;
386    }
387
388    /// Whether the merge needs a `SetFiles` op appended on commit, per
389    /// [`MergeCommitOutput::needs_setfiles`].
390    pub fn needs_setfiles(&self) -> bool {
391        self.needs_setfiles
392    }
393
394    /// Pending file conflicts (those without a non-defer resolution).
395    /// Mirrors [`Self::remaining_conflicts`].
396    pub fn remaining_file_conflicts(&self) -> Vec<&FileConflict> {
397        self.file_conflicts
398            .values()
399            .filter(|c| {
400                !matches!(
401                    self.file_resolutions.get(&c.path),
402                    Some(FileResolution::TakeOurs) | Some(FileResolution::TakeTheirs)
403                )
404            })
405            .collect()
406    }
407
408    /// Submit file resolutions in batch. Mirrors [`Self::resolve`]:
409    /// unlike sig resolutions there is no type-check to run (`SetFiles`
410    /// carries no program semantics), so this is the only resolve path
411    /// files need — no `resolve_files_checked` counterpart.
412    pub fn resolve_files(
413        &mut self,
414        resolutions: Vec<(FilePath, FileResolution)>,
415    ) -> Vec<FileResolveVerdict> {
416        let mut out = Vec::with_capacity(resolutions.len());
417        for (path, resolution) in resolutions {
418            if !self.file_conflicts.contains_key(&path) {
419                out.push(FileResolveVerdict {
420                    path: path.clone(),
421                    accepted: false,
422                    rejection: Some(FileResolutionRejection::UnknownConflict { path }),
423                });
424                continue;
425            }
426            self.file_resolutions.insert(path.clone(), resolution);
427            out.push(FileResolveVerdict { path, accepted: true, rejection: None });
428        }
429        out
430    }
431
432    /// Pending conflicts (those without a non-defer resolution).
433    pub fn remaining_conflicts(&self) -> Vec<&ConflictRecord> {
434        self.conflicts
435            .values()
436            .filter(|c| {
437                !matches!(self.resolutions.get(&c.conflict_id),
438                    Some(Resolution::TakeOurs)
439                    | Some(Resolution::TakeTheirs)
440                    | Some(Resolution::Custom { .. }))
441            })
442            .collect()
443    }
444
445    /// Submit resolutions in batch. Returns one verdict per input.
446    /// Accepted resolutions are recorded; rejected ones leave the
447    /// previous resolution (if any) in place so partial submissions
448    /// don't clobber earlier good work.
449    pub fn resolve(
450        &mut self,
451        resolutions: Vec<(ConflictId, Resolution)>,
452    ) -> Vec<ResolveVerdict> {
453        let mut out = Vec::with_capacity(resolutions.len());
454        for (conflict_id, resolution) in resolutions {
455            match self.validate_resolution(&conflict_id, &resolution) {
456                Ok(()) => {
457                    self.resolutions.insert(conflict_id.clone(), resolution);
458                    out.push(ResolveVerdict {
459                        conflict_id,
460                        accepted: true,
461                        rejection: None,
462                    });
463                }
464                Err(rej) => {
465                    out.push(ResolveVerdict {
466                        conflict_id,
467                        accepted: false,
468                        rejection: Some(rej),
469                    });
470                }
471            }
472        }
473        out
474    }
475
476    /// Submit resolutions in batch, **type-checking each** against the
477    /// composed program before accepting it (#834).
478    ///
479    /// This is the loop the session was built for — "submit N
480    /// resolutions, see which broke type-checking, fix them, retry" —
481    /// made real. Structural validation ([`Self::validate_resolution`])
482    /// runs first; a structurally-valid resolution is then overlaid on
483    /// dst's head together with every resolution accepted so far, and
484    /// the injected [`ResolutionChecker`] type-checks the result. A
485    /// resolution whose composed program doesn't type-check is rejected
486    /// with [`ResolutionRejection::TypeError`] and *not* recorded, so
487    /// the session's accepted set stays type-correct at every step.
488    ///
489    /// Resolutions are processed in order and accumulate: a later
490    /// resolution is checked against the program the earlier accepted
491    /// ones already produced. Interdependent picks (two conflicts that
492    /// only compose together) should therefore be submitted in
493    /// dependency order, or a rejected one resubmitted after its
494    /// partner lands — the same way `git` needs both halves of an
495    /// intertwined conflict resolved before the tree builds. Unresolved
496    /// conflicts contribute nothing to the projection: they leave dst's
497    /// (always-valid) side standing, so a partial batch still composes.
498    pub fn resolve_checked(
499        &mut self,
500        resolutions: Vec<(ConflictId, Resolution)>,
501        checker: &dyn ResolutionChecker,
502    ) -> Vec<ResolveVerdict> {
503        let mut out = Vec::with_capacity(resolutions.len());
504        for (conflict_id, resolution) in resolutions {
505            // 1. Structural: known conflict, custom op acknowledges
506            //    both sides. Cheap, and a malformed op can't be
507            //    type-checked meaningfully anyway.
508            if let Err(rej) = self.validate_resolution(&conflict_id, &resolution) {
509                out.push(ResolveVerdict { conflict_id, accepted: false, rejection: Some(rej) });
510                continue;
511            }
512            // 2. Type: overlay this resolution on the ones accepted so
513            //    far and type-check the composed program.
514            let mut trial = self.resolutions.clone();
515            trial.insert(conflict_id.clone(), resolution.clone());
516            let delta = self.projected_delta(&trial);
517            let errors = checker.typecheck_projection(&delta);
518            if !errors.is_empty() {
519                out.push(ResolveVerdict {
520                    conflict_id: conflict_id.clone(),
521                    accepted: false,
522                    rejection: Some(ResolutionRejection::TypeError { conflict_id, errors }),
523                });
524                continue;
525            }
526            self.resolutions.insert(conflict_id.clone(), resolution);
527            out.push(ResolveVerdict { conflict_id, accepted: true, rejection: None });
528        }
529        out
530    }
531
532    /// The projected post-merge head-delta **against dst's head**,
533    /// assuming `resolutions`. This is exactly the `entries` a
534    /// `StageTransition::Merge` would record, and the input the
535    /// [`ResolutionChecker`] overlays on dst's head:
536    ///
537    /// * `MergeOutcome::Src` (a change only src made) → set it.
538    /// * `MergeOutcome::Both` / `Dst` → dst's head already reflects it;
539    ///   no delta.
540    /// * conflict resolved `TakeTheirs` → set src's stage.
541    /// * conflict resolved `Custom` → set the custom op's target
542    ///   ([`OperationKind::merge_target`]).
543    /// * conflict resolved `TakeOurs` → dst already has it; no delta.
544    /// * conflict unresolved / `Defer` → no delta (dst's side stands).
545    fn projected_delta(
546        &self,
547        resolutions: &BTreeMap<ConflictId, Resolution>,
548    ) -> BTreeMap<SigId, Option<StageId>> {
549        let mut delta: BTreeMap<SigId, Option<StageId>> = BTreeMap::new();
550        for outcome in &self.auto_resolved {
551            if let MergeOutcome::Src { sig_id, stage_id } = outcome {
552                delta.insert(sig_id.clone(), stage_id.clone());
553            }
554        }
555        for (conflict_id, record) in &self.conflicts {
556            match resolutions.get(conflict_id) {
557                Some(Resolution::TakeTheirs) => {
558                    delta.insert(record.sig_id.clone(), record.theirs.clone());
559                }
560                Some(Resolution::Custom { op }) => {
561                    if let Some((sig, stage)) = op.kind.merge_target() {
562                        delta.insert(sig, stage);
563                    }
564                }
565                // TakeOurs (dst already has it), Defer, or unresolved:
566                // no change against dst's head.
567                _ => {}
568            }
569        }
570        delta
571    }
572
573    /// Validate a single resolution against the session's pending
574    /// conflicts. Pure (no side effects); the caller decides
575    /// whether to accept.
576    pub fn validate_resolution(
577        &self,
578        conflict_id: &ConflictId,
579        resolution: &Resolution,
580    ) -> Result<(), ResolutionRejection> {
581        if !self.conflicts.contains_key(conflict_id) {
582            return Err(ResolutionRejection::UnknownConflict { conflict_id: conflict_id.clone() });
583        }
584        if let Resolution::Custom { op } = resolution {
585            // Validate that the custom op's parent set acknowledges
586            // both sides. We don't have direct OpIds for the
587            // ours/theirs ops here (the conflict record carries
588            // stage ids), so the check is "the op has at least two
589            // parents" — a stronger check requires looking up the
590            // ops by sig and confirming they're in the parents,
591            // which is a follow-up enhancement.
592            //
593            // For the foundation slice this catches the obvious
594            // misuse (`Operation::new(kind, [])`) without
595            // reconstructing the merge engine's own validation.
596            if op.parents.len() < 2 {
597                return Err(ResolutionRejection::CustomOpMissingParents {
598                    conflict_id: conflict_id.clone(),
599                    expected: vec!["ours-op-id".into(), "theirs-op-id".into()],
600                    got: op.parents.clone(),
601                });
602            }
603        }
604        Ok(())
605    }
606
607    /// Finalize the merge. On success returns the resolved sig and
608    /// file resolutions, in id order, plus whether the caller must
609    /// append a `SetFiles`. The caller is responsible for synthesizing
610    /// the final `Operation::Merge` (and, if `needs_setfiles`, the
611    /// follow-up `SetFiles`) op against the store and persisting them;
612    /// this function returns the engine's view of "what to land," not
613    /// the persisted op ids.
614    ///
615    /// Sig conflicts are checked before file conflicts: a session with
616    /// both kinds pending reports [`CommitError::ConflictsRemaining`]
617    /// first, exactly the precedence #977's merge gate already used for
618    /// dependency conflicts vs. type errors — one blocker surfaced at a
619    /// time keeps the agent's retry loop simple.
620    pub fn commit(self) -> Result<MergeCommitOutput, CommitError> {
621        let unresolved: Vec<ConflictId> = self
622            .conflicts
623            .keys()
624            .filter(|id| {
625                !matches!(self.resolutions.get(*id),
626                    Some(Resolution::TakeOurs)
627                    | Some(Resolution::TakeTheirs)
628                    | Some(Resolution::Custom { .. }))
629            })
630            .cloned()
631            .collect();
632        if !unresolved.is_empty() {
633            return Err(CommitError::ConflictsRemaining(unresolved));
634        }
635        let unresolved_files: Vec<FilePath> = self
636            .file_conflicts
637            .keys()
638            .filter(|path| {
639                !matches!(
640                    self.file_resolutions.get(*path),
641                    Some(FileResolution::TakeOurs) | Some(FileResolution::TakeTheirs)
642                )
643            })
644            .cloned()
645            .collect();
646        if !unresolved_files.is_empty() {
647            return Err(CommitError::FileConflictsRemaining(unresolved_files));
648        }
649        let mut resolved: Vec<(ConflictId, Resolution)> = self.resolutions.into_iter().collect();
650        resolved.sort_by(|a, b| a.0.cmp(&b.0));
651        let mut resolved_files: Vec<(FilePath, FileResolution)> =
652            self.file_resolutions.into_iter().collect();
653        resolved_files.sort_by(|a, b| a.0.cmp(&b.0));
654        Ok(MergeCommitOutput { resolved, resolved_files, needs_setfiles: self.needs_setfiles })
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::operation::{OperationKind, OperationRecord, StageTransition};
662    use std::collections::BTreeSet;
663
664    /// Tiny fixture: one branch (dst) modifies fn::A from stage-0 to
665    /// stage-1; another (src) modifies fn::A to stage-2. The LCA is
666    /// the original add. The merge surfaces a `ModifyModify`
667    /// conflict on fn::A.
668    fn fixture() -> (tempfile::TempDir, OpLog, OpId, OpId) {
669        let tmp = tempfile::tempdir().unwrap();
670        let log = OpLog::open(tmp.path()).unwrap();
671        let r0 = OperationRecord::new(
672            Operation::new(
673                OperationKind::AddFunction {
674                    sig_id: "fn::A".into(),
675                    stage_id: "stage-0".into(),
676                    effects: BTreeSet::new(),
677                    budget_cost: None,
678                    in_file: None,
679                },
680                [],
681            ),
682            StageTransition::Create {
683                sig_id: "fn::A".into(),
684                stage_id: "stage-0".into(),
685            },
686        );
687        log.put(&r0).unwrap();
688
689        let r1 = OperationRecord::new(
690            Operation::new(
691                OperationKind::ModifyBody {
692                    sig_id: "fn::A".into(),
693                    from_stage_id: "stage-0".into(),
694                    to_stage_id: "stage-1".into(),
695                    from_budget: None,
696                    to_budget: None,
697                    to_sig_id: None,
698                },
699                [r0.op_id.clone()],
700            ),
701            StageTransition::Replace {
702                sig_id: "fn::A".into(),
703                from: "stage-0".into(),
704                to: "stage-1".into(),
705            },
706        );
707        log.put(&r1).unwrap();
708
709        let r2 = OperationRecord::new(
710            Operation::new(
711                OperationKind::ModifyBody {
712                    sig_id: "fn::A".into(),
713                    from_stage_id: "stage-0".into(),
714                    to_stage_id: "stage-2".into(),
715                    from_budget: None,
716                    to_budget: None,
717                    to_sig_id: None,
718                },
719                [r0.op_id.clone()],
720            ),
721            StageTransition::Replace {
722                sig_id: "fn::A".into(),
723                from: "stage-0".into(),
724                to: "stage-2".into(),
725            },
726        );
727        log.put(&r2).unwrap();
728
729        (tmp, log, r1.op_id, r2.op_id)
730    }
731
732    #[test]
733    fn start_collects_conflicts() {
734        let (_tmp, log, dst, src) = fixture();
735        let session =
736            MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
737        assert_eq!(session.remaining_conflicts().len(), 1);
738        assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
739        assert_eq!(
740            session.remaining_conflicts()[0].kind,
741            ConflictKind::ModifyModify
742        );
743        assert_eq!(
744            session.remaining_conflicts()[0].ours.as_deref(),
745            Some("stage-1"),
746        );
747        assert_eq!(
748            session.remaining_conflicts()[0].theirs.as_deref(),
749            Some("stage-2"),
750        );
751        assert_eq!(
752            session.remaining_conflicts()[0].base.as_deref(),
753            Some("stage-0"),
754        );
755    }
756
757    #[test]
758    fn no_conflicts_when_branches_dont_overlap() {
759        let tmp = tempfile::tempdir().unwrap();
760        let log = OpLog::open(tmp.path()).unwrap();
761        let r0 = OperationRecord::new(
762            Operation::new(
763                OperationKind::AddFunction {
764                    sig_id: "fn::A".into(),
765                    stage_id: "stage-0".into(),
766                    effects: BTreeSet::new(),
767                    budget_cost: None,
768                    in_file: None,
769                },
770                [],
771            ),
772            StageTransition::Create {
773                sig_id: "fn::A".into(),
774                stage_id: "stage-0".into(),
775            },
776        );
777        log.put(&r0).unwrap();
778        let r1 = OperationRecord::new(
779            Operation::new(
780                OperationKind::AddFunction {
781                    sig_id: "fn::B".into(),
782                    stage_id: "stage-B".into(),
783                    effects: BTreeSet::new(),
784                    budget_cost: None,
785                    in_file: None,
786                },
787                [r0.op_id.clone()],
788            ),
789            StageTransition::Create {
790                sig_id: "fn::B".into(),
791                stage_id: "stage-B".into(),
792            },
793        );
794        log.put(&r1).unwrap();
795
796        let session =
797            MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
798        assert!(session.remaining_conflicts().is_empty());
799        assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
800    }
801
802    #[test]
803    fn resolve_take_ours_clears_conflict() {
804        let (_tmp, log, dst, src) = fixture();
805        let mut session =
806            MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
807        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
808        assert_eq!(verdicts.len(), 1);
809        assert!(verdicts[0].accepted);
810        assert!(session.remaining_conflicts().is_empty());
811    }
812
813    #[test]
814    fn resolve_take_theirs_clears_conflict() {
815        let (_tmp, log, dst, src) = fixture();
816        let mut session =
817            MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
818        let verdicts =
819            session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
820        assert!(verdicts[0].accepted);
821        assert!(session.remaining_conflicts().is_empty());
822    }
823
824    #[test]
825    fn resolve_unknown_conflict_is_rejected() {
826        let (_tmp, log, dst, src) = fixture();
827        let mut session =
828            MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
829        let verdicts =
830            session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
831        assert_eq!(verdicts.len(), 1);
832        assert!(!verdicts[0].accepted);
833        assert!(matches!(
834            verdicts[0].rejection,
835            Some(ResolutionRejection::UnknownConflict { .. }),
836        ));
837    }
838
839    #[test]
840    fn custom_op_without_two_parents_is_rejected() {
841        let (_tmp, log, dst, src) = fixture();
842        let mut session =
843            MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
844        // A custom op with empty parents — clearly not a merge.
845        let bad_op = Operation::new(
846            OperationKind::ModifyBody {
847                sig_id: "fn::A".into(),
848                from_stage_id: "stage-0".into(),
849                to_stage_id: "stage-X".into(),
850                from_budget: None,
851                to_budget: None,
852                to_sig_id: None,
853            },
854            [],
855        );
856        let verdicts = session.resolve(vec![(
857            "fn::A".into(),
858            Resolution::Custom { op: bad_op },
859        )]);
860        assert!(!verdicts[0].accepted);
861        assert!(matches!(
862            verdicts[0].rejection,
863            Some(ResolutionRejection::CustomOpMissingParents { .. }),
864        ));
865        // The conflict is still pending — bad resolutions don't
866        // clobber the slot.
867        assert_eq!(session.remaining_conflicts().len(), 1);
868    }
869
870    #[test]
871    fn custom_op_with_two_parents_is_accepted() {
872        let (_tmp, log, dst, src) = fixture();
873        let mut session =
874            MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
875        let merge_op = Operation::new(
876            OperationKind::ModifyBody {
877                sig_id: "fn::A".into(),
878                from_stage_id: "stage-0".into(),
879                to_stage_id: "stage-merged".into(),
880                from_budget: None,
881                to_budget: None,
882                to_sig_id: None,
883            },
884            [src.clone(), dst.clone()],
885        );
886        let verdicts = session.resolve(vec![(
887            "fn::A".into(),
888            Resolution::Custom { op: merge_op },
889        )]);
890        assert!(verdicts[0].accepted);
891        assert!(session.remaining_conflicts().is_empty());
892    }
893
894    #[test]
895    fn defer_keeps_conflict_pending() {
896        let (_tmp, log, dst, src) = fixture();
897        let mut session =
898            MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
899        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
900        // Defer is a valid resolution — accepted — but the conflict
901        // stays in `remaining_conflicts` since it still requires
902        // human attention.
903        assert!(verdicts[0].accepted);
904        assert_eq!(session.remaining_conflicts().len(), 1);
905    }
906
907    #[test]
908    fn commit_with_no_conflicts_succeeds() {
909        let tmp = tempfile::tempdir().unwrap();
910        let log = OpLog::open(tmp.path()).unwrap();
911        let session = MergeSession::start("ms-9", &log, None, None).unwrap();
912        let out = session.commit().unwrap();
913        assert!(out.resolved.is_empty());
914        assert!(out.resolved_files.is_empty());
915        assert!(!out.needs_setfiles);
916    }
917
918    #[test]
919    fn commit_with_unresolved_conflict_fails() {
920        let (_tmp, log, dst, src) = fixture();
921        let session =
922            MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
923        let err = session.commit().unwrap_err();
924        match err {
925            CommitError::ConflictsRemaining(ids) => {
926                assert_eq!(ids, vec!["fn::A".to_string()]);
927            }
928            other => panic!("expected ConflictsRemaining, got {other:?}"),
929        }
930    }
931
932    #[test]
933    fn commit_with_defer_remaining_fails() {
934        let (_tmp, log, dst, src) = fixture();
935        let mut session =
936            MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
937        session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
938        let err = session.commit().unwrap_err();
939        match err {
940            CommitError::ConflictsRemaining(ids) => {
941                assert_eq!(ids, vec!["fn::A".to_string()]);
942            }
943            other => panic!("expected ConflictsRemaining, got {other:?}"),
944        }
945    }
946
947    #[test]
948    fn commit_after_resolve_succeeds() {
949        let (_tmp, log, dst, src) = fixture();
950        let mut session =
951            MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
952        session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
953        let out = session.commit().unwrap();
954        assert_eq!(out.resolved.len(), 1);
955        assert_eq!(out.resolved[0].0, "fn::A");
956        assert!(matches!(out.resolved[0].1, Resolution::TakeOurs));
957        assert!(out.resolved_files.is_empty());
958    }
959
960    #[test]
961    fn batch_resolve_accepts_partial() {
962        // Mixed batch: one valid, one referencing an unknown
963        // conflict. The valid one should land; the bad one should
964        // be rejected without clobbering anything else.
965        let (_tmp, log, dst, src) = fixture();
966        let mut session =
967            MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
968        let verdicts = session.resolve(vec![
969            ("fn::A".into(), Resolution::TakeOurs),
970            ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
971        ]);
972        assert_eq!(verdicts.len(), 2);
973        assert!(verdicts[0].accepted);
974        assert!(!verdicts[1].accepted);
975        // fn::A is now resolved.
976        assert!(session.remaining_conflicts().is_empty());
977    }
978
979    #[test]
980    fn auto_resolved_outcomes_are_visible() {
981        let tmp = tempfile::tempdir().unwrap();
982        let log = OpLog::open(tmp.path()).unwrap();
983        // Single branch: just an add; no second branch to merge,
984        // but `MergeSession::start(... None ...)` still runs the
985        // engine. This documents what `auto_resolved` carries.
986        let r0 = OperationRecord::new(
987            Operation::new(
988                OperationKind::AddFunction {
989                    sig_id: "fn::A".into(),
990                    stage_id: "stage-0".into(),
991                    effects: BTreeSet::new(),
992                    budget_cost: None,
993                    in_file: None,
994                },
995                [],
996            ),
997            StageTransition::Create {
998                sig_id: "fn::A".into(),
999                stage_id: "stage-0".into(),
1000            },
1001        );
1002        log.put(&r0).unwrap();
1003        let session =
1004            MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
1005        assert!(session.remaining_conflicts().is_empty());
1006        // src had a unique op vs the missing dst → it's an Src
1007        // outcome surfaced as auto-resolved.
1008        assert_eq!(session.auto_resolved.len(), 1);
1009    }
1010
1011    // ---- #834: resolve_checked type-checks resolutions ----
1012
1013    /// A `ResolutionChecker` that rejects any projection setting the
1014    /// conflicted sig to a named "poison" stage — a stand-in for the
1015    /// real store-backed checker, which composes+type-checks. Records
1016    /// the deltas it was asked about so tests can assert the
1017    /// projection shape the session hands the checker.
1018    struct MockChecker {
1019        poison_stage: &'static str,
1020        seen: std::cell::RefCell<Vec<BTreeMap<SigId, Option<StageId>>>>,
1021    }
1022    impl MockChecker {
1023        fn new(poison_stage: &'static str) -> Self {
1024            Self { poison_stage, seen: std::cell::RefCell::new(Vec::new()) }
1025        }
1026    }
1027    impl ResolutionChecker for MockChecker {
1028        fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String> {
1029            self.seen.borrow_mut().push(delta.clone());
1030            if delta.values().any(|s| s.as_deref() == Some(self.poison_stage)) {
1031                vec![format!("stage {} does not type-check", self.poison_stage)]
1032            } else {
1033                Vec::new()
1034            }
1035        }
1036    }
1037
1038    #[test]
1039    fn resolve_checked_rejects_a_resolution_that_breaks_typechecking() {
1040        // theirs == stage-2. A checker that poisons stage-2 must
1041        // reject TakeTheirs and NOT record it — the session's
1042        // accepted set stays type-correct.
1043        let (_tmp, log, dst, src) = fixture();
1044        let mut session = MergeSession::start("ms-c1", &log, Some(&src), Some(&dst)).unwrap();
1045        let checker = MockChecker::new("stage-2");
1046
1047        let verdicts = session.resolve_checked(
1048            vec![("fn::A".into(), Resolution::TakeTheirs)],
1049            &checker,
1050        );
1051        assert_eq!(verdicts.len(), 1);
1052        assert!(!verdicts[0].accepted);
1053        assert!(matches!(
1054            verdicts[0].rejection,
1055            Some(ResolutionRejection::TypeError { .. })
1056        ), "expected TypeError, got {:?}", verdicts[0].rejection);
1057        // Not recorded → the conflict is still pending.
1058        assert_eq!(session.remaining_conflicts().len(), 1);
1059    }
1060
1061    #[test]
1062    fn resolve_checked_accepts_a_resolution_that_composes() {
1063        // TakeOurs keeps stage-1 (dst's side): the projection is
1064        // empty (dst already has it), so the checker sees no poison
1065        // and accepts.
1066        let (_tmp, log, dst, src) = fixture();
1067        let mut session = MergeSession::start("ms-c2", &log, Some(&src), Some(&dst)).unwrap();
1068        let checker = MockChecker::new("stage-2");
1069
1070        let verdicts = session.resolve_checked(
1071            vec![("fn::A".into(), Resolution::TakeOurs)],
1072            &checker,
1073        );
1074        assert_eq!(verdicts.len(), 1);
1075        assert!(verdicts[0].accepted, "got {:?}", verdicts[0].rejection);
1076        assert!(session.remaining_conflicts().is_empty());
1077        // TakeOurs contributes no delta against dst's head.
1078        assert_eq!(checker.seen.borrow().last().unwrap().len(), 0);
1079    }
1080
1081    #[test]
1082    fn resolve_checked_still_rejects_structurally_invalid_before_typechecking() {
1083        // An unknown conflict is rejected structurally; the checker
1084        // is never consulted for it.
1085        let (_tmp, log, dst, src) = fixture();
1086        let mut session = MergeSession::start("ms-c3", &log, Some(&src), Some(&dst)).unwrap();
1087        let checker = MockChecker::new("stage-2");
1088        let verdicts = session.resolve_checked(
1089            vec![("fn::NOPE".into(), Resolution::TakeTheirs)],
1090            &checker,
1091        );
1092        assert!(!verdicts[0].accepted);
1093        assert!(matches!(
1094            verdicts[0].rejection,
1095            Some(ResolutionRejection::UnknownConflict { .. })
1096        ));
1097        assert!(checker.seen.borrow().is_empty(), "checker must not run on a structural reject");
1098    }
1099
1100    #[test]
1101    fn projected_delta_sets_theirs_for_take_theirs() {
1102        let (_tmp, log, dst, src) = fixture();
1103        let session = MergeSession::start("ms-c4", &log, Some(&src), Some(&dst)).unwrap();
1104        let mut res = BTreeMap::new();
1105        res.insert("fn::A".to_string(), Resolution::TakeTheirs);
1106        let delta = session.projected_delta(&res);
1107        assert_eq!(delta.get("fn::A"), Some(&Some("stage-2".to_string())));
1108    }
1109
1110    // ---- #1007 PR 7: file conflicts on a merge session ----
1111
1112    fn some_entry(n: u8) -> FileEntry {
1113        FileEntry { blob: format!("{n:0>64}"), mode: "100644".into(), size: n as u64 }
1114    }
1115
1116    fn file_conflict(path: &str) -> FileConflict {
1117        FileConflict {
1118            path: path.into(),
1119            base: Some(some_entry(1)),
1120            ours: Some(some_entry(2)),
1121            theirs: Some(some_entry(3)),
1122        }
1123    }
1124
1125    #[test]
1126    fn no_file_conflicts_by_default() {
1127        // A session that never gets `attach_file_conflicts` called on
1128        // it (every merge before #1007, and any merge whose manifests
1129        // already agree) has no files dimension at all.
1130        let tmp = tempfile::tempdir().unwrap();
1131        let log = OpLog::open(tmp.path()).unwrap();
1132        let session = MergeSession::start("ms-f0", &log, None, None).unwrap();
1133        assert!(session.remaining_file_conflicts().is_empty());
1134        assert!(!session.needs_setfiles());
1135        let out = session.commit().unwrap();
1136        assert!(out.resolved_files.is_empty());
1137        assert!(!out.needs_setfiles);
1138    }
1139
1140    #[test]
1141    fn attach_file_conflicts_surfaces_them_as_pending() {
1142        let tmp = tempfile::tempdir().unwrap();
1143        let log = OpLog::open(tmp.path()).unwrap();
1144        let mut session = MergeSession::start("ms-f1", &log, None, None).unwrap();
1145        session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1146        assert!(session.needs_setfiles());
1147        let remaining = session.remaining_file_conflicts();
1148        assert_eq!(remaining.len(), 1);
1149        assert_eq!(remaining[0].path, "README.md");
1150    }
1151
1152    #[test]
1153    fn commit_blocked_by_unresolved_file_conflict() {
1154        let tmp = tempfile::tempdir().unwrap();
1155        let log = OpLog::open(tmp.path()).unwrap();
1156        let mut session = MergeSession::start("ms-f2", &log, None, None).unwrap();
1157        session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1158        let err = session.commit().unwrap_err();
1159        match err {
1160            CommitError::FileConflictsRemaining(paths) => {
1161                assert_eq!(paths, vec!["README.md".to_string()]);
1162            }
1163            other => panic!("expected FileConflictsRemaining, got {other:?}"),
1164        }
1165    }
1166
1167    #[test]
1168    fn sig_conflicts_take_precedence_over_file_conflicts_in_commit_error() {
1169        // A session with BOTH an unresolved sig conflict and an
1170        // unresolved file conflict reports the sig one first — the
1171        // agent fixes one blocker at a time.
1172        let (_tmp, log, dst, src) = fixture();
1173        let mut session = MergeSession::start("ms-f3", &log, Some(&src), Some(&dst)).unwrap();
1174        session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1175        let err = session.commit().unwrap_err();
1176        assert!(matches!(err, CommitError::ConflictsRemaining(_)));
1177    }
1178
1179    #[test]
1180    fn resolve_files_take_ours_clears_conflict_and_commits() {
1181        let tmp = tempfile::tempdir().unwrap();
1182        let log = OpLog::open(tmp.path()).unwrap();
1183        let mut session = MergeSession::start("ms-f4", &log, None, None).unwrap();
1184        session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1185        let verdicts =
1186            session.resolve_files(vec![("README.md".into(), FileResolution::TakeOurs)]);
1187        assert!(verdicts[0].accepted);
1188        assert!(session.remaining_file_conflicts().is_empty());
1189        let out = session.commit().unwrap();
1190        assert_eq!(out.resolved_files, vec![("README.md".to_string(), FileResolution::TakeOurs)]);
1191        assert!(out.needs_setfiles);
1192    }
1193
1194    #[test]
1195    fn resolve_files_unknown_path_is_rejected() {
1196        let tmp = tempfile::tempdir().unwrap();
1197        let log = OpLog::open(tmp.path()).unwrap();
1198        let mut session = MergeSession::start("ms-f5", &log, None, None).unwrap();
1199        session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1200        let verdicts =
1201            session.resolve_files(vec![("nope.txt".into(), FileResolution::TakeOurs)]);
1202        assert!(!verdicts[0].accepted);
1203        assert!(matches!(
1204            verdicts[0].rejection,
1205            Some(FileResolutionRejection::UnknownConflict { .. })
1206        ));
1207        // The real conflict is untouched.
1208        assert_eq!(session.remaining_file_conflicts().len(), 1);
1209    }
1210
1211    #[test]
1212    fn defer_on_file_conflict_keeps_it_pending() {
1213        let tmp = tempfile::tempdir().unwrap();
1214        let log = OpLog::open(tmp.path()).unwrap();
1215        let mut session = MergeSession::start("ms-f6", &log, None, None).unwrap();
1216        session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1217        let verdicts =
1218            session.resolve_files(vec![("README.md".into(), FileResolution::Defer)]);
1219        assert!(verdicts[0].accepted);
1220        assert_eq!(session.remaining_file_conflicts().len(), 1, "defer is not a resolution");
1221        assert!(matches!(
1222            session.commit().unwrap_err(),
1223            CommitError::FileConflictsRemaining(_)
1224        ));
1225    }
1226}