pub mod layout;
pub mod output;
pub mod types;
use std::collections::HashMap;
use std::collections::HashSet;
use jiff::Timestamp;
use self::types::BookmarkSegment;
use self::types::BranchStack;
use self::types::ChangeGraph;
use self::types::RemoteState;
use self::types::SegmentCommit;
use crate::error::StakkError;
use crate::jj::Jj;
use crate::jj::runner::JjRunner;
use crate::jj::types::Bookmark;
struct TraversalResult {
segments: Vec<BookmarkSegment>,
already_seen_change_id: Option<String>,
excluded: bool,
}
pub async fn build_change_graph<R: JjRunner>(
jj: &Jj<R>,
bookmarks_revset: &str,
heads_revset: &str,
) -> Result<ChangeGraph, StakkError> {
let bookmarks = jj.get_my_bookmarks(bookmarks_revset).await?;
let user_bookmark_names: HashSet<String> = bookmarks.iter().map(|b| b.name.clone()).collect();
let mut fully_collected: HashSet<String> = HashSet::new();
let mut adjacency_list: HashMap<String, String> = HashMap::new();
let mut segments: HashMap<String, BookmarkSegment> = HashMap::new();
let mut tainted_change_ids: HashSet<String> = HashSet::new();
let mut excluded_bookmarks: Vec<String> = Vec::new();
let mut excluded_head_count: usize = 0;
for bookmark in &bookmarks {
if fully_collected.contains(&bookmark.name) {
continue;
}
let result = traverse_and_discover_segments(
&bookmark.commit_id,
jj,
&fully_collected,
&mut tainted_change_ids,
&user_bookmark_names,
)
.await?;
if result.excluded {
excluded_bookmarks.push(bookmark.name.clone());
continue;
}
integrate_traversal_result(
result,
&mut adjacency_list,
&mut segments,
&mut fully_collected,
);
}
let bookmarked_commit_ids: HashSet<String> =
bookmarks.iter().map(|b| b.commit_id.clone()).collect();
let heads = jj.get_heads(heads_revset).await?;
for head in &heads {
if bookmarked_commit_ids.contains(&head.commit_id) {
continue;
}
if segments.contains_key(&head.change_id) {
continue;
}
let result = traverse_and_discover_segments(
&head.commit_id,
jj,
&fully_collected,
&mut tainted_change_ids,
&user_bookmark_names,
)
.await?;
if result.excluded {
excluded_head_count += 1;
continue;
}
integrate_traversal_result(
result,
&mut adjacency_list,
&mut segments,
&mut fully_collected,
);
}
let parent_ids: HashSet<&String> = adjacency_list.values().collect();
let stack_leaves: HashSet<String> = segments
.keys()
.filter(|id| !parent_ids.contains(id))
.cloned()
.collect();
let mut stacks = group_segments_into_stacks(&stack_leaves, &adjacency_list, &segments);
fetch_file_lists(jj, &mut stacks).await?;
for stack in &stacks {
for segment in &stack.segments {
if let Some(seg) = segments.get_mut(&segment.change_id) {
*seg = segment.clone();
}
}
}
let bookmark_remote_states = derive_remote_states(&bookmarks, &stacks);
Ok(ChangeGraph {
adjacency_list,
stack_leaves,
segments,
tainted_change_ids,
bookmark_remote_states,
excluded_bookmarks,
excluded_head_count,
stacks,
})
}
fn derive_remote_states(
bookmarks: &[Bookmark],
stacks: &[BranchStack],
) -> HashMap<String, RemoteState> {
let synced: HashMap<&str, bool> = bookmarks
.iter()
.map(|b| (b.name.as_str(), b.synced))
.collect();
let mut states = HashMap::new();
for stack in stacks {
for segment in &stack.segments {
let Some(boundary) = segment.commits.first() else {
continue;
};
for name in &segment.bookmark_names {
let state = if !synced.get(name.as_str()).copied().unwrap_or(true) {
RemoteState::Diverged
} else if has_real_remote(&boundary.remote_bookmark_names, name) {
RemoteState::Synced
} else {
RemoteState::Unpushed
};
states.insert(name.clone(), state);
}
}
}
states
}
fn has_real_remote(remote_names: &[String], name: &str) -> bool {
remote_names.iter().any(|entry| {
entry
.strip_prefix(name)
.and_then(|rest| rest.strip_prefix('@'))
.is_some_and(|remote| remote != "git")
})
}
fn integrate_traversal_result(
result: TraversalResult,
adjacency_list: &mut HashMap<String, String>,
segments: &mut HashMap<String, BookmarkSegment>,
fully_collected: &mut HashSet<String>,
) {
for seg in &result.segments {
for name in &seg.bookmark_names {
fully_collected.insert(name.clone());
}
}
for window in result.segments.windows(2) {
let child_id = &window[0].change_id;
let parent_id = &window[1].change_id;
adjacency_list.insert(child_id.clone(), parent_id.clone());
}
if let Some(ref seen_id) = result.already_seen_change_id
&& let Some(last_seg) = result.segments.last()
{
adjacency_list.insert(last_seg.change_id.clone(), seen_id.clone());
}
for seg in result.segments {
segments.insert(seg.change_id.clone(), seg);
}
}
async fn traverse_and_discover_segments<R: JjRunner>(
start_commit_id: &str,
jj: &Jj<R>,
fully_collected: &HashSet<String>,
tainted_change_ids: &mut HashSet<String>,
user_bookmark_names: &HashSet<String>,
) -> Result<TraversalResult, StakkError> {
let mut segments: Vec<BookmarkSegment> = Vec::new();
let mut current_segment: Option<BookmarkSegment> = None;
let mut last_seen_commit: Option<String> = None;
let mut already_seen_change_id: Option<String> = None;
let mut seen_change_ids: Vec<String> = Vec::new();
'page_loop: loop {
let changes = jj
.get_branch_changes_paginated("trunk()", start_commit_id, last_seen_commit.as_deref())
.await?;
if changes.is_empty() {
break;
}
for change in &changes {
seen_change_ids.push(change.change_id.clone());
if change.parents.len() > 1 || tainted_change_ids.contains(&change.change_id) {
for id in &seen_change_ids {
tainted_change_ids.insert(id.clone());
}
return Ok(TraversalResult {
segments: Vec::new(),
already_seen_change_id: None,
excluded: true,
});
}
let user_bookmarks: Vec<String> = change
.local_bookmark_names
.iter()
.filter(|name| user_bookmark_names.contains(*name))
.cloned()
.collect();
if !user_bookmarks.is_empty() {
if let Some(seg) = current_segment.take() {
segments.push(seg);
}
if user_bookmarks
.iter()
.any(|name| fully_collected.contains(name))
{
already_seen_change_id = Some(change.change_id.clone());
break 'page_loop;
}
current_segment = Some(BookmarkSegment {
bookmark_names: user_bookmarks,
change_id: change.change_id.clone(),
commits: Vec::new(),
});
}
if current_segment.is_none() {
current_segment = Some(BookmarkSegment {
bookmark_names: vec![],
change_id: change.change_id.clone(),
commits: Vec::new(),
});
}
if let Some(ref mut seg) = current_segment {
seg.commits.push(SegmentCommit {
commit_id: change.commit_id.clone(),
change_id: change.change_id.clone(),
description: change.description.clone(),
author: change.author.clone(),
committer: change.committer.clone(),
short_change_id: change.short_change_id.clone(),
files: vec![],
is_immutable: change.immutable,
local_bookmark_names: change.local_bookmark_names.clone(),
remote_bookmark_names: change.remote_bookmark_names.clone(),
});
}
}
if changes.len() < 100 {
break; }
last_seen_commit = changes.last().map(|c| c.commit_id.clone());
}
if let Some(seg) = current_segment {
segments.push(seg);
}
Ok(TraversalResult {
segments,
already_seen_change_id,
excluded: false,
})
}
async fn fetch_file_lists<R: JjRunner>(
jj: &Jj<R>,
stacks: &mut [BranchStack],
) -> Result<(), StakkError> {
let mut tasks: Vec<(usize, usize, usize, String)> = Vec::new();
for (si, stack) in stacks.iter().enumerate() {
for (sgi, segment) in stack.segments.iter().enumerate() {
for (ci, commit) in segment.commits.iter().enumerate() {
if commit.files.is_empty() {
tasks.push((si, sgi, ci, commit.commit_id.clone()));
}
}
}
}
let futures: Vec<_> = tasks
.iter()
.map(|(_, _, _, commit_id)| jj.get_diff_files(commit_id))
.collect();
let results = futures::future::join_all(futures).await;
for ((si, sgi, ci, _), result) in tasks.iter().zip(results) {
stacks[*si].segments[*sgi].commits[*ci].files = result?;
}
Ok(())
}
fn group_segments_into_stacks(
stack_leaves: &HashSet<String>,
adjacency_list: &HashMap<String, String>,
segments: &HashMap<String, BookmarkSegment>,
) -> Vec<BranchStack> {
let mut stacks = Vec::new();
for leaf_id in stack_leaves {
let mut path = vec![leaf_id.clone()];
let mut current = leaf_id.clone();
while let Some(parent) = adjacency_list.get(¤t) {
path.push(parent.clone());
current = parent.clone();
}
path.reverse();
let stack_segments: Vec<BookmarkSegment> = path
.iter()
.filter_map(|id| segments.get(id).cloned())
.collect();
stacks.push(BranchStack {
segments: stack_segments,
});
}
stacks.sort_by(|a, b| {
let ts_a = collect_timestamps_desc(a);
let ts_b = collect_timestamps_desc(b);
ts_b.cmp(&ts_a).then_with(|| {
fn leaf_change_id(s: &BranchStack) -> &str {
s.segments
.last()
.map(|seg| seg.change_id.as_str())
.unwrap_or_default()
}
leaf_change_id(a).cmp(leaf_change_id(b))
})
});
stacks
}
fn collect_timestamps_desc(stack: &BranchStack) -> Vec<Option<Timestamp>> {
let mut timestamps: Vec<Option<Timestamp>> = stack
.segments
.iter()
.flat_map(|seg| seg.commits.iter())
.map(|c| c.committer.timestamp.parse().ok())
.collect();
timestamps.sort_unstable_by(|a, b| b.cmp(a));
timestamps
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jj::JjError;
use crate::jj::runner::JjRunner;
struct MockJjRunner<F: Fn(&[&str]) -> Result<String, JjError> + Send + Sync> {
handler: F,
}
impl<F> JjRunner for MockJjRunner<F>
where
F: Fn(&[&str]) -> Result<String, JjError> + Send + Sync,
{
async fn run_jj(&self, args: &[&str]) -> Result<String, JjError> {
(self.handler)(args)
}
}
fn bookmark_json(name: &str, commit_id: &str, change_id: &str) -> String {
format!(
r#"{{"name":"{name}","synced":false,"target":{{"commit_id":"{commit_id}","parents":[],"change_id":"{change_id}","description":"","author":{{"name":"T","email":"t@t.t","timestamp":"T"}},"committer":{{"name":"T","email":"t@t.t","timestamp":"T"}}}}}}"#,
)
}
fn log_entry_json(
commit_id: &str,
change_id: &str,
parents: &[&str],
local_bookmarks: &[&str],
) -> String {
log_entry_json_full(commit_id, change_id, parents, local_bookmarks, false)
}
fn log_entry_json_full(
commit_id: &str,
change_id: &str,
parents: &[&str],
local_bookmarks: &[&str],
immutable: bool,
) -> String {
let parents_json: Vec<String> = parents.iter().map(|p| format!("\"{p}\"")).collect();
let parents_str = parents_json.join(",");
let bookmarks_json: Vec<String> = local_bookmarks
.iter()
.map(|b| format!(r#"{{"name":"{b}","target":["{commit_id}"]}}"#))
.collect();
let bookmarks_str = bookmarks_json.join(",");
let short = &change_id[..4.min(change_id.len())];
format!(
r#"{{"commit":{{"commit_id":"{commit_id}","parents":[{parents_str}],"change_id":"{change_id}","description":"desc {commit_id}","author":{{"name":"T","email":"t@t.t","timestamp":"T"}},"committer":{{"name":"T","email":"t@t.t","timestamp":"T"}}}},"local_bookmarks":[{bookmarks_str}],"remote_bookmarks":[],"immutable":{immutable},"short_change_id":"{short}"}}"#,
)
}
#[tokio::test]
async fn linear_stack() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
let lines = [
bookmark_json("bm_b", "c_b", "ch_b"),
bookmark_json("bm_a", "c_a", "ch_a"),
];
return Ok(lines.join("\n"));
}
let revset = args[2];
if revset.contains("c_b") {
let lines = [
log_entry_json("c_b", "ch_b", &["c_a"], &["bm_b"]),
log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 2);
assert_eq!(graph.stacks.len(), 1);
assert_eq!(graph.stack_leaves.len(), 1);
assert!(graph.stack_leaves.contains("ch_b"));
assert_eq!(graph.adjacency_list.get("ch_b").unwrap(), "ch_a");
let stack = &graph.stacks[0];
assert_eq!(stack.segments.len(), 2);
assert_eq!(stack.segments[0].bookmark_names, vec!["bm_a"]);
assert_eq!(stack.segments[1].bookmark_names, vec!["bm_b"]);
}
#[tokio::test]
async fn branching_shared_root() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
let lines = [
bookmark_json("bm_b", "c_b", "ch_b"),
bookmark_json("bm_c", "c_c", "ch_c"),
bookmark_json("bm_a", "c_a", "ch_a"),
];
return Ok(lines.join("\n"));
}
let revset = args[2];
if revset.contains("c_b") {
let lines = [
log_entry_json("c_b", "ch_b", &["c_a"], &["bm_b"]),
log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]),
];
return Ok(lines.join("\n"));
}
if revset.contains("c_c") {
let lines = [
log_entry_json("c_c", "ch_c", &["c_a"], &["bm_c"]),
log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 3);
assert_eq!(graph.stacks.len(), 2);
assert!(graph.stack_leaves.contains("ch_b"));
assert!(graph.stack_leaves.contains("ch_c"));
assert_eq!(graph.adjacency_list.get("ch_b").unwrap(), "ch_a");
assert_eq!(graph.adjacency_list.get("ch_c").unwrap(), "ch_a");
for stack in &graph.stacks {
assert_eq!(stack.segments[0].bookmark_names, vec!["bm_a"]);
assert_eq!(stack.segments.len(), 2);
}
}
#[tokio::test]
async fn merge_commit_excluded() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("bm_merge", "c_merge", "ch_merge"));
}
let revset = args[2];
if revset.contains("c_merge") {
return Ok(log_entry_json(
"c_merge",
"ch_merge",
&["parent_a", "parent_b"],
&["bm_merge"],
));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.stacks.len(), 0);
assert_eq!(graph.excluded_bookmarks, vec!["bm_merge"]);
assert!(graph.tainted_change_ids.contains("ch_merge"));
}
#[tokio::test]
async fn merge_taint_propagation() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
let lines = [
bookmark_json("bm_b", "c_b", "ch_b"),
bookmark_json("bm_a", "c_a", "ch_a"),
];
return Ok(lines.join("\n"));
}
let revset = args[2];
if revset.contains("c_b") {
let lines = [
log_entry_json("c_b", "ch_b", &["c_a"], &["bm_b"]),
log_entry_json("c_a", "ch_a", &["p1", "p2"], &["bm_a"]),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.stacks.len(), 0);
assert_eq!(graph.excluded_bookmarks, vec!["bm_b"]);
assert!(graph.tainted_change_ids.contains("ch_a"));
assert!(graph.tainted_change_ids.contains("ch_b"));
}
#[tokio::test]
async fn taint_from_previous_traversal() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
let lines = [
bookmark_json("bm_merge", "c_merge", "ch_merge"),
bookmark_json("bm_child", "c_child", "ch_child"),
];
return Ok(lines.join("\n"));
}
let revset = args[2];
if revset.contains("c_merge") {
return Ok(log_entry_json(
"c_merge",
"ch_merge",
&["p1", "p2"],
&["bm_merge"],
));
}
if revset.contains("c_child") {
let lines = [
log_entry_json("c_child", "ch_child", &["c_merge"], &["bm_child"]),
log_entry_json("c_merge", "ch_merge", &["p1", "p2"], &["bm_merge"]),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.stacks.len(), 0);
assert_eq!(graph.excluded_bookmarks, vec!["bm_merge", "bm_child"]);
assert!(graph.tainted_change_ids.contains("ch_merge"));
assert!(graph.tainted_change_ids.contains("ch_child"));
}
#[tokio::test]
async fn multiple_bookmarks_same_change() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
let lines = [
bookmark_json("bm_a", "c_x", "ch_x"),
bookmark_json("bm_b", "c_x", "ch_x"),
];
return Ok(lines.join("\n"));
}
let revset = args[2];
if revset.contains("c_x") {
return Ok(log_entry_json(
"c_x",
"ch_x",
&["trunk_c"],
&["bm_a", "bm_b"],
));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 1);
assert_eq!(graph.stacks.len(), 1);
let seg = graph.segments.get("ch_x").unwrap();
assert_eq!(seg.bookmark_names.len(), 2);
assert!(seg.bookmark_names.contains(&"bm_a".to_string()));
assert!(seg.bookmark_names.contains(&"bm_b".to_string()));
}
#[tokio::test]
async fn no_bookmarks() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(String::new());
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert!(graph.segments.is_empty());
assert!(graph.stacks.is_empty());
assert!(graph.stack_leaves.is_empty());
assert!(graph.excluded_bookmarks.is_empty());
assert_eq!(graph.excluded_head_count, 0);
}
#[tokio::test]
async fn multi_commit_segment() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
let lines = [
bookmark_json("bm_b", "c4", "ch_b"),
bookmark_json("bm_a", "c2", "ch_a"),
];
return Ok(lines.join("\n"));
}
let revset = args[2];
if revset.contains("c4") {
let lines = [
log_entry_json("c4", "ch_b", &["c3"], &["bm_b"]),
log_entry_json("c3", "ch_3", &["c2"], &[]),
log_entry_json("c2", "ch_a", &["c1"], &["bm_a"]),
log_entry_json("c1", "ch_1", &["trunk_c"], &[]),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 2);
assert_eq!(graph.stacks.len(), 1);
let seg_b = graph.segments.get("ch_b").unwrap();
assert_eq!(seg_b.commits.len(), 2);
assert_eq!(seg_b.commits[0].commit_id, "c4");
assert_eq!(seg_b.commits[1].commit_id, "c3");
let seg_a = graph.segments.get("ch_a").unwrap();
assert_eq!(seg_a.commits.len(), 2);
assert_eq!(seg_a.commits[0].commit_id, "c2");
assert_eq!(seg_a.commits[1].commit_id, "c1");
let stack = &graph.stacks[0];
assert_eq!(stack.segments[0].change_id, "ch_a");
assert_eq!(stack.segments[1].change_id, "ch_b");
}
#[tokio::test]
async fn already_collected_early_stop() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
let lines = [
bookmark_json("bm_b", "c_b", "ch_b"),
bookmark_json("bm_c", "c_c", "ch_c"),
bookmark_json("bm_a", "c_a", "ch_a"),
];
return Ok(lines.join("\n"));
}
let revset = args[2];
if revset.contains("c_b") {
let lines = [
log_entry_json("c_b", "ch_b", &["c_a"], &["bm_b"]),
log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]),
];
return Ok(lines.join("\n"));
}
if revset.contains("c_c") {
let lines = [
log_entry_json("c_c", "ch_c", &["c_a"], &["bm_c"]),
log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]),
];
return Ok(lines.join("\n"));
}
if is_heads_query(args) {
return Ok(String::new());
}
panic!("unexpected revset: {revset}");
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 3);
assert_eq!(graph.stacks.len(), 2);
assert_eq!(graph.adjacency_list.get("ch_b").unwrap(), "ch_a");
assert_eq!(graph.adjacency_list.get("ch_c").unwrap(), "ch_a");
}
#[tokio::test]
async fn single_bookmark_single_commit() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("bm_x", "c_x", "ch_x"));
}
let revset = args[2];
if revset.contains("c_x") {
return Ok(log_entry_json("c_x", "ch_x", &["trunk_c"], &["bm_x"]));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 1);
assert_eq!(graph.stacks.len(), 1);
assert!(graph.stack_leaves.contains("ch_x"));
assert!(graph.adjacency_list.is_empty());
let stack = &graph.stacks[0];
assert_eq!(stack.segments.len(), 1);
assert_eq!(stack.segments[0].bookmark_names, vec!["bm_x"]);
assert_eq!(stack.segments[0].commits.len(), 1);
assert_eq!(stack.segments[0].commits[0].commit_id, "c_x");
}
#[tokio::test]
async fn segment_commit_metadata() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("feat", "c1", "ch1"));
}
let revset = args[2];
if revset.contains("c1") {
return Ok(log_entry_json("c1", "ch1", &["trunk_c"], &["feat"]));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
let seg = graph.segments.get("ch1").unwrap();
assert_eq!(seg.commits[0].commit_id, "c1");
assert_eq!(seg.commits[0].change_id, "ch1");
assert_eq!(seg.commits[0].description, "desc c1");
assert_eq!(seg.commits[0].author.name, "T");
}
#[test]
fn stacks_are_ordered_by_timestamp() {
use crate::jj::types::Signature;
let mut segments = HashMap::new();
let adjacency_list = HashMap::new();
let mut stack_leaves = HashSet::new();
let test_sig = |ts: &str| Signature {
name: "T".to_string(),
email: "t@t.t".to_string(),
timestamp: ts.to_string(),
};
for (id, ts) in [
("z_leaf", "2026-03-01T00:00:00Z"),
("a_leaf", "2026-03-03T00:00:00Z"),
("m_leaf", "2026-03-02T00:00:00Z"),
] {
segments.insert(
id.to_string(),
BookmarkSegment {
bookmark_names: vec![id.to_string()],
change_id: id.to_string(),
commits: vec![SegmentCommit {
commit_id: format!("c_{id}"),
change_id: id.to_string(),
description: String::new(),
author: test_sig(ts),
committer: test_sig(ts),
short_change_id: id[..4].to_string(),
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
remote_bookmark_names: vec![],
}],
},
);
stack_leaves.insert(id.to_string());
}
let stacks = group_segments_into_stacks(&stack_leaves, &adjacency_list, &segments);
assert_eq!(stacks.len(), 3);
assert_eq!(stacks[0].segments[0].change_id, "a_leaf"); assert_eq!(stacks[1].segments[0].change_id, "m_leaf"); assert_eq!(stacks[2].segments[0].change_id, "z_leaf"); }
#[test]
fn stacks_ordered_by_offset_aware_timestamps() {
use crate::jj::types::Signature;
let mut segments = HashMap::new();
let adjacency_list = HashMap::new();
let mut stack_leaves = HashSet::new();
let test_sig = |ts: &str| Signature {
name: "T".to_string(),
email: "t@t.t".to_string(),
timestamp: ts.to_string(),
};
for (id, ts) in [
("utc", "2026-01-01T12:00:00+00:00"),
("offset", "2026-01-01T12:30:00+02:00"),
] {
segments.insert(
id.to_string(),
BookmarkSegment {
bookmark_names: vec![id.to_string()],
change_id: id.to_string(),
commits: vec![SegmentCommit {
commit_id: format!("c_{id}"),
change_id: id.to_string(),
description: String::new(),
author: test_sig(ts),
committer: test_sig(ts),
short_change_id: id[..3].to_string(),
files: vec![],
is_immutable: false,
local_bookmark_names: vec![],
remote_bookmark_names: vec![],
}],
},
);
stack_leaves.insert(id.to_string());
}
let stacks = group_segments_into_stacks(&stack_leaves, &adjacency_list, &segments);
assert_eq!(stacks.len(), 2);
assert_eq!(stacks[0].segments[0].change_id, "utc");
assert_eq!(stacks[1].segments[0].change_id, "offset");
}
#[tokio::test]
async fn non_user_bookmarks_filtered_from_segment() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("bm_user", "c_x", "ch_x"));
}
let revset = args[2];
if revset.contains("c_x") {
return Ok(log_entry_json(
"c_x",
"ch_x",
&["trunk_c"],
&["bm_user", "bm_other"],
));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 1);
let seg = graph.segments.get("ch_x").unwrap();
assert_eq!(seg.bookmark_names, vec!["bm_user"]);
}
#[tokio::test]
async fn immutable_flag_and_local_bookmarks_threaded() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("bm_leaf", "c_leaf", "ch_leaf"));
}
let revset = args[2];
if revset.contains("c_leaf") {
let lines = [
log_entry_json("c_leaf", "ch_leaf", &["c_mid"], &["bm_leaf"]),
log_entry_json_full("c_mid", "ch_mid", &["trunk_c"], &["bm_pinned"], true),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 1);
let seg = graph.segments.get("ch_leaf").unwrap();
assert_eq!(seg.bookmark_names, vec!["bm_leaf"]);
assert_eq!(seg.commits.len(), 2);
let leaf_commit = &seg.commits[0];
assert!(!leaf_commit.is_immutable);
assert_eq!(leaf_commit.local_bookmark_names, vec!["bm_leaf"]);
let mid_commit = &seg.commits[1];
assert!(mid_commit.is_immutable);
assert_eq!(mid_commit.local_bookmark_names, vec!["bm_pinned"]);
}
#[tokio::test]
async fn only_non_user_bookmarks_no_segment_boundary() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("bm_user", "c_user", "ch_user"));
}
let revset = args[2];
if revset.contains("c_user") {
let lines = [
log_entry_json("c_user", "ch_user", &["c_other"], &["bm_user"]),
log_entry_json("c_other", "ch_other", &["trunk_c"], &["bm_other"]),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 1);
assert_eq!(graph.stacks.len(), 1);
let seg = graph.segments.get("ch_user").unwrap();
assert_eq!(seg.bookmark_names, vec!["bm_user"]);
assert_eq!(seg.commits.len(), 2);
assert_eq!(seg.commits[0].commit_id, "c_user");
assert_eq!(seg.commits[1].commit_id, "c_other");
}
fn is_heads_query(args: &[&str]) -> bool {
args[0] == "log" && args[2].contains("heads(")
}
#[tokio::test]
async fn unbookmarked_head_discovered() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("bm_a", "c_a", "ch_a"));
}
if is_heads_query(args) {
return Ok(log_entry_json("c_h", "ch_h", &["c_a"], &[]));
}
let revset = args[2];
if revset.contains("c_a") {
return Ok(log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]));
}
if revset.contains("c_h") {
let lines = [
log_entry_json("c_h", "ch_h", &["c_a"], &[]),
log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 2);
assert!(graph.segments.contains_key("ch_a"));
assert!(graph.segments.contains_key("ch_h"));
let head_seg = graph.segments.get("ch_h").unwrap();
assert!(head_seg.bookmark_names.is_empty());
assert_eq!(head_seg.commits.len(), 1);
assert_eq!(head_seg.commits[0].commit_id, "c_h");
assert_eq!(graph.adjacency_list.get("ch_h").unwrap(), "ch_a");
assert_eq!(graph.stacks.len(), 1);
let stack = &graph.stacks[0];
assert_eq!(stack.segments.len(), 2);
assert_eq!(stack.segments[0].change_id, "ch_a");
assert_eq!(stack.segments[1].change_id, "ch_h");
}
#[tokio::test]
async fn unbookmarked_head_at_bookmarked_commit_skipped() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("bm_a", "c_a", "ch_a"));
}
if is_heads_query(args) {
return Ok(log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]));
}
let revset = args[2];
if revset.contains("c_a") {
return Ok(log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 1);
assert_eq!(graph.stacks.len(), 1);
assert!(graph.segments.contains_key("ch_a"));
}
#[tokio::test]
async fn multiple_unbookmarked_heads() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("bm_a", "c_a", "ch_a"));
}
if is_heads_query(args) {
let lines = [
log_entry_json("c_h1", "ch_h1", &["c_a"], &[]),
log_entry_json("c_h2", "ch_h2", &["c_a"], &[]),
];
return Ok(lines.join("\n"));
}
let revset = args[2];
if revset.contains("c_a") {
return Ok(log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]));
}
if revset.contains("c_h1") {
let lines = [
log_entry_json("c_h1", "ch_h1", &["c_a"], &[]),
log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]),
];
return Ok(lines.join("\n"));
}
if revset.contains("c_h2") {
let lines = [
log_entry_json("c_h2", "ch_h2", &["c_a"], &[]),
log_entry_json("c_a", "ch_a", &["trunk_c"], &["bm_a"]),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 3);
assert_eq!(graph.stacks.len(), 2);
assert_eq!(graph.adjacency_list.get("ch_h1").unwrap(), "ch_a");
assert_eq!(graph.adjacency_list.get("ch_h2").unwrap(), "ch_a");
assert!(
graph
.segments
.get("ch_h1")
.unwrap()
.bookmark_names
.is_empty()
);
assert!(
graph
.segments
.get("ch_h2")
.unwrap()
.bookmark_names
.is_empty()
);
}
#[tokio::test]
async fn unbookmarked_head_with_bookmarked_ancestor() {
let runner = MockJjRunner {
handler: |args: &[&str]| {
if args[0] == "diff" {
return Ok(String::new());
}
if args[0] == "bookmark" {
return Ok(bookmark_json("bm_mid", "c_mid", "ch_mid"));
}
if is_heads_query(args) {
return Ok(log_entry_json("c_head", "ch_head", &["c_mid"], &[]));
}
let revset = args[2];
if revset.contains("c_mid") {
return Ok(log_entry_json("c_mid", "ch_mid", &["trunk_c"], &["bm_mid"]));
}
if revset.contains("c_head") {
let lines = [
log_entry_json("c_head", "ch_head", &["c_mid"], &[]),
log_entry_json("c_mid", "ch_mid", &["trunk_c"], &["bm_mid"]),
];
return Ok(lines.join("\n"));
}
Ok(String::new())
},
};
let jj = Jj::new(runner);
let graph = build_change_graph(
&jj,
"mine() ~ trunk() ~ immutable()",
"heads((mine() ~ empty() ~ immutable()) & trunk()..)",
)
.await
.unwrap();
assert_eq!(graph.segments.len(), 2);
assert_eq!(graph.stacks.len(), 1);
let head_seg = graph.segments.get("ch_head").unwrap();
assert!(head_seg.bookmark_names.is_empty());
let mid_seg = graph.segments.get("ch_mid").unwrap();
assert_eq!(mid_seg.bookmark_names, vec!["bm_mid"]);
assert_eq!(graph.adjacency_list.get("ch_head").unwrap(), "ch_mid");
let stack = &graph.stacks[0];
assert_eq!(stack.segments.len(), 2);
assert_eq!(stack.segments[0].change_id, "ch_mid");
assert_eq!(stack.segments[1].change_id, "ch_head");
}
fn bookmark(name: &str, synced: bool) -> Bookmark {
Bookmark {
name: name.to_string(),
commit_id: format!("c_{name}"),
change_id: format!("ch_{name}"),
synced,
}
}
fn stack_with(name: &str, remotes: &[&str]) -> BranchStack {
BranchStack {
segments: vec![BookmarkSegment {
bookmark_names: vec![name.to_string()],
change_id: format!("ch_{name}"),
commits: vec![SegmentCommit {
commit_id: format!("c_{name}"),
change_id: format!("ch_{name}"),
description: String::new(),
author: test_signature(),
committer: test_signature(),
short_change_id: "ch".to_string(),
files: vec![],
is_immutable: false,
local_bookmark_names: vec![name.to_string()],
remote_bookmark_names: remotes.iter().map(ToString::to_string).collect(),
}],
}],
}
}
fn test_signature() -> crate::jj::types::Signature {
crate::jj::types::Signature {
name: "T".to_string(),
email: "t@t.t".to_string(),
timestamp: "2026-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn synced_alone_does_not_separate_unpushed_from_synced() {
let states = derive_remote_states(
&[bookmark("fresh", true), bookmark("live", true)],
&[
stack_with("fresh", &[]),
stack_with("live", &["live@origin"]),
],
);
assert_eq!(states["fresh"], RemoteState::Unpushed);
assert_eq!(states["live"], RemoteState::Synced);
}
#[test]
fn an_out_of_date_remote_is_diverged() {
let states = derive_remote_states(&[bookmark("moved", false)], &[stack_with("moved", &[])]);
assert_eq!(states["moved"], RemoteState::Diverged);
}
#[test]
fn the_internal_git_remote_does_not_count_as_pushed() {
let states = derive_remote_states(
&[bookmark("local", true)],
&[stack_with("local", &["local@git"])],
);
assert_eq!(states["local"], RemoteState::Unpushed);
}
#[test]
fn a_longer_bookmark_name_is_not_a_match() {
let states = derive_remote_states(
&[bookmark("feat", true)],
&[stack_with("feat", &["feat-2@origin"])],
);
assert_eq!(states["feat"], RemoteState::Unpushed);
}
#[test]
fn an_unbookmarked_segment_contributes_nothing() {
let mut stack = stack_with("head", &[]);
stack.segments[0].bookmark_names.clear();
assert!(derive_remote_states(&[], &[stack]).is_empty());
}
}