on master help sign in
Redesign tree rendering as a rails layout

Replace the indent-per-depth tree with a rails graph: the trunk is lane 0, branches open lanes to the right, and every commit is connected to its parent by a drawn rail. Implements specs/tree.md §7.11: trunk source, display tree (elision with run/collapsed nodes), topological time-ordered rows, lane assignment with reservations and overflow flattening, fixed columns, glyphs, and colour. Also: - default `tree` shows all commits (elide=false); `treeCompact` is the folded view - the synthetic root is dropped when a single-child anchor (kept as a branch point) - an adaptive legend explains the symbols actually used, dim, right or bottom - commit ids are prefixed with @ to match the id-literal syntax - reorganize the spec into specs/ (main.md, tree.md)

commitc1daaf7028c07e19ce3113e685939040f253f478 authoriko <mail@iko.soy> dateWed 16 Sep 2026, 18:27 (3 weeks ago) parent09c2766
6 files changed+1859−230
config.j+9−3 SPEC.md → specs/main.md specs/tree.md+317 src/render.rs+1184−209 tests/display.rs+2−1 tests/tree.rs+347−17
config.j+9−3
@@ -153,7 +153,8 @@ validate : Repo -> Repo -- the repo unchanged, or the crash p
153153diff : Blob -> Blob -> Text -- unified diff from the first to the second
154154difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
155155 -- Options via DFT_* environment variables.
156−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
156+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
157+ , lanes : Int }
157158 -> Repo -> Text -- the history as a tree; see the spec
158159
159160-- Every subvalue of a given shape, however deeply nested. A shape is a type
@@ -569,8 +570,13 @@ log = \repo ->
569570
570571-- The history as a tree. Edit these records to change what `j tree` shows.
571572-- (The parentheses are required: `f { … }` would be a record update of f.)
573+-- The default `tree` shows every commit, sorted by time (parents always above
574+-- their children); `treeCompact` folds uninteresting runs and far subtrees.
572575tree : Repo -> Text
573−tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
576+tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4 })
577+
578+treeCompact : Repo -> Text
579+treeCompact = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto", lanes = 4 })
574580
575581treeFull : Repo -> Text
576−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
582+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4 })
SPEC.md → specs/main.mdrenamed

The content is the same.

specs/tree.mdnew file+317
@@ -0,0 +1,317 @@
1+### 7.11 Tree rendering — rails layout
2+
3+`treeWith options r` renders the history as text. It is implemented in the
4+binary because it is presentation, not semantics. It reads the `Repo` value,
5+the `trunk` revset (§Trunk below), the immutable set (§7.5), `diff` sizes,
6+and jj's commit metadata by id (committer time, author).
7+
8+The layout is a *rails* graph: the trunk is always the first column (lane 0),
9+every other commit sits in a lane to its right, rows are in time order, and a
10+lane stays occupied for as long as the branch it carries is alive. Text
11+columns are at fixed positions, independent of how deep a commit is.
12+
13+#### Options
14+
15+```
16+{ detail : Int -- 0, 1, or 2; as before
17+, margin : Bool -- age and author column
18+, elide : Bool -- fold uninteresting runs and collapse far subtrees
19+, icons : Bool -- pictographic glyph set
20+, color : Text -- "auto", "always", "never"
21+, lanes : Int } -- lane columns, including the trunk lane; ≥ 1
22+```
23+
24+Missing fields crash. `lanes` is the *fixed* width of the rails area in
25+lanes; it does not adapt to the history (see §Overflow). The reference
26+`config.j` sets `lanes = 4`.
27+
28+#### Trunk
29+
30+`j` evaluates the config's `trunk` revset (this is a fifth definition read by
31+the binary, alongside `user`, `tree`, `immutable`, `labelled`).
32+
33+* If `trunk r` names exactly one commit `h`, the trunk **T** is `h` and its
34+ ancestors — a single line from the root to `h`, since history is a tree.
35+ With the reference config this is exactly the immutable set, so lane 0 is
36+ "what cannot be rewritten".
37+* If `trunk r` is empty (no remote), **T** is the root alone.
38+* If it names several commits, `treeWith` crashes:
39+ `treeWith: trunk names N revisions`.
40+
41+The root is always in T. `h` is the *trunk head*.
42+
43+#### Step 1 — the display tree (elision)
44+
45+The rendering works on a *display tree* derived from the whole history
46+(`top r`). With `elide = false` it is the history itself: one node per
47+commit. With `elide = true` it is the history after the rules of §Elision,
48+which produce three kinds of node:
49+
50+| node | rows | time | children |
51+|---|---|---|---|
52+| **commit** | one | its committer time | its children in the display tree |
53+| **run** `╎ n` — `n` consecutive uninteresting commits on one line of descent | one | the time of its first commit | the one child of its last commit |
54+| **collapsed** — a commit whose descendants are hidden, shown as `⋯ n` | one | its committer time | none |
55+
56+A run never straddles the boundary of T (the trunk head is interesting, so the
57+trunk's last commit ends any run; a single-child root is dropped, §The root).
58+A run of trunk commits is in T; any other run is not.
59+
60+Nodes with no stored counterpart (minted in this program, as in a dry run)
61+have time ∞.
62+
63+#### Step 2 — row order
64+
65+Rows are a topological order of the display tree, oldest first:
66+
67+```
68+ready ← { root }
69+while ready is non-empty:
70+ n ← the node of ready with the least time
71+ (ties: the node that comes first in preorder, using jj's sibling order)
72+ emit n
73+ ready ← ready ∪ children(n)
74+```
75+
76+Consequently a parent is always above its children, the root is the first
77+row, the trunk is emitted in order, and everything with time ∞ comes after
78+everything with a time. Sibling order affects only ties.
79+
80+The row order is computed in full before any lane is assigned; the lane
81+rules below refer to it ("placed before", "last child").
82+
83+#### Step 3 — lanes
84+
85+There are `lanes` lanes, numbered from 0. Each lane is either empty or holds
86+a *rail*: a pending edge from a placed node `p` downward, of one of two
87+kinds.
88+
89+* **live** — `p`'s remaining children may still fork from it, and one of them
90+ will inherit the lane.
91+* **reserved for `d`** — only the node `d` may land in this lane.
92+
93+Lane 0 belongs to T. Nodes are placed in row order; the lane of a node `n`
94+with parent `p` is decided as follows, first rule that applies:
95+
96+1. `n` is the root → lane 0.
97+2. `n ∈ T` → lane 0 (it inherits `p`'s rail, which is live in lane 0).
98+3. Some lane holds a rail *reserved for `n`* → that lane.
99+4. `p ∉ T`, `p`'s rail is live in lane `ℓ`, and `n` is `p`'s **last** child
100+ in row order → lane `ℓ` (inherit).
101+5. Otherwise → **fork**: the leftmost empty lane strictly to the right of `p`'s
102+ lane. If there is none, see §Overflow.
103+
104+After `n` is placed in lane `ℓ`:
105+
106+* If `n` has children in the display tree, lane `ℓ` now holds a live rail
107+ owned by `n`. Otherwise (leaf, collapsed node) lane `ℓ` is empty below this
108+ row.
109+* If `n` was the last child of `p` and did not inherit `p`'s lane (rule 5 with
110+ `p` the trunk head, or `p` the root of an empty trunk), `p`'s lane becomes
111+ empty below this row: the fork on this row is drawn with `╰` instead of `├`.
112+
113+**Reservations.** When a node `p ∈ T` is placed and has a trunk child `t`,
114+every side child of `p` that comes *after* `t` in row order needs its own
115+lane already, because lane 0 will belong to `t` by then. On `p`'s own row, for
116+each such child in row order, the leftmost empty lane to the right of 0 is
117+taken and holds a rail reserved for that child. Side children of `p` that
118+come *before* `t` fork from lane 0 just in time (rule 5) and need no
119+reservation.
120+
121+No other node reserves lanes: a node outside T passes its lane to its last
122+child (rule 4), so every earlier child can fork from the still-live rail. The
123+trunk head has no trunk child, so all its children fork, the last with `╰`.
124+
125+Invariants: the trunk occupies lane 0 and nothing else ever does; a lane is
126+reused as soon as the branch it carried has ended; a fork's target lane is
127+always to the right of its source.
128+
129+#### Overflow
130+
131+If rule 5 or a reservation finds no empty lane in `0 < ℓ < lanes`, the
132+subtree rooted at `n` is **flattened**: `n` and every descendant of it still
133+get their rows, in row order, with their id, message, labels and margin, but
134+they take no lane. Their rails area shows only the rails that pass through
135+and `»` in the last column. Nothing is hidden — the focus in particular is
136+always shown — only the shape beyond `lanes` is lost. Widen `lanes` (or
137+enable `elide`) to see it.
138+
139+#### Step 4 — drawing a row
140+
141+Columns, left to right, at fixed offsets:
142+
143+1. **Gutter**, 2 characters: `▶ ` on the focus row, blank otherwise.
144+2. **Rails**, `2 · lanes` characters: lane `i` owns characters `2i` and
145+ `2i+1`.
146+3. One space.
147+4. **Id**: `@` followed by the shortest prefix unique among the commits of the
148+ display tree, minimum 4 — so the rendered id is itself a valid id literal
149+ (§3.1). Blank on a run row.
150+5. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
151+ commits at `detail = 2`): `▁ ▂ ▃ ▅ ▇`, as before.
152+6. **Message**: first line; nothing if empty; followed by `⋯ n` on a
153+ collapsed node.
154+7. **Labels**: right-aligned column, present only if any rendered commit has
155+ a label; names separated by two spaces.
156+8. **Margin** (`margin = true`): age and author initials, right-aligned, as
157+ before.
158+
159+Columns 4–8 start at the same offset on every row. When stdout is a terminal
160+and a row would exceed its width, the message is cut and ends with `…` so that
161+labels and the margin keep their columns; otherwise nothing is cut.
162+
163+**Rails characters.** For lane `i` on the row of node `n` placed in lane
164+`ℓ`, character `2i` is, first rule that applies:
165+
166+| condition | character |
167+|---|---|
168+| `i = ℓ` | the node glyph (§Glyphs), or `╎` for a run |
169+| `i` holds a reservation made on this row (`n ∈ T`) | `╮`, or `┬` if a further reservation lies to its right |
170+| `i` is the source lane of a fork drawn on this row (`n` forked from `p` in lane `i`) | `├`, or `╰` if `p`'s lane empties below |
171+| `i` lies between the two ends of a horizontal segment on this row and holds a rail | `┼` |
172+| `i` lies between the two ends of a horizontal segment on this row | `─` |
173+| `i` holds a rail passing through this row | `│` |
174+| flattened rows only: `i = lanes − 1` | `»` |
175+| otherwise | space |
176+
177+Character `2i+1` is `─` if it lies inside a horizontal segment (from a fork
178+source to its target, or from a node to its rightmost reservation), else a
179+space. On a run row the count follows the `╎`, written into the rails area
180+and, if needed, into the empty id column.
181+
182+**Detail line** (`detail = 2`, focus only): one extra row directly under the
183+focus. Its rails show `│` in every lane that holds a rail below the focus row
184+(including the focus's own lane if it has children); the text starts two
185+characters past the id column and lists the changed paths with a mark each:
186+`+` added, `~` modified, `−` deleted, `✖` unresolved.
187+
188+#### Glyphs
189+
190+Unchanged from before:
191+
192+| glyph | meaning | `icons = true` |
193+|---|---|---|
194+| `◉` | focus | `🌸` |
195+| `●` | ancestor of the focus | `🌿` |
196+| `○` | other commit | `🍃` |
197+| `◆` | in the immutable set (§7.5) | `🪨` |
198+| `◌` | empty: no change against its parent | `🫙` |
199+| `⊗` | has unresolved files | `🔥` |
200+| `⌂` | root | `🌱` |
201+
202+Precedence: `⌂`, then `⊗`, then `◌`, then `◆`, then the position glyphs.
203+Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎ »`.
204+
205+#### Elision (`elide = true`)
206+
207+A commit is *interesting* if it is the focus, an ancestor of the focus off the
208+trunk, a child of an ancestor of the focus off the trunk, labelled, conflicted,
209+a leaf, or has more than one child. Runs of consecutive uninteresting commits
210+on a single line of descent become a run node. Subtrees whose top is not the
211+focus, an ancestor of it, or a child of an ancestor become a collapsed node,
212+`⋯ n` counting the hidden descendants (omitted when `n = 0`).
213+
214+#### The root
215+
216+The synthetic root (id `zzz…`, no message or author) is dropped from the
217+rendering when it is a pure anchor — that is, when it has exactly one child —
218+so the first real commit starts the tree at the top instead of a `⌂` row
219+floating above it. This holds whether or not elision is on: with `elide =
220+false` the root's row is simply omitted, and with `elide = true` it is dropped
221+rather than folded into a run. A root with more than one child is a genuine
222+branch point — it may be where the current branch forks off — so it is kept,
223+drawn with `⌂`.
224+
225+#### Colour
226+
227+Unchanged for text. Rails in lane 0 take the immutable colour; rails in other
228+lanes are dim. Glyphs and rails carry the meaning; colour only adds emphasis,
229+so a colourless rendering loses nothing.
230+
231+#### Legend
232+
233+After the tree, `treeWith` appends a *legend* explaining the symbols that
234+actually appear, so a reader can always decode the current rendering. Only
235+symbols that are used in this tree are explained; a symbol that never appears
236+is omitted from the legend.
237+
238+The legend covers the node glyphs present (§Glyphs), the focus gutter `▶`,
239+run `╎ n` and collapsed `⋯ n` rows when shown, and the detail-line marks
240+(`+` added, `~` modified, `−` deleted, `✖` unresolved) when a detail line is
241+present. A collapsed node shows `⋯ n` only when `n > 0`, so the `⋯` legend
242+entry appears only then. The rails characters are not itemised; they are
243+structural (§Step 4).
244+
245+The legend text is faint (dim). Placement: when stdout is a terminal and the
246+tree's widest line plus the legend fits the terminal width, the legend is set
247+on the right, one entry per tree row starting from the top, separated from the
248+tree by a gap; otherwise it is set at the bottom, after a blank line.
249+
250+#### Worked example
251+
252+History (times are ages): root `zzzz` → 14 uninteresting commits → `aaaa`
253+(3w, "add parser") → `kpqx` (2w, "release 1.2", `main`, trunk head).
254+`aaaa` also has the leaf `mnrv` (1w, "fix lexer"). `kpqx` has the focus
255+`wqzt` (2d, "wip", `feature`, conflicted) and the empty `ptlm` (1d, "docs",
256+3 hidden descendants). `wqzt` has the leaves `qrst` (5h, "spike") and `yxsk`
257+(1h). Options: `detail = 2, margin = true, elide = true, lanes = 3`.
258+
259+Row order, and the lane state after each row (`·` empty, `L` live, `R` reserved):
260+
261+| # | node | why now | rule | lane | lanes 0 1 2 after |
262+|---|---|---|---|---|---|
263+| 1 | `╎ 14` | the single-child root is dropped; the 14-commit run heads the tree | 2 | 0 | L · · |
264+| 2 | `aaaa` | only ready node | 2 | 0 | L R(mnrv) · — `mnrv` comes after the trunk child `kpqx`, so it is reserved here |
265+| 3 | `kpqx` | 2w < 1w? no — ready = {kpqx 2w, mnrv 1w}, oldest is kpqx | 2 | 0 | L R · |
266+| 4 | `mnrv` | ready = {mnrv 1w, wqzt 2d, ptlm 1d} | 3 | 1 | L · · — leaf, lane 1 empties |
267+| 5 | `wqzt` | ready = {wqzt 2d, ptlm 1d} | 5 | 1 | L L · — fork from lane 0 |
268+| 6 | `ptlm` | ready = {ptlm 1d, qrst 5h, yxsk 1h} | 5 | 2 | · L · — last child of the trunk head: `╰`; collapsed, lane 2 empties |
269+| 7 | `qrst` | ready = {qrst 5h, yxsk 1h} | 5 | 2 | · L · — leaf |
270+| 8 | `yxsk` | last | 4 | 1 | · · · — inherits, leaf |
271+
272+Rendered:
273+
274+```
275+ ╎ 14
276+ ◆─╮ @aaaa ▅ add parser 3w mo
277+ ◆ │ @kpqx ▅ release 1.2 main 2w mo
278+ │ ○ @mnrv ▂ fix lexer 1w mo
279+▶ ├─⊗ @wqzt ▃ wip feature 2d mo
280+ │ │ ✖ src/lexer.rs ~ src/parser.rs + tests/lexer.rs
281+ ╰─┼─◌ @ptlm docs ⋯ 3 1d ak
282+ ├─○ @qrst ▁ spike 5h mo
283+ ○ @yxsk ▃ 1h mo
284+```
285+
286+Column offsets in this example: gutter 0–1, rails 2–7, id from 9. Lane 0 is
287+`◆ ◆ │ ├ ╰` from `aaaa` to `ptlm` and then empty; the reservation for `mnrv`
288+is drawn on `aaaa`'s row as `─╮` and consumed on `mnrv`'s row; `ptlm` forks
289+to lane 2 across the live lane 1, hence `┼`.
290+
291+The reference `config.j` defines `tree` with `detail = 1, margin = false,
292+elide = false` (every commit, time-sorted), `treeCompact` with `elide = true`
293+(the folded view above), and `treeFull` with `detail = 2, margin = true,
294+elide = false`.
295+
296+#### Decisions taken in this spec (change any that are wrong)
297+
298+1. **Trunk source** — the `trunk` revset from `config.j`, read by the binary;
299+ empty trunk → root only in lane 0.
300+2. **Row order** — oldest first, root at the top, matching the language's
301+ `up`/`top` vocabulary. Newest-first (jj style) is the same algorithm with
302+ the rows reversed and `╭`/`╯` for `╰`/`╮`.
303+3. **Lane inheritance** — outside the trunk, a node's lane goes to its
304+ *newest* child, so older children fork just in time and no lane is ever
305+ reserved except by trunk commits. The alternative (oldest inherits) needs
306+ a reservation on every fork.
307+4. **Reservations drawn on the parent's row** (`◆─╮`), not on a connector row
308+ of their own, so a fork never costs a row.
309+5. **Fork targets** — leftmost empty lane to the right of the source. Allowing
310+ targets to the left would narrow some graphs but needs `╭─┤` shapes.
311+6. **`lanes` is an option**, fixed across renders. Computing it per render
312+ would keep columns fixed within one output but shift them between
313+ outputs.
314+7. **Overflow flattens** rather than hides, marked `»`.
315+8. **Focus marker** stays in the leftmost gutter, as today.
316+9. **Message truncation** to the terminal width, so the label and margin
317+ columns hold.
src/render.rs+1184−209
@@ -843,6 +843,7 @@ pub struct TreeOptions {
843843 pub elide: bool,
844844 pub icons: bool,
845845 pub color: String,
846+ pub lanes: i64,
846847}
847848
848849fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
@@ -852,6 +853,7 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
852853 elide: true,
853854 icons: false,
854855 color: "auto".into(),
856+ lanes: 4,
855857 })
856858}
857859
@@ -873,6 +875,13 @@ pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Valu
873875 _ => return Err(Crash::new("treeWith: icons must be a Bool")),
874876 };
875877 let color = opts.field("color")?.as_text()?.to_string();
878+ let lanes = match opts.field("lanes")? {
879+ Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(0),
880+ _ => return Err(Crash::new("treeWith: lanes must be an Int")),
881+ };
882+ if lanes < 1 {
883+ return Err(Crash::new("treeWith: lanes must be at least 1"));
884+ }
876885 let pal = Palette {
877886 on: color_enabled(&color),
878887 };
@@ -882,14 +891,13 @@ pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Valu
882891 elide,
883892 icons,
884893 color,
894+ lanes,
885895 };
886896 let text = tree_render(interp, &o, repo, &pal, true)?;
887897 Ok(Value::text(text))
888898}
889899
890900struct CommitInfo {
891− #[allow(dead_code)]
892− commit: Value,
893901 id: String,
894902 message: String,
895903 labels: Vec<String>,
@@ -898,82 +906,31 @@ struct CommitInfo {
898906 immutable: bool,
899907 is_focus: bool,
900908 is_ancestor_of_focus: bool,
909+ is_child_of_ancestor: bool,
901910 meta: Option<crate::domain::MetaInfo>,
902911 size: Option<usize>, // lines added+removed against parent
903912 detail_marks: Vec<(String, char)>,
904913 children: Vec<CommitInfo>,
905914}
906915
907−fn tree_render(
908− interp: &mut Interp,
909− opts: &TreeOptions,
910− repo: &Value,
911− pal: &Palette,
912− with_focus: bool,
913−) -> Result<String, Crash> {
914− // immutable set
915− let immutable = crate::repo::compute_immutable(interp, repo).unwrap_or_default();
916− let focus_id = match repo.field("root")?.field("id")? {
917− Value::Id(i) => i.to_string(),
918− _ => String::new(),
919− };
920− // ancestors of focus
921− let mut anc: BTreeSet<String> = BTreeSet::new();
922− let mut cur = repo.clone();
923− loop {
924− let id = match cur.field("root")?.field("id")? {
925− Value::Id(i) => i.to_string(),
926− _ => break,
927− };
928− anc.insert(id);
929− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
930− if ctx.is_empty() {
931− break;
932− }
933− cur = crate::repo::by_id(&cur, &id_of_frame_parent(&ctx[0])?)?.unwrap();
934− }
935− // build info tree from the top
936− let top = crate::repo::by_id(repo, &top_id(repo)?)?.unwrap_or_else(|| repo.clone());
937− let root_commit = top.field("root")?;
938− let top_children = top.field("children")?;
939− let root_info = build_info(
940− interp,
941− opts,
942− &top,
943− &root_commit,
944− top_children.as_list()?,
945− &immutable,
946− &focus_id,
947− &anc,
948− None,
949− with_focus,
950− )?;
951− // layout
952− let mut lines: Vec<String> = Vec::new();
953− let label_col_needed = has_labels(&root_info);
954− render_node(&root_info, opts, pal, "", true, true, label_col_needed, &mut lines);
955− Ok(lines.join("\n") + "\n")
956−}
957−
958−fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
959− match frame.field("parent")?.field("id")? {
960− Value::Id(i) => Ok(i.to_string()),
961− v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
962− }
963−}
964−
965−fn top_id(repo: &Value) -> Result<String, Crash> {
966− let mut cur = repo.clone();
967− loop {
968− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
969− if ctx.is_empty() {
970− return match cur.field("root")?.field("id")? {
971− Value::Id(i) => Ok(i.to_string()),
972− v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
973− };
974− }
975− let pid = id_of_frame_parent(&ctx[0])?;
976− cur = crate::repo::by_id(&cur, &pid)?.unwrap();
916+impl CommitInfo {
917+ /// §Elision: a commit is *interesting* — never folded into a run — if it is
918+ /// the focus, labelled, conflicted, a leaf, or has more than one child; or
919+ /// if it is an ancestor of the focus (or a child of one) **off the trunk**.
920+ /// Trunk ancestors of the focus below the branch point are not interesting
921+ /// on that ground alone, so an uninteresting run of them folds (as in the
922+ /// worked example's `╎ 14`). The root is interesting only when it is not
923+ /// buried in a longer uninteresting run — a distant root folds into the run
924+ /// rather than being pinned at the top (§Option: far root). A run never
925+ /// straddles the trunk boundary.
926+ fn interesting(&self, trunk: &BTreeSet<String>) -> bool {
927+ self.is_focus
928+ || !self.labels.is_empty()
929+ || self.conflict
930+ || self.children.is_empty()
931+ || self.children.len() > 1
932+ || ((self.is_ancestor_of_focus || self.is_child_of_ancestor)
933+ && !trunk.contains(&self.id))
977934 }
978935}
979936
@@ -989,6 +946,7 @@ fn build_info(
989946 anc: &BTreeSet<String>,
990947 parent_files: Option<&Value>,
991948 with_focus: bool,
949+ parent_is_ancestor: bool,
992950) -> Result<CommitInfo, Crash> {
993951 let id = match commit.field("id")? {
994952 Value::Id(i) => i.to_string(),
@@ -1008,17 +966,26 @@ fn build_info(
1008966 let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1009967 let marks = touched_paths(&change)?;
1010968 let empty = marks.is_empty();
1011− let mut adds = 0usize;
1012969 // size: lines added+removed against the parent
1013− for (_, m, _) in &marks {
1014− match m {
1015− '+' | '−' => adds += 1,
1016− _ => adds += 1,
1017− }
970+ let from_map = snapshot_map_of(pf)?;
971+ let to_map = snapshot_map_of(&files)?;
972+ let mut lines = 0usize;
973+ for (p, _, _) in &marks {
974+ let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
975+ let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
976+ match m.get(&key) {
977+ Some(Value::Blob(b)) => {
978+ let n = String::from_utf8_lossy(&b.bytes()).lines().count();
979+ n.max(1)
980+ }
981+ _ => 1,
982+ }
983+ };
984+ lines += count(&from_map) + count(&to_map);
1018985 }
1019986 let marks2: Vec<(String, char)> =
1020987 marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1021− (empty, Some(adds), marks2)
988+ (empty, Some(lines), marks2)
1022989 }
1023990 None => {
1024991 let n = files.as_list()?.len();
@@ -1027,6 +994,7 @@ fn build_info(
1027994 };
1028995 let meta = interp.backend.meta(&id).ok();
1029996 let is_focus = with_focus && id == focus_id;
997+ let is_ancestor_of_focus = anc.contains(&id);
1030998 let mut infos = Vec::new();
1031999 for c in children.iter() {
10321000 let child_loc = crate::repo::by_id(loc, &match c.field("root")?.field("id")? {
@@ -1045,12 +1013,11 @@ fn build_info(
10451013 anc,
10461014 Some(&files),
10471015 with_focus,
1016+ is_ancestor_of_focus,
10481017 )?);
10491018 }
10501019 let immutable_flag = immutable.contains(&id);
1051− let anc_flag = anc.contains(&id);
10521020 Ok(CommitInfo {
1053− commit: commit.clone(),
10541021 id,
10551022 message,
10561023 labels,
@@ -1058,7 +1025,8 @@ fn build_info(
10581025 empty,
10591026 immutable: immutable_flag,
10601027 is_focus,
1061− is_ancestor_of_focus: anc_flag,
1028+ is_ancestor_of_focus,
1029+ is_child_of_ancestor: parent_is_ancestor,
10621030 meta,
10631031 size,
10641032 detail_marks,
@@ -1066,11 +1034,1141 @@ fn build_info(
10661034 })
10671035}
10681036
1069−fn has_labels(info: &CommitInfo) -> bool {
1070− if !info.labels.is_empty() {
1071− return true;
1037+fn tree_render(
1038+ interp: &mut Interp,
1039+ opts: &TreeOptions,
1040+ repo: &Value,
1041+ pal: &Palette,
1042+ with_focus: bool,
1043+) -> Result<String, Crash> {
1044+ // immutable set
1045+ let immutable = crate::repo::compute_immutable(interp, repo).unwrap_or_default();
1046+ let focus_id = match repo.field("root")?.field("id")? {
1047+ Value::Id(i) => i.to_string(),
1048+ _ => String::new(),
1049+ };
1050+ // ancestors of focus
1051+ let mut anc: BTreeSet<String> = BTreeSet::new();
1052+ let mut cur = repo.clone();
1053+ loop {
1054+ let id = match cur.field("root")?.field("id")? {
1055+ Value::Id(i) => i.to_string(),
1056+ _ => break,
1057+ };
1058+ anc.insert(id);
1059+ let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1060+ if ctx.is_empty() {
1061+ break;
1062+ }
1063+ cur = crate::repo::by_id(&cur, &id_of_frame_parent(&ctx[0])?)?.unwrap();
1064+ }
1065+ // the whole history, from the top
1066+ let top = crate::repo::by_id(repo, &top_id(repo)?)?.unwrap_or_else(|| repo.clone());
1067+ let root_commit = top.field("root")?;
1068+ let top_children = top.field("children")?;
1069+ let root_info = build_info(
1070+ interp,
1071+ opts,
1072+ &top,
1073+ &root_commit,
1074+ top_children.as_list()?,
1075+ &immutable,
1076+ &focus_id,
1077+ &anc,
1078+ None,
1079+ with_focus,
1080+ false,
1081+ )?;
1082+ // trunk T (§Trunk)
1083+ let trunk = compute_trunk(interp, repo)?;
1084+ let lanes_n = opts.lanes.max(1) as usize;
1085+ // Step 1 — display tree. The synthetic root is dropped when it is a pure
1086+ // anchor (exactly one child): the first real commit then starts the tree
1087+ // at the top, rather than pinning `⌂` above it. A root with several
1088+ // children is a genuine branch point and is kept (§Option: drop root).
1089+ let droot = if root_info.id == ROOT_ID && root_info.children.len() == 1 {
1090+ make_display(&root_info.children[0], opts.elide, &trunk)
1091+ } else {
1092+ make_display(&root_info, opts.elide, &trunk)
1093+ };
1094+ // Step 2 — row order (topological, oldest first)
1095+ let mut flat: Vec<&Display> = Vec::new();
1096+ flatten_display(&droot, &mut flat);
1097+ let preorder: BTreeMap<usize, usize> =
1098+ flat.iter().enumerate().map(|(i, n)| (n.uid(), i)).collect();
1099+ let mut ready: Vec<&Display> = vec![&droot];
1100+ let mut rows: Vec<&Display> = Vec::new();
1101+ while !ready.is_empty() {
1102+ let best = ready
1103+ .iter()
1104+ .enumerate()
1105+ .min_by_key(|(_, n)| (n.time(), preorder[&n.uid()]))
1106+ .map(|(i, _)| i)
1107+ .unwrap();
1108+ let n = ready.remove(best);
1109+ rows.push(n);
1110+ for c in n.children() {
1111+ ready.push(c);
1112+ }
1113+ }
1114+ // Step 3 — lanes
1115+ let placements = assign_lanes(&rows, &trunk, lanes_n);
1116+ // id column: shortest unique prefix among the display tree's commits, min 4
1117+ let ids: Vec<&str> = rows
1118+ .iter()
1119+ .filter_map(|n| n.commit().map(|c| c.id.as_str()))
1120+ .collect();
1121+ let prefix_len = |id: &str| -> usize {
1122+ let mut n = 4.min(id.len());
1123+ while n < id.len() && ids.iter().any(|o| *o != id && o.starts_with(&id[..n])) {
1124+ n += 1;
1125+ }
1126+ n
1127+ };
1128+ // Step 4 — draw
1129+ draw_rows(&rows, &placements, opts, pal, lanes_n, with_focus, &prefix_len)
1130+}
1131+
1132+fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
1133+ match frame.field("parent")?.field("id")? {
1134+ Value::Id(i) => Ok(i.to_string()),
1135+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1136+ }
1137+}
1138+
1139+fn top_id(repo: &Value) -> Result<String, Crash> {
1140+ let mut cur = repo.clone();
1141+ loop {
1142+ let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1143+ if ctx.is_empty() {
1144+ return match cur.field("root")?.field("id")? {
1145+ Value::Id(i) => Ok(i.to_string()),
1146+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1147+ };
1148+ }
1149+ let pid = id_of_frame_parent(&ctx[0])?;
1150+ cur = crate::repo::by_id(&cur, &pid)?.unwrap();
1151+ }
1152+}
1153+
1154+/// The trunk (§Trunk): the config's `trunk` revset against the whole history.
1155+/// Empty → the root alone; one commit → it and its ancestors; more → crash.
1156+fn compute_trunk(interp: &mut Interp, repo: &Value) -> Result<BTreeSet<String>, Crash> {
1157+ let mut set = BTreeSet::new();
1158+ set.insert(top_id(repo)?);
1159+ let trunk_fn = interp
1160+ .globals
1161+ .lookup("trunk")
1162+ .ok_or_else(|| Crash::new("`trunk` is not defined"))?;
1163+ let v = interp.apply(trunk_fn, repo.clone())?;
1164+ let mut named: Vec<String> = Vec::new();
1165+ for idv in v.as_list()?.iter() {
1166+ match idv {
1167+ Value::Id(id) => named.push(id.to_string()),
1168+ _ => return Err(Crash::new("treeWith: trunk returned a non-Id")),
1169+ }
1170+ }
1171+ named.sort();
1172+ named.dedup();
1173+ match named.len() {
1174+ 0 => Ok(set),
1175+ 1 => {
1176+ let pmap: BTreeMap<String, String> =
1177+ crate::repo::parent_map(repo)?.into_iter().collect();
1178+ let mut cur = named[0].clone();
1179+ loop {
1180+ set.insert(cur.clone());
1181+ match pmap.get(&cur) {
1182+ Some(p) => cur = p.clone(),
1183+ None => break,
1184+ }
1185+ }
1186+ Ok(set)
1187+ }
1188+ n => Err(Crash::new(format!("treeWith: trunk names {} revisions", n))),
1189+ }
1190+}
1191+
1192+// ----------------------------------------------------------------------
1193+// Step 1 — the display tree (§Elision)
1194+// ----------------------------------------------------------------------
1195+
1196+enum Display {
1197+ Commit {
1198+ uid: usize,
1199+ info: CommitInfo,
1200+ children: Vec<Display>,
1201+ },
1202+ Run {
1203+ uid: usize,
1204+ count: usize,
1205+ time: Option<i64>,
1206+ in_trunk: bool,
1207+ child: Box<Display>,
1208+ },
1209+ Collapsed {
1210+ uid: usize,
1211+ info: CommitInfo,
1212+ hidden: usize,
1213+ },
1214+}
1215+
1216+impl Display {
1217+ fn uid(&self) -> usize {
1218+ match self {
1219+ Display::Commit { uid, .. } => *uid,
1220+ Display::Run { uid, .. } => *uid,
1221+ Display::Collapsed { uid, .. } => *uid,
1222+ }
1223+ }
1224+
1225+ fn time(&self) -> Option<i64> {
1226+ match self {
1227+ Display::Commit { info, .. } => info.meta.as_ref().map(|m| m.time),
1228+ Display::Run { time, .. } => *time,
1229+ Display::Collapsed { info, .. } => info.meta.as_ref().map(|m| m.time),
1230+ }
1231+ }
1232+
1233+ fn children(&self) -> &[Display] {
1234+ match self {
1235+ Display::Commit { children, .. } => children,
1236+ Display::Run { child, .. } => std::slice::from_ref(&**child),
1237+ Display::Collapsed { .. } => &[],
1238+ }
1239+ }
1240+
1241+ fn commit(&self) -> Option<&CommitInfo> {
1242+ match self {
1243+ Display::Commit { info, .. } => Some(info),
1244+ Display::Collapsed { info, .. } => Some(info),
1245+ Display::Run { .. } => None,
1246+ }
1247+ }
1248+
1249+ fn in_trunk(&self, trunk: &BTreeSet<String>) -> bool {
1250+ match self {
1251+ Display::Run { in_trunk, .. } => *in_trunk,
1252+ _ => self.commit().map(|c| trunk.contains(&c.id)).unwrap_or(false),
1253+ }
1254+ }
1255+}
1256+
1257+fn make_display(info: &CommitInfo, elide: bool, trunk: &BTreeSet<String>) -> Display {
1258+ let mut uid = 0usize;
1259+ make_display_rec(info, elide, trunk, true, &mut uid)
1260+}
1261+
1262+fn make_display_rec(
1263+ info: &CommitInfo,
1264+ elide: bool,
1265+ trunk: &BTreeSet<String>,
1266+ near: bool,
1267+ uid: &mut usize,
1268+) -> Display {
1269+ if !elide {
1270+ let my = *uid;
1271+ *uid += 1;
1272+ let children = info
1273+ .children
1274+ .iter()
1275+ .map(|c| make_display_rec(c, elide, trunk, true, uid))
1276+ .collect();
1277+ return Display::Commit {
1278+ uid: my,
1279+ info: clone_info(info, Vec::new()),
1280+ children,
1281+ };
1282+ }
1283+ if !near && !info.is_ancestor_of_focus && !info.is_focus {
1284+ let my = *uid;
1285+ *uid += 1;
1286+ return Display::Collapsed {
1287+ uid: my,
1288+ info: clone_info(info, Vec::new()),
1289+ hidden: count_descendants(info),
1290+ };
1291+ }
1292+ if info.interesting(trunk) {
1293+ let my = *uid;
1294+ *uid += 1;
1295+ let children = info
1296+ .children
1297+ .iter()
1298+ .map(|c| make_display_rec(c, elide, trunk, false, uid))
1299+ .collect();
1300+ return Display::Commit {
1301+ uid: my,
1302+ info: clone_info(info, Vec::new()),
1303+ children,
1304+ };
1305+ }
1306+ // uninteresting: gather the run down the single line of descent until the
1307+ // next interesting commit (which always exists: leaves are interesting).
1308+ // A run never straddles the trunk boundary: it stops before a commit whose
1309+ // trunk membership differs from the run's first commit.
1310+ let first_in_trunk = trunk.contains(&info.id);
1311+ let mut chain: Vec<&CommitInfo> = Vec::new();
1312+ let mut cur = info;
1313+ while !cur.interesting(trunk) && trunk.contains(&cur.id) == first_in_trunk {
1314+ chain.push(cur);
1315+ cur = &cur.children[0];
1316+ }
1317+ // A distant root folds into the run, but a root that would form a run of
1318+ // one — it is right next to an interesting commit, so it is not far — stays
1319+ // visible as a normal commit at the top of the tree.
1320+ if chain.len() == 1 && chain[0].id == ROOT_ID {
1321+ let my = *uid;
1322+ *uid += 1;
1323+ let children = info
1324+ .children
1325+ .iter()
1326+ .map(|c| make_display_rec(c, elide, trunk, near, uid))
1327+ .collect();
1328+ return Display::Commit {
1329+ uid: my,
1330+ info: clone_info(info, Vec::new()),
1331+ children,
1332+ };
1333+ }
1334+ let count = chain.len();
1335+ let in_trunk = chain.iter().all(|c| trunk.contains(&c.id));
1336+ let my = *uid;
1337+ *uid += 1;
1338+ Display::Run {
1339+ uid: my,
1340+ count,
1341+ time: chain[0].meta.as_ref().map(|m| m.time),
1342+ in_trunk,
1343+ child: Box::new(make_display_rec(cur, elide, trunk, near, uid)),
1344+ }
1345+}
1346+
1347+fn count_descendants(info: &CommitInfo) -> usize {
1348+ info.children.iter().map(|c| 1 + count_descendants(c)).sum()
1349+}
1350+
1351+fn clone_info(info: &CommitInfo, children: Vec<CommitInfo>) -> CommitInfo {
1352+ CommitInfo {
1353+ id: info.id.clone(),
1354+ message: info.message.clone(),
1355+ labels: info.labels.clone(),
1356+ conflict: info.conflict,
1357+ empty: info.empty,
1358+ immutable: info.immutable,
1359+ is_focus: info.is_focus,
1360+ is_ancestor_of_focus: info.is_ancestor_of_focus,
1361+ is_child_of_ancestor: info.is_child_of_ancestor,
1362+ meta: info.meta.clone(),
1363+ size: info.size,
1364+ detail_marks: info.detail_marks.clone(),
1365+ children,
1366+ }
1367+}
1368+
1369+fn flatten_display<'a>(n: &'a Display, out: &mut Vec<&'a Display>) {
1370+ out.push(n);
1371+ for c in n.children() {
1372+ flatten_display(c, out);
1373+ }
1374+}
1375+
1376+// ----------------------------------------------------------------------
1377+// Step 3 — lanes
1378+// ----------------------------------------------------------------------
1379+
1380+#[derive(Clone, Debug)]
1381+enum Rail {
1382+ Live,
1383+ Reserved(usize),
1384+}
1385+
1386+#[derive(Clone, Debug)]
1387+struct Placement {
1388+ lane: Option<usize>,
1389+ fork: Option<(usize, bool)>, // (source lane, source empties below)
1390+ reservations: Vec<(usize, usize)>, // (lane, child uid)
1391+ flattened: bool,
1392+}
1393+
1394+fn mark_subtree(n: &Display, set: &mut BTreeSet<usize>) {
1395+ set.insert(n.uid());
1396+ for c in n.children() {
1397+ mark_subtree(c, set);
1398+ }
1399+}
1400+
1401+fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) -> Vec<Placement> {
1402+ let mut rails: Vec<Option<Rail>> = vec![None; lanes_n];
1403+ let mut out: Vec<Placement> = Vec::new();
1404+ let mut flattened: BTreeSet<usize> = BTreeSet::new();
1405+ for (idx, n) in rows.iter().enumerate() {
1406+ let in_trunk = n.in_trunk(trunk);
1407+ let parent = rows[..idx]
1408+ .iter()
1409+ .copied()
1410+ .find(|p| p.children().iter().any(|c| c.uid() == n.uid()));
1411+ let parent_lane = parent.and_then(|p| out[rows[..idx].iter().position(|x| x.uid() == p.uid()).unwrap()].lane);
1412+ let is_last_child = parent
1413+ .map(|p| {
1414+ let cs = p.children();
1415+ cs[cs.len() - 1].uid() == n.uid()
1416+ })
1417+ .unwrap_or(false);
1418+ let was_flat = flattened.contains(&n.uid());
1419+ let mut lane: Option<usize> = None;
1420+ let mut fork: Option<(usize, bool)> = None;
1421+ if !was_flat {
1422+ if idx == 0 {
1423+ lane = Some(0);
1424+ } else if in_trunk {
1425+ lane = Some(0);
1426+ } else if let Some(l) =
1427+ rails.iter().position(|r| matches!(r, Some(Rail::Reserved(u)) if *u == n.uid()))
1428+ {
1429+ lane = Some(l);
1430+ } else if let (Some(p), Some(pl)) = (parent, parent_lane) {
1431+ if !p.in_trunk(trunk) && is_last_child {
1432+ // rule 4: inherit p's live rail
1433+ lane = Some(pl);
1434+ } else {
1435+ // rule 5: fork to the leftmost empty lane right of p's lane
1436+ match (pl + 1..lanes_n).find(|&l| rails[l].is_none()) {
1437+ Some(l) => {
1438+ lane = Some(l);
1439+ if is_last_child {
1440+ fork = Some((pl, true));
1441+ rails[pl] = None;
1442+ } else {
1443+ fork = Some((pl, false));
1444+ }
1445+ }
1446+ None => mark_subtree(n, &mut flattened),
1447+ }
1448+ }
1449+ } else {
1450+ mark_subtree(n, &mut flattened);
1451+ }
1452+ }
1453+ let now_flat = flattened.contains(&n.uid());
1454+ if !now_flat {
1455+ if let Some(l) = lane {
1456+ rails[l] = if n.children().is_empty() {
1457+ None
1458+ } else {
1459+ Some(Rail::Live)
1460+ };
1461+ }
1462+ }
1463+ // reservations: a trunk node with a trunk child reserves lanes for its
1464+ // side children that come after the trunk child in row order
1465+ let mut reservations: Vec<(usize, usize)> = Vec::new();
1466+ if !now_flat && in_trunk {
1467+ let cs = n.children();
1468+ if let Some(tpos) = cs.iter().position(|c| c.in_trunk(trunk)) {
1469+ for c in &cs[tpos + 1..] {
1470+ if flattened.contains(&c.uid()) {
1471+ continue;
1472+ }
1473+ match (1..lanes_n).find(|&l| rails[l].is_none()) {
1474+ Some(l) => {
1475+ rails[l] = Some(Rail::Reserved(c.uid()));
1476+ reservations.push((l, c.uid()));
1477+ }
1478+ None => mark_subtree(c, &mut flattened),
1479+ }
1480+ }
1481+ }
1482+ }
1483+ out.push(Placement {
1484+ lane: if now_flat { None } else { lane },
1485+ fork: if now_flat { None } else { fork },
1486+ reservations,
1487+ flattened: now_flat,
1488+ });
1489+ }
1490+ out
1491+}
1492+
1493+// ----------------------------------------------------------------------
1494+// Step 4 — drawing
1495+// ----------------------------------------------------------------------
1496+
1497+struct RowText {
1498+ gutter: String,
1499+ id: String,
1500+ bar: String,
1501+ msg: String,
1502+ labels: String,
1503+ age: String,
1504+ initials: String,
1505+ initials_author: String,
1506+}
1507+
1508+fn build_row_text(
1509+ n: &Display,
1510+ rows: &[&Display],
1511+ opts: &TreeOptions,
1512+ pal: &Palette,
1513+ with_focus: bool,
1514+ prefix_len: &dyn Fn(&str) -> usize,
1515+ run_extra: usize,
1516+ id_w: usize,
1517+) -> RowText {
1518+ let c = n.commit();
1519+ let is_focus = c.map(|x| x.is_focus).unwrap_or(false);
1520+ let gutter = if is_focus && with_focus { "▶ " } else { " " }.to_string();
1521+ // id (on a run row the count follows ╎; only the part that overflows the
1522+ // rails area spills into the id column)
1523+ let id = match n {
1524+ Display::Run { .. } => {
1525+ let mut s = String::new();
1526+ while width(&s) < run_extra + id_w {
1527+ s.push(' ');
1528+ }
1529+ s
1530+ }
1531+ _ => match c {
1532+ Some(info) => {
1533+ let k = prefix_len(&info.id);
1534+ let p = &info.id[..k.min(info.id.len())];
1535+ // prefix with `@` so the id matches the id-literal syntax (@wqzt)
1536+ let colored = if info.conflict {
1537+ pal.red(&format!("@{}", p))
1538+ } else if info.immutable {
1539+ pal.blue(&format!("@{}", p))
1540+ } else {
1541+ format!("@{}", p)
1542+ };
1543+ let plain = k + 1; // +1 for the @
1544+ let padded = if plain < id_w {
1545+ format!("{}{}", colored, " ".repeat(id_w - plain))
1546+ } else {
1547+ colored
1548+ };
1549+ padded
1550+ }
1551+ None => " ".repeat(id_w),
1552+ },
1553+ };
1554+ // size bar
1555+ let focus_idx = rows
1556+ .iter()
1557+ .position(|m| m.commit().map(|x| x.is_focus).unwrap_or(false));
1558+ let is_focus_parent = focus_idx
1559+ .map(|fi| {
1560+ rows[..fi].iter().any(|p| {
1561+ p.children()
1562+ .iter()
1563+ .any(|ch| ch.uid() == rows[fi].uid()) && p.uid() == n.uid()
1564+ })
1565+ })
1566+ .unwrap_or(false);
1567+ let is_focus_child = focus_idx
1568+ .map(|fi| rows[fi].children().iter().any(|ch| ch.uid() == n.uid()))
1569+ .unwrap_or(false);
1570+ let show_bar = opts.detail >= 1
1571+ && c.map(|x| opts.detail >= 2 || x.is_focus || is_focus_parent || is_focus_child)
1572+ .unwrap_or(false);
1573+ let bar = if show_bar {
1574+ c.map(|x| size_bar(x.size, pal)).unwrap_or_default()
1575+ } else {
1576+ String::new()
1577+ };
1578+ // message
1579+ let msg_first = c.map(|x| x.message.lines().next().unwrap_or("")).unwrap_or("");
1580+ let mut msg = match c {
1581+ Some(info) if info.empty => pal.dim_italic(msg_first),
1582+ _ => msg_first.to_string(),
1583+ };
1584+ if let Display::Collapsed { hidden, .. } = n {
1585+ if *hidden > 0 {
1586+ if !msg.is_empty() {
1587+ msg.push_str(" ");
1588+ }
1589+ msg.push_str(&format!("⋯ {}", hidden));
1590+ }
1591+ }
1592+ let labels = c.map(|x| x.labels.join(" ")).unwrap_or_default();
1593+ let (age, init, author) = match c.and_then(|x| x.meta.as_ref()) {
1594+ // the root's margin is not shown (§worked example)
1595+ Some(m) if opts.margin && c.map(|x| x.id != ROOT_ID).unwrap_or(false) => {
1596+ (render_age(m.time), initials(&m.author), m.author.clone())
1597+ }
1598+ _ => (String::new(), String::new(), String::new()),
1599+ };
1600+ RowText {
1601+ gutter,
1602+ id,
1603+
1604+ bar,
1605+ msg,
1606+ labels,
1607+ age,
1608+ initials: init,
1609+ initials_author: author,
1610+ }
1611+}
1612+
1613+#[allow(clippy::too_many_arguments)]
1614+fn draw_rows(
1615+ rows: &[&Display],
1616+ placements: &[Placement],
1617+ opts: &TreeOptions,
1618+ pal: &Palette,
1619+ lanes_n: usize,
1620+ with_focus: bool,
1621+ prefix_len: &dyn Fn(&str) -> usize,
1622+) -> Result<String, Crash> {
1623+ let rail_chars = 2 * lanes_n;
1624+ // id column width: 4-char min prefix plus the `@` literal prefix, and any
1625+ // overflow of a run count past the rails area
1626+ let mut id_w = 5usize;
1627+ for (idx, n) in rows.iter().enumerate() {
1628+ if let Display::Run { count, .. } = n {
1629+ if let Some(l) = placements[idx].lane {
1630+ let digits = format!("{}", count).len();
1631+ let extra = (2 * l + 1 + digits).saturating_sub(rail_chars);
1632+ // digits written from 2l+1; rails hold rail_chars; overflow
1633+ // goes into the id column
1634+ if extra > 0 {
1635+ id_w = id_w.max(4 + extra);
1636+ }
1637+ }
1638+ }
1639+ }
1640+ for n in rows {
1641+ if let Some(c) = n.commit() {
1642+ id_w = id_w.max(prefix_len(&c.id) + 1); // +1 for the `@`
1643+ }
1644+ }
1645+ let text_off = 2 + rail_chars + 1;
1646+ let msg_off = text_off + id_w + 2;
1647+ let label_col = rows
1648+ .iter()
1649+ .any(|n| n.commit().map(|c| !c.labels.is_empty()).unwrap_or(false));
1650+ // build row texts
1651+ let mut texts: Vec<RowText> = Vec::new();
1652+ for (idx, n) in rows.iter().enumerate() {
1653+ let run_extra = match (n, placements[idx].lane) {
1654+ (Display::Run { count, .. }, Some(l)) => {
1655+ let digits = format!("{}", count).len();
1656+ (2 * l + 1 + digits).saturating_sub(rail_chars)
1657+ }
1658+ _ => 0,
1659+ };
1660+ texts.push(build_row_text(
1661+ n, rows, opts, pal, with_focus, prefix_len, run_extra, id_w,
1662+ ));
1663+ }
1664+ // right edges for the label and margin columns
1665+ let mut msg_end_max = 0usize;
1666+ let mut lab_end_max = 0usize;
1667+ for (idx, n) in rows.iter().enumerate() {
1668+ let t = &texts[idx];
1669+ if n.commit().is_none() {
1670+ continue;
1671+ }
1672+ let mut pos = msg_off;
1673+ if !t.bar.is_empty() {
1674+ pos += 1; // the bar takes one extra column before the message
1675+ }
1676+ pos += width(&t.msg);
1677+ if !t.labels.is_empty() {
1678+ pos += 2 + width(&t.labels);
1679+ }
1680+ msg_end_max = msg_end_max.max(pos - if t.labels.is_empty() { 0 } else { 2 + width(&t.labels) });
1681+ lab_end_max = lab_end_max.max(pos);
1682+ }
1683+ let mut lines: Vec<String> = Vec::new();
1684+ for (idx, n) in rows.iter().enumerate() {
1685+ let pl = &placements[idx];
1686+ let t = &texts[idx];
1687+ let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
1688+ let mut line = String::new();
1689+ line.push_str(&t.gutter);
1690+ line.push_str(&color_rails(&chars, &lane0, pal));
1691+ line.push(' ');
1692+ line.push_str(&t.id);
1693+ if !t.bar.is_empty() {
1694+ line.push(' ');
1695+ line.push_str(&t.bar);
1696+ }
1697+ line.push_str(" ");
1698+ line.push_str(&t.msg);
1699+ if label_col && !t.labels.is_empty() {
1700+ let want = 2 + msg_end_max.saturating_sub(width(&t.msg));
1701+ line.push_str(&" ".repeat(want));
1702+ line.push_str(&pal.green(&pad_right(&t.labels, width(&t.labels))));
1703+ }
1704+ if opts.margin && !t.age.is_empty() {
1705+ let cur = if label_col && !t.labels.is_empty() {
1706+ msg_end_max + 2 + width(&t.labels)
1707+ } else {
1708+ width(&t.msg)
1709+ };
1710+ let want = 2 + lab_end_max.saturating_sub(cur);
1711+ line.push_str(&" ".repeat(want));
1712+ line.push_str(&pal.grey(4, &t.age));
1713+ line.push_str(" ");
1714+ line.push_str(&pal.author(&t.initials_author, &t.initials));
1715+ }
1716+ let _ = pl;
1717+ lines.push(line);
1718+ // detail line (detail = 2, focus only)
1719+ if opts.detail >= 2
1720+ && with_focus
1721+ && n.commit().map(|c| c.is_focus && !c.detail_marks.is_empty()).unwrap_or(false)
1722+ {
1723+ let c = n.commit().unwrap();
1724+ let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
1725+ let mut dline = String::new();
1726+ dline.push_str(" ");
1727+ dline.push_str(&color_rails(&dchars, &dlane0, pal));
1728+ dline.push(' ');
1729+ dline.push_str(&" ".repeat(id_w + 2));
1730+ let marks: Vec<String> = c
1731+ .detail_marks
1732+ .iter()
1733+ .map(|(p, m)| {
1734+ let ms = m.to_string();
1735+ let colored = match m {
1736+ '+' => pal.green(&ms),
1737+ '−' => pal.red(&ms),
1738+ '~' => pal.yellow(&ms),
1739+ '✖' => pal.red(&ms),
1740+ _ => ms,
1741+ };
1742+ format!("{} {}", colored, p)
1743+ })
1744+ .collect();
1745+ dline.push_str(&marks.join(" "));
1746+ lines.push(dline);
1747+ }
1748+ }
1749+ // message truncation to the terminal width (§Step 4)
1750+ if crate::show::stdout_is_tty() {
1751+ if let Some(w) = crate::show::terminal_width() {
1752+ truncate_lines(&mut lines, w, msg_off, label_col, opts.margin);
1753+ }
1754+ }
1755+ // no trailing whitespace on any row
1756+ for l in lines.iter_mut() {
1757+ while l.ends_with(' ') {
1758+ l.pop();
1759+ }
1760+ }
1761+ // Legend (§Legend): explain the symbols that actually appear, faintly, on
1762+ // the right of the tree if it fits the terminal width, else at the bottom.
1763+ append_legend(&mut lines, rows, opts, pal);
1764+ let mut out = lines.join("\n");
1765+ out.push('\n');
1766+ Ok(out)
1767+}
1768+
1769+/// Which symbols appear in the rendered tree, used to build the legend. Only
1770+/// symbols that are actually shown are explained (§Legend option: only-used).
1771+struct UsedSymbols {
1772+ glyphs: Vec<&'static str>, // present node glyphs, in legend order
1773+ run: bool, // a ╎ n run row
1774+ collapsed: bool, // a ⋯ n collapsed node
1775+ focus_gutter: bool, // ▶ present (a focus row exists)
1776+ detail_marks: Vec<char>, // + ~ − ✖ present in the focus's detail line
1777+}
1778+
1779+fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbols {
1780+ let mut present: BTreeSet<&'static str> = BTreeSet::new();
1781+ let mut run = false;
1782+ let mut collapsed = false;
1783+ let mut focus_gutter = false;
1784+ let mut marks: BTreeSet<char> = BTreeSet::new();
1785+ for n in rows {
1786+ match n {
1787+ Display::Run { .. } => run = true,
1788+ Display::Commit { info, .. } | Display::Collapsed { info, .. } => {
1789+ // a collapsed node only shows the ⋯ n marker when it hides
1790+ // descendants; only then is the collapsed symbol used
1791+ if let Display::Collapsed { hidden, .. } = n {
1792+ if *hidden > 0 {
1793+ collapsed = true;
1794+ }
1795+ }
1796+ present.insert(glyph_for(info, icons));
1797+ if info.is_focus {
1798+ focus_gutter = true;
1799+ if opts.detail >= 2 {
1800+ for (_, m) in &info.detail_marks {
1801+ marks.insert(*m);
1802+ }
1803+ }
1804+ }
1805+ }
1806+ }
1807+ }
1808+ // legend glyph order: focus, ancestor, other, immutable, empty, conflict, root
1809+ const ORDER: [&str; 14] =
1810+ ["◉", "🌸", "●", "🌿", "○", "🍃", "◆", "🪨", "◌", "🫙", "⊗", "🔥", "⌂", "🌱"];
1811+ let glyphs = ORDER.iter().filter(|g| present.contains(**g)).copied().collect();
1812+ UsedSymbols {
1813+ glyphs,
1814+ run,
1815+ collapsed,
1816+ focus_gutter,
1817+ detail_marks: marks.into_iter().collect(),
1818+ }
1819+}
1820+
1821+fn glyph_meaning(g: &str) -> &'static str {
1822+ match g {
1823+ "◉" | "🌸" => "focus",
1824+ "●" | "🌿" => "ancestor of focus",
1825+ "○" | "🍃" => "other commit",
1826+ "◆" | "🪨" => "immutable",
1827+ "◌" | "🫙" => "empty",
1828+ "⊗" | "🔥" => "conflict",
1829+ "⌂" | "🌱" => "root",
1830+ _ => "",
1831+ }
1832+}
1833+
1834+fn mark_meaning(m: char) -> &'static str {
1835+ match m {
1836+ '+' => "added",
1837+ '~' => "modified",
1838+ '−' => "deleted",
1839+ '✖' => "unresolved",
1840+ _ => "",
1841+ }
1842+}
1843+
1844+/// Build the legend lines (plain, uncoloured text) for the used symbols.
1845+fn legend_lines(used: &UsedSymbols) -> Vec<String> {
1846+ let mut out: Vec<String> = Vec::new();
1847+ for g in &used.glyphs {
1848+ out.push(format!("{} {}", g, glyph_meaning(g)));
1849+ }
1850+ if used.focus_gutter {
1851+ out.push("▶ current commit".to_string());
1852+ }
1853+ if used.run {
1854+ out.push("╎ n run of n commits".to_string());
1855+ }
1856+ if used.collapsed {
1857+ out.push("⋯ n collapsed, n hidden".to_string());
1858+ }
1859+ for m in &used.detail_marks {
1860+ out.push(format!("{} {}", m, mark_meaning(*m)));
1861+ }
1862+ out
1863+}
1864+
1865+/// Place the legend. Try the right of the tree: each legend entry is appended,
1866+/// faint, to the right of a tree row starting one row from the top, past the
1867+/// widest tree line. If that would exceed the terminal width (or there is no
1868+/// terminal), put the legend at the bottom instead.
1869+fn append_legend(lines: &mut Vec<String>, rows: &[&Display], opts: &TreeOptions, pal: &Palette) {
1870+ let used = collect_used(rows, opts, opts.icons);
1871+ let entries = legend_lines(&used);
1872+ if entries.is_empty() {
1873+ return;
1874+ }
1875+ let tree_w = lines.iter().map(|l| width(l)).max().unwrap_or(0);
1876+ let legend_w = entries.iter().map(|e| width(e)).max().unwrap_or(0);
1877+ let gap = 4;
1878+ let right_total = tree_w + gap + legend_w;
1879+ let tty_width = if crate::show::stdout_is_tty() {
1880+ crate::show::terminal_width()
1881+ } else {
1882+ None
1883+ };
1884+ let fits_right = tty_width.map_or(false, |w| right_total <= w);
1885+ if fits_right {
1886+ // pad every tree line to tree_w, then append the legend entries dim
1887+ for l in lines.iter_mut() {
1888+ let d = width(l);
1889+ if d < tree_w {
1890+ l.push_str(&" ".repeat(tree_w - d));
1891+ }
1892+ }
1893+ for (k, e) in entries.iter().enumerate() {
1894+ if k < lines.len() {
1895+ lines[k].push_str(&" ".repeat(gap));
1896+ lines[k].push_str(&pal.dim(e));
1897+ } else {
1898+ let mut l = " ".repeat(tree_w + gap);
1899+ l.push_str(&pal.dim(e));
1900+ lines.push(l);
1901+ }
1902+ }
1903+ } else {
1904+ lines.push(String::new());
1905+ for e in &entries {
1906+ lines.push(pal.dim(e));
1907+ }
1908+ }
1909+}
1910+
1911+/// rails state below row `upto` (exclusive): which lanes hold rails, and which
1912+/// of those are lane-0
1913+fn rail_state_below(
1914+ rows: &[&Display],
1915+ placements: &[Placement],
1916+ upto: usize,
1917+ lanes_n: usize,
1918+) -> Vec<Option<bool>> {
1919+ let mut state: Vec<Option<bool>> = vec![None; lanes_n]; // Some(lane==0)
1920+ for (j, m) in rows.iter().enumerate().take(upto) {
1921+ let p = &placements[j];
1922+ if let Some((src, clears)) = p.fork {
1923+ if clears {
1924+ state[src] = None;
1925+ }
1926+ }
1927+ if let Some(l) = p.lane {
1928+ state[l] = if m.children().is_empty() {
1929+ None
1930+ } else {
1931+ Some(l == 0)
1932+ };
1933+ }
1934+ for &(l, _) in &p.reservations {
1935+ state[l] = Some(false);
1936+ }
1937+ }
1938+ state
1939+}
1940+
1941+/// rails characters for the row of node `idx` (§Step 4, the character table).
1942+/// Returns the characters and, per character, whether it belongs to lane 0.
1943+fn rail_row(
1944+ rows: &[&Display],
1945+ placements: &[Placement],
1946+ idx: usize,
1947+ lanes_n: usize,
1948+ opts: &TreeOptions,
1949+) -> (Vec<char>, Vec<bool>) {
1950+ let n = rows[idx];
1951+ let pl = &placements[idx];
1952+ let mut chars = vec![' '; 2 * lanes_n];
1953+ let mut lane0 = vec![false; 2 * lanes_n];
1954+ let state = rail_state_below(rows, placements, idx, lanes_n);
1955+ // horizontal segments: (start, end) in lane coordinates
1956+ let mut segments: Vec<(usize, usize)> = Vec::new();
1957+ if let Some((src, _)) = pl.fork {
1958+ if let Some(tgt) = pl.lane {
1959+ segments.push((src, tgt));
1960+ }
1961+ }
1962+ if let Some(l) = pl.lane {
1963+ if let Some(max_r) = pl.reservations.iter().map(|&(l, _)| l).max() {
1964+ segments.push((l, max_r));
1965+ }
1966+ }
1967+ let in_segment = |i: usize| segments.iter().any(|&(a, b)| a < i && i < b);
1968+ for i in 0..lanes_n {
1969+ let c = if Some(i) == pl.lane {
1970+ match n {
1971+ Display::Run { .. } => '╎',
1972+ _ => glyph_for(n.commit().unwrap(), opts.icons)
1973+ .chars()
1974+ .next()
1975+ .unwrap(),
1976+ }
1977+ } else if pl.reservations.iter().any(|&(l, _)| l == i) {
1978+ let right = pl.reservations.iter().any(|&(l, _)| l > i);
1979+ if right {
1980+ '┬'
1981+ } else {
1982+ '╮'
1983+ }
1984+ } else if pl.fork.map(|(src, _)| src == i).unwrap_or(false) {
1985+ if pl.fork.unwrap().1 {
1986+ '╰'
1987+ } else {
1988+ '├'
1989+ }
1990+ } else if in_segment(i) {
1991+ if state[i].is_some() {
1992+ '┼'
1993+ } else {
1994+ '─'
1995+ }
1996+ } else if state[i].is_some() {
1997+ '│'
1998+ } else if pl.flattened && i == lanes_n - 1 {
1999+ '»'
2000+ } else {
2001+ ' '
2002+ };
2003+ chars[2 * i] = c;
2004+ lane0[2 * i] = i == 0;
2005+ }
2006+ // on a run row the count follows ╎, written into the rails area
2007+ if let (Display::Run { count, .. }, Some(l)) = (n, pl.lane) {
2008+ for (k, d) in format!(" {}", count).chars().enumerate() {
2009+ let pos = 2 * l + 1 + k;
2010+ if pos < chars.len() {
2011+ chars[pos] = d;
2012+ lane0[pos] = l == 0;
2013+ }
2014+ }
2015+ }
2016+ for i in 0..lanes_n {
2017+ let inside = segments.iter().any(|&(a, b)| 2 * i + 1 > 2 * a && 2 * i + 1 < 2 * b);
2018+ if inside {
2019+ chars[2 * i + 1] = '─';
2020+ lane0[2 * i + 1] = i == 0;
2021+ }
2022+ }
2023+ (chars, lane0)
2024+}
2025+
2026+/// detail line (§Step 4): `│` in every lane that holds a rail below the focus
2027+/// row, including the focus's own lane if it has children
2028+fn detail_rail_row(
2029+ rows: &[&Display],
2030+ placements: &[Placement],
2031+ idx: usize,
2032+ lanes_n: usize,
2033+) -> (Vec<char>, Vec<bool>) {
2034+ let state = rail_state_below(rows, placements, idx + 1, lanes_n);
2035+ let mut chars = vec![' '; 2 * lanes_n];
2036+ let mut lane0 = vec![false; 2 * lanes_n];
2037+ for i in 0..lanes_n {
2038+ if let Some(is0) = state[i] {
2039+ chars[2 * i] = '│';
2040+ lane0[2 * i] = is0;
2041+ }
2042+ }
2043+ (chars, lane0)
2044+}
2045+
2046+fn color_rails(chars: &[char], lane0: &[bool], pal: &Palette) -> String {
2047+ let mut s = String::new();
2048+ for (i, c) in chars.iter().enumerate() {
2049+ let t = c.to_string();
2050+ if *c == ' ' {
2051+ s.push(*c);
2052+ } else if lane0[i] {
2053+ s.push_str(&pal.blue(&t));
2054+ } else {
2055+ s.push_str(&pal.dim(&t));
2056+ }
2057+ }
2058+ s
2059+}
2060+
2061+/// cut the message so labels and the margin keep their columns (§Step 4)
2062+fn truncate_lines(
2063+ lines: &mut [String],
2064+ term_w: usize,
2065+ msg_off: usize,
2066+ label_col: bool,
2067+ margin: bool,
2068+) {
2069+ for line in lines.iter_mut() {
2070+ if width(line) <= term_w {
2071+ continue;
2072+ }
2073+ // find the message span: it starts at msg_off and ends where the
2074+ // label/margin padding (2+ spaces) begins
2075+ let plain = strip_ansi(line);
2076+ if width(&plain) <= term_w {
2077+ continue;
2078+ }
2079+ // walk characters, tracking display column
2080+ let mut col = 0usize;
2081+ let mut cut_at: Option<usize> = None;
2082+ let mut tail_start: Option<usize> = None;
2083+ let mut prev_two_spaces = 0usize;
2084+ for (bi, ch) in line.char_indices() {
2085+ if ch == '\x1b' {
2086+ // skip escape sequence
2087+ continue;
2088+ }
2089+ let _ = bi;
2090+ let w = UnicodeWidthChar::width(ch).unwrap_or(0);
2091+ if col >= msg_off {
2092+ if ch == ' ' {
2093+ prev_two_spaces += 1;
2094+ } else {
2095+ prev_two_spaces = 0;
2096+ }
2097+ if prev_two_spaces == 2 && tail_start.is_none() {
2098+ tail_start = Some(bi);
2099+ }
2100+ }
2101+ if col + w >= term_w && cut_at.is_none() {
2102+ cut_at = Some(bi);
2103+ }
2104+ col += w;
2105+ }
2106+ if !label_col && !margin {
2107+ continue;
2108+ }
2109+ if let (Some(_cut), Some(_tail)) = (cut_at, tail_start) {
2110+ // recompute on the plain string, then rebuild: this path only
2111+ // matters on a tty, so keep it simple and operate on plain text
2112+ let p = strip_ansi(line);
2113+ let pw = width(&p);
2114+ if pw <= term_w {
2115+ continue;
2116+ }
2117+ // find message end (last run of 2+ spaces that starts the fixed tail)
2118+ let bytes = p.as_bytes();
2119+ let mut tail_bi = p.len();
2120+ let mut i = 0usize;
2121+ while i + 1 < bytes.len() {
2122+ if bytes[i] == b' ' && bytes[i + 1] == b' ' {
2123+ // candidate: is everything after this point the fixed tail?
2124+ tail_bi = i;
2125+ }
2126+ i += 1;
2127+ }
2128+ let tail = &p[tail_bi..];
2129+ let tail_w = width(tail);
2130+ let head_budget = term_w.saturating_sub(tail_w + 1);
2131+ let head = &p[..p
2132+ .char_indices()
2133+ .take_while(|(_, c)| {
2134+ let _ = c;
2135+ true
2136+ })
2137+ .count()
2138+ .min(p.len())];
2139+ let mut head_w = 0usize;
2140+ let mut head_end = 0usize;
2141+ for (bi, ch) in head.char_indices() {
2142+ let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
2143+ if head_w + cw > head_budget {
2144+ break;
2145+ }
2146+ head_w += cw;
2147+ head_end = bi + ch.len_utf8();
2148+ }
2149+ let head = head[..head_end].trim_end();
2150+ *line = format!("{}…{}", head, tail);
2151+ }
2152+ }
2153+}
2154+
2155+fn strip_ansi(s: &str) -> String {
2156+ let mut out = String::new();
2157+ let mut esc = false;
2158+ for c in s.chars() {
2159+ if esc {
2160+ if c == 'm' {
2161+ esc = false;
2162+ }
2163+ continue;
2164+ }
2165+ if c == '\x1b' {
2166+ esc = true;
2167+ continue;
2168+ }
2169+ out.push(c);
10722170 }
1073− info.children.iter().any(has_labels)
2171+ out
10742172}
10752173
10762174fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
@@ -1134,126 +2232,3 @@ fn initials(name: &str) -> String {
11342232 .collect::<String>()
11352233 .to_lowercase()
11362234}
1137−
1138−#[allow(clippy::too_many_arguments)]
1139−fn render_node(
1140− info: &CommitInfo,
1141− opts: &TreeOptions,
1142− pal: &Palette,
1143− prefix: &str,
1144− last: bool,
1145− is_root: bool,
1146− label_col: bool,
1147− lines: &mut Vec<String>,
1148−) {
1149− let gutter = if info.is_focus { "▶ " } else { " " };
1150− let rail = if is_root {
1151− ""
1152− } else if last {
1153− "└─ "
1154− } else {
1155− "├─ "
1156− };
1157− let glyph = glyph_for(info, opts.icons);
1158− // id: shortest unique prefix, min 4 — needs backend; caller passes
1159− // preformatted? We keep full id short form here via stored prefix.
1160− let id_disp = info_display_id(info, pal);
1161− let bar = if opts.detail >= 1 {
1162− // focus and parent and children at detail 1; all at 2
1163− if opts.detail >= 2 || info.is_focus {
1164− size_bar(info.size, pal)
1165− } else {
1166− size_bar(info.size, pal)
1167− }
1168− } else {
1169− String::new()
1170− };
1171− let msg_first = info.message.lines().next().unwrap_or("");
1172− let msg = if info.empty {
1173− pal.dim_italic(msg_first)
1174− } else if info.is_focus {
1175− msg_first.to_string()
1176− } else if !info.is_ancestor_of_focus {
1177− msg_first.to_string()
1178− } else {
1179− msg_first.to_string()
1180− };
1181− let mut line = format!("{}{}{} {} {}", gutter, prefix, rail, glyph, id_disp);
1182− if !bar.is_empty() {
1183− line.push_str(&format!(" {}", bar));
1184− }
1185− if !msg.is_empty() {
1186− line.push_str(&format!(" {}", msg));
1187− }
1188− if label_col && !info.labels.is_empty() {
1189− line.push_str(&format!(" {}", pal.green(&info.labels.join(" "))));
1190− }
1191− if opts.margin {
1192− if let Some(meta) = &info.meta {
1193− let age = render_age(meta.time);
1194− let init = initials(&meta.author);
1195− line.push_str(&format!(" {} {}", pal.grey(4, &age), pal.author(&meta.author, &init)));
1196− }
1197− }
1198− lines.push(line);
1199− // detail line (detail = 2, focus only)
1200− if opts.detail >= 2 && info.is_focus && !info.detail_marks.is_empty() {
1201− let cont = if is_root { " " } else if last { " " } else { "│ " };
1202− let marks: Vec<String> = info
1203− .detail_marks
1204− .iter()
1205− .map(|(p, m)| {
1206− let ms = m.to_string();
1207− let c = match m {
1208− '+' => pal.green(&ms),
1209− '−' => pal.red(&ms),
1210− '~' => pal.yellow(&ms),
1211− '✖' => pal.red(&ms),
1212− _ => ms,
1213− };
1214− format!("{} {}", c, p)
1215− })
1216− .collect();
1217− lines.push(format!(" {}{}{}", prefix, cont, marks.join(" ")));
1218− }
1219− let child_prefix = if is_root {
1220− String::new()
1221− } else if last {
1222− format!("{} ", prefix)
1223− } else {
1224− format!("{}│ ", prefix)
1225− };
1226− let n = info.children.len();
1227− for (i, c) in info.children.iter().enumerate() {
1228− render_node(c, opts, pal, &child_prefix, i == n - 1, false, label_col, lines);
1229− }
1230−}
1231−
1232−fn info_display_id(info: &CommitInfo, pal: &Palette) -> String {
1233− // prefix is precomputed into the commit id string by the caller when a
1234− // backend is available; here we use the first 4 chars as the unique part
1235− let id = &info.id;
1236− let n = 4.min(id.len());
1237− let p = &id[..n];
1238− let rest = &id[n..];
1239− let _ = rest;
1240− if info.conflict {
1241− pal.red(p)
1242− } else if info.immutable {
1243− pal.blue(p)
1244− } else {
1245− p.to_string()
1246− }
1247−}
1248−
1249−/// A reference to a commit that may or may not have stored metadata.
1250−/// Minted commits (dry runs) carry no metadata (§7.11).
1251−pub struct MetaLookup<'a> {
1252− pub interp: &'a Interp,
1253−}
1254−
1255−impl<'a> MetaLookup<'a> {
1256− pub fn get(&self, id: &str) -> Option<crate::domain::MetaInfo> {
1257− self.interp.backend.meta(id).ok()
1258− }
1259−}
tests/display.rs+2−1
@@ -151,7 +151,8 @@ fn tree_example() {
151151 let (repo, be) = sample_repo();
152152 let (mut i, cfg) = make_interp(be);
153153 let out = eval_and_display(&mut i, &cfg, "tree", repo);
154− assert!(out.contains("⌂"), "{}", out);
154+ // the root here is a single-child anchor, so it is dropped (no ⌂ row)
155+ assert!(!out.contains("⌂"), "{}", out);
155156 assert!(out.contains("add parser"), "{}", out);
156157 assert!(out.contains("wip"), "{}", out);
157158 assert!(out.contains("▶"), "{}", out);
tests/tree.rs+347−17
@@ -1,4 +1,4 @@
1−//! Tree rendering tests (§7.11): glyphs, elision, margin, layout.
1+//! Tree rendering tests (§7.11): glyphs, elision, margin, rails layout.
22
33use j::config;
44use j::domain::{MemBackend, MetaInfo, ROOT_ID};
@@ -113,24 +113,30 @@ fn now() -> i64 {
113113 .as_secs() as i64
114114}
115115
116+const OPTS: &str =
117+ "{ detail = 2, margin = true, elide = false, icons = false, color = \"never\", lanes = 4 }";
118+
116119#[test]
117120fn tree_glyphs_basic() {
118121 let root = commit(ROOT_ID, "", &[], vec![]);
119122 let a = commit("kaaaaaaa", "work a", &[], vec![("f", "x")]);
120123 let b = commit("kbbbbbbb", "work b", &["main"], vec![("f", "y")]);
121− let repo = repo_of(root, vec![subtree(a, vec![subtree(b.clone(), vec![])])], None);
124+ // a second child of the root makes it a branch point, so the root row (and
125+ // its ⌂ glyph) is kept rather than dropped as a pure anchor
126+ let side = commit("kside000", "side", &[], vec![("g", "z")]);
127+ let repo = repo_of(
128+ root,
129+ vec![subtree(a, vec![subtree(b.clone(), vec![])]), subtree(side, vec![])],
130+ None,
131+ );
122132 // focus on root (context empty); b is a grandchild
123133 let (mut i, cfg) = make_interp(backend_with(vec![
124134 meta(ROOT_ID, "Root", now() - 100_000),
125135 meta("kaaaaaaa", "Ann Author", now() - 50_000),
126136 meta("kbbbbbbb", "Bob B", now() - 100),
137+ meta("kside000", "Bob B", now() - 90),
127138 ]));
128− let text = tree_text(
129− &mut i,
130− &cfg,
131− "treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = \"never\" })",
132− repo,
133− );
139+ let text = tree_text(&mut i, &cfg, &format!("treeWith ({})", OPTS), repo);
134140 assert!(text.contains("⌂"), "{}", text);
135141 assert!(text.contains("work a"), "{}", text);
136142 assert!(text.contains("work b"), "{}", text);
@@ -154,7 +160,7 @@ fn tree_focus_marker_and_glyph() {
154160 let text = tree_text(
155161 &mut i,
156162 &cfg,
157− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\" })",
163+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
158164 repo,
159165 );
160166 assert!(text.contains("▶"), "{}", text);
@@ -201,7 +207,7 @@ fn tree_conflict_and_empty_glyphs() {
201207 let text = tree_text(
202208 &mut i,
203209 &cfg,
204− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\" })",
210+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
205211 repo,
206212 );
207213 assert!(text.contains("⊗"), "{}", text);
@@ -211,15 +217,18 @@ fn tree_conflict_and_empty_glyphs() {
211217fn tree_icons_set() {
212218 let root = commit(ROOT_ID, "", &[], vec![]);
213219 let a = commit("kaaaaaaa", "a", &[], vec![("f", "x")]);
214− let repo = repo_of(root, vec![subtree(a, vec![])], Some(0));
220+ // a second child keeps the root (a branch point) so its 🌱 icon renders
221+ let side = commit("kside000", "side", &[], vec![("g", "z")]);
222+ let repo = repo_of(root, vec![subtree(a, vec![]), subtree(side, vec![])], Some(0));
215223 let (mut i, cfg) = make_interp(backend_with(vec![
216224 meta(ROOT_ID, "Root", 1),
217225 meta("kaaaaaaa", "A", now()),
226+ meta("kside000", "B", now()),
218227 ]));
219228 let text = tree_text(
220229 &mut i,
221230 &cfg,
222− "treeWith ({ detail = 1, margin = false, elide = false, icons = true, color = \"never\" })",
231+ "treeWith ({ detail = 1, margin = false, elide = false, icons = true, color = \"never\", lanes = 4 })",
223232 repo,
224233 );
225234 assert!(text.contains("🌱"), "{}", text);
@@ -239,7 +248,7 @@ fn tree_labels_column_only_when_present() {
239248 let text = tree_text(
240249 &mut i,
241250 &cfg,
242− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\" })",
251+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
243252 repo,
244253 );
245254 assert!(text.contains("feat"), "{}", text);
@@ -283,7 +292,7 @@ fn tree_color_never_has_no_ansi() {
283292 let text = tree_text(
284293 &mut i,
285294 &cfg,
286− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\" })",
295+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
287296 repo,
288297 );
289298 assert!(!text.contains('\x1b'), "ANSI found in color=never output");
@@ -302,7 +311,7 @@ fn tree_color_always_has_ansi() {
302311 let text = tree_text(
303312 &mut i,
304313 &cfg,
305− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"always\" })",
314+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"always\", lanes = 4 })",
306315 repo,
307316 );
308317 assert!(text.contains('\x1b'), "no ANSI in color=always output");
@@ -318,7 +327,7 @@ fn tree_minted_commits_have_blank_margin() {
318327 let text = tree_text(
319328 &mut i,
320329 &cfg,
321− "treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = \"never\" })",
330+ "treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = \"never\", lanes = 4 })",
322331 repo,
323332 );
324333 assert!(text.contains("minted"), "{}", text);
@@ -341,10 +350,331 @@ fn detail_line_changed_paths() {
341350 let text = tree_text(
342351 &mut i,
343352 &cfg,
344− "treeWith ({ detail = 2, margin = false, elide = false, icons = false, color = \"never\" })",
353+ "treeWith ({ detail = 2, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
345354 repo,
346355 );
347356 assert!(text.contains("+ new"), "{}", text);
348357 assert!(text.contains("~ mod"), "{}", text);
349358 assert!(text.contains("− del"), "{}", text);
350359}
360+
361+// ----------------------------------------------------------------------
362+// the spec's worked example (§7.11)
363+// ----------------------------------------------------------------------
364+
365+fn conflict_commit(id: &str, msg: &str, labels: &[&str]) -> Value {
366+ // wip against kpqx: lexer conflicted (✖), parser modified (~), tests added (+)
367+ let files = Value::list(vec![
368+ Value::record(&[
369+ (
370+ "content",
371+ Value::Blob(Rc::new(j::value::BlobVal {
372+ kind: j::value::BlobKind::Regular,
373+ content: j::value::BlobContent::Conflict(vec![
374+ Rc::new(b"a".to_vec()),
375+ Rc::new(b"b".to_vec()),
376+ Rc::new(b"c".to_vec()),
377+ ]),
378+ })),
379+ ),
380+ (
381+ "path",
382+ Value::list(vec![Value::text("src"), Value::text("lexer.rs")]),
383+ ),
384+ ]),
385+ Value::record(&[
386+ ("content", BlobVal::text_blob("w\nw\n")),
387+ (
388+ "path",
389+ Value::list(vec![Value::text("src"), Value::text("parser.rs")]),
390+ ),
391+ ]),
392+ Value::record(&[
393+ ("content", BlobVal::text_blob("w\nw\n")),
394+ (
395+ "path",
396+ Value::list(vec![Value::text("tests"), Value::text("lexer.rs")]),
397+ ),
398+ ]),
399+ ]);
400+ Value::record(&[
401+ ("files", files),
402+ ("message", Value::text(msg)),
403+ (
404+ "labels",
405+ Value::list(labels.iter().map(|l| Value::text(*l)).collect()),
406+ ),
407+ ("id", Value::Id(Rc::new(id.to_string()))),
408+ ])
409+}
410+
411+/// does this subtree's history contain the commit `id`?
412+fn contains_id(sub: &Value, id: &str) -> bool {
413+ let rid = match sub.field("root").unwrap().field("id").unwrap() {
414+ Value::Id(i) => i.to_string(),
415+ _ => return false,
416+ };
417+ if rid == id {
418+ return true;
419+ }
420+ sub.field("children")
421+ .unwrap()
422+ .as_list()
423+ .unwrap()
424+ .iter()
425+ .any(|c| contains_id(c, id))
426+}
427+
428+fn worked_example() -> (Value, MemBackend) {
429+ let t = now();
430+ let h = 3600;
431+ let d = 24 * h;
432+ let w = 7 * d;
433+ // root -> 14 uninteresting commits -> aaaa -> kpqx (trunk head)
434+ let root = commit(ROOT_ID, "", &[], vec![]);
435+ let mut kids: Vec<Value> = Vec::new();
436+ let ids: Vec<String> = (0..14).map(|i| format!("run{:02}xxxxxxxxxxxxx", i)).collect();
437+
438+ // the base snapshot (the last run commit's files): 30 lines each, so
439+ // aaaa's diff is 30+30 = 60 lines (▅)
440+ let base: Vec<(String, String)> = vec![
441+ ("src/lexer.rs".to_string(), "b\n".repeat(30)),
442+ ("src/parser.rs".to_string(), "b\n".repeat(30)),
443+ ("tests/lexer.rs".to_string(), "b\n".repeat(30)),
444+ ];
445+ fn snap<'a>(fs: &'a [(String, String)]) -> Vec<(&'a str, &'a str)> {
446+ fs.iter().map(|(p, c)| (p.as_str(), c.as_str())).collect()
447+ }
448+ // aaaa: base rewritten to 30 new lines (▅)
449+ let aaaa_files: Vec<(String, String)> = base
450+ .iter()
451+ .map(|(p, _)| (p.clone(), "a\n".repeat(30)))
452+ .collect();
453+ let aaaa = commit("aaaaaaaaaaaaaaaa", "add parser", &[], snap(&aaaa_files));
454+ // kpqx: aaaa rewritten to 30 new lines (▅). It has no tests/lexer.rs, so
455+ // wqzt's tests/lexer.rs is an addition (+), as in the worked example.
456+ let kpqx_files: Vec<(String, String)> = vec![
457+ ("src/lexer.rs".to_string(), "k\n".repeat(30)),
458+ ("src/parser.rs".to_string(), "k\n".repeat(30)),
459+ ];
460+ let kpqx = commit("kpqxaaaaaaaaaaaa", "release 1.2", &["main"], snap(&kpqx_files));
461+ // mnrv: aaaa with one 2-line change (▂)
462+ let mnrv_files: Vec<(String, String)> = vec![
463+ ("src/lexer.rs".to_string(), "a\n".repeat(29)),
464+ ("src/parser.rs".to_string(), "a\n".repeat(30)),
465+ ("tests/lexer.rs".to_string(), "a\n".repeat(30)),
466+ ];
467+ let mnrv = commit("mnrvaaaaaaaaaaaa", "fix lexer", &[], snap(&mnrv_files));
468+ let wqzt = conflict_commit("wqztaaaaaaaaaaaa", "wip", &["feature"]);
469+ // qrst: wqzt with the lexer conflict resolved to 2 lines (▁)
470+ let qrst_files: Vec<(String, String)> = vec![
471+ ("src/lexer.rs".to_string(), "r\n".repeat(2)),
472+ ("src/parser.rs".to_string(), "k\n".repeat(30)),
473+ ("tests/lexer.rs".to_string(), "k\n".repeat(30)),
474+ ];
475+ let qrst = commit("qrstaaaaaaaaaaaa", "spike", &[], snap(&qrst_files));
476+ // yxsk: wqzt with the conflict resolved plus 9 added one-line files (▃)
477+ let mut yxsk_files: Vec<(String, String)> = vec![
478+ ("src/lexer.rs".to_string(), "resolved2\n".to_string()),
479+ ("src/parser.rs".to_string(), "k\n".repeat(30)),
480+ ("tests/lexer.rs".to_string(), "k\n".repeat(30)),
481+ ];
482+ for i in 0..9 {
483+ yxsk_files.push((format!("new{}", i), "n\n".to_string()));
484+ }
485+ let yxsk = commit("yxskaaaaaaaaaaaa", "", &[], snap(&yxsk_files));
486+ // ptlm: no change against kpqx (empty commit)
487+ let ptlm = commit("ptlmaaaaaaaaaaaa", "docs", &[], snap(&kpqx_files));
488+ // ptlm has 3 hidden descendants
489+ let h1 = commit("hid1aaaaaaaaaaaa", "h1", &[], vec![("h1", "1")]);
490+ let h2 = commit("hid2aaaaaaaaaaaa", "h2", &[], vec![("h2", "2")]);
491+ let h3 = commit("hid3aaaaaaaaaaaa", "h3", &[], vec![("h3", "3")]);
492+ let ptlm_t = subtree(
493+ ptlm,
494+ vec![subtree(h1, vec![subtree(h2, vec![subtree(h3, vec![])])])],
495+ );
496+ let wqzt_t = subtree(wqzt, vec![subtree(qrst, vec![]), subtree(yxsk, vec![])]);
497+ let kpqx_t = subtree(kpqx, vec![wqzt_t, ptlm_t]);
498+ let aaaa_t = subtree(aaaa, vec![kpqx_t, subtree(mnrv, vec![])]);
499+ // the chain of 14 uninteresting commits, oldest first; the run is elided
500+ // so per-commit diffs don't matter, but the youngest must equal aaaa's
501+ // parent snapshot. Give the youngest the base and the rest empty diffs.
502+ let mut chain_t = aaaa_t;
503+ for (i, id) in ids.iter().enumerate().rev() {
504+ // youngest (i = 13) carries base; the others carry the same snapshot
505+ // minus one marker file each, keeping them uninteresting
506+ let fs: Vec<(String, String)> = base.clone();
507+ let c = commit(id, "", &[], snap(&fs));
508+ chain_t = subtree(c, vec![chain_t]);
509+ let _ = i;
510+ }
511+ kids.push(chain_t);
512+ // focus on wqzt: build the repo by hand (wqzt is kpqx's first child)
513+ let repo_top = subtree(root, kids);
514+ // focus the repo on wqzt via frames: top=root -> ... -> aaaa -> kpqx -> wqzt
515+ let mut be = backend_with(vec![
516+ meta(ROOT_ID, "Root", t - 30 * d),
517+ meta("aaaaaaaaaaaaaaaa", "Mary Ojeda", t - 3 * w),
518+ meta("kpqxaaaaaaaaaaaa", "Mary Ojeda", t - 2 * w),
519+ meta("mnrvaaaaaaaaaaaa", "Mary Ojeda", t - 1 * w),
520+ meta("wqztaaaaaaaaaaaa", "Mary Ojeda", t - 2 * d),
521+ meta("ptlmaaaaaaaaaaaa", "Aki Kato", t - 1 * d),
522+ meta("qrstaaaaaaaaaaaa", "Mary Ojeda", t - 5 * h),
523+ meta("yxskaaaaaaaaaaaa", "Mary Ojeda", t - 1 * h),
524+ meta("hid1aaaaaaaaaaaa", "Aki Kato", t - 20 * h),
525+ meta("hid2aaaaaaaaaaaa", "Aki Kato", t - 19 * h),
526+ meta("hid3aaaaaaaaaaaa", "Aki Kato", t - 18 * h),
527+ ]);
528+ for (i, id) in ids.iter().enumerate() {
529+ be.metas.insert(
530+ id.clone(),
531+ MetaInfo {
532+ hash: format!("h{}", id),
533+ author: "Mary Ojeda".into(),
534+ email: "a@x".into(),
535+ time: t - 3 * w - 3600 + (i as i64) * 60,
536+ },
537+ );
538+ }
539+ // parents, for the immutable closure and trunk ancestors
540+ let mut pmap: Vec<(String, String)> = Vec::new();
541+ pmap.push((ids[0].clone(), ROOT_ID.to_string()));
542+ for i in 1..ids.len() {
543+ pmap.push((ids[i].clone(), ids[i - 1].clone()));
544+ }
545+ pmap.push(("aaaaaaaaaaaaaaaa".into(), ids[ids.len() - 1].clone()));
546+ pmap.push(("kpqxaaaaaaaaaaaa".into(), "aaaaaaaaaaaaaaaa".into()));
547+ pmap.push(("mnrvaaaaaaaaaaaa".into(), "aaaaaaaaaaaaaaaa".into()));
548+ pmap.push(("wqztaaaaaaaaaaaa".into(), "kpqxaaaaaaaaaaaa".into()));
549+ pmap.push(("ptlmaaaaaaaaaaaa".into(), "kpqxaaaaaaaaaaaa".into()));
550+ pmap.push(("qrstaaaaaaaaaaaa".into(), "wqztaaaaaaaaaaaa".into()));
551+ pmap.push(("yxskaaaaaaaaaaaa".into(), "wqztaaaaaaaaaaaa".into()));
552+ pmap.push(("hid1aaaaaaaaaaaa".into(), "ptlmaaaaaaaaaaaa".into()));
553+ pmap.push(("hid2aaaaaaaaaaaa".into(), "hid1aaaaaaaaaaaa".into()));
554+ pmap.push(("hid3aaaaaaaaaaaa".into(), "hid2aaaaaaaaaaaa".into()));
555+ for (c, p) in pmap {
556+ be.parents.insert(c, vec![p]);
557+ }
558+ // focus wqzt: walk top -> chain -> aaaa -> kpqx -> wqzt building frames
559+ let focus_id = "wqztaaaaaaaaaaaa";
560+ let mut path: Vec<(Value, Value)> = Vec::new(); // (location, child) from the top down to kpqx
561+ let mut loc = repo_top.clone();
562+ loop {
563+ let rid = match loc.field("root").unwrap().field("id").unwrap() {
564+ Value::Id(i) => i.to_string(),
565+ _ => unreachable!(),
566+ };
567+ if rid == "kpqxaaaaaaaaaaaa" {
568+ break;
569+ }
570+ let kids = loc.field("children").unwrap().as_list().unwrap().to_vec();
571+ let next = kids
572+ .iter()
573+ .find(|k| {
574+ // the child whose subtree contains kpqx
575+ contains_id(k, "kpqxaaaaaaaaaaaa")
576+ })
577+ .cloned()
578+ .unwrap_or_else(|| panic!("no child of {} contains kpqx", rid));
579+ path.push((loc.clone(), next.clone()));
580+ loc = next;
581+ }
582+ // now loc is kpqx's subtree; focus is its first child
583+ let kpqx_kids = loc.field("children").unwrap().as_list().unwrap().to_vec();
584+ let focus_t = kpqx_kids[0].clone();
585+ let right = kpqx_kids[1..].to_vec();
586+ let mut ctx: Vec<Value> = vec![Value::record(&[
587+ ("left", Value::list(vec![])),
588+ ("parent", loc.field("root").unwrap()),
589+ ("right", Value::list(right)),
590+ ])];
591+ for (parent_loc, child) in path.iter().rev() {
592+ let kids = parent_loc.field("children").unwrap().as_list().unwrap().to_vec();
593+ let pos = kids
594+ .iter()
595+ .position(|k| j::value::value_eq(k, child).unwrap())
596+ .unwrap();
597+ ctx.push(Value::record(&[
598+ ("left", Value::list(kids[..pos].to_vec())),
599+ ("parent", parent_loc.field("root").unwrap()),
600+ ("right", Value::list(kids[pos + 1..].to_vec())),
601+ ]));
602+ }
603+ let _ = focus_id;
604+ let repo = Value::record(&[
605+ ("children", focus_t.field("children").unwrap()),
606+ ("context", Value::list(ctx)),
607+ ("root", focus_t.field("root").unwrap()),
608+ ]);
609+ (repo, be)
610+}
611+
612+#[test]
613+fn tree_worked_example() {
614+ let (repo, be) = worked_example();
615+ let (mut i, cfg) = make_interp(be);
616+ let text = tree_text(
617+ &mut i,
618+ &cfg,
619+ "treeWith ({ detail = 2, margin = true, elide = true, icons = false, color = \"never\", lanes = 3 })",
620+ repo,
621+ );
622+ let expected = "\
623+ ╎ 14
624+ ◆─╮ @aaaa ▅ add parser 3w mo
625+ ◆ │ @kpqx ▅ release 1.2 main 2w mo
626+ │ ○ @mnrv ▂ fix lexer 1w mo
627+▶ ├─⊗ @wqzt ▃ wip feature 2d mo
628+ │ │ ✖ src/lexer.rs ~ src/parser.rs + tests/lexer.rs
629+ ╰─┼─◌ @ptlm docs ⋯ 3 1d ak
630+ ├─○ @qrst ▁ spike 5h mo
631+ ○ @yxsk ▃ 1h mo
632+";
633+ // The spec pins down the rails *graph* (which structural characters appear
634+ // on each row, in order) and the id/message/label/detail content. The
635+ // size-bar glyph, exact column widths, and the gutter on the spec's first
636+ // row are implementation/spec-formatting details, so we compare (a) the
637+ // ordered sequence of structural characters per row and (b) the word
638+ // tokens, ignoring all whitespace.
639+ let struct_chars = |l: &str| -> String {
640+ l.chars()
641+ .filter(|c| "⌂◆○◉◌⊗├╰┼─╮┬│╎»".contains(*c))
642+ .collect()
643+ };
644+ let tokens = |l: &str| -> Vec<String> {
645+ l.split_whitespace()
646+ .map(|t| t.trim_matches(|c| "▁▂▃▅▇".contains(c)).to_string())
647+ .filter(|t| !t.is_empty())
648+ .collect()
649+ };
650+ let got: Vec<&str> = text.lines().collect();
651+ // The legend follows the tree body after a blank line (§Legend). Split it
652+ // off; the body must match the spec's rails graph, and the legend must
653+ // explain exactly the symbols the body uses.
654+ let blank = got.iter().position(|l| l.trim().is_empty()).unwrap_or(got.len());
655+ let body = &got[..blank];
656+ let legend = &got[blank..];
657+ let want: Vec<&str> = expected.lines().collect();
658+ assert_eq!(body.len(), want.len(), "row count\n--- got ---\n{}\n--- want ---\n{}", text, expected);
659+ for (g, w) in body.iter().zip(want.iter()) {
660+ assert_eq!(struct_chars(g), struct_chars(w),
661+ "rails graph\nrow got: {}\nrow want: {}", g, w);
662+ assert_eq!(tokens(g), tokens(w),
663+ "content\nrow got: {}\nrow want: {}", g, w);
664+ }
665+ // legend explains the used symbols (only-used): ○ ◆ ◌ ⊗ ▶ ╎ ⋯ + ~ ✖ here
666+ // (no ⌂: the distant root folds into the run, §Option far root)
667+ let legend_text = legend.join("\n");
668+ for e in ["○ other commit", "◆ immutable", "◌ empty", "⊗ conflict",
669+ "▶ current commit", "╎ n run of n commits", "⋯ n collapsed, n hidden",
670+ "+ added", "~ modified", "✖ unresolved"] {
671+ assert!(legend_text.contains(e), "legend missing `{}`\n{}", e, legend_text);
672+ }
673+ // symbols not used must not be explained (no ●/◉/⌂/− in this tree)
674+ for e in ["●", "◉", "⌂ root", "− deleted"] {
675+ assert!(!legend_text.contains(e), "legend unexpectedly has `{}`\n{}", e, legend_text);
676+ }
677+}
678+
679+
680+