use std::collections::HashMap;
use std::fmt::Write as _;
use serde::Serialize;
use crate::cli::GraphFormat;
use crate::graph::layout::CONNECTOR_TAIL;
use crate::graph::layout::CONNECTOR_TEE;
use crate::graph::layout::GUTTER_CELL;
use crate::graph::layout::GraphRow;
use crate::graph::layout::LayoutNode;
use crate::graph::layout::NODE_OTHER;
use crate::graph::layout::TRUNK_CHAR;
use crate::graph::layout::build_layout;
use crate::graph::types::BookmarkSegment;
use crate::graph::types::ChangeGraph;
use crate::graph::types::RemoteState;
use crate::jj::remote::parse_github_url;
use crate::jj::types::GitRemote;
const SCHEMA_VERSION: u32 = 2;
pub struct GraphData<'a> {
pub default_branch: &'a str,
pub remotes: &'a [GitRemote],
pub graph: &'a ChangeGraph,
pub github_host: Option<&'a str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsonProjection {
Sparse,
Full,
}
pub fn render(data: &GraphData, format: GraphFormat, colors: bool) -> String {
match json_projection(format) {
None => render_pretty(data, colors),
Some(projection) => render_json(data, projection),
}
}
fn json_projection(format: GraphFormat) -> Option<JsonProjection> {
match format {
GraphFormat::Pretty => None,
GraphFormat::Json => Some(JsonProjection::Sparse),
GraphFormat::JsonFull => Some(JsonProjection::Full),
}
}
#[derive(Serialize)]
struct GraphReport<'a> {
schema_version: u32,
default_branch: &'a str,
remotes: Vec<RemoteReport<'a>>,
excluded_bookmarks: &'a [String],
excluded_head_count: usize,
stacks: Vec<StackReport<'a>>,
}
#[derive(Serialize)]
struct RemoteReport<'a> {
name: &'a str,
url: &'a str,
github: Option<String>,
}
#[derive(Serialize)]
struct StackReport<'a> {
segments: Vec<SegmentReport<'a>>,
}
#[derive(Serialize)]
struct SegmentReport<'a> {
bookmarks: Vec<SegmentBookmarkReport<'a>>,
commits: Vec<CommitReport<'a>>,
}
#[derive(Serialize)]
struct SegmentBookmarkReport<'a> {
name: &'a str,
remote_state: &'static str,
}
#[derive(Serialize)]
struct CommitReport<'a> {
change_id: &'a str,
short_change_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
commit_id: Option<&'a str>,
title: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
author: Option<AuthorReport<'a>>,
committer_timestamp: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
files: Option<&'a [String]>,
is_immutable: bool,
local_bookmark_names: &'a [String],
is_boundary: bool,
is_leaf: bool,
}
#[derive(Serialize)]
struct AuthorReport<'a> {
name: &'a str,
email: &'a str,
timestamp: &'a str,
}
fn render_json(data: &GraphData, projection: JsonProjection) -> String {
let report = build_report(data, projection);
let mut out =
serde_json::to_string_pretty(&report).expect("GraphReport is always serializable");
out.push('\n');
out
}
fn build_report<'a>(data: &GraphData<'a>, projection: JsonProjection) -> GraphReport<'a> {
GraphReport {
schema_version: SCHEMA_VERSION,
default_branch: data.default_branch,
remotes: data
.remotes
.iter()
.map(|r| RemoteReport {
name: &r.name,
url: &r.url,
github: parse_github_url(&r.url, data.github_host).map(|g| g.to_string()),
})
.collect(),
excluded_bookmarks: &data.graph.excluded_bookmarks,
excluded_head_count: data.graph.excluded_head_count,
stacks: data
.graph
.stacks
.iter()
.map(|stack| StackReport {
segments: stack
.segments
.iter()
.enumerate()
.map(|(seg_idx, segment)| {
segment_report(
segment,
seg_idx == stack.segments.len() - 1,
&data.graph.bookmark_remote_states,
projection,
)
})
.collect(),
})
.collect(),
}
}
fn segment_report<'a>(
segment: &'a BookmarkSegment,
is_last_segment: bool,
remote_states: &HashMap<String, RemoteState>,
projection: JsonProjection,
) -> SegmentReport<'a> {
let full = projection == JsonProjection::Full;
let commit_count = segment.commits.len();
SegmentReport {
bookmarks: segment
.bookmark_names
.iter()
.map(|name| SegmentBookmarkReport {
name,
remote_state: remote_states
.get(name)
.copied()
.unwrap_or(RemoteState::Unpushed)
.as_str(),
})
.collect(),
commits: segment
.commits
.iter()
.rev()
.enumerate()
.map(|(idx, commit)| {
let is_boundary = idx == commit_count - 1;
CommitReport {
change_id: &commit.change_id,
short_change_id: &commit.short_change_id,
commit_id: full.then_some(commit.commit_id.as_str()),
title: commit.description.lines().next().unwrap_or(""),
description: full.then_some(commit.description.as_str()),
author: full.then(|| AuthorReport {
name: &commit.author.name,
email: &commit.author.email,
timestamp: &commit.author.timestamp,
}),
committer_timestamp: &commit.committer.timestamp,
files: full.then_some(commit.files.as_slice()),
is_immutable: commit.is_immutable,
local_bookmark_names: &commit.local_bookmark_names,
is_boundary,
is_leaf: is_boundary && is_last_segment,
}
})
.collect(),
}
}
fn render_pretty(data: &GraphData, colors: bool) -> String {
let mut out = String::new();
let _ = writeln!(out, "Default branch: {}", data.default_branch);
for remote in data.remotes {
let github = parse_github_url(&remote.url, data.github_host)
.map(|r| format!(" ({r})"))
.unwrap_or_default();
let _ = writeln!(out, "Remote: {} {}{}", remote.name, remote.url, github);
}
out.push('\n');
if data.graph.stacks.is_empty() {
out.push_str("No bookmark stacks found.\n");
} else {
let layout = build_layout(data.graph);
for row in &layout.rows {
out.push_str(&render_row(row, &layout.nodes, colors));
out.push('\n');
}
}
if !data.graph.excluded_bookmarks.is_empty() {
out.push('\n');
let _ = writeln!(
out,
"({} excluded due to merge commits)",
data.graph.excluded_bookmarks.join(", "),
);
}
if data.graph.excluded_head_count > 0 {
if data.graph.excluded_bookmarks.is_empty() {
out.push('\n');
}
let _ = writeln!(
out,
"({} unbookmarked head(s) excluded due to merge commits)",
data.graph.excluded_head_count,
);
}
out
}
fn render_row(row: &GraphRow, nodes: &[LayoutNode], colors: bool) -> String {
let mut line = String::from(" ");
match *row {
GraphRow::Commit { node, col } => {
for _ in 0..col {
line.push_str(GUTTER_CELL);
}
let node = &nodes[node];
if node.is_trunk {
line.push_str(TRUNK_CHAR);
line.push_str(" trunk");
} else {
line.push_str(NODE_OTHER);
line.push_str(" ");
line.push_str(&paint(
&node.short_change_id,
&console::Style::new().magenta(),
colors,
));
line.push_str(" ");
if !node.bookmark_names.is_empty() {
line.push_str(&paint(
&node.bookmark_names.join(", "),
&console::Style::new().green().bold(),
colors,
));
line.push_str(" ");
}
if node.summary == "(no description)" {
line.push_str(&paint(
"(no description set)",
&console::Style::new().dim(),
colors,
));
} else {
let _ = write!(line, "\"{}\"", node.summary);
}
if let Some(hint) = node_hint(node) {
line.push_str(" ");
line.push_str(&paint(&hint, &console::Style::new().dim(), colors));
}
}
}
GraphRow::Connector { col } => {
for _ in 0..col.saturating_sub(1) {
line.push_str(GUTTER_CELL);
}
line.push_str(CONNECTOR_TEE);
line.push_str(CONNECTOR_TAIL);
}
}
line
}
fn node_hint(node: &LayoutNode) -> Option<String> {
match (node.is_immutable, node.excluded_bookmarks.is_empty()) {
(true, false) => Some(format!(
"(immutable — bookmark {} excluded by --bookmarks-revset)",
node.excluded_bookmarks.join(", "),
)),
(true, true) => Some("(immutable)".to_string()),
(false, false) => Some(format!(
"(bookmark {} excluded by --bookmarks-revset)",
node.excluded_bookmarks.join(", "),
)),
(false, true) => None,
}
}
fn paint(text: &str, style: &console::Style, colors: bool) -> String {
if colors {
style.clone().force_styling(true).apply_to(text).to_string()
} else {
text.to_string()
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::collections::HashSet;
use super::*;
use crate::graph::types::BranchStack;
use crate::graph::types::SegmentCommit;
use crate::jj::types::Signature;
fn make_graph(stacks: Vec<BranchStack>) -> ChangeGraph {
ChangeGraph {
adjacency_list: HashMap::new(),
stack_leaves: HashSet::new(),
segments: HashMap::new(),
tainted_change_ids: HashSet::new(),
bookmark_remote_states: HashMap::new(),
excluded_bookmarks: Vec::new(),
excluded_head_count: 0,
stacks,
}
}
fn make_segment_at(
names: &[&str],
commits: &[(&str, &str)],
timestamp: &str,
) -> BookmarkSegment {
BookmarkSegment {
bookmark_names: names.iter().map(ToString::to_string).collect(),
change_id: commits[0].0.to_string(),
commits: commits
.iter()
.enumerate()
.map(|(i, (change_id, desc))| SegmentCommit {
commit_id: format!("c_{change_id}"),
change_id: (*change_id).to_string(),
description: (*desc).to_string(),
author: Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "2026-01-01T00:00:00Z".to_string(),
},
committer: Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: timestamp.to_string(),
},
files: vec![format!("src/{change_id}.rs")],
short_change_id: change_id[..4.min(change_id.len())].to_string(),
is_immutable: false,
local_bookmark_names: if i == 0 {
names.iter().map(ToString::to_string).collect()
} else {
vec![]
},
remote_bookmark_names: vec![],
})
.collect(),
}
}
const MULTILINE_DESCRIPTION: &str =
"feat b work\n\nThe body explains why.\nIt spans two lines.";
fn sample_graph() -> ChangeGraph {
let base = make_segment_at(
&["base"],
&[("qzvsmyxk", "extend base"), ("mnrqxtvo", "add base")],
"2026-01-01T00:00:00Z",
);
let feat_a = make_segment_at(
&["feat-a"],
&[("wmtkoylq", ""), ("ptszrkwu", "feat a work")],
"2026-03-01T00:00:00Z",
);
let mut feat_b = make_segment_at(
&["feat-b"],
&[("rlkvnnup", MULTILINE_DESCRIPTION)],
"2026-02-01T00:00:00Z",
);
feat_b.commits[0].is_immutable = true;
feat_b.commits[0]
.local_bookmark_names
.push("old-mark".to_string());
let mut base = base;
base.commits[0]
.remote_bookmark_names
.push("base@origin".to_string());
let mut graph = make_graph(vec![
BranchStack {
segments: vec![base.clone(), feat_a],
},
BranchStack {
segments: vec![base, feat_b],
},
]);
graph.bookmark_remote_states = HashMap::from([
("base".to_string(), RemoteState::Synced),
("feat-a".to_string(), RemoteState::Unpushed),
("feat-b".to_string(), RemoteState::Diverged),
]);
graph.excluded_bookmarks = vec!["merged-work".to_string()];
graph.excluded_head_count = 1;
graph
}
fn sample_remotes() -> Vec<GitRemote> {
vec![
GitRemote {
name: "origin".to_string(),
url: "git@github.com:glennib/stakk.git".to_string(),
},
GitRemote {
name: "mirror".to_string(),
url: "https://gitlab.com/x/y.git".to_string(),
},
]
}
#[test]
fn pretty_graph_snapshot() {
let graph = sample_graph();
let remotes = sample_remotes();
let data = GraphData {
default_branch: "main",
remotes: &remotes,
graph: &graph,
github_host: None,
};
insta::assert_snapshot!(render_pretty(&data, false));
}
#[test]
fn pretty_no_stacks() {
let graph = make_graph(vec![]);
let remotes = sample_remotes();
let data = GraphData {
default_branch: "main",
remotes: &remotes,
graph: &graph,
github_host: None,
};
let out = render_pretty(&data, false);
assert!(out.contains("No bookmark stacks found."));
assert!(out.starts_with("Default branch: main\n"));
}
#[test]
fn pretty_reports_exclusions_when_no_stack_survives() {
let mut graph = make_graph(vec![]);
graph.excluded_bookmarks = vec!["bm_merge".to_string()];
graph.excluded_head_count = 1;
let remotes = sample_remotes();
let data = GraphData {
default_branch: "main",
remotes: &remotes,
graph: &graph,
github_host: None,
};
let out = render_pretty(&data, false);
assert!(out.contains("No bookmark stacks found."));
assert!(out.contains("(bm_merge excluded due to merge commits)"));
assert!(out.contains("(1 unbookmarked head(s) excluded due to merge commits)"));
}
fn sample_json(projection: JsonProjection) -> serde_json::Value {
let graph = sample_graph();
let remotes = sample_remotes();
let data = GraphData {
default_branch: "main",
remotes: &remotes,
graph: &graph,
github_host: None,
};
serde_json::from_str(&render_json(&data, projection)).unwrap()
}
fn sample_json_via_format(format: GraphFormat) -> serde_json::Value {
let graph = sample_graph();
let remotes = sample_remotes();
let data = GraphData {
default_branch: "main",
remotes: &remotes,
graph: &graph,
github_host: None,
};
serde_json::from_str(&render(&data, format, false)).unwrap()
}
#[test]
fn json_snapshot() {
let graph = sample_graph();
let remotes = sample_remotes();
let data = GraphData {
default_branch: "main",
remotes: &remotes,
graph: &graph,
github_host: None,
};
insta::assert_snapshot!(render_json(&data, JsonProjection::Sparse));
}
#[test]
fn json_full_snapshot() {
let graph = sample_graph();
let remotes = sample_remotes();
let data = GraphData {
default_branch: "main",
remotes: &remotes,
graph: &graph,
github_host: None,
};
insta::assert_snapshot!(render_json(&data, JsonProjection::Full));
}
fn assert_subset(sparse: &serde_json::Value, full: &serde_json::Value, path: &str) {
match (sparse, full) {
(serde_json::Value::Object(s), serde_json::Value::Object(f)) => {
for (key, value) in s {
let sub = f
.get(key)
.unwrap_or_else(|| panic!("{path}.{key} missing from the full document"));
assert_subset(value, sub, &format!("{path}.{key}"));
}
}
(serde_json::Value::Array(s), serde_json::Value::Array(f)) => {
assert_eq!(s.len(), f.len(), "{path} length differs");
for (i, (value, sub)) in s.iter().zip(f).enumerate() {
assert_subset(value, sub, &format!("{path}[{i}]"));
}
}
(s, f) => assert_eq!(s, f, "{path} differs"),
}
}
#[test]
fn sparse_is_a_strict_subset_of_full() {
let sparse = sample_json(JsonProjection::Sparse);
let full = sample_json(JsonProjection::Full);
assert_ne!(sparse, full, "the two projections must differ");
assert_subset(&sparse, &full, "$");
}
const SAMPLE_COMMIT_COUNT: usize = 7;
fn all_commits(v: &serde_json::Value) -> Vec<&serde_json::Map<String, serde_json::Value>> {
v["stacks"]
.as_array()
.unwrap()
.iter()
.flat_map(|stack| stack["segments"].as_array().unwrap())
.flat_map(|segment| segment["commits"].as_array().unwrap())
.map(|commit| commit.as_object().unwrap())
.collect()
}
#[test]
fn sparse_omits_full_only_fields() {
let v = sample_json(JsonProjection::Sparse);
assert_eq!(v["schema_version"], 2);
assert_eq!(v["default_branch"], "main");
assert_eq!(v["remotes"][0]["github"], "glennib/stakk");
assert_eq!(v["excluded_bookmarks"][0], "merged-work");
assert_eq!(v["excluded_head_count"], 1);
let commits = all_commits(&v);
assert_eq!(commits.len(), SAMPLE_COMMIT_COUNT, "no commits inspected");
for (i, commit) in commits.iter().enumerate() {
for field in ["commit_id", "description", "author", "files"] {
assert!(
commit.get(field).is_none(),
"{field} leaked into sparse commit {i}"
);
}
assert_eq!(
commit.keys().collect::<Vec<_>>(),
vec![
"change_id",
"committer_timestamp",
"is_boundary",
"is_immutable",
"is_leaf",
"local_bookmark_names",
"short_change_id",
"title",
],
"commit {i}",
);
}
}
const SPARSE_COMMIT_FIELDS: [&str; 8] = [
"\"change_id\"",
"\"short_change_id\"",
"\"title\"",
"\"committer_timestamp\"",
"\"is_immutable\"",
"\"local_bookmark_names\"",
"\"is_boundary\"",
"\"is_leaf\"",
];
const FULL_COMMIT_FIELDS: [&str; 12] = [
"\"change_id\"",
"\"short_change_id\"",
"\"commit_id\"",
"\"title\"",
"\"description\"",
"\"author\"",
"\"committer_timestamp\"",
"\"files\"",
"\"is_immutable\"",
"\"local_bookmark_names\"",
"\"is_boundary\"",
"\"is_leaf\"",
];
fn commit_chunks(rendered: &str) -> Vec<&str> {
let starts: Vec<usize> = rendered
.match_indices("\"change_id\"")
.map(|(at, _)| at)
.collect();
starts
.iter()
.enumerate()
.map(|(i, &start)| {
let end = starts.get(i + 1).copied().unwrap_or(rendered.len());
&rendered[start..end]
})
.collect()
}
fn assert_commit_field_order(rendered: &str, fields: &[&str], label: &str) {
let chunks = commit_chunks(rendered);
assert_eq!(
chunks.len(),
SAMPLE_COMMIT_COUNT,
"{label}: unexpected commit object count"
);
for (i, chunk) in chunks.iter().enumerate() {
let mut cursor = 0;
for field in fields {
let at = chunk[cursor..].find(field).unwrap_or_else(|| {
panic!("{label} commit {i}: {field} is missing or out of order in:\n{chunk}")
});
cursor += at + field.len();
}
}
}
#[test]
fn sparse_field_order_matches_full() {
let graph = sample_graph();
let remotes = sample_remotes();
let data = GraphData {
default_branch: "main",
remotes: &remotes,
graph: &graph,
github_host: None,
};
let sparse = render_json(&data, JsonProjection::Sparse);
let full = render_json(&data, JsonProjection::Full);
assert_commit_field_order(&sparse, &SPARSE_COMMIT_FIELDS, "sparse");
assert_commit_field_order(&full, &FULL_COMMIT_FIELDS, "full");
let mut full_fields = FULL_COMMIT_FIELDS.iter();
for field in SPARSE_COMMIT_FIELDS {
assert!(
full_fields.any(|f| *f == field),
"{field} does not appear in the full field order after the fields before it"
);
}
}
#[test]
fn title_is_the_first_line_of_the_description() {
let full = sample_json(JsonProjection::Full);
let base = &full["stacks"][0]["segments"][0]["commits"][0];
assert_eq!(base["title"], "add base");
assert_eq!(base["description"], "add base");
let feat_b = &full["stacks"][1]["segments"][1]["commits"][0];
assert!(
feat_b.is_object(),
"the multi-line fixture commit is not where the test looks"
);
assert_eq!(feat_b["title"], "feat b work");
assert_eq!(feat_b["description"], MULTILINE_DESCRIPTION);
assert_ne!(
feat_b["title"], feat_b["description"],
"title must not be the whole description"
);
assert_eq!(
sample_json(JsonProjection::Sparse)["stacks"][1]["segments"][1]["commits"][0]["title"],
"feat b work"
);
let feat_a_tip = &full["stacks"][0]["segments"][1]["commits"][1];
assert_eq!(feat_a_tip["title"], "");
assert_eq!(feat_a_tip["description"], "");
assert_eq!(
sample_json(JsonProjection::Sparse)["stacks"][0]["segments"][1]["commits"][1]["title"],
""
);
}
#[test]
fn render_emits_the_projection_the_format_selects() {
let sparse_keys = [
"change_id",
"committer_timestamp",
"is_boundary",
"is_immutable",
"is_leaf",
"local_bookmark_names",
"short_change_id",
"title",
];
let full_keys = [
"author",
"change_id",
"commit_id",
"committer_timestamp",
"description",
"files",
"is_boundary",
"is_immutable",
"is_leaf",
"local_bookmark_names",
"short_change_id",
"title",
];
for (format, expected) in [
(GraphFormat::Json, sparse_keys.as_slice()),
(GraphFormat::JsonFull, full_keys.as_slice()),
] {
let v = sample_json_via_format(format);
let commits = all_commits(&v);
assert_eq!(
commits.len(),
SAMPLE_COMMIT_COUNT,
"{format:?}: no commits inspected"
);
for (i, commit) in commits.iter().enumerate() {
assert_eq!(
commit.keys().collect::<Vec<_>>(),
expected.iter().collect::<Vec<_>>(),
"{format:?} commit {i}",
);
}
}
let graph = sample_graph();
let remotes = sample_remotes();
let data = GraphData {
default_branch: "main",
remotes: &remotes,
graph: &graph,
github_host: None,
};
let pretty = render(&data, GraphFormat::Pretty, false);
assert!(
pretty.starts_with("Default branch: main\n"),
"pretty rendered as: {pretty}"
);
}
#[test]
fn json_projection_per_format() {
assert_eq!(json_projection(GraphFormat::Pretty), None);
assert_eq!(
json_projection(GraphFormat::Json),
Some(JsonProjection::Sparse)
);
assert_eq!(
json_projection(GraphFormat::JsonFull),
Some(JsonProjection::Full)
);
}
#[test]
fn json_shape() {
let v = sample_json(JsonProjection::Full);
assert_eq!(v["schema_version"], 2);
assert_eq!(v["default_branch"], "main");
assert_eq!(v["remotes"][0]["name"], "origin");
assert_eq!(v["remotes"][0]["github"], "glennib/stakk");
assert!(v["remotes"][1]["github"].is_null());
assert_eq!(v["excluded_bookmarks"][0], "merged-work");
assert_eq!(v["excluded_head_count"], 1);
let stacks = v["stacks"].as_array().unwrap();
assert_eq!(stacks.len(), 2);
assert_eq!(stacks[0]["segments"][0]["bookmarks"][0]["name"], "base");
assert_eq!(
stacks[0]["segments"][0]["bookmarks"][0]["remote_state"],
"synced"
);
assert_eq!(
stacks[0]["segments"][1]["bookmarks"][0]["remote_state"],
"unpushed"
);
assert_eq!(
stacks[1]["segments"][1]["bookmarks"][0]["remote_state"],
"diverged"
);
let base_commits = stacks[0]["segments"][0]["commits"].as_array().unwrap();
assert_eq!(base_commits[0]["description"], "add base");
assert_eq!(base_commits[0]["is_boundary"], false);
assert_eq!(base_commits[1]["description"], "extend base");
assert_eq!(base_commits[1]["is_boundary"], true);
assert_eq!(base_commits[1]["is_leaf"], false);
let feat_a_commits = stacks[0]["segments"][1]["commits"].as_array().unwrap();
assert_eq!(feat_a_commits[1]["is_leaf"], true);
assert_eq!(
base_commits[1]["author"]["timestamp"],
"2026-01-01T00:00:00Z"
);
assert_eq!(
feat_a_commits[1]["author"]["timestamp"],
"2026-01-01T00:00:00Z"
);
assert_eq!(
feat_a_commits[1]["committer_timestamp"],
"2026-03-01T00:00:00Z"
);
assert_eq!(base_commits[1]["change_id"], "qzvsmyxk");
assert_eq!(base_commits[1]["short_change_id"], "qzvs");
assert_eq!(base_commits[1]["commit_id"], "c_qzvsmyxk");
assert_eq!(base_commits[1]["author"]["email"], "test@test.com");
assert_eq!(base_commits[1]["files"][0], "src/qzvsmyxk.rs");
assert_eq!(base_commits[0]["change_id"], "mnrqxtvo");
assert_eq!(base_commits[0]["short_change_id"], "mnrq");
let base_again = stacks[1]["segments"][0]["commits"].as_array().unwrap();
assert_eq!(base_again, base_commits);
assert_eq!(
base_commits[0]["local_bookmark_names"]
.as_array()
.unwrap()
.len(),
0
);
let feat_b_commit = &stacks[1]["segments"][1]["commits"][0];
assert_eq!(feat_b_commit["is_immutable"], true);
assert_eq!(
feat_b_commit["local_bookmark_names"]
.as_array()
.unwrap()
.len(),
2
);
}
}