use std::collections::HashMap;
use jiff::Timestamp;
use crate::graph::types::ChangeGraph;
use crate::jj::types::Signature;
pub const NODE_SELECTED: &str = "\u{25cf}"; pub const NODE_OTHER: &str = "\u{25cb}"; pub const TRUNK_CHAR: &str = "\u{25c6}"; pub const ELLIPSIS: &str = "\u{22ef}"; pub const GUTTER_CELL: &str = "\u{2502} "; pub const CONNECTOR_TEE: &str = "\u{251c}"; pub const CONNECTOR_TAIL: &str = "\u{2500}\u{256f}"; pub const LEAF_MARKER: &str = "\u{25c0}";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayoutNode {
pub change_id: String,
pub commit_id: String,
pub summary: String,
pub description: String,
pub bookmark_names: Vec<String>,
pub excluded_bookmarks: Vec<String>,
pub is_immutable: bool,
pub is_trunk: bool,
pub is_leaf: bool,
pub short_change_id: String,
pub author: Signature,
pub files: Vec<String>,
pub parent: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GraphRow {
Commit { node: usize, col: usize },
Connector { col: usize },
}
#[derive(Debug, Clone)]
pub struct GraphLayout {
pub nodes: Vec<LayoutNode>,
pub rows: Vec<GraphRow>,
pub leaves: Vec<usize>,
}
impl GraphLayout {
pub fn leaf_nodes(&self) -> Vec<&LayoutNode> {
self.leaves.iter().map(|&i| &self.nodes[i]).collect()
}
pub fn path_node_indices(&self, node: usize) -> Vec<usize> {
let mut path = Vec::new();
let mut current = Some(node);
while let Some(i) = current {
path.push(i);
current = self.nodes[i].parent;
}
path.reverse();
path
}
pub fn path_to_leaf(&self, node: usize) -> Vec<&LayoutNode> {
self.path_node_indices(node)
.into_iter()
.map(|i| &self.nodes[i])
.collect()
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "used in tests; useful layout diagnostic")
)]
pub fn row_of_node(&self, node: usize) -> Option<usize> {
self.rows.iter().position(|r| match r {
GraphRow::Commit { node: n, .. } => *n == node,
GraphRow::Connector { .. } => false,
})
}
}
pub fn build_layout(graph: &ChangeGraph) -> GraphLayout {
if graph.stacks.is_empty() {
return GraphLayout {
nodes: vec![],
rows: vec![],
leaves: vec![],
};
}
let mut nodes: Vec<LayoutNode> = vec![LayoutNode {
change_id: String::new(),
commit_id: String::new(),
summary: "trunk".to_string(),
description: String::new(),
bookmark_names: vec![],
excluded_bookmarks: vec![],
is_immutable: false,
is_trunk: true,
is_leaf: false,
short_change_id: String::new(),
author: Signature {
name: String::new(),
email: String::new(),
timestamp: String::new(),
},
files: vec![],
parent: None,
}];
let mut children: Vec<Vec<usize>> = vec![vec![]];
let mut own_ts: Vec<Option<Timestamp>> = vec![None];
let mut placed: HashMap<String, usize> = HashMap::new();
for stack in &graph.stacks {
let mut prev: usize = 0;
for (seg_idx, segment) in stack.segments.iter().enumerate() {
let is_last_segment = seg_idx == stack.segments.len() - 1;
let commits: Vec<_> = segment.commits.iter().rev().collect();
for (commit_idx, commit) in commits.iter().enumerate() {
if let Some(&existing) = placed.get(&commit.commit_id) {
prev = existing;
continue;
}
let is_last_commit_in_segment = commit_idx == commits.len() - 1;
let bookmark_names = if is_last_commit_in_segment {
segment.bookmark_names.clone()
} else {
vec![]
};
let excluded_bookmarks: Vec<String> = commit
.local_bookmark_names
.iter()
.filter(|name| !bookmark_names.contains(name))
.cloned()
.collect();
let summary = commit
.description
.lines()
.next()
.map(str::trim)
.filter(|l| !l.is_empty())
.unwrap_or("(no description)")
.to_string();
let idx = nodes.len();
nodes.push(LayoutNode {
change_id: commit.change_id.clone(),
commit_id: commit.commit_id.clone(),
summary,
description: commit.description.clone(),
bookmark_names,
excluded_bookmarks,
is_immutable: commit.is_immutable,
is_trunk: false,
is_leaf: is_last_segment && is_last_commit_in_segment,
short_change_id: commit.short_change_id.clone(),
author: commit.author.clone(),
files: commit.files.clone(),
parent: Some(prev),
});
children.push(vec![]);
own_ts.push(commit.committer.timestamp.parse().ok());
children[prev].push(idx);
placed.insert(commit.commit_id.clone(), idx);
prev = idx;
}
}
}
let mut subtree_ts = own_ts;
for i in (0..nodes.len()).rev() {
for &child in &children[i] {
if subtree_ts[child] > subtree_ts[i] {
subtree_ts[i] = subtree_ts[child];
}
}
}
for siblings in &mut children {
siblings.sort_by(|&a, &b| {
subtree_ts[b]
.cmp(&subtree_ts[a])
.then_with(|| nodes[a].change_id.cmp(&nodes[b].change_id))
});
}
let rows = build_rows(&children);
let leaves: Vec<usize> = rows
.iter()
.filter_map(|row| match row {
GraphRow::Commit { node, .. } if nodes[*node].is_leaf => Some(*node),
_ => None,
})
.collect();
GraphLayout {
nodes,
rows,
leaves,
}
}
fn build_rows(children: &[Vec<usize>]) -> Vec<GraphRow> {
enum Work {
Visit { node: usize, col: usize },
EmitCommit { node: usize, col: usize },
EmitConnector { col: usize },
}
let mut rows = Vec::new();
let mut work = vec![Work::Visit { node: 0, col: 0 }];
while let Some(item) = work.pop() {
match item {
Work::Visit { node, col } => {
work.push(Work::EmitCommit { node, col });
for &sibling in children[node].iter().skip(1).rev() {
work.push(Work::EmitConnector { col: col + 1 });
work.push(Work::Visit {
node: sibling,
col: col + 1,
});
}
if let Some(&first) = children[node].first() {
work.push(Work::Visit { node: first, col });
}
}
Work::EmitCommit { node, col } => rows.push(GraphRow::Commit { node, col }),
Work::EmitConnector { col } => rows.push(GraphRow::Connector { col }),
}
}
rows
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::collections::HashSet;
use super::*;
use crate::graph::types::BookmarkSegment;
use crate::graph::types::BranchStack;
use crate::graph::types::ChangeGraph;
use crate::graph::types::SegmentCommit;
fn make_graph(stacks: Vec<BranchStack>) -> ChangeGraph {
ChangeGraph {
adjacency_list: HashMap::new(),
stack_leaves: HashSet::new(),
stack_roots: HashSet::new(),
segments: HashMap::new(),
tainted_change_ids: HashSet::new(),
excluded_bookmark_count: 0,
stacks,
}
}
fn make_segment(names: &[&str], change_id: &str, descriptions: &[&str]) -> BookmarkSegment {
make_segment_at(names, change_id, descriptions, "T")
}
fn make_segment_at(
names: &[&str],
change_id: &str,
descriptions: &[&str],
timestamp: &str,
) -> BookmarkSegment {
BookmarkSegment {
bookmark_names: names.iter().map(ToString::to_string).collect(),
change_id: change_id.to_string(),
commits: descriptions
.iter()
.enumerate()
.map(|(i, desc)| SegmentCommit {
commit_id: format!("c_{change_id}_{i}"),
change_id: change_id.to_string(),
description: desc.to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: timestamp.to_string(),
},
files: vec![],
short_change_id: change_id[..4.min(change_id.len())].to_string(),
is_immutable: false,
local_bookmark_names: vec![],
})
.collect(),
}
}
fn rows_to_string(layout: &GraphLayout) -> String {
layout
.rows
.iter()
.map(|row| match row {
GraphRow::Commit { node, col } => {
format!("{col}:{}", layout.nodes[*node].summary)
}
GraphRow::Connector { col } => format!("{col}:├─╯"),
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn empty_graph_layout() {
let graph = make_graph(vec![]);
let layout = build_layout(&graph);
assert!(layout.nodes.is_empty());
assert!(layout.rows.is_empty());
assert!(layout.leaves.is_empty());
}
#[test]
fn single_linear_stack() {
let graph = make_graph(vec![BranchStack {
segments: vec![
make_segment(&["base"], "ch_a", &["add base"]),
make_segment(&["leaf"], "ch_b", &["add leaf"]),
],
}]);
let layout = build_layout(&graph);
assert_eq!(layout.nodes.len(), 3);
assert!(layout.nodes[0].is_trunk);
let base = &layout.nodes[1];
assert_eq!(base.bookmark_names, vec!["base"]);
assert!(!base.is_leaf);
assert_eq!(base.parent, Some(0));
let leaf = &layout.nodes[2];
assert_eq!(leaf.bookmark_names, vec!["leaf"]);
assert!(leaf.is_leaf);
assert_eq!(leaf.parent, Some(1));
assert_eq!(rows_to_string(&layout), "0:add leaf\n0:add base\n0:trunk");
}
#[test]
fn two_branching_stacks_ordered_newest_first() {
let graph = make_graph(vec![
BranchStack {
segments: vec![make_segment_at(
&["alpha"],
"ch_alpha",
&["alpha work"],
"2026-01-01T00:00:00Z",
)],
},
BranchStack {
segments: vec![make_segment_at(
&["beta"],
"ch_beta",
&["beta work"],
"2026-02-01T00:00:00Z",
)],
},
]);
let layout = build_layout(&graph);
assert_eq!(layout.nodes.len(), 3);
assert_eq!(
rows_to_string(&layout),
"0:beta work\n1:alpha work\n1:├─╯\n0:trunk"
);
let leaves = layout.leaf_nodes();
assert_eq!(leaves[0].bookmark_names, vec!["beta"]);
assert_eq!(leaves[1].bookmark_names, vec!["alpha"]);
}
#[test]
fn sibling_order_uses_offset_aware_timestamps() {
let graph = make_graph(vec![
BranchStack {
segments: vec![make_segment_at(
&["utc"],
"ch_utc",
&["utc work"],
"2026-01-01T12:00:00+00:00",
)],
},
BranchStack {
segments: vec![make_segment_at(
&["offset"],
"ch_offset",
&["offset work"],
"2026-01-01T12:30:00+02:00",
)],
},
]);
let layout = build_layout(&graph);
let leaves = layout.leaf_nodes();
assert_eq!(leaves[0].bookmark_names, vec!["utc"]);
assert_eq!(leaves[1].bookmark_names, vec!["offset"]);
}
#[test]
fn sibling_order_tiebreaks_on_change_id() {
let graph = make_graph(vec![
BranchStack {
segments: vec![make_segment(&["zeta"], "ch_z", &["z work"])],
},
BranchStack {
segments: vec![make_segment(&["alpha"], "ch_a", &["a work"])],
},
]);
let layout = build_layout(&graph);
let leaves = layout.leaf_nodes();
assert_eq!(leaves[0].bookmark_names, vec!["alpha"]);
assert_eq!(leaves[1].bookmark_names, vec!["zeta"]);
}
#[test]
fn shared_root_segment() {
let graph = make_graph(vec![
BranchStack {
segments: vec![
make_segment(&["base"], "ch_shared", &["shared base"]),
make_segment_at(&["feat-a"], "ch_a", &["feature a"], "2026-02-01T00:00:00Z"),
],
},
BranchStack {
segments: vec![
make_segment(&["base"], "ch_shared", &["shared base"]),
make_segment_at(&["feat-b"], "ch_b", &["feature b"], "2026-01-01T00:00:00Z"),
],
},
]);
let layout = build_layout(&graph);
assert_eq!(layout.nodes.len(), 4);
let shared: Vec<_> = layout
.nodes
.iter()
.filter(|n| n.change_id == "ch_shared")
.collect();
assert_eq!(shared.len(), 1);
assert_eq!(
rows_to_string(&layout),
"0:feature a\n1:feature b\n1:├─╯\n0:shared base\n0:trunk"
);
}
#[test]
fn multi_commit_segment() {
let graph = make_graph(vec![BranchStack {
segments: vec![make_segment(
&["feat"],
"ch_a",
&["second commit", "first commit"],
)],
}]);
let layout = build_layout(&graph);
assert_eq!(layout.nodes.len(), 3);
assert_eq!(
rows_to_string(&layout),
"0:second commit\n0:first commit\n0:trunk"
);
let first = layout
.nodes
.iter()
.find(|n| n.summary == "first commit")
.unwrap();
let second = layout
.nodes
.iter()
.find(|n| n.summary == "second commit")
.unwrap();
assert!(first.bookmark_names.is_empty());
assert_eq!(second.bookmark_names, vec!["feat"]);
}
#[test]
fn path_to_leaf_linear() {
let graph = make_graph(vec![BranchStack {
segments: vec![
make_segment(&["base"], "ch_a", &["base work"]),
make_segment(&["leaf"], "ch_b", &["leaf work"]),
],
}]);
let layout = build_layout(&graph);
let path = layout.path_to_leaf(layout.leaves[0]);
assert_eq!(path.len(), 3);
assert!(path[0].is_trunk);
assert_eq!(path[1].change_id, "ch_a");
assert_eq!(path[2].change_id, "ch_b");
}
#[test]
fn path_to_leaf_branching() {
let graph = make_graph(vec![
BranchStack {
segments: vec![
make_segment(&["base"], "ch_shared", &["shared"]),
make_segment(&["feat-a"], "ch_a", &["feature a"]),
],
},
BranchStack {
segments: vec![
make_segment(&["base"], "ch_shared", &["shared"]),
make_segment(&["feat-b"], "ch_b", &["feature b"]),
],
},
]);
let layout = build_layout(&graph);
let feat_b = layout
.nodes
.iter()
.position(|n| n.change_id == "ch_b")
.unwrap();
let path = layout.path_to_leaf(feat_b);
assert_eq!(path.len(), 3);
assert!(path[0].is_trunk);
assert_eq!(path[1].change_id, "ch_shared");
assert_eq!(path[2].change_id, "ch_b");
}
#[test]
fn immutable_flag_and_excluded_bookmarks_threaded() {
let mut segment = make_segment(&["feat"], "ch_a", &["second commit", "first commit"]);
segment.commits[0].local_bookmark_names =
vec!["feat".to_string(), "other-user".to_string()];
segment.commits[1].is_immutable = true;
segment.commits[1].local_bookmark_names = vec!["pinned".to_string()];
let graph = make_graph(vec![BranchStack {
segments: vec![segment],
}]);
let layout = build_layout(&graph);
let trunk = &layout.nodes[0];
assert!(trunk.is_trunk);
assert!(!trunk.is_immutable);
assert!(trunk.excluded_bookmarks.is_empty());
let mid = layout
.nodes
.iter()
.find(|n| n.summary == "first commit")
.unwrap();
assert!(mid.is_immutable);
assert!(mid.bookmark_names.is_empty());
assert_eq!(mid.excluded_bookmarks, vec!["pinned"]);
let boundary = layout
.nodes
.iter()
.find(|n| n.summary == "second commit")
.unwrap();
assert!(!boundary.is_immutable);
assert_eq!(boundary.bookmark_names, vec!["feat"]);
assert_eq!(boundary.excluded_bookmarks, vec!["other-user"]);
}
#[test]
fn leaf_nodes_returns_only_leaves() {
let graph = make_graph(vec![
BranchStack {
segments: vec![
make_segment(&["base"], "ch_a", &["base"]),
make_segment(&["leaf-1"], "ch_b", &["leaf 1"]),
],
},
BranchStack {
segments: vec![make_segment(&["leaf-2"], "ch_c", &["leaf 2"])],
},
]);
let layout = build_layout(&graph);
let leaves = layout.leaf_nodes();
assert_eq!(leaves.len(), 2);
assert!(leaves.iter().all(|n| n.is_leaf));
}
#[test]
fn row_of_node_finds_commit_rows() {
let graph = make_graph(vec![BranchStack {
segments: vec![
make_segment(&["base"], "ch_a", &["base work"]),
make_segment(&["leaf"], "ch_b", &["leaf work"]),
],
}]);
let layout = build_layout(&graph);
assert_eq!(layout.row_of_node(layout.leaves[0]), Some(0));
assert_eq!(layout.row_of_node(0), Some(2));
}
#[test]
fn nested_siblings_layout() {
let auth = make_segment_at(&["auth"], "ch_auth", &["auth work"], "2026-01-01T00:00:00Z");
let graph = make_graph(vec![
BranchStack {
segments: vec![
auth.clone(),
make_segment_at(
&["email"],
"ch_email",
&["email work"],
"2026-06-01T00:00:00Z",
),
],
},
BranchStack {
segments: vec![
auth.clone(),
make_segment_at(&["api"], "ch_api", &["api work"], "2026-02-01T00:00:00Z"),
make_segment_at(
&["integration"],
"ch_int",
&["integration work"],
"2026-04-01T00:00:00Z",
),
],
},
BranchStack {
segments: vec![
auth,
make_segment_at(&["api"], "ch_api", &["api work"], "2026-02-01T00:00:00Z"),
make_segment_at(
&["ratelimit"],
"ch_rate",
&["ratelimit work"],
"2026-03-01T00:00:00Z",
),
],
},
]);
let layout = build_layout(&graph);
insta::assert_snapshot!(rows_to_string(&layout));
let leaves = layout.leaf_nodes();
let names: Vec<_> = leaves.iter().map(|l| l.bookmark_names[0].clone()).collect();
assert_eq!(names, vec!["email", "integration", "ratelimit"]);
}
}