OXYGENHUB

oxy log draws a graph: lanes, bookmarks inline, unique ID prefixes (closes #50) 2bf22305bc48

Each change takes two lines: its ChangeId with the shortest unique prefix
bright, author, time, bookmarks and tags at its heads, hash and signature;
then the subject. lanes::graph lays out one edge per change, to its newest
dependency, eliding edges longer than twelve rows (~) and joining lanes
headed for the same change into the left one. A whole dependency graph
needs up to 125 lanes on oxygen's own history; this keeps it to six, two
typically, while drawing 316 of 372 links. --json is unchanged.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 16:59 UTC
Signature
✔ signed by Andy McCall
Hash
2bf22305bc48ecd927347e41e90e63ddb36c5d7af9658653f13db6b4251f4462
Depends on
119bbf50cb86 135212de5370 14edbaaf5291 2863456a0f6d 4cce912a0953 69816d688300 75167672ebcd 8fd78eb52ad2 94084087a876 957fee30826e c6aa5bd9d1c8 d4dbab97e948 ee06a6c557b7 fce44ae9ad29
Depended on by
79b5a90ffd43 5cde7882d767 47ce8b8e2d4b 33472ccc08cc 98a1620a3840 e8c8b34d8348
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.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
24 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **`oxy log` is a graph (#50).** Each change takes two lines: its ID (the shortest unique prefix bright), author, time, bookmarks and tags, hash and signature, then its subject. Lanes follow the change each one most directly builds on, and join where two lines of work meet, so independent work shows side by side. A dependency further than twelve rows down is marked `~`, which keeps the graph to a few lanes even on a long history. `--json` is unchanged.
6 +
5 7
- **A remote's bookmarks work like local ones (#48).**
6 8
  - `origin/NAME` names a remote's bookmark wherever a bookmark is expected, and `oxy bookmark list --all` lists those with no local bookmark.
7 9
  - `oxy bookmark switch NAME` makes a local bookmark from `origin/NAME` when there is none (`REMOTE/NAME` picks another remote).
…
42 unchanged lines
modified crates/oxy-core/src/lanes.rs +162 −0
…
93 unchanged lines
94 94
        .unwrap_or(1)
95 95
}
96 96
97 +
/// One cell of a graph row's lower line, the line under the change.
98 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99 +
pub enum Cell {
100 +
    /// Nothing in this column.
101 +
    Blank,
102 +
    /// A lane carries on down.
103 +
    Line,
104 +
    /// The change's lane ends at a dependency not drawn (too far down, or
105 +
    /// not in the list): `~`.
106 +
    Elided,
107 +
    /// The left end of a join: this lane carries on, and the lane to its
108 +
    /// right turns into it (`├`).
109 +
    JoinLeft,
110 +
    /// A join passes a column with no lane (`─`).
111 +
    JoinPass,
112 +
    /// A join crosses a lane that carries on (`┼`).
113 +
    JoinCross,
114 +
    /// The right end of a join: this lane turns left and ends (`╯`).
115 +
    JoinRight,
116 +
}
117 +
118 +
/// A row of the drawn graph: the change's column, which other columns
119 +
/// carry a lane past it, and the line under it.
120 +
#[derive(Clone, Debug, PartialEq, Eq)]
121 +
pub struct GraphRow {
122 +
    pub column: usize,
123 +
    /// Columns with a lane on the change's own line, besides its own.
124 +
    pub passing: Vec<usize>,
125 +
    /// The line under the change, one cell per column.
126 +
    pub below: Vec<Cell>,
127 +
}
128 +
129 +
/// Lay out `changes` (newest first, as `(hash, deps)`) for a terminal
130 +
/// graph. A change's dependencies form a wide graph, since a change builds
131 +
/// on whatever last touched the lines it edits, so only one edge is drawn
132 +
/// per change: to its newest dependency, the change it most directly
133 +
/// builds on. An edge longer than `max_edge` rows, or to a change not in
134 +
/// the list, is elided (`~`), as are the other dependencies, so the
135 +
/// picture keeps to a few lanes. Two lanes heading for the same change
136 +
/// join into the left one.
137 +
pub fn graph(changes: &[(ChangeHash, Vec<ChangeHash>)], max_edge: usize) -> Vec<GraphRow> {
138 +
    let index: HashMap<&ChangeHash, usize> = changes
139 +
        .iter()
140 +
        .enumerate()
141 +
        .map(|(i, (h, _))| (h, i))
142 +
        .collect();
143 +
    let mut active: Vec<Option<ChangeHash>> = Vec::new();
144 +
    let mut rows = Vec::with_capacity(changes.len());
145 +
    for (i, (hash, deps)) in changes.iter().enumerate() {
146 +
        let column = match active.iter().position(|a| a.as_ref() == Some(hash)) {
147 +
            Some(c) => c,
148 +
            None => match active.iter().position(Option::is_none) {
149 +
                Some(c) => {
150 +
                    active[c] = Some(*hash);
151 +
                    c
152 +
                }
153 +
                None => {
154 +
                    active.push(Some(*hash));
155 +
                    active.len() - 1
156 +
                }
157 +
            },
158 +
        };
159 +
        let passing: Vec<usize> = active
160 +
            .iter()
161 +
            .enumerate()
162 +
            .filter(|(c, a)| *c != column && a.is_some())
163 +
            .map(|(c, _)| c)
164 +
            .collect();
165 +
        // The newest dependency below this row (the list is newest first).
166 +
        let nearest = deps
167 +
            .iter()
168 +
            .filter_map(|d| index.get(d).copied())
169 +
            .filter(|&j| j > i)
170 +
            .min();
171 +
        let parent = nearest.filter(|&j| j - i <= max_edge).map(|j| changes[j].0);
172 +
        active[column] = None;
173 +
        let mut join: Option<(usize, usize)> = None;
174 +
        if let Some(p) = parent {
175 +
            match active.iter().position(|a| a.as_ref() == Some(&p)) {
176 +
                // Another lane already heads there: join the two, leftwards.
177 +
                Some(other) => {
178 +
                    let (left, right) = (other.min(column), other.max(column));
179 +
                    active[right] = None;
180 +
                    active[left] = Some(p);
181 +
                    join = Some((left, right));
182 +
                }
183 +
                None => active[column] = Some(p),
184 +
            }
185 +
        }
186 +
        let width = active.len().max(column + 1);
187 +
        let mut below = vec![Cell::Blank; width];
188 +
        for (c, a) in active.iter().enumerate() {
189 +
            if a.is_some() {
190 +
                below[c] = Cell::Line;
191 +
            }
192 +
        }
193 +
        if let Some((left, right)) = join {
194 +
            below[left] = Cell::JoinLeft;
195 +
            for cell in below.iter_mut().take(right).skip(left + 1) {
196 +
                *cell = if *cell == Cell::Line {
197 +
                    Cell::JoinCross
198 +
                } else {
199 +
                    Cell::JoinPass
200 +
                };
201 +
            }
202 +
            below[right] = Cell::JoinRight;
203 +
        } else if parent.is_none() && !deps.is_empty() {
204 +
            below[column] = Cell::Elided;
205 +
        }
206 +
        while below.len() > 1 && below.last() == Some(&Cell::Blank) {
207 +
            below.pop();
208 +
        }
209 +
        rows.push(GraphRow {
210 +
            column,
211 +
            passing,
212 +
            below,
213 +
        });
214 +
        while active.last().is_some_and(Option::is_none) {
215 +
            active.pop();
216 +
        }
217 +
    }
218 +
    rows
219 +
}
220 +
97 221
#[cfg(test)]
98 222
mod tests {
99 223
    use super::*;
…
27 unchanged lines
127 251
            "the second branch joins the first lane"
128 252
        );
129 253
        assert_eq!(width(&rows), 2);
254 +
    }
255 +
256 +
    #[test]
257 +
    fn the_graph_draws_the_newest_dependency_and_joins_lanes_left() {
258 +
        // 4 and 3 both build on 2; 2 on 1; 3 also depends on 1 (not drawn).
259 +
        let rows = graph(
260 +
            &[
261 +
                (h(4), vec![h(2)]),
262 +
                (h(3), vec![h(1), h(2)]),
263 +
                (h(2), vec![h(1)]),
264 +
                (h(1), vec![]),
265 +
            ],
266 +
            12,
267 +
        );
268 +
        assert_eq!(rows[0].column, 0);
269 +
        assert_eq!(rows[0].below, vec![Cell::Line]);
270 +
        assert_eq!(rows[1].column, 1, "a second head takes a new lane");
271 +
        assert_eq!(rows[1].passing, vec![0]);
272 +
        assert_eq!(rows[1].below, vec![Cell::JoinLeft, Cell::JoinRight]);
273 +
        assert_eq!(rows[2].column, 0);
274 +
        assert_eq!(rows[3].below, vec![Cell::Blank], "a root ends its lane");
275 +
    }
276 +
277 +
    #[test]
278 +
    fn a_long_edge_is_elided() {
279 +
        let mut changes = vec![(h(100), vec![h(1)])];
280 +
        for n in (2..=20).rev() {
281 +
            changes.push((h(n), vec![]));
282 +
        }
283 +
        changes.push((h(1), vec![]));
284 +
        let rows = graph(&changes, 5);
285 +
        assert_eq!(rows[0].below, vec![Cell::Elided]);
286 +
        assert!(
287 +
            rows.iter().all(|r| r.below.len() == 1),
288 +
            "one lane all the way"
289 +
        );
290 +
        let rows = graph(&changes, 100);
291 +
        assert_eq!(rows[0].below, vec![Cell::Line], "drawn when short enough");
130 292
    }
131 293
}
modified docs/guide.md +21 −0
…
63 unchanged lines
64 64
oxy show 658990aa          # one change by ChangeId prefix, #hash prefix, or @
65 65
```
66 66
67 +
`oxy log` draws the history as a graph, newest first, two lines per change:
68 +
69 +
```
70 +
◉  2f8518c0 Andy McCall just now
71 +
│  (working copy, clean)
72 +
●  1b5888b1 Andy McCall 2 minutes ago main #cc23b999 ✔ signed
73 +
│  Edit b
74 +
│ ●  d6f52eca Andy McCall 3 minutes ago main #ec6a7dc4 ✔ signed
75 +
├─╯  Edit a
76 +
○  c0593116 Andy McCall 5 minutes ago #eeac7940 ✔ signed
77 +
   Both files
78 +
```
79 +
80 +
The bright start of each ID is the shortest prefix that names it. `●` marks
81 +
a head, `◉` the working copy. Bookmarks and tags show beside their heads,
82 +
unless they have more than three. A lane follows the change each one most
83 +
directly builds on, its newest dependency. Two edits that build on the same
84 +
change take two lanes, which join above it, so independent work shows side
85 +
by side. `~` means the change builds on something further down than the
86 +
graph draws, or not shown; `-v` lists every dependency.
87 +
67 88
Commit part of what changed with `oxy commit PATH.. -m "..."` (only those
68 89
paths; the rest stays as uncommitted work) or `oxy commit -i -m "..."`, which
69 90
offers each hunk in turn like `git add -p`; a hunk that another chosen hunk
…
482 unchanged lines
modified src/cli/format.rs +0 −4
…
10 unchanged lines
11 11
    c.change_id.short(8)
12 12
}
13 13
14 -
pub fn short_meta_id(c: &ChangeMeta) -> String {
15 -
    c.change_id.short(8)
16 -
}
17 -
18 14
/// `n head` / `n heads`.
19 15
pub fn plural(n: usize, word: &str) -> String {
20 16
    format!("{n} {word}{}", if n == 1 { "" } else { "s" })
…
120 unchanged lines
modified src/cli/ui.rs +0 −5
…
131 unchanged lines
132 132
    }
133 133
}
134 134
135 -
/// The vertical rail joining log entries.
136 -
pub fn rail() -> String {
137 -
    dim(glyph("│", "|"))
138 -
}
139 -
140 135
/// A status glyph and word for a working-copy change.
141 136
pub fn status_mark(tag: &str) -> String {
142 137
    let (g, a, word) = match tag {
…
110 unchanged lines
modified src/cli/workspace.rs +177 −45
1 1
//! Commands that act on the local repository and working copy.
2 2
3 3
use super::format::{
4 -
    author_name, deps_line, deps_of, plural, short_id, short_meta_id, signature_line,
5 -
    signature_mark, subject_of, when,
4 +
    author_name, deps_line, deps_of, plural, short_id, signature_line, signature_mark, subject_of,
5 +
    when,
6 6
};
7 7
use super::json;
8 8
use super::open;
…
586 unchanged lines
595 595
    }
596 596
    let total = entries.len();
597 597
    let shown = limit.unwrap_or(total).min(total);
598 -
    let rail = ui::rail();
599 -
    for (i, e) in entries.iter().take(shown).enumerate() {
598 +
    // Lay out a little past what is shown, so edges into the cut are drawn.
599 +
    let window: Vec<(ChangeHash, Vec<ChangeHash>)> = entries
600 +
        .iter()
601 +
        .take((shown + MAX_EDGE).min(total))
602 +
        .map(|e| (e.hash, e.change.deps.clone()))
603 +
        .collect();
604 +
    let rows = oxy_core::lanes::graph(&window, MAX_EDGE);
605 +
    // Highlight the shortest prefix that names each change: against the
606 +
    // whole log, which is what `resolve_change` searches.
607 +
    let everything;
608 +
    let all = if path.is_some() || lines.is_some() {
609 +
        everything = repo.log()?;
610 +
        &everything
611 +
    } else {
612 +
        &entries
613 +
    };
614 +
    let id_prefix = unique_prefixes(all.iter().map(|e| e.change.change_id.to_hex()));
615 +
    let labels = reference_labels(&repo)?;
616 +
    for (e, row) in entries.iter().take(shown).zip(&rows) {
600 617
        let c = &e.change;
601 618
        let bullet = ui::log_bullet(
602 619
            e.is_working_copy,
603 620
            e.is_head,
604 621
            e.is_working_copy && conflicted,
605 622
        );
606 -
        let mut labels = Vec::new();
607 -
        if e.is_working_copy {
608 -
            labels.push(ui::warn("working copy"));
623 +
        let id = c.change_id.to_hex();
624 +
        let n = id_prefix.get(&id).copied().unwrap_or(8).min(8);
625 +
        let mut head = vec![format!("{}{}", ui::id(&id[..n]), ui::dim(&id[n..8]))];
626 +
        head.push(ui::author(author_name(&repo, c)));
627 +
        head.push(ui::dim(ui::ago(c.hlc.wall_ms)));
628 +
        if let Some(names) = labels.get(&e.hash) {
629 +
            head.push(names.join(" "));
609 630
        }
610 631
        if c.kind == ChangeKind::Resolution {
611 -
            labels.push(ui::warn("resolution"));
632 +
            head.push(ui::warn("resolution"));
612 633
        }
613 -
        let labels = if labels.is_empty() {
614 -
            String::new()
615 -
        } else {
616 -
            format!(
617 -
                " {}{}{}",
618 -
                ui::dim("("),
619 -
                labels.join(ui::dim(", ").as_str()),
620 -
                ui::dim(")")
621 -
            )
622 -
        };
634 +
        if !e.is_working_copy {
635 +
            head.push(ui::hash(format!("#{}", e.hash.short(8))));
636 +
            head.push(signature_mark(&repo, &e.hash, Some(c))?);
637 +
        }
638 +
        println!("{}{}", node_line(row, &bullet), head.join(" "));
623 639
        let subject = if e.is_working_copy && c.message.is_empty() {
624 640
            ui::dim(if clean {
625 -
                "(clean)"
641 +
                "(working copy, clean)"
626 642
            } else {
627 -
                "(uncommitted changes)"
643 +
                "(working copy, uncommitted changes)"
628 644
            })
645 +
        } else if c.message.trim().is_empty() {
646 +
            ui::dim("(no description)")
629 647
        } else {
630 -
            ui::subject(subject_of(&c.message))
648 +
            subject_of(&c.message).to_string()
631 649
        };
632 -
        println!("{bullet} {}{labels}  {subject}", ui::id(short_meta_id(c)));
633 -
        let meta = format!(
634 -
            "{}{}{}{}{}{}{}",
635 -
            ui::hash(format!("#{}", e.hash.short(12))),
636 -
            ui::sep(),
637 -
            ui::author(author_name(&repo, c)),
638 -
            ui::sep(),
639 -
            ui::dim(ui::ago(c.hlc.wall_ms)),
640 -
            ui::sep(),
641 -
            if e.is_working_copy {
642 -
                ui::dim("not yet committed")
643 -
            } else {
644 -
                signature_mark(&repo, &e.hash, Some(c))?
645 -
            }
646 -
        );
647 -
        println!("{rail} {meta}");
650 +
        println!("{}{subject}", below_line(row));
648 651
        if verbose {
652 +
            let rail = continuation(row);
649 653
            if !e.is_working_copy {
650 654
                println!(
651 655
                    "{rail}{}",
…
17 unchanged lines
669 673
                .skip(1)
670 674
                .skip_while(|l| l.trim().is_empty())
671 675
                .collect();
672 -
            if !body.is_empty() {
673 -
                println!("{rail}");
674 -
                for line in body {
675 -
                    println!("{rail}   {line}");
676 -
                }
676 +
            for line in body {
677 +
                println!("{rail}  {line}");
677 678
            }
678 679
        }
679 -
        if i + 1 < shown {
680 -
            println!("{rail}");
681 -
        }
682 680
    }
683 681
    if shown < total {
682 +
        if let Some(last) = rows.get(shown.saturating_sub(1)) {
683 +
            let cut: String = last
684 +
                .below
685 +
                .iter()
686 +
                .map(|c| match c {
687 +
                    Cell::Blank | Cell::Elided | Cell::JoinRight => "  ",
688 +
                    _ => "~ ",
689 +
                })
690 +
                .collect();
691 +
            if cut.trim().is_empty() {
692 +
                println!("{}", ui::dim("~"));
693 +
            } else {
694 +
                println!("{}", ui::dim(cut.trim_end()));
695 +
            }
696 +
        }
684 697
        println!(
685 698
            "{}",
686 699
            ui::dim(format!(
…
4 unchanged lines
691 704
        );
692 705
    }
693 706
    Ok(0)
707 +
}
708 +
709 +
/// Rows a drawn edge may span in `oxy log`; a longer one is elided (`~`).
710 +
const MAX_EDGE: usize = 12;
711 +
712 +
use oxy_core::hash::ChangeHash;
713 +
use oxy_core::lanes::{Cell, GraphRow};
714 +
715 +
/// A graph cell, two columns wide.
716 +
fn cell(c: Cell) -> &'static str {
717 +
    match c {
718 +
        Cell::Blank => "  ",
719 +
        Cell::Line => ui::glyph("│ ", "| "),
720 +
        Cell::Elided => "~ ",
721 +
        Cell::JoinLeft => ui::glyph("├─", "|-"),
722 +
        Cell::JoinPass => ui::glyph("──", "--"),
723 +
        Cell::JoinCross => ui::glyph("┼─", "+-"),
724 +
        Cell::JoinRight => ui::glyph("╯ ", "' "),
725 +
    }
726 +
}
727 +
728 +
/// The graph part of a change's own line, its bullet in its column.
729 +
fn node_line(row: &GraphRow, bullet: &str) -> String {
730 +
    let width = row
731 +
        .passing
732 +
        .iter()
733 +
        .copied()
734 +
        .max()
735 +
        .unwrap_or(0)
736 +
        .max(row.column)
737 +
        + 1;
738 +
    let mut out = String::new();
739 +
    for c in 0..width {
740 +
        if c == row.column {
741 +
            out.push_str(bullet);
742 +
            out.push(' ');
743 +
        } else if row.passing.contains(&c) {
744 +
            out.push_str(&ui::dim(cell(Cell::Line)));
745 +
        } else {
746 +
            out.push_str("  ");
747 +
        }
748 +
    }
749 +
    out.push(' ');
750 +
    out
751 +
}
752 +
753 +
/// The graph part of the line under a change: lanes carrying on, a join,
754 +
/// or `~` for an elided edge.
755 +
fn below_line(row: &GraphRow) -> String {
756 +
    let g: String = row.below.iter().map(|c| cell(*c)).collect();
757 +
    format!("{} ", ui::dim(g))
758 +
}
759 +
760 +
/// The graph part of any further line of a change (`-v`).
761 +
fn continuation(row: &GraphRow) -> String {
762 +
    let g: String = row
763 +
        .below
764 +
        .iter()
765 +
        .map(|c| match c {
766 +
            Cell::Line | Cell::JoinLeft | Cell::JoinCross => cell(Cell::Line),
767 +
            _ => "  ",
768 +
        })
769 +
        .collect();
770 +
    format!("{} ", ui::dim(g))
771 +
}
772 +
773 +
/// For each hex ID, the length of its shortest prefix no other ID shares.
774 +
fn unique_prefixes(ids: impl Iterator<Item = String>) -> std::collections::HashMap<String, usize> {
775 +
    let mut v: Vec<String> = ids.collect();
776 +
    v.sort();
777 +
    v.dedup();
778 +
    let common = |a: &str, b: &str| a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
779 +
    let mut out = std::collections::HashMap::with_capacity(v.len());
780 +
    for i in 0..v.len() {
781 +
        let before = if i > 0 { common(&v[i - 1], &v[i]) } else { 0 };
782 +
        let after = if i + 1 < v.len() {
783 +
            common(&v[i], &v[i + 1])
784 +
        } else {
785 +
            0
786 +
        };
787 +
        out.insert(v[i].clone(), before.max(after) + 1);
788 +
    }
789 +
    out
790 +
}
791 +
792 +
/// Bookmark, remote bookmark and tag names to show beside the changes they
793 +
/// point at. A name is shown on each head of its state; one with more than
794 +
/// three heads names too much of the log to help, so it is left out (oxy
795 +
/// bookmark list shows it). A remote bookmark at the same state as the
796 +
/// local one of that name is not repeated.
797 +
fn reference_labels(repo: &Repo) -> Result<std::collections::HashMap<ChangeHash, Vec<String>>> {
798 +
    const MAX_HEADS: usize = 3;
799 +
    let view = repo.view()?;
800 +
    let mut out: std::collections::HashMap<ChangeHash, Vec<String>> = Default::default();
801 +
    let mut add = |heads: Vec<ChangeHash>, label: String| {
802 +
        if heads.len() <= MAX_HEADS {
803 +
            for h in heads {
804 +
                out.entry(h).or_default().push(label.clone());
805 +
            }
806 +
        }
807 +
    };
808 +
    for (name, st) in &view.bookmarks {
809 +
        add(repo.store().get_state(st)?.heads, ui::bookmark(name));
810 +
    }
811 +
    for (remote, bookmarks) in &view.remotes {
812 +
        for (name, st) in bookmarks {
813 +
            if view.bookmarks.get(name) == Some(st) {
814 +
                continue;
815 +
            }
816 +
            add(
817 +
                repo.store().get_state(st)?.heads,
818 +
                ui::dim(format!("{remote}/{name}")),
819 +
            );
820 +
        }
821 +
    }
822 +
    for t in repo.tags()? {
823 +
        add(t.heads.clone(), ui::warn(&t.name));
824 +
    }
825 +
    Ok(out)
694 826
}
695 827
696 828
pub fn diff(change: Option<&str>, as_json: bool) -> Result<i32> {
…
1319 unchanged lines
modified tests/cli_json.rs +30 −0
…
590 unchanged lines
591 591
        "{out}"
592 592
    );
593 593
}
594 +
595 +
/// oxygen#50: the log is a graph. Two changes that build on the same one
596 +
/// take two lanes that join above it; each change shows its ID, author,
597 +
/// time and hash on one line and its subject on the next.
598 +
#[test]
599 +
fn the_log_draws_lanes_that_join() {
600 +
    let tmp = tempfile::tempdir().unwrap();
601 +
    let home = tmp.path().join("home");
602 +
    std::fs::create_dir_all(&home).unwrap();
603 +
    let repo = tmp.path().join("r");
604 +
    assert_eq!(oxy(tmp.path(), &home, &["init", repo.to_str().unwrap()]).0, 0);
605 +
    write(&repo, "a.txt", "a\n");
606 +
    write(&repo, "b.txt", "b\n");
607 +
    assert_eq!(oxy(&repo, &home, &["commit", "-m", "both files"]).0, 0);
608 +
    write(&repo, "a.txt", "a\na2\n");
609 +
    assert_eq!(oxy(&repo, &home, &["commit", "-m", "edit a"]).0, 0);
610 +
    write(&repo, "b.txt", "b\nb2\n");
611 +
    assert_eq!(oxy(&repo, &home, &["commit", "-m", "edit b"]).0, 0);
612 +
613 +
    let (code, out, err) = oxy(&repo, &home, &["log"]);
614 +
    assert_eq!(code, 0, "{err}");
615 +
    let out = String::from_utf8_lossy(&out);
616 +
    let lines: Vec<&str> = out.lines().collect();
617 +
    // The working copy, then "edit b", "edit a" beside it, then their base.
618 +
    assert!(lines[0].starts_with('@'), "{out}");
619 +
    assert!(lines.iter().any(|l| l.starts_with("|-'") && l.contains("edit a")), "{out}");
620 +
    assert!(lines.iter().any(|l| l.starts_with("| *") || l.starts_with("| o")), "{out}");
621 +
    let base = lines.iter().position(|l| l.contains("both files")).unwrap();
622 +
    assert!(lines[base - 1].contains('#'), "the header line carries the hash: {out}");
623 +
}