use std::collections::{HashMap, HashSet};
use anyhow::Result;
use crate::jj::types::{Bookmark, BookmarkSegment, BranchStack, LogEntry};
use crate::jj::Jj;
use super::traversal;
#[derive(Debug, Clone)]
pub struct ChangeGraph {
pub bookmarks: HashMap<String, Bookmark>,
pub bookmark_to_change_id: HashMap<String, String>,
pub adjacency_list: HashMap<String, String>,
pub change_id_to_segment: HashMap<String, Vec<LogEntry>>,
pub stack_leafs: HashSet<String>,
pub stack_roots: HashSet<String>,
pub stacks: Vec<BranchStack>,
}
pub fn build_change_graph(jj: &dyn Jj) -> Result<ChangeGraph> {
let bookmarks = jj.get_my_bookmarks()?;
let mut all_bookmarks: HashMap<String, Bookmark> = HashMap::new();
let mut bookmark_to_change_id: HashMap<String, String> = HashMap::new();
let mut adjacency_list: HashMap<String, String> = HashMap::new();
let mut change_id_to_segment: HashMap<String, Vec<LogEntry>> = HashMap::new();
let mut fully_collected: HashSet<String> = HashSet::new();
let mut foreign_bases: HashMap<String, String> = HashMap::new();
let mut merge_source_map: HashMap<String, Vec<String>> = HashMap::new();
for bookmark in &bookmarks {
all_bookmarks.insert(bookmark.name.clone(), bookmark.clone());
bookmark_to_change_id.insert(bookmark.name.clone(), bookmark.change_id.clone());
}
for bookmark in &bookmarks {
let result = traversal::traverse_and_discover_segments(
jj,
&bookmark.commit_id,
&fully_collected,
&all_bookmarks,
)?;
let mut prev_change_id: Option<String> = None;
for segment in &result.segments {
if let Some(first_change) = segment.changes.first() {
let segment_change_id = segment
.bookmarks
.first()
.map(|b| b.change_id.clone())
.unwrap_or_else(|| first_change.change_id.clone());
change_id_to_segment
.insert(segment_change_id.clone(), segment.changes.clone());
if !segment.merge_source_names.is_empty() {
merge_source_map.insert(
segment_change_id.clone(),
segment.merge_source_names.clone(),
);
}
if let Some(prev) = &prev_change_id {
adjacency_list.insert(prev.clone(), segment_change_id.clone());
}
prev_change_id = Some(segment_change_id.clone());
fully_collected.insert(segment_change_id);
}
}
if let (Some(last), Some(stopped)) = (&prev_change_id, &result.stopped_at) {
adjacency_list.insert(last.clone(), stopped.clone());
}
if let (Some(root), Some(base)) = (&prev_change_id, &result.foreign_base) {
foreign_bases.insert(root.clone(), base.clone());
}
for change_id in result.seen_change_ids {
fully_collected.insert(change_id);
}
}
let parents: HashSet<&String> = adjacency_list.values().collect();
let children: HashSet<&String> = adjacency_list.keys().collect();
let stack_leafs: HashSet<String> = children
.iter()
.filter(|id| !parents.contains(*id))
.map(|id| id.to_string())
.chain(
bookmarks
.iter()
.filter(|b| {
!adjacency_list.contains_key(&b.change_id)
&& !parents.contains(&b.change_id)
})
.map(|b| b.change_id.clone()),
)
.collect();
let stack_roots: HashSet<String> = parents
.iter()
.filter(|id| !children.contains(*id))
.map(|id| id.to_string())
.collect();
let stacks = build_stacks(
&stack_leafs,
&adjacency_list,
&change_id_to_segment,
&all_bookmarks,
&foreign_bases,
&merge_source_map,
);
Ok(ChangeGraph {
bookmarks: all_bookmarks,
bookmark_to_change_id,
adjacency_list,
change_id_to_segment,
stack_leafs,
stack_roots,
stacks,
})
}
fn build_stacks(
leafs: &HashSet<String>,
adjacency_list: &HashMap<String, String>,
change_id_to_segment: &HashMap<String, Vec<LogEntry>>,
bookmarks: &HashMap<String, Bookmark>,
foreign_bases: &HashMap<String, String>,
merge_source_map: &HashMap<String, Vec<String>>,
) -> Vec<BranchStack> {
let mut parent_to_child: HashMap<&String, &String> = HashMap::new();
for (child, parent) in adjacency_list {
parent_to_child.insert(parent, child);
}
let mut stacks = Vec::new();
let mut sorted_leafs: Vec<&String> = leafs.iter().collect();
sorted_leafs.sort();
for leaf in sorted_leafs {
let mut path = vec![leaf.clone()];
let mut current = leaf;
while let Some(parent) = adjacency_list.get(current) {
path.push(parent.clone());
current = parent;
}
path.reverse();
let segments: Vec<BookmarkSegment> = path
.iter()
.filter_map(|change_id| {
let changes = change_id_to_segment.get(change_id)?.clone();
let mut segment_bookmarks: Vec<Bookmark> = bookmarks
.values()
.filter(|b| b.change_id == *change_id)
.cloned()
.collect();
segment_bookmarks.sort_by(|a, b| a.name.cmp(&b.name));
if segment_bookmarks.is_empty() {
return None;
}
Some(BookmarkSegment {
bookmarks: segment_bookmarks,
changes,
merge_source_names: merge_source_map
.get(change_id)
.cloned()
.unwrap_or_default(),
})
})
.collect();
if !segments.is_empty() {
let base_branch = path.first().and_then(|root| foreign_bases.get(root)).cloned();
stacks.push(BranchStack { segments, base_branch });
}
}
stacks
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jj::types::GitRemote;
use crate::jj::Jj;
struct StubJj {
bookmarks: Vec<Bookmark>,
log_entries: HashMap<String, Vec<LogEntry>>,
}
impl Jj for StubJj {
fn git_fetch(&self) -> Result<()> {
Ok(())
}
fn get_my_bookmarks(&self) -> Result<Vec<Bookmark>> {
Ok(self.bookmarks.clone())
}
fn get_changes_to_commit(&self, to_commit_id: &str) -> Result<Vec<LogEntry>> {
Ok(self
.log_entries
.get(to_commit_id)
.cloned()
.unwrap_or_default())
}
fn get_git_remotes(&self) -> Result<Vec<GitRemote>> {
Ok(vec![])
}
fn get_default_branch(&self) -> Result<String> {
Ok("main".to_string())
}
fn push_bookmark(&self, _name: &str, _remote: &str) -> Result<()> {
Ok(())
}
fn get_working_copy_commit_id(&self) -> Result<String> {
Ok("wc_commit".to_string())
}
fn rebase_onto(&self, _source: &str, _dest: &str) -> Result<()> { unimplemented!() }
}
fn make_log_entry(
commit_id: &str,
change_id: &str,
parents: Vec<&str>,
bookmarks: Vec<&str>,
) -> LogEntry {
LogEntry {
commit_id: commit_id.to_string(),
change_id: change_id.to_string(),
author_name: "Test".to_string(),
author_email: "test@test.com".to_string(),
description: "test".to_string(),
description_first_line: "test".to_string(),
parents: parents.into_iter().map(|s| s.to_string()).collect(),
local_bookmarks: bookmarks.into_iter().map(|s| s.to_string()).collect(),
remote_bookmarks: vec![],
is_working_copy: false,
}
}
fn make_bookmark(name: &str, commit_id: &str, change_id: &str) -> Bookmark {
Bookmark {
name: name.to_string(),
commit_id: commit_id.to_string(),
change_id: change_id.to_string(),
has_remote: false,
is_synced: false,
}
}
#[test]
fn test_empty_repo() {
let jj = StubJj {
bookmarks: vec![],
log_entries: HashMap::new(),
};
let graph = build_change_graph(&jj).unwrap();
assert!(graph.stacks.is_empty());
assert!(graph.bookmarks.is_empty());
}
#[test]
fn test_single_bookmark_linear_stack() {
let jj = StubJj {
bookmarks: vec![make_bookmark("feature", "commit_a", "change_a")],
log_entries: HashMap::from([(
"commit_a".to_string(),
vec![make_log_entry(
"commit_a",
"change_a",
vec!["trunk"],
vec!["feature"],
)],
)]),
};
let graph = build_change_graph(&jj).unwrap();
assert_eq!(graph.bookmarks.len(), 1);
assert!(graph.bookmarks.contains_key("feature"));
}
#[test]
fn test_multi_bookmark_stack() {
let jj = StubJj {
bookmarks: vec![
make_bookmark("auth", "commit_a", "change_a"),
make_bookmark("profile", "commit_b", "change_b"),
],
log_entries: HashMap::from([
(
"commit_a".to_string(),
vec![make_log_entry(
"commit_a",
"change_a",
vec!["trunk"],
vec!["auth"],
)],
),
(
"commit_b".to_string(),
vec![
make_log_entry("commit_b", "change_b", vec!["commit_a"], vec!["profile"]),
make_log_entry("commit_a", "change_a", vec!["trunk"], vec!["auth"]),
],
),
]),
};
let graph = build_change_graph(&jj).unwrap();
assert_eq!(graph.bookmarks.len(), 2);
assert!(!graph.stacks.is_empty());
let stack = &graph.stacks[0];
assert_eq!(stack.segments.len(), 2);
assert_eq!(stack.segments[0].bookmarks[0].name, "auth");
assert_eq!(stack.segments[1].bookmarks[0].name, "profile");
}
#[test]
fn test_merge_commit_included_in_stack() {
let jj = StubJj {
bookmarks: vec![make_bookmark("feature", "commit_a", "change_a")],
log_entries: HashMap::from([(
"commit_a".to_string(),
vec![make_log_entry(
"commit_a",
"change_a",
vec!["p1", "p2"],
vec!["feature"],
)],
)]),
};
let graph = build_change_graph(&jj).unwrap();
assert_eq!(graph.stacks.len(), 1);
assert_eq!(graph.stacks[0].segments[0].bookmarks[0].name, "feature");
assert_eq!(graph.stacks[0].segments[0].merge_source_names, vec!["p2"]);
}
#[test]
fn test_two_independent_stacks() {
let jj = StubJj {
bookmarks: vec![
make_bookmark("alpha", "commit_a", "change_a"),
make_bookmark("beta", "commit_b", "change_b"),
],
log_entries: HashMap::from([
(
"commit_a".to_string(),
vec![make_log_entry(
"commit_a",
"change_a",
vec!["trunk"],
vec!["alpha"],
)],
),
(
"commit_b".to_string(),
vec![make_log_entry(
"commit_b",
"change_b",
vec!["trunk"],
vec!["beta"],
)],
),
]),
};
let graph = build_change_graph(&jj).unwrap();
assert_eq!(graph.bookmarks.len(), 2);
assert_eq!(graph.stacks.len(), 2);
}
fn make_log_entry_with_remote_bookmarks(
commit_id: &str,
change_id: &str,
parents: Vec<&str>,
bookmarks: Vec<&str>,
remote_bookmarks: Vec<&str>,
) -> LogEntry {
let mut e = make_log_entry(commit_id, change_id, parents, bookmarks);
e.remote_bookmarks = remote_bookmarks.into_iter().map(|s| s.to_string()).collect();
e
}
#[test]
fn test_stack_with_foreign_base() {
let jj = StubJj {
bookmarks: vec![make_bookmark("feature", "commit_a", "change_a")],
log_entries: HashMap::from([(
"commit_a".to_string(),
vec![
make_log_entry("commit_a", "change_a", vec!["coworker_c"], vec!["feature"]),
make_log_entry_with_remote_bookmarks(
"coworker_c", "coworker_ch", vec!["trunk"],
vec![], vec!["coworker-feat@origin"],
),
],
)]),
};
let graph = build_change_graph(&jj).unwrap();
assert_eq!(graph.stacks.len(), 1);
assert_eq!(
graph.stacks[0].base_branch,
Some("coworker-feat".to_string()),
);
assert_eq!(graph.stacks[0].segments.len(), 1);
assert_eq!(graph.stacks[0].segments[0].bookmarks[0].name, "feature");
}
#[test]
fn test_stack_without_foreign_base() {
let jj = StubJj {
bookmarks: vec![make_bookmark("feature", "commit_a", "change_a")],
log_entries: HashMap::from([(
"commit_a".to_string(),
vec![make_log_entry(
"commit_a", "change_a", vec!["trunk"], vec!["feature"],
)],
)]),
};
let graph = build_change_graph(&jj).unwrap();
assert_eq!(graph.stacks.len(), 1);
assert!(graph.stacks[0].base_branch.is_none());
}
#[test]
fn test_multi_segment_stack_with_foreign_base() {
let jj = StubJj {
bookmarks: vec![
make_bookmark("auth", "commit_a", "change_a"),
make_bookmark("profile", "commit_b", "change_b"),
],
log_entries: HashMap::from([
(
"commit_a".to_string(),
vec![
make_log_entry("commit_a", "change_a", vec!["coworker_c"], vec!["auth"]),
make_log_entry_with_remote_bookmarks(
"coworker_c", "coworker_ch", vec!["trunk"],
vec![], vec!["coworker-feat@origin"],
),
],
),
(
"commit_b".to_string(),
vec![
make_log_entry("commit_b", "change_b", vec!["commit_a"], vec!["profile"]),
make_log_entry("commit_a", "change_a", vec!["coworker_c"], vec!["auth"]),
make_log_entry_with_remote_bookmarks(
"coworker_c", "coworker_ch", vec!["trunk"],
vec![], vec!["coworker-feat@origin"],
),
],
),
]),
};
let graph = build_change_graph(&jj).unwrap();
assert_eq!(graph.stacks.len(), 1);
assert_eq!(
graph.stacks[0].base_branch,
Some("coworker-feat".to_string()),
"multi-segment stack should propagate foreign base"
);
assert_eq!(graph.stacks[0].segments.len(), 2);
assert_eq!(graph.stacks[0].segments[0].bookmarks[0].name, "auth");
assert_eq!(graph.stacks[0].segments[1].bookmarks[0].name, "profile");
}
#[test]
fn test_diamond_merge_included_in_stack() {
let jj = StubJj {
bookmarks: vec![
make_bookmark("B", "commit_b", "change_b"),
make_bookmark("C", "commit_c", "change_c"),
make_bookmark("D", "commit_d", "change_d"),
],
log_entries: HashMap::from([
(
"commit_b".to_string(),
vec![make_log_entry("commit_b", "change_b", vec!["trunk"], vec!["B"])],
),
(
"commit_c".to_string(),
vec![make_log_entry("commit_c", "change_c", vec!["trunk"], vec!["C"])],
),
(
"commit_d".to_string(),
vec![
make_log_entry("commit_d", "change_d", vec!["commit_b", "commit_c"], vec!["D"]),
make_log_entry("commit_b", "change_b", vec!["trunk"], vec!["B"]),
make_log_entry("commit_c", "change_c", vec!["trunk"], vec!["C"]),
],
),
]),
};
let graph = build_change_graph(&jj).unwrap();
let all_stack_names: HashSet<String> = graph
.stacks
.iter()
.flat_map(|s| s.segments.iter().flat_map(|seg| seg.bookmarks.iter().map(|b| b.name.clone())))
.collect();
assert!(all_stack_names.contains("B"));
assert!(all_stack_names.contains("C"));
assert!(all_stack_names.contains("D"));
}
#[test]
fn test_merge_skipped_arm_forms_own_stack() {
let jj = StubJj {
bookmarks: vec![
make_bookmark("D", "commit_d", "change_d"),
make_bookmark("B", "commit_b", "change_b"),
make_bookmark("C", "commit_c", "change_c"),
],
log_entries: HashMap::from([
(
"commit_d".to_string(),
vec![
make_log_entry("commit_d", "change_d", vec!["commit_b", "commit_c"], vec!["D"]),
make_log_entry("commit_b", "change_b", vec!["trunk"], vec!["B"]),
make_log_entry("commit_c", "change_c", vec!["trunk"], vec!["C"]),
],
),
(
"commit_b".to_string(),
vec![make_log_entry("commit_b", "change_b", vec!["trunk"], vec!["B"])],
),
(
"commit_c".to_string(),
vec![make_log_entry("commit_c", "change_c", vec!["trunk"], vec!["C"])],
),
]),
};
let graph = build_change_graph(&jj).unwrap();
let c_stack = graph.stacks.iter().find(|s|
s.segments.iter().any(|seg| seg.bookmarks.iter().any(|b| b.name == "C"))
);
assert!(c_stack.is_some(), "C should have its own stack");
}
#[test]
fn test_merge_source_names_on_segment() {
let jj = StubJj {
bookmarks: vec![
make_bookmark("D", "commit_d", "change_d"),
make_bookmark("B", "commit_b", "change_b"),
make_bookmark("C", "commit_c", "change_c"),
],
log_entries: HashMap::from([
(
"commit_d".to_string(),
vec![
make_log_entry("commit_d", "change_d", vec!["commit_b", "commit_c"], vec!["D"]),
make_log_entry("commit_b", "change_b", vec!["trunk"], vec!["B"]),
make_log_entry("commit_c", "change_c", vec!["trunk"], vec!["C"]),
],
),
(
"commit_b".to_string(),
vec![make_log_entry("commit_b", "change_b", vec!["trunk"], vec!["B"])],
),
(
"commit_c".to_string(),
vec![make_log_entry("commit_c", "change_c", vec!["trunk"], vec!["C"])],
),
]),
};
let graph = build_change_graph(&jj).unwrap();
let d_segment = graph.stacks.iter()
.flat_map(|s| &s.segments)
.find(|seg| seg.bookmarks.iter().any(|b| b.name == "D"))
.expect("D should be in a stack");
assert_eq!(d_segment.merge_source_names, vec!["C"]);
}
#[test]
fn test_bookmark_above_merge_included() {
let jj = StubJj {
bookmarks: vec![make_bookmark("top", "commit_top", "change_top")],
log_entries: HashMap::from([(
"commit_top".to_string(),
vec![
make_log_entry("commit_top", "change_top", vec!["commit_m"], vec!["top"]),
make_log_entry("commit_m", "change_m", vec!["p1", "p2"], vec![]),
],
)]),
};
let graph = build_change_graph(&jj).unwrap();
assert_eq!(graph.stacks.len(), 1);
assert_eq!(graph.stacks[0].segments.len(), 1);
assert_eq!(graph.stacks[0].segments[0].bookmarks[0].name, "top");
}
}