@@ -843,6 +843,7 @@ pub struct TreeOptions {
843 pub elide: bool,
843 pub elide: bool,
844 pub icons: bool,
844 pub icons: bool,
845 pub color: String,
845 pub color: String,
848fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
849fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
@@ -852,6 +853,7 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
852 elide: true,
853 elide: true,
853 icons: false,
854 icons: false,
854 color: "auto".into(),
855 color: "auto".into(),
@@ -873,6 +875,13 @@ pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Valu
873 _ => return Err(Crash::new("treeWith: icons must be a Bool")),
875 _ => return Err(Crash::new("treeWith: icons must be a Bool")),
875 let color = opts.field("color")?.as_text()?.to_string();
877 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")),
883+ return Err(Crash::new("treeWith: lanes must be at least 1"));
876 let pal = Palette {
885 let pal = Palette {
877 on: color_enabled(&color),
886 on: color_enabled(&color),
@@ -882,14 +891,13 @@ pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Valu
886 let text = tree_render(interp, &o, repo, &pal, true)?;
896 let text = tree_render(interp, &o, repo, &pal, true)?;
887 Ok(Value::text(text))
897 Ok(Value::text(text))
890struct CommitInfo {
900struct CommitInfo {
893 id: String,
901 id: String,
894 message: String,
902 message: String,
895 labels: Vec<String>,
903 labels: Vec<String>,
@@ -898,82 +906,31 @@ struct CommitInfo {
898 immutable: bool,
906 immutable: bool,
899 is_focus: bool,
907 is_focus: bool,
900 is_ancestor_of_focus: bool,
908 is_ancestor_of_focus: bool,
909+ is_child_of_ancestor: bool,
901 meta: Option<crate::domain::MetaInfo>,
910 meta: Option<crate::domain::MetaInfo>,
902 size: Option<usize>, // lines added+removed against parent
911 size: Option<usize>, // lines added+removed against parent
903 detail_marks: Vec<(String, char)>,
912 detail_marks: Vec<(String, char)>,
904 children: Vec<CommitInfo>,
913 children: Vec<CommitInfo>,
907−fn tree_render(
916+impl CommitInfo {
908− interp: &mut Interp,
917+ /// §Elision: a commit is *interesting* — never folded into a run — if it is
909− opts: &TreeOptions,
918+ /// the focus, labelled, conflicted, a leaf, or has more than one child; or
910− repo: &Value,
919+ /// if it is an ancestor of the focus (or a child of one) **off the trunk**.
913−) -> Result<String, Crash> {
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(),
920− // ancestors of focus
920+ /// Trunk ancestors of the focus below the branch point are not interesting
921− let mut anc: BTreeSet<String> = BTreeSet::new();
921+ /// on that ground alone, so an uninteresting run of them folds (as in the
922− let mut cur = repo.clone();
922+ /// worked example's `╎ 14`). The root is interesting only when it is not
923− loop {
923+ /// buried in a longer uninteresting run — a distant root folds into the run
924− let id = match cur.field("root")?.field("id")? {
924+ /// rather than being pinned at the top (§Option: far root). A run never
925− Value::Id(i) => i.to_string(),
925+ /// straddles the trunk boundary.
926− _ => break,
926+ fn interesting(&self, trunk: &BTreeSet<String>) -> bool {
927− };
927+ self.is_focus
928− anc.insert(id);
928+ || !self.labels.is_empty()
929− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
929+ || self.conflict
933− cur = crate::repo::by_id(&cur, &id_of_frame_parent(&ctx[0])?)?.unwrap();
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(
944− top_children.as_list()?,
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")
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()))),
965−fn top_id(repo: &Value) -> Result<String, Crash> {
966− let mut cur = repo.clone();
968− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
969− if ctx.is_empty() {
930+ || self.children.is_empty()
970− return match cur.field("root")?.field("id")? {
931+ || self.children.len() > 1
971− Value::Id(i) => Ok(i.to_string()),
932+ || ((self.is_ancestor_of_focus || self.is_child_of_ancestor)
972− v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
933+ && !trunk.contains(&self.id))
975− let pid = id_of_frame_parent(&ctx[0])?;
976− cur = crate::repo::by_id(&cur, &pid)?.unwrap();
@@ -989,6 +946,7 @@ fn build_info(
989 anc: &BTreeSet<String>,
946 anc: &BTreeSet<String>,
990 parent_files: Option<&Value>,
947 parent_files: Option<&Value>,
991 with_focus: bool,
948 with_focus: bool,
949+ parent_is_ancestor: bool,
992) -> Result<CommitInfo, Crash> {
950) -> Result<CommitInfo, Crash> {
993 let id = match commit.field("id")? {
951 let id = match commit.field("id")? {
994 Value::Id(i) => i.to_string(),
952 Value::Id(i) => i.to_string(),
@@ -1008,17 +966,26 @@ fn build_info(
1008 let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
966 let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1009 let marks = touched_paths(&change)?;
967 let marks = touched_paths(&change)?;
1010 let empty = marks.is_empty();
968 let empty = marks.is_empty();
1011− let mut adds = 0usize;
1012 // size: lines added+removed against the parent
969 // size: lines added+removed against the parent
970+ let from_map = snapshot_map_of(pf)?;
971+ let to_map = snapshot_map_of(&files)?;
972+ let mut lines = 0usize;
1013− for (_, m, _) in &marks {
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 {
1014− match m {
976+ match m.get(&key) {
1015− '+' | '−' => adds += 1,
977+ Some(Value::Blob(b)) => {
1016− _ => adds += 1,
978+ let n = String::from_utf8_lossy(&b.bytes()).lines().count();
984+ lines += count(&from_map) + count(&to_map);
1019 let marks2: Vec<(String, char)> =
986 let marks2: Vec<(String, char)> =
1020 marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
987 marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1021− (empty, Some(adds), marks2)
988+ (empty, Some(lines), marks2)
1023 None => {
990 None => {
1024 let n = files.as_list()?.len();
991 let n = files.as_list()?.len();
@@ -1027,6 +994,7 @@ fn build_info(
1028 let meta = interp.backend.meta(&id).ok();
995 let meta = interp.backend.meta(&id).ok();
1029 let is_focus = with_focus && id == focus_id;
996 let is_focus = with_focus && id == focus_id;
997+ let is_ancestor_of_focus = anc.contains(&id);
1030 let mut infos = Vec::new();
998 let mut infos = Vec::new();
1031 for c in children.iter() {
999 for c in children.iter() {
1032 let child_loc = crate::repo::by_id(loc, &match c.field("root")?.field("id")? {
1000 let child_loc = crate::repo::by_id(loc, &match c.field("root")?.field("id")? {
@@ -1045,12 +1013,11 @@ fn build_info(
1046 Some(&files),
1014 Some(&files),
1047 with_focus,
1015 with_focus,
1016+ is_ancestor_of_focus,
1050 let immutable_flag = immutable.contains(&id);
1019 let immutable_flag = immutable.contains(&id);
1051− let anc_flag = anc.contains(&id);
1052 Ok(CommitInfo {
1020 Ok(CommitInfo {
1053− commit: commit.clone(),
1055 message,
1022 message,
@@ -1058,7 +1025,8 @@ fn build_info(
1059 immutable: immutable_flag,
1026 immutable: immutable_flag,
1060 is_focus,
1027 is_focus,
1061− is_ancestor_of_focus: anc_flag,
1028+ is_ancestor_of_focus,
1029+ is_child_of_ancestor: parent_is_ancestor,
1064 detail_marks,
1032 detail_marks,
@@ -1066,11 +1034,1141 @@ fn build_info(
1069−fn has_labels(info: &CommitInfo) -> bool {
1037+fn tree_render(
1070− if !info.labels.is_empty() {
1038+ interp: &mut Interp,
1071− return true;
1039+ opts: &TreeOptions,
1043+) -> Result<String, Crash> {
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(),
1050+ // ancestors of focus
1051+ let mut anc: BTreeSet<String> = BTreeSet::new();
1052+ let mut cur = repo.clone();
1054+ let id = match cur.field("root")?.field("id")? {
1055+ Value::Id(i) => i.to_string(),
1059+ let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1060+ if ctx.is_empty() {
1063+ cur = crate::repo::by_id(&cur, &id_of_frame_parent(&ctx[0])?)?.unwrap();
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(
1074+ top_children.as_list()?,
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)
1092+ make_display(&root_info, opts.elide, &trunk)
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() {
1105+ .min_by_key(|(_, n)| (n.time(), preorder[&n.uid()]))
1108+ let n = ready.remove(best);
1110+ for c in n.children() {
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
1119+ .filter_map(|n| n.commit().map(|c| c.id.as_str()))
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])) {
1129+ draw_rows(&rows, &placements, opts, pal, lanes_n, with_focus, &prefix_len)
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()))),
1139+fn top_id(repo: &Value) -> Result<String, Crash> {
1140+ let mut cur = repo.clone();
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()))),
1149+ let pid = id_of_frame_parent(&ctx[0])?;
1150+ cur = crate::repo::by_id(&cur, &pid)?.unwrap();
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
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() {
1167+ Value::Id(id) => named.push(id.to_string()),
1168+ _ => return Err(Crash::new("treeWith: trunk returned a non-Id")),
1173+ match named.len() {
1176+ let pmap: BTreeMap<String, String> =
1177+ crate::repo::parent_map(repo)?.into_iter().collect();
1178+ let mut cur = named[0].clone();
1180+ set.insert(cur.clone());
1181+ match pmap.get(&cur) {
1182+ Some(p) => cur = p.clone(),
1188+ n => Err(Crash::new(format!("treeWith: trunk names {} revisions", n))),
1192+// ----------------------------------------------------------------------
1193+// Step 1 — the display tree (§Elision)
1194+// ----------------------------------------------------------------------
1200+ children: Vec<Display>,
1207+ child: Box<Display>,
1217+ fn uid(&self) -> usize {
1219+ Display::Commit { uid, .. } => *uid,
1220+ Display::Run { uid, .. } => *uid,
1221+ Display::Collapsed { uid, .. } => *uid,
1225+ fn time(&self) -> Option<i64> {
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),
1233+ fn children(&self) -> &[Display] {
1235+ Display::Commit { children, .. } => children,
1236+ Display::Run { child, .. } => std::slice::from_ref(&**child),
1237+ Display::Collapsed { .. } => &[],
1241+ fn commit(&self) -> Option<&CommitInfo> {
1243+ Display::Commit { info, .. } => Some(info),
1244+ Display::Collapsed { info, .. } => Some(info),
1245+ Display::Run { .. } => None,
1249+ fn in_trunk(&self, trunk: &BTreeSet<String>) -> bool {
1251+ Display::Run { in_trunk, .. } => *in_trunk,
1252+ _ => self.commit().map(|c| trunk.contains(&c.id)).unwrap_or(false),
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)
1262+fn make_display_rec(
1265+ trunk: &BTreeSet<String>,
1272+ let children = info
1275+ .map(|c| make_display_rec(c, elide, trunk, true, uid))
1277+ return Display::Commit {
1279+ info: clone_info(info, Vec::new()),
1283+ if !near && !info.is_ancestor_of_focus && !info.is_focus {
1286+ return Display::Collapsed {
1288+ info: clone_info(info, Vec::new()),
1289+ hidden: count_descendants(info),
1292+ if info.interesting(trunk) {
1295+ let children = info
1298+ .map(|c| make_display_rec(c, elide, trunk, false, uid))
1300+ return Display::Commit {
1302+ info: clone_info(info, Vec::new()),
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 {
1315+ cur = &cur.children[0];
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 {
1323+ let children = info
1326+ .map(|c| make_display_rec(c, elide, trunk, near, uid))
1328+ return Display::Commit {
1330+ info: clone_info(info, Vec::new()),
1334+ let count = chain.len();
1335+ let in_trunk = chain.iter().all(|c| trunk.contains(&c.id));
1341+ time: chain[0].meta.as_ref().map(|m| m.time),
1343+ child: Box::new(make_display_rec(cur, elide, trunk, near, uid)),
1347+fn count_descendants(info: &CommitInfo) -> usize {
1348+ info.children.iter().map(|c| 1 + count_descendants(c)).sum()
1351+fn clone_info(info: &CommitInfo, children: Vec<CommitInfo>) -> CommitInfo {
1353+ id: info.id.clone(),
1354+ message: info.message.clone(),
1355+ labels: info.labels.clone(),
1356+ conflict: info.conflict,
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(),
1364+ detail_marks: info.detail_marks.clone(),
1369+fn flatten_display<'a>(n: &'a Display, out: &mut Vec<&'a Display>) {
1371+ for c in n.children() {
1372+ flatten_display(c, out);
1376+// ----------------------------------------------------------------------
1378+// ----------------------------------------------------------------------
1380+#[derive(Clone, Debug)]
1386+#[derive(Clone, Debug)]
1388+ lane: Option<usize>,
1389+ fork: Option<(usize, bool)>, // (source lane, source empties below)
1390+ reservations: Vec<(usize, usize)>, // (lane, child uid)
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);
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]
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
1414+ let cs = p.children();
1415+ cs[cs.len() - 1].uid() == n.uid()
1418+ let was_flat = flattened.contains(&n.uid());
1419+ let mut lane: Option<usize> = None;
1420+ let mut fork: Option<(usize, bool)> = None;
1424+ } else if in_trunk {
1426+ } else if let Some(l) =
1427+ rails.iter().position(|r| matches!(r, Some(Rail::Reserved(u)) if *u == n.uid()))
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
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()) {
1440+ fork = Some((pl, true));
1443+ fork = Some((pl, false));
1446+ None => mark_subtree(n, &mut flattened),
1450+ mark_subtree(n, &mut flattened);
1453+ let now_flat = flattened.contains(&n.uid());
1455+ if let Some(l) = lane {
1456+ rails[l] = if n.children().is_empty() {
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()) {
1473+ match (1..lanes_n).find(|&l| rails[l].is_none()) {
1475+ rails[l] = Some(Rail::Reserved(c.uid()));
1476+ reservations.push((l, c.uid()));
1478+ None => mark_subtree(c, &mut flattened),
1483+ out.push(Placement {
1484+ lane: if now_flat { None } else { lane },
1485+ fork: if now_flat { None } else { fork },
1487+ flattened: now_flat,
1493+// ----------------------------------------------------------------------
1495+// ----------------------------------------------------------------------
1505+ initials_author: String,
1511+ opts: &TreeOptions,
1514+ prefix_len: &dyn Fn(&str) -> usize,
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)
1524+ Display::Run { .. } => {
1525+ let mut s = String::new();
1526+ while width(&s) < run_extra + id_w {
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))
1543+ let plain = k + 1; // +1 for the @
1544+ let padded = if plain < id_w {
1545+ format!("{}{}", colored, " ".repeat(id_w - plain))
1551+ None => " ".repeat(id_w),
1555+ let focus_idx = rows
1557+ .position(|m| m.commit().map(|x| x.is_focus).unwrap_or(false));
1558+ let is_focus_parent = focus_idx
1560+ rows[..fi].iter().any(|p| {
1563+ .any(|ch| ch.uid() == rows[fi].uid()) && p.uid() == n.uid()
1567+ let is_focus_child = focus_idx
1568+ .map(|fi| rows[fi].children().iter().any(|ch| ch.uid() == n.uid()))
1570+ let show_bar = opts.detail >= 1
1571+ && c.map(|x| opts.detail >= 2 || x.is_focus || is_focus_parent || is_focus_child)
1573+ let bar = if show_bar {
1574+ c.map(|x| size_bar(x.size, pal)).unwrap_or_default()
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(),
1584+ if let Display::Collapsed { hidden, .. } = n {
1586+ if !msg.is_empty() {
1589+ msg.push_str(&format!("⋯ {}", hidden));
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())
1598+ _ => (String::new(), String::new(), String::new()),
1609+ initials_author: author,
1613+#[allow(clippy::too_many_arguments)]
1616+ placements: &[Placement],
1617+ opts: &TreeOptions,
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
1635+ id_w = id_w.max(4 + extra);
1641+ if let Some(c) = n.commit() {
1642+ id_w = id_w.max(prefix_len(&c.id) + 1); // +1 for the `@`
1645+ let text_off = 2 + rail_chars + 1;
1646+ let msg_off = text_off + id_w + 2;
1647+ let label_col = rows
1649+ .any(|n| n.commit().map(|c| !c.labels.is_empty()).unwrap_or(false));
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)
1660+ texts.push(build_row_text(
1661+ n, rows, opts, pal, with_focus, prefix_len, run_extra, id_w,
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() {
1672+ let mut pos = msg_off;
1673+ if !t.bar.is_empty() {
1674+ pos += 1; // the bar takes one extra column before the message
1676+ pos += width(&t.msg);
1677+ if !t.labels.is_empty() {
1678+ pos += 2 + width(&t.labels);
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);
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));
1692+ line.push_str(&t.id);
1693+ if !t.bar.is_empty() {
1695+ line.push_str(&t.bar);
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))));
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)
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));
1718+ // detail line (detail = 2, focus only)
1719+ if opts.detail >= 2
1721+ && n.commit().map(|c| c.is_focus && !c.detail_marks.is_empty()).unwrap_or(false)
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));
1729+ dline.push_str(&" ".repeat(id_w + 2));
1730+ let marks: Vec<String> = c
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),
1742+ format!("{} {}", colored, p)
1745+ dline.push_str(&marks.join(" "));
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);
1755+ // no trailing whitespace on any row
1756+ for l in lines.iter_mut() {
1757+ while l.ends_with(' ') {
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");
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
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();
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 {
1796+ present.insert(glyph_for(info, icons));
1798+ focus_gutter = true;
1799+ if opts.detail >= 2 {
1800+ for (_, m) in &info.detail_marks {
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();
1817+ detail_marks: marks.into_iter().collect(),
1821+fn glyph_meaning(g: &str) -> &'static str {
1823+ "◉" | "🌸" => "focus",
1824+ "●" | "🌿" => "ancestor of focus",
1825+ "○" | "🍃" => "other commit",
1826+ "◆" | "🪨" => "immutable",
1827+ "◌" | "🫙" => "empty",
1828+ "⊗" | "🔥" => "conflict",
1829+ "⌂" | "🌱" => "root",
1834+fn mark_meaning(m: char) -> &'static str {
1839+ '✖' => "unresolved",
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)));
1850+ if used.focus_gutter {
1851+ out.push("▶ current commit".to_string());
1854+ out.push("╎ n run of n commits".to_string());
1856+ if used.collapsed {
1857+ out.push("⋯ n collapsed, n hidden".to_string());
1859+ for m in &used.detail_marks {
1860+ out.push(format!("{} {}", m, mark_meaning(*m)));
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() {
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);
1878+ let right_total = tree_w + gap + legend_w;
1879+ let tty_width = if crate::show::stdout_is_tty() {
1880+ crate::show::terminal_width()
1884+ let fits_right = tty_width.map_or(false, |w| right_total <= w);
1886+ // pad every tree line to tree_w, then append the legend entries dim
1887+ for l in lines.iter_mut() {
1890+ l.push_str(&" ".repeat(tree_w - d));
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));
1898+ let mut l = " ".repeat(tree_w + gap);
1899+ l.push_str(&pal.dim(e));
1904+ lines.push(String::new());
1905+ for e in &entries {
1906+ lines.push(pal.dim(e));
1911+/// rails state below row `upto` (exclusive): which lanes hold rails, and which
1912+/// of those are lane-0
1913+fn rail_state_below(
1915+ placements: &[Placement],
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 {
1927+ if let Some(l) = p.lane {
1928+ state[l] = if m.children().is_empty() {
1934+ for &(l, _) in &p.reservations {
1935+ state[l] = Some(false);
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.
1945+ placements: &[Placement],
1948+ opts: &TreeOptions,
1949+) -> (Vec<char>, Vec<bool>) {
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));
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));
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 {
1971+ Display::Run { .. } => '╎',
1972+ _ => glyph_for(n.commit().unwrap(), opts.icons)
1977+ } else if pl.reservations.iter().any(|&(l, _)| l == i) {
1978+ let right = pl.reservations.iter().any(|&(l, _)| l > i);
1984+ } else if pl.fork.map(|(src, _)| src == i).unwrap_or(false) {
1985+ if pl.fork.unwrap().1 {
1990+ } else if in_segment(i) {
1991+ if state[i].is_some() {
1996+ } else if state[i].is_some() {
1998+ } else if pl.flattened && i == lanes_n - 1 {
2004+ lane0[2 * i] = i == 0;
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() {
2012+ lane0[pos] = l == 0;
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);
2019+ chars[2 * i + 1] = '─';
2020+ lane0[2 * i + 1] = i == 0;
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
2030+ placements: &[Placement],
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;
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();
2052+ } else if lane0[i] {
2053+ s.push_str(&pal.blue(&t));
2055+ s.push_str(&pal.dim(&t));
2061+/// cut the message so labels and the margin keep their columns (§Step 4)
2063+ lines: &mut [String],
2069+ for line in lines.iter_mut() {
2070+ if width(line) <= term_w {
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 {
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() {
2086+ // skip escape sequence
2090+ let w = UnicodeWidthChar::width(ch).unwrap_or(0);
2091+ if col >= msg_off {
2093+ prev_two_spaces += 1;
2095+ prev_two_spaces = 0;
2097+ if prev_two_spaces == 2 && tail_start.is_none() {
2098+ tail_start = Some(bi);
2101+ if col + w >= term_w && cut_at.is_none() {
2106+ if !label_col && !margin {
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);
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?
2128+ let tail = &p[tail_bi..];
2129+ let tail_w = width(tail);
2130+ let head_budget = term_w.saturating_sub(tail_w + 1);
2133+ .take_while(|(_, c)| {
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 {
2147+ head_end = bi + ch.len_utf8();
2149+ let head = head[..head_end].trim_end();
2150+ *line = format!("{}…{}", head, tail);
2155+fn strip_ansi(s: &str) -> String {
2156+ let mut out = String::new();
2157+ let mut esc = false;
2158+ for c in s.chars() {
1073− info.children.iter().any(has_labels)
2171+ out
1076fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
2174fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
@@ -1134,126 +2232,3 @@ fn initials(name: &str) -> String {
1134 .collect::<String>()
2232 .collect::<String>()
1135 .to_lowercase()
2233 .to_lowercase()
1138−#[allow(clippy::too_many_arguments)]
1141− opts: &TreeOptions,
1147− lines: &mut Vec<String>,
1149− let gutter = if info.is_focus { "▶ " } else { " " };
1150− let rail = if is_root {
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)
1166− size_bar(info.size, pal)
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()
1179− msg_first.to_string()
1181− let mut line = format!("{}{}{} {} {}", gutter, prefix, rail, glyph, id_disp);
1182− if !bar.is_empty() {
1183− line.push_str(&format!(" {}", bar));
1185− if !msg.is_empty() {
1186− line.push_str(&format!(" {}", msg));
1188− if label_col && !info.labels.is_empty() {
1189− line.push_str(&format!(" {}", pal.green(&info.labels.join(" "))));
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)));
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
1206− let ms = m.to_string();
1208− '+' => pal.green(&ms),
1209− '−' => pal.red(&ms),
1210− '~' => pal.yellow(&ms),
1211− '✖' => pal.red(&ms),
1214− format!("{} {}", c, p)
1217− lines.push(format!(" {}{}{}", prefix, cont, marks.join(" ")));
1219− let child_prefix = if is_root {
1222− format!("{} ", prefix)
1224− format!("{}│ ", prefix)
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);
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
1236− let n = 4.min(id.len());
1238− let rest = &id[n..];
1242− } else if info.immutable {
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,
1255−impl<'a> MetaLookup<'a> {
1256− pub fn get(&self, id: &str) -> Option<crate::domain::MetaInfo> {
1257− self.interp.backend.meta(id).ok()