on master help sign in
Add date, files, and author data columns to tree

Three new opt-in treeWith columns, each behind a Bool option: - date: absolute commit date (YYYY-MM-DD, UTC) - files: number of files changed - author: full author name They render grey, in order, in the right-aligned metadata block before the margin, independent of the margin option. The reference config gains treeData enabling all three; tree/treeCompact/treeFull keep them off.

commitdc38b51c3dae0f22658a5b099512bc2d982d3ccd authoriko <mail@iko.soy> dateWed 16 Sep 2026, 20:55 (3 weeks ago) parente8d9fc2
4 files changed+110−21
config.j+8−4 specs/tree.md+10−2 src/render.rs+82−5 tests/tree.rs+10−10
config.j+8−4
@@ -154,7 +154,7 @@ diff : Blob -> Blob -> Text -- unified diff from the first to the
154154difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
155155 -- Options via DFT_* environment variables.
156156treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
157− , lanes : Int }
157+ , lanes : Int, author : Bool, date : Bool, files : Bool }
158158 -> Repo -> Text -- the history as a tree; see the spec
159159
160160-- Every subvalue of a given shape, however deeply nested. A shape is a type
@@ -573,10 +573,14 @@ log = \repo ->
573573-- The default `tree` shows every commit, sorted by time (parents always above
574574-- their children); `treeCompact` folds uninteresting runs and far subtrees.
575575tree : Repo -> Text
576−tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4 })
576+tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
577577
578578treeCompact : Repo -> Text
579−treeCompact = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto", lanes = 4 })
579+treeCompact = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
580580
581581treeFull : Repo -> Text
582−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4 })
582+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
583+
584+-- The full tree plus the extra data columns: date, files changed, author.
585+treeData : Repo -> Text
586+treeData = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = true, date = true, files = true })
specs/tree.md+10−2
@@ -18,12 +18,16 @@ columns are at fixed positions, independent of how deep a commit is.
1818, elide : Bool -- fold uninteresting runs and collapse far subtrees
1919, icons : Bool -- pictographic glyph set
2020, color : Text -- "auto", "always", "never"
21−, lanes : Int } -- lane columns, including the trunk lane; ≥ 1
21+, lanes : Int -- lane columns, including the trunk lane; ≥ 1
22+, author : Bool -- full author name column
23+, date : Bool -- absolute commit date column (YYYY-MM-DD)
24+, files : Bool } -- number of files changed column
2225```
2326
2427Missing fields crash. `lanes` is the *fixed* width of the rails area in
2528lanes; it does not adapt to the history (see §Overflow). The reference
26−`config.j` sets `lanes = 4`.
29+`config.j` sets `lanes = 4`. The `author`, `date`, and `files` columns are off
30+by default; `treeData` enables all three.
2731
2832#### Trunk
2933
@@ -155,6 +159,10 @@ Columns, left to right, at fixed offsets:
155159 a label; names separated by two spaces.
1561608. **Margin** (`margin = true`): age and author initials, right-aligned, as
157161 before.
162+9. **Data columns** (off unless enabled): `date` shows the commit's absolute
163+ date `YYYY-MM-DD`; `files` shows the number of files changed as `n files`;
164+ `author` shows the full author name. They appear in that order, grey,
165+ immediately before the margin in the same right-aligned block.
158166
159167Columns 4–8 start at the same offset on every row. When stdout is a terminal
160168and a row would exceed its width, the message is cut and ends with `…` so that
src/render.rs+82−5
@@ -623,6 +623,24 @@ pub fn render_age(time: i64) -> String {
623623 }
624624}
625625
626+/// Format unix-seconds `time` as an absolute date `YYYY-MM-DD` (UTC), using
627+/// the civil-from-days algorithm (no external date crate).
628+pub fn render_date(time: i64) -> String {
629+ let days = time.div_euclid(86_400);
630+ // Howard Hinnant's civil_from_days
631+ let z = days + 719_468;
632+ let era = z.div_euclid(146_097);
633+ let doe = z.rem_euclid(146_097);
634+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
635+ let y = yoe + era * 400;
636+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
637+ let mp = (5 * doy + 2) / 153;
638+ let d = doy - (153 * mp + 2) / 5 + 1;
639+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
640+ let y = if m <= 2 { y + 1 } else { y };
641+ format!("{:04}-{:02}-{:02}", y, m, d)
642+}
643+
626644/// the repo value in context (the loaded repo), for resolving ids to commits
627645fn top_of(interp: &Interp, _x: &Value) -> Result<Value, Crash> {
628646 let old = interp.old_repo.borrow().clone();
@@ -844,6 +862,12 @@ pub struct TreeOptions {
844862 pub icons: bool,
845863 pub color: String,
846864 pub lanes: i64,
865+ /// show the full author name column
866+ pub author: bool,
867+ /// show the absolute commit date column (YYYY-MM-DD)
868+ pub date: bool,
869+ /// show the number of files changed column
870+ pub files: bool,
847871}
848872
849873fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
@@ -854,6 +878,9 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
854878 icons: false,
855879 color: "auto".into(),
856880 lanes: 4,
881+ author: false,
882+ date: false,
883+ files: false,
857884 })
858885}
859886
@@ -882,6 +909,18 @@ pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Valu
882909 if lanes < 1 {
883910 return Err(Crash::new("treeWith: lanes must be at least 1"));
884911 }
912+ let author = match opts.field("author")? {
913+ Value::Bool(b) => b,
914+ _ => return Err(Crash::new("treeWith: author must be a Bool")),
915+ };
916+ let date = match opts.field("date")? {
917+ Value::Bool(b) => b,
918+ _ => return Err(Crash::new("treeWith: date must be a Bool")),
919+ };
920+ let files = match opts.field("files")? {
921+ Value::Bool(b) => b,
922+ _ => return Err(Crash::new("treeWith: files must be a Bool")),
923+ };
885924 let pal = Palette {
886925 on: color_enabled(&color),
887926 };
@@ -892,6 +931,9 @@ pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Valu
892931 icons,
893932 color,
894933 lanes,
934+ author,
935+ date,
936+ files,
895937 };
896938 let text = tree_render(interp, &o, repo, &pal, true)?;
897939 Ok(Value::text(text))
@@ -909,6 +951,7 @@ struct CommitInfo {
909951 is_child_of_ancestor: bool,
910952 meta: Option<crate::domain::MetaInfo>,
911953 size: Option<usize>, // lines added+removed against parent
954+ nfiles: usize, // number of files in this commit's snapshot
912955 detail_marks: Vec<(String, char)>,
913956 children: Vec<CommitInfo>,
914957}
@@ -961,6 +1004,7 @@ fn build_info(
9611004 .collect::<Result<_, _>>()?;
9621005 let conflict = has_conflict(commit)?;
9631006 let files = commit.field("files")?;
1007+ let nfiles = files.as_list()?.len();
9641008 let (empty, size, detail_marks) = match parent_files {
9651009 Some(pf) => {
9661010 let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
@@ -1029,6 +1073,7 @@ fn build_info(
10291073 is_child_of_ancestor: parent_is_ancestor,
10301074 meta,
10311075 size,
1076+ nfiles,
10321077 detail_marks,
10331078 children: infos,
10341079 })
@@ -1361,6 +1406,7 @@ fn clone_info(info: &CommitInfo, children: Vec<CommitInfo>) -> CommitInfo {
13611406 is_child_of_ancestor: info.is_child_of_ancestor,
13621407 meta: info.meta.clone(),
13631408 size: info.size,
1409+ nfiles: info.nfiles,
13641410 detail_marks: info.detail_marks.clone(),
13651411 children,
13661412 }
@@ -1503,6 +1549,9 @@ struct RowText {
15031549 age: String,
15041550 initials: String,
15051551 initials_author: String,
1552+ // extra right-side columns, already joined in display order (§Step 4):
1553+ // date, files changed, full author name — only the enabled ones, plain
1554+ meta_extra: Vec<String>,
15061555}
15071556
15081557fn build_row_text(
@@ -1597,16 +1646,35 @@ fn build_row_text(
15971646 }
15981647 _ => (String::new(), String::new(), String::new()),
15991648 };
1649+ // extra columns (§Step 4): date, files changed, full author name
1650+ let mut meta_extra: Vec<String> = Vec::new();
1651+ if let Some(info) = c {
1652+ if info.id != ROOT_ID {
1653+ if opts.date {
1654+ if let Some(m) = info.meta.as_ref() {
1655+ meta_extra.push(render_date(m.time));
1656+ }
1657+ }
1658+ if opts.files {
1659+ meta_extra.push(format!("{} files", info.nfiles));
1660+ }
1661+ if opts.author {
1662+ if let Some(m) = info.meta.as_ref() {
1663+ meta_extra.push(m.author.clone());
1664+ }
1665+ }
1666+ }
1667+ }
16001668 RowText {
16011669 gutter,
16021670 id,
1603−
16041671 bar,
16051672 msg,
16061673 labels,
16071674 age,
16081675 initials: init,
16091676 initials_author: author,
1677+ meta_extra,
16101678 }
16111679}
16121680
@@ -1701,7 +1769,10 @@ fn draw_rows(
17011769 line.push_str(&" ".repeat(want));
17021770 line.push_str(&pal.green(&pad_right(&t.labels, width(&t.labels))));
17031771 }
1704− if opts.margin && !t.age.is_empty() {
1772+ // metadata block: extra columns (date / files / author) then the margin
1773+ // (age + initials), right-aligned together past the message+labels edge
1774+ let has_margin = opts.margin && !t.age.is_empty();
1775+ if !t.meta_extra.is_empty() || has_margin {
17051776 let cur = if label_col && !t.labels.is_empty() {
17061777 msg_end_max + 2 + width(&t.labels)
17071778 } else {
@@ -1709,9 +1780,15 @@ fn draw_rows(
17091780 };
17101781 let want = 2 + lab_end_max.saturating_sub(cur);
17111782 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));
1783+ let mut meta: Vec<String> = Vec::new();
1784+ for e in &t.meta_extra {
1785+ meta.push(pal.grey(4, e));
1786+ }
1787+ if has_margin {
1788+ meta.push(pal.grey(4, &t.age));
1789+ meta.push(pal.author(&t.initials_author, &t.initials));
1790+ }
1791+ line.push_str(&meta.join(" "));
17151792 }
17161793 let _ = pl;
17171794 lines.push(line);
tests/tree.rs+10−10
@@ -114,7 +114,7 @@ fn now() -> i64 {
114114}
115115
116116const OPTS: &str =
117− "{ detail = 2, margin = true, elide = false, icons = false, color = \"never\", lanes = 4 }";
117+ "{ detail = 2, margin = true, elide = false, icons = false, color = \"never\", lanes = 4, author = false, date = false, files = false }";
118118
119119#[test]
120120fn tree_glyphs_basic() {
@@ -160,7 +160,7 @@ fn tree_focus_marker_and_glyph() {
160160 let text = tree_text(
161161 &mut i,
162162 &cfg,
163− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
163+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4, author = false, date = false, files = false })",
164164 repo,
165165 );
166166 assert!(text.contains("▶"), "{}", text);
@@ -207,7 +207,7 @@ fn tree_conflict_and_empty_glyphs() {
207207 let text = tree_text(
208208 &mut i,
209209 &cfg,
210− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
210+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4, author = false, date = false, files = false })",
211211 repo,
212212 );
213213 assert!(text.contains("⊗"), "{}", text);
@@ -228,7 +228,7 @@ fn tree_icons_set() {
228228 let text = tree_text(
229229 &mut i,
230230 &cfg,
231− "treeWith ({ detail = 1, margin = false, elide = false, icons = true, color = \"never\", lanes = 4 })",
231+ "treeWith ({ detail = 1, margin = false, elide = false, icons = true, color = \"never\", lanes = 4, author = false, date = false, files = false })",
232232 repo,
233233 );
234234 assert!(text.contains("🌱"), "{}", text);
@@ -248,7 +248,7 @@ fn tree_labels_column_only_when_present() {
248248 let text = tree_text(
249249 &mut i,
250250 &cfg,
251− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
251+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4, author = false, date = false, files = false })",
252252 repo,
253253 );
254254 assert!(text.contains("feat"), "{}", text);
@@ -292,7 +292,7 @@ fn tree_color_never_has_no_ansi() {
292292 let text = tree_text(
293293 &mut i,
294294 &cfg,
295− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
295+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"never\", lanes = 4, author = false, date = false, files = false })",
296296 repo,
297297 );
298298 assert!(!text.contains('\x1b'), "ANSI found in color=never output");
@@ -311,7 +311,7 @@ fn tree_color_always_has_ansi() {
311311 let text = tree_text(
312312 &mut i,
313313 &cfg,
314− "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"always\", lanes = 4 })",
314+ "treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = \"always\", lanes = 4, author = false, date = false, files = false })",
315315 repo,
316316 );
317317 assert!(text.contains('\x1b'), "no ANSI in color=always output");
@@ -327,7 +327,7 @@ fn tree_minted_commits_have_blank_margin() {
327327 let text = tree_text(
328328 &mut i,
329329 &cfg,
330− "treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = \"never\", lanes = 4 })",
330+ "treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = \"never\", lanes = 4, author = false, date = false, files = false })",
331331 repo,
332332 );
333333 assert!(text.contains("minted"), "{}", text);
@@ -350,7 +350,7 @@ fn detail_line_changed_paths() {
350350 let text = tree_text(
351351 &mut i,
352352 &cfg,
353− "treeWith ({ detail = 2, margin = false, elide = false, icons = false, color = \"never\", lanes = 4 })",
353+ "treeWith ({ detail = 2, margin = false, elide = false, icons = false, color = \"never\", lanes = 4, author = false, date = false, files = false })",
354354 repo,
355355 );
356356 assert!(text.contains("+ new"), "{}", text);
@@ -616,7 +616,7 @@ fn tree_worked_example() {
616616 let text = tree_text(
617617 &mut i,
618618 &cfg,
619− "treeWith ({ detail = 2, margin = true, elide = true, icons = false, color = \"never\", lanes = 3 })",
619+ "treeWith ({ detail = 2, margin = true, elide = true, icons = false, color = \"never\", lanes = 3, author = false, date = false, files = false })",
620620 repo,
621621 );
622622 let expected = "\