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 | + | } |