@@ -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−}