OXYGENHUB

Conflict resolution by editing marked files; working copy stays on its own base after commit; oxy resolve 20247a03a596

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 18:17 UTC
Signature
✔ signed by Andy McCall
Hash
20247a03a59675618c4e8dbd6394d93e9cee5b50e22feef4b590d87e642d613a
Depends on
1983686fb7a7 f9c98deef326 ff0af1ee299d
Depended on by
c0d4075cadc0 9bd98cfb4f7e e8d38e47e9d7 75167672ebcd 50a046e439e9 fce44ae9ad29 750251b21a96 5b9c6ad45f6e 86e682c242c6 b288cd254343 5e388efbde57
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
32 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified crates/oxy-core/src/repo.rs +193 −13
…
1 unchanged lines
2 2
//! log, and the working-copy change (spec §12, §13.1).
3 3
//!
4 4
//! Not yet implemented from the spec: seals, attestations and trust policy,
5 -
//! packs, checkpoints, multiple workspaces, remotes, and resolving conflicts
6 -
//! by editing markers. Changes are signed individually when `core.sign` is
5 +
//! packs, checkpoints, multiple workspaces, and remotes. Changes are signed individually when `core.sign` is
7 6
//! `change`; the default `seal` leaves them unsigned until push exists.
8 7
9 8
use crate::diff::{ranges, split_lines, text_edits};
…
128 unchanged lines
138 137
    Modified,
139 138
    Removed,
140 139
    ModeChanged,
141 -
    /// The file is conflicted in the base state and was edited in the working copy.
140 +
    /// The file is conflicted in the base state and still contains conflict markers.
142 141
    ConflictEdited,
142 +
    /// The file was conflicted in the base state and the working copy resolves it.
143 +
    Resolved,
144 +
}
145 +
146 +
/// Lines that mark a conflict region in a materialized file (spec §9.5).
147 +
pub fn has_conflict_markers(content: &[u8]) -> bool {
148 +
    split_lines(content).iter().any(|l| {
149 +
        l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict")
150 +
    })
143 151
}
144 152
145 153
#[derive(Clone, Debug, PartialEq, Eq)]
…
12 unchanged lines
158 166
    pub statuses: Vec<PathStatus>,
159 167
    /// Whether the snapshot rewrote the working-copy change.
160 168
    pub changed: bool,
169 +
    /// Whether the working copy resolves conflicts of its base (spec §9.6).
170 +
    pub resolves: bool,
161 171
}
162 172
163 173
pub struct LogEntry {
…
417 unchanged lines
581 591
        let base_heads = base.maximal(&old_wc.deps);
582 592
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
583 593
        deps.extend(referenced_changes(&wc, &base));
594 +
        deps.extend(b.contributors.iter().copied());
584 595
        wc.deps = deps.into_iter().collect();
585 596
        wc.normalize();
597 +
        let resolves = b.resolves;
586 598
587 599
        let mut statuses = b.statuses;
588 600
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
…
6 unchanged lines
595 607
            view.workspaces.insert(self.workspace, new_hash);
596 608
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
597 609
        }
598 -
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed })
610 +
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves })
599 611
    }
600 612
601 613
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
…
2 unchanged lines
604 616
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
605 617
            return fail("nothing to commit: the working copy matches its base");
606 618
        }
607 -
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
608 -
            return fail("a conflicted file was edited; resolving conflicts is not implemented yet");
619 +
        if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
620 +
            return fail(format!("{} still contains conflict markers", s.path));
609 621
        }
610 622
        if message.trim().is_empty() {
611 623
            return fail("a commit message is required");
612 624
        }
613 625
        let (op_ref, op) = self.head_op()?;
614 626
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
627 +
        if snap.resolves {
628 +
            // A resolution depends on every change that contributed to the
629 +
            // conflict (spec §8.1); those are already among the working copy's
630 +
            // dependencies, so keep every one that is not merely a base head.
631 +
            let heads: BTreeSet<ChangeHash> = snap.base.maximal(&snap.wc.deps).into_iter().collect();
632 +
            deps.extend(snap.wc.deps.iter().filter(|d| !heads.contains(d) || touches_conflict(&snap)).copied());
633 +
        }
615 634
        for d in extra_deps {
616 635
            if !snap.base.contains(d) {
617 636
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
…
11 unchanged lines
629 648
            message: message.trim_end().to_string(),
630 649
            tree_ops: snap.wc.tree_ops.clone(),
631 650
            edits: snap.wc.edits.clone(),
632 -
            kind: ChangeKind::Normal,
651 +
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
633 652
        };
634 653
        change.normalize();
635 654
        let hash = self.store.put_change(&change)?;
…
2 unchanged lines
638 657
        }
639 658
640 659
        // New bookmark heads: maximal elements of old heads plus the new change.
660 +
        // The working copy continues on its own base plus the new change, which
661 +
        // is exactly what the files on disk contain; the bookmark may hold
662 +
        // other heads (and conflicts) the working copy has not checked out.
641 663
        let old_heads = op.view.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default();
642 -
        let mut with_new: Vec<ChangeHash> = old_heads.clone();
643 -
        with_new.push(hash);
644 -
        let mut st = self.load_state(&with_new)?;
664 +
        let old_base = snap.base.maximal(&snap.wc.deps);
665 +
        let mut all: Vec<ChangeHash> = old_heads.clone();
666 +
        all.extend(old_base.iter().copied());
667 +
        all.push(hash);
668 +
        let mut st = self.load_state(&all)?;
645 669
        st.add(change)?;
670 +
        let mut with_new = old_heads.clone();
671 +
        with_new.push(hash);
646 672
        let new_heads = st.maximal(&with_new);
673 +
        let mut wc_base = old_base.clone();
674 +
        wc_base.push(hash);
675 +
        let wc_base = st.maximal(&wc_base);
647 676
648 -
        let wc = self.new_working_copy(new_heads.clone(), "");
677 +
        let wc = self.new_working_copy(wc_base, "");
649 678
        let wc_hash = self.store.put_change(&wc)?;
650 679
        let mut view = op.view.clone();
651 680
        view.bookmarks.insert(self.config.bookmark.clone(), new_heads);
…
254 unchanged lines
906 935
    statuses: Vec<PathStatus>,
907 936
    dir_nodes: HashMap<String, NodeId>,
908 937
    reuse: HashMap<String, (NodeId, NodeKind)>,
938 +
    /// Changes that contributed to conflicts this working copy resolves.
939 +
    contributors: BTreeSet<ChangeHash>,
940 +
    resolves: bool,
909 941
}
910 942
911 943
impl<'a> ChangeBuilder<'a> {
…
5 unchanged lines
917 949
                dir_nodes.insert(p.clone(), f.node);
918 950
            }
919 951
        }
920 -
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse }
952 +
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false }
921 953
    }
922 954
923 955
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
…
48 unchanged lines
972 1004
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
973 1005
        let entry = &self.base_snapshot.tree.entries[path];
974 1006
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
1007 +
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
1008 +
            self.resolves = true;
1009 +
        }
975 1010
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
976 1011
    }
977 1012
…
14 unchanged lines
992 1027
            FileContent::Dir => {}
993 1028
            FileContent::Text { bytes, materialized } => {
994 1029
                if materialized.is_conflicted() {
995 -
                    if bytes != &e.content {
1030 +
                    if bytes == &e.content {
1031 +
                        return Ok(());
1032 +
                    }
1033 +
                    if has_conflict_markers(&e.content) {
996 1034
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
1035 +
                        return Ok(());
997 1036
                    }
1037 +
                    // Resolution (spec §9.6): diff against the unconflicted lines
1038 +
                    // with each region as a gap, then retire every region atom.
1039 +
                    // Whatever the author kept from a region is re-inserted as
1040 +
                    // new atoms, which leaves the graph totally ordered.
1041 +
                    let regions = self.base.conflict_regions(&node);
1042 +
                    let mut edits = text_edits(node, &regions.plain, &e.content);
1043 +
                    if !regions.region_atoms.is_empty() {
1044 +
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
1045 +
                    }
1046 +
                    self.edits.extend(edits);
1047 +
                    self.contributors.extend(regions.contributors);
1048 +
                    self.resolves = true;
1049 +
                    if e.exec != expected.mode.exec {
1050 +
                        if let Some(old) = mode_atom {
1051 +
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1052 +
                        }
1053 +
                    }
1054 +
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
998 1055
                    return Ok(());
999 1056
                }
1000 1057
                if bytes != &e.content {
…
70 unchanged lines
1071 1128
    }
1072 1129
}
1073 1130
1131 +
/// Whether a working copy touches a node that is conflicted in its base.
1132 +
fn touches_conflict(snap: &SnapshotResult) -> bool {
1133 +
    let conflicted: BTreeSet<NodeId> = snap.base_snapshot.files.values().filter(|f| f.is_conflicted()).map(|f| f.node).collect();
1134 +
    snap.wc.tree_ops.iter().any(|op| conflicted.contains(&op.node())) || snap.wc.edits.iter().any(|e| conflicted.contains(&e.node()))
1135 +
}
1136 +
1074 1137
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
1075 1138
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
1076 1139
}
…
288 unchanged lines
1365 1428
        assert_eq!(s.statuses.len(), 3);
1366 1429
    }
1367 1430
1431 +
    /// Make `main` conflicted: two commits on the same base that edit the same line.
1432 +
    fn conflicted_repo() -> (tempfile::TempDir, Repo, ChangeHash, ChangeHash, ChangeHash) {
1433 +
        let dir = tempfile::tempdir().unwrap();
1434 +
        let root = dir.path();
1435 +
        let repo = Repo::init(root).unwrap();
1436 +
        write(root, "f.txt", b"a\nb\nc\n");
1437 +
        let h1 = repo.commit("base", &[]).unwrap();
1438 +
        write(root, "f.txt", b"a\nB\nc\n");
1439 +
        let h2 = repo.commit("upper", &[]).unwrap();
1440 +
        repo.checkout(&[h1], false, "checkout").unwrap();
1441 +
        write(root, "f.txt", b"a\nX\nc\n");
1442 +
        let h3 = repo.commit("ex", &[]).unwrap();
1443 +
        (dir, repo, h1, h2, h3)
1444 +
    }
1445 +
1446 +
    #[test]
1447 +
    fn concurrent_edits_conflict_and_can_be_resolved() {
1448 +
        let (dir, repo, h1, h2, h3) = conflicted_repo();
1449 +
        let root = dir.path();
1450 +
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1451 +
        assert_eq!(heads, [h2, h3].into_iter().collect());
1452 +
        let st = repo.load_state(&[h2, h3]).unwrap();
1453 +
        assert!(st.materialize().is_conflicted());
1454 +
1455 +
        // Checking out main writes markers.
1456 +
        let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
1457 +
        assert!(r.conflicted);
1458 +
        let marked = fs::read_to_string(root.join("f.txt")).unwrap();
1459 +
        assert!(marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"), "{marked}");
1460 +
        assert!(marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"), "{marked}");
1461 +
        assert!(has_conflict_markers(marked.as_bytes()));
1462 +
        // Untouched markers: clean working copy; commit has nothing to do.
1463 +
        let s = repo.snapshot("status").unwrap();
1464 +
        assert!(s.statuses.is_empty());
1465 +
        assert!(!s.resolves);
1466 +
1467 +
        // Partially edited file that still has markers cannot be committed.
1468 +
        write(root, "f.txt", marked.replace("B\n", "BB\n").as_bytes());
1469 +
        let s = repo.snapshot("status").unwrap();
1470 +
        assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::ConflictEdited)]);
1471 +
        assert!(repo.commit("no", &[]).is_err());
1472 +
1473 +
        // Resolve by keeping one side and adding a line elsewhere.
1474 +
        write(root, "f.txt", b"a\nX\nc\nd\n");
1475 +
        let s = repo.snapshot("status").unwrap();
1476 +
        assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]);
1477 +
        assert!(s.resolves);
1478 +
        let h4 = repo.commit("resolve", &[]).unwrap();
1479 +
        let c4 = repo.store().get_change(&h4).unwrap();
1480 +
        assert_eq!(c4.kind, ChangeKind::Resolution);
1481 +
        assert!(c4.deps.contains(&h2) && c4.deps.contains(&h3) && c4.deps.contains(&h1), "{:?}", c4.deps);
1482 +
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h4]);
1483 +
        let snap = repo.load_state(&[h4]).unwrap().materialize();
1484 +
        assert!(!snap.is_conflicted());
1485 +
        assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
1486 +
        assert!(!repo.snapshot("status").unwrap().changed);
1487 +
        // A peer holding only h2 and h3 and receiving h4 sees the same result,
1488 +
        // whatever order the changes arrive in.
1489 +
        let mut st = State::new();
1490 +
        for h in [h4, h3, h1, h2] {
1491 +
            st.add_unchecked(repo.store().get_change(&h).unwrap());
1492 +
        }
1493 +
        st.validate().unwrap();
1494 +
        assert!(matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
1495 +
    }
1496 +
1497 +
    #[test]
1498 +
    fn resolution_may_keep_both_sides_or_neither() {
1499 +
        let (dir, repo, _h1, _h2, _h3) = conflicted_repo();
1500 +
        let root = dir.path();
1501 +
        repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
1502 +
        // Keep both sides in a chosen order.
1503 +
        write(root, "f.txt", b"a\nX\nB\nc\n");
1504 +
        let h = repo.commit("both", &[]).unwrap();
1505 +
        let snap = repo.load_state(&[h]).unwrap().materialize();
1506 +
        assert!(!snap.is_conflicted());
1507 +
        assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n"));
1508 +
        // Undo and instead drop the whole region.
1509 +
        repo.undo().unwrap();
1510 +
        write(root, "f.txt", b"a\nc\n");
1511 +
        let h = repo.commit("neither", &[]).unwrap();
1512 +
        let snap = repo.load_state(&[h]).unwrap().materialize();
1513 +
        assert!(!snap.is_conflicted());
1514 +
        assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n"));
1515 +
    }
1516 +
1517 +
    #[test]
1518 +
    fn name_conflict_resolved_by_removing_a_candidate() {
1519 +
        let dir = tempfile::tempdir().unwrap();
1520 +
        let root = dir.path();
1521 +
        let repo = Repo::init(root).unwrap();
1522 +
        write(root, "keep.txt", b"k\n");
1523 +
        let h1 = repo.commit("base", &[]).unwrap();
1524 +
        write(root, "new.txt", b"one\n");
1525 +
        repo.commit("one", &[]).unwrap();
1526 +
        repo.checkout(&[h1], false, "checkout").unwrap();
1527 +
        write(root, "new.txt", b"two\n");
1528 +
        repo.commit("two", &[]).unwrap();
1529 +
        let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
1530 +
        assert!(r.conflicted);
1531 +
        assert!(root.join("new.txt.oxy-conflict-1").exists() && root.join("new.txt.oxy-conflict-2").exists());
1532 +
        assert!(!root.join("new.txt").exists());
1533 +
        // Keep candidate 2 under the real name.
1534 +
        fs::remove_file(root.join("new.txt.oxy-conflict-1")).unwrap();
1535 +
        fs::rename(root.join("new.txt.oxy-conflict-2"), root.join("new.txt")).unwrap();
1536 +
        let h = repo.commit("pick two", &[]).unwrap();
1537 +
        assert_eq!(repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution);
1538 +
        // The resolution does not touch keep.txt, so it does not depend on h1;
1539 +
        // the bookmark keeps both as heads and their union is the state.
1540 +
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1541 +
        assert_eq!(heads, [h1, h].into_iter().collect());
1542 +
        let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize();
1543 +
        assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts);
1544 +
        assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), ["keep.txt", "new.txt"]);
1545 +
        assert!(matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n"));
1546 +
    }
1547 +
1368 1548
    #[test]
1369 1549
    fn identities_and_signed_commits() {
1370 1550
        let dir = tempfile::tempdir().unwrap();
…
191 unchanged lines
modified crates/oxy-core/src/state.rs +47 −0
…
53 unchanged lines
54 54
    }
55 55
}
56 56
57 +
/// See `State::conflict_regions`.
58 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
59 +
pub struct ConflictRegions {
60 +
    pub plain: Vec<(AtomKey, Vec<u8>)>,
61 +
    pub region_atoms: Vec<AtomKey>,
62 +
    pub contributors: Vec<ChangeHash>,
63 +
}
64 +
57 65
/// The whole tree of a state, materialized.
58 66
#[derive(Clone, Debug, Default)]
59 67
pub struct Snapshot {
…
179 unchanged lines
239 247
        }
240 248
    }
241 249
250 +
    /// For resolving a conflicted text node (spec §9.6): the live atoms that
251 +
    /// are not part of any conflict region in order, every live atom that is
252 +
    /// part of a region, and every change that contributed to a region.
253 +
    pub fn conflict_regions(&self, node: &NodeId) -> ConflictRegions {
254 +
        let mut out = ConflictRegions::default();
255 +
        let Some(g) = self.files.get(node) else { return out };
256 +
        let m = g.materialize(self);
257 +
        let mut contributors = BTreeSet::new();
258 +
        for seg in &m.segments {
259 +
            match seg {
260 +
                crate::graph::Segment::Lines(atoms) => {
261 +
                    for a in atoms {
262 +
                        out.plain.push((*a, g.content(a).unwrap_or_default().to_vec()));
263 +
                    }
264 +
                }
265 +
                crate::graph::Segment::Conflict(sides) => {
266 +
                    for side in sides {
267 +
                        for a in side {
268 +
                            out.region_atoms.push(*a);
269 +
                            contributors.insert(a.0);
270 +
                        }
271 +
                    }
272 +
                }
273 +
                crate::graph::Segment::Zombie { deleted, inserted } => {
274 +
                    for a in deleted {
275 +
                        contributors.insert(a.0);
276 +
                        contributors.extend(g.deleters(a).copied());
277 +
                    }
278 +
                    for a in inserted {
279 +
                        out.region_atoms.push(*a);
280 +
                        contributors.insert(a.0);
281 +
                    }
282 +
                }
283 +
            }
284 +
        }
285 +
        out.contributors = contributors.into_iter().collect();
286 +
        out
287 +
    }
288 +
242 289
    pub fn render_file(&self, node: &NodeId) -> (Vec<u8>, Materialized) {
243 290
        let Some(g) = self.files.get(node) else { return (Vec::new(), Materialized::default()) };
244 291
        let m = g.materialize(self);
…
151 unchanged lines
modified src/main.rs +45 −2
…
38 unchanged lines
39 39
        #[arg(long)]
40 40
        discard: bool,
41 41
    },
42 +
    /// Report which conflicts of the working copy's base are resolved by the current files
43 +
    Resolve,
42 44
    /// Undo the last operation
43 45
    Undo,
44 46
    /// Manage the signing identity
…
106 unchanged lines
151 153
                    ChangeStatus::Removed => "D",
152 154
                    ChangeStatus::ModeChanged => "X",
153 155
                    ChangeStatus::ConflictEdited => "C!",
156 +
                    ChangeStatus::Resolved => "R",
154 157
                };
155 158
                println!("{tag:2} {}", s.path);
156 159
            }
157 160
            let mut code = 0;
158 161
            if snap.base_snapshot.is_conflicted() {
159 -
                println!("\nConflicts in the base state:");
162 +
                println!("\nConflicts in the base state (edit the marked files, or the .oxy-conflict-N candidates, then commit):");
160 163
                for c in &snap.base_snapshot.tree.conflicts {
161 164
                    println!("  tree: {c:?}");
162 165
                }
163 166
                for (p, f) in &snap.base_snapshot.files {
164 167
                    if f.is_conflicted() {
165 -
                        println!("  {p}");
168 +
                        let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
169 +
                            Some(ChangeStatus::Resolved) => "resolved in working copy",
170 +
                            Some(ChangeStatus::ConflictEdited) => "markers remain",
171 +
                            _ => "unresolved",
172 +
                        };
173 +
                        println!("  {p}  ({state})");
166 174
                    }
167 175
                }
168 176
                code = 2;
…
72 unchanged lines
241 249
            }
242 250
            Ok(0)
243 251
        }
252 +
        Cmd::Resolve => {
253 +
            let repo = open()?;
254 +
            let snap = repo.snapshot("oxy resolve")?;
255 +
            if !snap.base_snapshot.is_conflicted() {
256 +
                println!("The working copy's base has no conflicts.");
257 +
                return Ok(0);
258 +
            }
259 +
            let mut remaining = 0;
260 +
            for (p, f) in &snap.base_snapshot.files {
261 +
                if !f.is_conflicted() {
262 +
                    continue;
263 +
                }
264 +
                match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
265 +
                    Some(ChangeStatus::Resolved) => println!("resolved   {p}"),
266 +
                    Some(ChangeStatus::ConflictEdited) => {
267 +
                        remaining += 1;
268 +
                        println!("markers    {p}");
269 +
                    }
270 +
                    _ => {
271 +
                        remaining += 1;
272 +
                        println!("unresolved {p}");
273 +
                    }
274 +
                }
275 +
            }
276 +
            for c in &snap.base_snapshot.tree.conflicts {
277 +
                remaining += 1;
278 +
                println!("tree       {c:?}");
279 +
            }
280 +
            if remaining == 0 {
281 +
                println!("All conflicts are resolved; `oxy commit` will record the resolution.");
282 +
                Ok(0)
283 +
            } else {
284 +
                Ok(2)
285 +
            }
286 +
        }
244 287
        Cmd::Undo => {
245 288
            let repo = open()?;
246 289
            let op = repo.undo()?;
…
72 unchanged lines