use crate::core::graph::{self, RenderOpts, Theme};
use crate::core::repo::{
BranchInfo, CommitInfo, ContextCommit, FileChange, RemoteStatus, RepoInfo,
};
use crate::core::test_helpers::{base_info, commit, oid, strip_ansi};
fn default_opts() -> RenderOpts {
RenderOpts {
terminal_width: None,
theme: Theme::dark(),
cwd_prefix: String::new(),
}
}
fn render_plain(info: RepoInfo) -> String {
let ids = crate::core::shortid::IdAllocator::new(info.collect_entities());
strip_ansi(&graph::render_sections(
&graph::build_sections(info),
&ids,
&default_opts(),
))
}
fn render_plain_with_width(info: RepoInfo, width: u16) -> String {
let opts = RenderOpts {
terminal_width: Some(width),
theme: Theme::dark(),
cwd_prefix: String::new(),
};
let ids = crate::core::shortid::IdAllocator::new(info.collect_entities());
strip_ansi(&graph::render_sections(
&graph::build_sections(info),
&ids,
&opts,
))
}
fn commit_with_files(
byte: u8,
message: &str,
parent: Option<u8>,
files: Vec<FileChange>,
) -> CommitInfo {
CommitInfo {
files,
..commit(byte, message, parent)
}
}
#[test]
fn no_commits_no_changes() {
let output = render_plain(base_info());
assert_eq!(
output,
"\
╭─ zz [local changes]
│ no changes
│
● aaa0000 (upstream) [origin/main] Initial commit
"
);
}
#[test]
fn a_conflict_marked_on_one_side_only_still_renders() {
let mut info = base_info();
info.working_changes = vec![FileChange {
path: "both_added.rs".to_string(),
index: '!',
worktree: '?',
}];
let output = render_plain(info);
assert!(output.contains("!! both_added.rs"), "{output}");
}
#[test]
fn working_changes_shown() {
let mut info = base_info();
info.working_changes = vec![
FileChange {
path: "src/main.rs".to_string(),
index: ' ',
worktree: 'M',
},
FileChange {
path: "new_file.txt".to_string(),
index: 'A',
worktree: ' ',
},
];
let output = render_plain(info);
assert!(
output
.starts_with("╭─ zz [local changes]\n│ ma M src/main.rs\n│ nf A new_file.txt\n")
);
}
#[test]
fn single_branch() {
let mut info = base_info();
info.commits = vec![commit(2, "A2", Some(1)), commit(1, "A1", None)];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: None,
}];
let output = render_plain(info);
assert_eq!(
output,
"\
╭─ zz [local changes]
│ no changes
│
│╭─ fa [feature-a]
│● 02 0000002 A2
│● 01 0000001 A1
├╯
│
● aaa0000 (upstream) [origin/main] Initial commit
"
);
}
#[test]
fn independent_branches() {
let mut info = base_info();
info.commits = vec![commit(2, "B1", None), commit(1, "A1", None)];
info.branches = vec![
BranchInfo {
name: "feature-b".to_string(),
tip_oid: oid(2),
remote: None,
},
BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(1),
remote: None,
},
];
let output = render_plain(info);
assert!(
output.contains("├╯\n│\n│╭─"),
"expected independent branches, got:\n{}",
output
);
}
#[test]
fn stacked_branches() {
let mut info = base_info();
info.commits = vec![
commit(4, "B2", Some(3)),
commit(3, "B1", Some(2)), commit(2, "A2", Some(1)),
commit(1, "A1", None),
];
info.branches = vec![
BranchInfo {
name: "feature-b".to_string(),
tip_oid: oid(4),
remote: None,
},
BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: None,
},
];
let output = render_plain(info);
assert!(
output.contains("││\n│├─"),
"expected stacked branches, got:\n{}",
output
);
assert_eq!(
output.matches("├╯").count(),
1,
"expected single ├╯, got:\n{}",
output
);
}
#[test]
fn loose_commits_on_integration_line() {
let mut info = base_info();
info.commits = vec![commit(2, "Fix typo", Some(1)), commit(1, "Refactor", None)];
let output = render_plain(info);
assert!(
output.contains("● 02 0000002 Fix typo\n● 01 0000001 Refactor"),
"expected loose commits, got:\n{}",
output
);
assert!(
!output.contains("│╭─"),
"unexpected branch header in:\n{}",
output
);
}
#[test]
fn mixed_loose_and_branch() {
let mut info = base_info();
info.commits = vec![
commit(3, "Loose on top", Some(2)),
commit(2, "B tip", Some(1)),
commit(1, "B base", None),
];
info.branches = vec![BranchInfo {
name: "feature-b".to_string(),
tip_oid: oid(2),
remote: None,
}];
let output = render_plain(info);
assert!(
output.contains("● 03 0000003 Loose on top\n│\n│╭─ fb [feature-b]"),
"expected loose then branch, got:\n{}",
output
);
}
#[test]
fn upstream_ahead_shows_indicator() {
let mut info = base_info();
info.upstream.commits_ahead = 3;
let output = render_plain(info);
assert!(
output.contains("│● [origin/main] ⏫ 3 new commits\n├╯ aaa0000 (common base) 2025-07-06 Initial commit\n"),
"expected upstream-ahead indicator, got:\n{}",
output
);
}
#[test]
fn upstream_ahead_singular() {
let mut info = base_info();
info.upstream.commits_ahead = 1;
let output = render_plain(info);
assert!(
output.contains("⏫ 1 new commit\n"),
"expected singular 'commit', got:\n{}",
output
);
assert!(
!output.contains("commits\n"),
"should not contain plural 'commits', got:\n{}",
output
);
}
#[test]
fn merge_based_integration_branch() {
let mut info = base_info();
info.commits = vec![
commit(0x14, "Feature 1 improvement", Some(0x13)),
commit(0x13, "fixup Feature 1", Some(0x12)),
commit(0x12, "Bugfix 1", Some(0x11)),
commit(0x11, "Feature 1", Some(0xaa)), commit(0x23, "Feature 3 depends on Feature 2", Some(0x22)),
commit(0x22, "fixup Feature 2", Some(0x21)),
commit(0x21, "Feature 2", Some(0xaa)), ];
info.branches = vec![BranchInfo {
name: "feature-1".to_string(),
tip_oid: oid(0x14),
remote: None,
}];
let output = render_plain(info);
let branch_section_start = output.find("│╭─").expect("no branch header found");
let branch_section_end = output[branch_section_start..]
.find("├╯")
.expect("no branch close found")
+ branch_section_start;
let branch_section = &output[branch_section_start..branch_section_end];
let branch_dots = branch_section.matches("│●").count();
assert_eq!(
branch_dots, 4,
"feature-1 should have exactly 4 commits, got {} in:\n{}",
branch_dots, output
);
assert!(
output.contains("● 23 0000023 Feature 3 depends on Feature 2"),
"expected loose integration commit, got:\n{}",
output
);
}
#[test]
fn co_located_branches_show_all_names() {
let mut info = base_info();
info.commits = vec![commit(2, "A2", Some(1)), commit(1, "A1", None)];
info.branches = vec![
BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: None,
},
BranchInfo {
name: "feature-a-v2".to_string(),
tip_oid: oid(2),
remote: None,
},
];
let output = render_plain(info);
assert!(
output.contains("[feature-a]"),
"expected [feature-a] header, got:\n{}",
output
);
assert!(
output.contains("[feature-a-v2]"),
"expected [feature-a-v2] header, got:\n{}",
output
);
let v2_pos = output.find("[feature-a-v2]").unwrap();
let a_pos = output.find("[feature-a]").unwrap();
assert!(
v2_pos < a_pos,
"expected feature-a-v2 on top of feature-a, got:\n{}",
output
);
assert!(
output.contains("│╭─") && output.contains("│├─"),
"expected ╭─ then ├─ for co-located branches, got:\n{}",
output
);
assert_eq!(
output.matches("│●").count(),
2,
"expected 2 commit dots (not duplicated), got:\n{}",
output
);
}
#[test]
fn branch_at_upstream_shown_as_section() {
let mut info = base_info();
info.branches = vec![BranchInfo {
name: "feature-4".to_string(),
tip_oid: oid(0xaa), remote: None,
}];
let output = render_plain(info);
assert!(
output.contains("│╭─") && output.contains("[feature-4]"),
"expected branch section for feature-4, got:\n{}",
output
);
assert!(
output.contains("├╯"),
"expected branch close, got:\n{}",
output
);
}
#[test]
fn short_ids_for_files_use_filename() {
let mut info = base_info();
info.working_changes = vec![
FileChange {
path: "src/graph.rs".to_string(),
index: ' ',
worktree: 'M',
},
FileChange {
path: "src/git.rs".to_string(),
index: ' ',
worktree: 'M',
},
];
let output = render_plain(info);
assert!(
output.contains("gr M src/graph.rs"),
"expected file short ID 'gr', got:\n{}",
output
);
assert!(
output.contains("gi M src/git.rs"),
"expected file short ID 'gi', got:\n{}",
output
);
}
#[test]
fn short_ids_collision_extends() {
let mut info = base_info();
info.working_changes = vec![
FileChange {
path: "src/main.rs".to_string(),
index: ' ',
worktree: 'M',
},
FileChange {
path: "src/manifest.rs".to_string(),
index: 'A',
worktree: ' ',
},
];
let output = render_plain(info);
let lines: Vec<&str> = output.lines().collect();
let main_line = lines.iter().find(|l| l.contains("main.rs")).unwrap();
let manifest_line = lines.iter().find(|l| l.contains("manifest.rs")).unwrap();
assert_ne!(main_line, manifest_line);
}
#[test]
fn files_shown_under_branch_commits() {
let mut info = base_info();
info.commits = vec![
commit_with_files(
2,
"A2",
Some(1),
vec![
FileChange {
path: "src/graph.rs".to_string(),
index: 'M',
worktree: ' ',
},
FileChange {
path: "new_file.txt".to_string(),
index: 'A',
worktree: ' ',
},
],
),
commit_with_files(
1,
"A1",
None,
vec![FileChange {
path: "src/status.rs".to_string(),
index: 'M',
worktree: ' ',
}],
),
];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: None,
}];
let output = render_plain(info);
assert!(
output.contains(
"│● 02 0000002 A2\n│┊ 02:0 M src/graph.rs\n│┊ 02:1 A new_file.txt\n"
),
"expected files under A2, got:\n{}",
output
);
assert!(
output.contains("│● 01 0000001 A1\n│┊ 01:0 M src/status.rs\n"),
"expected files under A1, got:\n{}",
output
);
}
#[test]
fn files_shown_under_loose_commits() {
let mut info = base_info();
info.commits = vec![commit_with_files(
2,
"Fix typo",
Some(1),
vec![FileChange {
path: "README.md".to_string(),
index: 'M',
worktree: ' ',
}],
)];
let output = render_plain(info);
assert!(
output.contains("● 02 0000002 Fix typo\n┊ 02:0 M README.md\n"),
"expected files under loose commit, got:\n{}",
output
);
}
#[test]
fn commit_file_ids_use_commit_sid_colon_index() {
let mut info = base_info();
info.commits = vec![commit_with_files(
2,
"A1",
None,
vec![
FileChange {
path: "foo.rs".to_string(),
index: 'A',
worktree: ' ',
},
FileChange {
path: "bar.rs".to_string(),
index: 'M',
worktree: ' ',
},
],
)];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: None,
}];
let output = render_plain(info);
assert!(
output.contains("02:0 A foo.rs"),
"expected 02:0 for first file, got:\n{}",
output
);
assert!(
output.contains("02:1 M bar.rs"),
"expected 02:1 for second file, got:\n{}",
output
);
}
#[test]
fn root_commit_files_shown() {
let mut info = base_info();
info.commits = vec![commit_with_files(
1,
"Initial",
None,
vec![FileChange {
path: "init.rs".to_string(),
index: 'A',
worktree: ' ',
}],
)];
let output = render_plain(info);
assert!(
output.contains("● 01 0000001 Initial\n┊ 01:0 A init.rs\n"),
"expected file under root commit, got:\n{}",
output
);
}
#[test]
fn same_file_in_multiple_commits_gets_unique_ids() {
let mut info = base_info();
info.commits = vec![
commit_with_files(
2,
"A2",
Some(1),
vec![FileChange {
path: "src/main.rs".to_string(),
index: 'M',
worktree: ' ',
}],
),
commit_with_files(
1,
"A1",
None,
vec![FileChange {
path: "src/main.rs".to_string(),
index: 'M',
worktree: ' ',
}],
),
];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: None,
}];
let output = render_plain(info);
assert!(
output.contains("02:0 M src/main.rs"),
"expected 02:0 for commit 2's file, got:\n{}",
output
);
assert!(
output.contains("01:0 M src/main.rs"),
"expected 01:0 for commit 1's file, got:\n{}",
output
);
}
#[test]
fn context_commits_shown_below_upstream() {
let mut info = base_info();
info.context_commits = vec![
ContextCommit {
short_hash: "bbb0001".to_string(),
message: "Earlier commit".to_string(),
date: "2025-07-05".to_string(),
},
ContextCommit {
short_hash: "bbb0002".to_string(),
message: "Even earlier".to_string(),
date: "2025-07-04".to_string(),
},
];
let output = render_plain(info);
assert!(
output.contains("· bbb0001 2025-07-05 Earlier commit\n· bbb0002 2025-07-04 Even earlier\n"),
"expected context commits below upstream, got:\n{}",
output
);
let upstream_pos = output.find("(upstream)").unwrap();
let context_pos = output.find("· bbb0001").unwrap();
assert!(
upstream_pos < context_pos,
"upstream should appear before context commits, got:\n{}",
output
);
}
#[test]
fn context_commits_shown_below_diverged_upstream() {
let mut info = base_info();
info.upstream.commits_ahead = 2;
info.context_commits = vec![ContextCommit {
short_hash: "ccc0001".to_string(),
message: "Before base".to_string(),
date: "2025-07-01".to_string(),
}];
let output = render_plain(info);
let base_pos = output.find("(common base)").unwrap();
let context_pos = output.find("· ccc0001").unwrap();
assert!(
base_pos < context_pos,
"context should appear below common base, got:\n{}",
output
);
}
#[test]
fn no_context_when_default() {
let info = base_info();
let output = render_plain(info);
assert!(
!output.contains('·'),
"no middle-dot lines expected with empty context_commits, got:\n{}",
output
);
}
#[test]
fn tracked_files_shown_before_untracked() {
let mut info = base_info();
info.working_changes = vec![
FileChange {
path: ".claude/".to_string(),
index: '?',
worktree: '?',
},
FileChange {
path: "src/main.rs".to_string(),
index: ' ',
worktree: 'M',
},
FileChange {
path: "todo.md".to_string(),
index: '?',
worktree: '?',
},
FileChange {
path: "new_file.txt".to_string(),
index: 'A',
worktree: ' ',
},
];
let output = render_plain(info);
let main_pos = output.find("src/main.rs").unwrap();
let new_file_pos = output.find("new_file.txt").unwrap();
let claude_pos = output.find(".claude/").unwrap();
let todo_pos = output.find("todo.md").unwrap();
assert!(
main_pos < claude_pos && main_pos < todo_pos,
"tracked files should appear before untracked, got:\n{}",
output
);
assert!(
new_file_pos < claude_pos && new_file_pos < todo_pos,
"tracked files should appear before untracked, got:\n{}",
output
);
}
#[test]
fn only_untracked_skips_no_changes() {
let mut info = base_info();
info.working_changes = vec![
FileChange {
path: ".claude/".to_string(),
index: '?',
worktree: '?',
},
FileChange {
path: "todo.md".to_string(),
index: '?',
worktree: '?',
},
];
let output = render_plain(info);
assert!(
!output.contains("no changes"),
"should not show 'no changes' when untracked files exist, got:\n{}",
output
);
assert!(
output.contains(" ⁕ .claude/"),
"expected untracked file, got:\n{}",
output
);
assert!(
output.contains(" ⁕ todo.md"),
"expected untracked file, got:\n{}",
output
);
}
#[test]
fn untracked_below_threshold_stays_single_column() {
let mut info = base_info();
info.working_changes = (1..=5)
.map(|i| FileChange {
path: format!("file{}.txt", i),
index: '?',
worktree: '?',
})
.collect();
let output = render_plain_with_width(info, 120);
for i in 1..=5 {
let pattern = format!(" ⁕ file{}.txt", i);
assert!(
output.contains(&pattern),
"expected single-column entry for file{}.txt, got:\n{}",
i,
output
);
}
let untracked_lines: Vec<&str> = output.lines().filter(|l| l.contains('⁕')).collect();
assert_eq!(
untracked_lines.len(),
5,
"expected 5 single-column lines, got {} in:\n{}",
untracked_lines.len(),
output
);
}
#[test]
fn untracked_above_threshold_uses_multicolumn() {
let mut info = base_info();
info.working_changes = (1..=6)
.map(|i| FileChange {
path: format!("f{}.txt", i),
index: '?',
worktree: '?',
})
.collect();
let output = render_plain_with_width(info, 80);
for i in 1..=6 {
let pattern = format!("f{}.txt", i);
assert!(
output.contains(&pattern),
"expected f{}.txt in output, got:\n{}",
i,
output
);
}
let untracked_lines: Vec<&str> = output.lines().filter(|l| l.contains('⁕')).collect();
assert!(
untracked_lines.len() < 6,
"expected multi-column (fewer than 6 lines), got {} lines:\n{:?}",
untracked_lines.len(),
untracked_lines
);
}
#[test]
fn multicolumn_no_tty_stays_single_column() {
let mut info = base_info();
info.working_changes = (1..=10)
.map(|i| FileChange {
path: format!("file{}.txt", i),
index: '?',
worktree: '?',
})
.collect();
let output = render_plain(info);
let untracked_lines: Vec<&str> = output.lines().filter(|l| l.contains('⁕')).collect();
assert_eq!(
untracked_lines.len(),
10,
"expected single-column (10 lines) when no TTY, got {} lines:\n{:?}",
untracked_lines.len(),
untracked_lines
);
}
#[test]
fn multicolumn_fills_top_to_bottom_left_to_right() {
let mut info = base_info();
info.working_changes = vec!["aa", "bb", "cc", "dd", "ee", "ff"]
.into_iter()
.map(|name| FileChange {
path: format!("{}.txt", name),
index: '?',
worktree: '?',
})
.collect();
let output = render_plain_with_width(info, 55);
let untracked_lines: Vec<&str> = output.lines().filter(|l| l.contains('⁕')).collect();
assert_eq!(
untracked_lines.len(),
2,
"expected 2 rows with 3 columns, got {} lines:\n{:?}",
untracked_lines.len(),
untracked_lines
);
assert!(
untracked_lines[0].contains("aa.txt")
&& untracked_lines[0].contains("cc.txt")
&& untracked_lines[0].contains("ee.txt"),
"row 0 should be aa, cc, ee (column-major), got: {}",
untracked_lines[0]
);
assert!(
untracked_lines[1].contains("bb.txt")
&& untracked_lines[1].contains("dd.txt")
&& untracked_lines[1].contains("ff.txt"),
"row 1 should be bb, dd, ff (column-major), got: {}",
untracked_lines[1]
);
}
#[test]
fn multicolumn_has_pipe_separators() {
let mut info = base_info();
info.working_changes = (1..=6)
.map(|i| FileChange {
path: format!("f{}.txt", i),
index: '?',
worktree: '?',
})
.collect();
let output = render_plain_with_width(info, 80);
let untracked_lines: Vec<&str> = output.lines().filter(|l| l.contains('⁕')).collect();
assert!(
untracked_lines[0].contains(" │ "),
"expected pipe separator between columns, got: {}",
untracked_lines[0]
);
}
#[test]
fn remote_synced_shows_checkmark() {
let mut info = base_info();
info.commits = vec![commit(2, "A2", Some(1)), commit(1, "A1", None)];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: Some(RemoteStatus::Synced),
}];
let output = render_plain(info);
assert!(
output.contains("[feature-a] ✓"),
"expected synced indicator, got:\n{}",
output
);
}
#[test]
fn remote_ahead_shows_up_arrow() {
let mut info = base_info();
info.commits = vec![commit(2, "A2", Some(1)), commit(1, "A1", None)];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: Some(RemoteStatus::Different),
}];
let output = render_plain(info);
assert!(
output.contains("[feature-a] ↑"),
"expected ahead indicator, got:\n{}",
output
);
}
#[test]
fn remote_gone_shows_cross() {
let mut info = base_info();
info.commits = vec![commit(2, "A2", Some(1)), commit(1, "A1", None)];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: Some(RemoteStatus::Gone),
}];
let output = render_plain(info);
assert!(
output.contains("[feature-a] ✗"),
"expected gone indicator, got:\n{}",
output
);
}
#[test]
fn no_remote_shows_no_indicator() {
let mut info = base_info();
info.commits = vec![commit(2, "A2", Some(1)), commit(1, "A1", None)];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: None,
}];
let output = render_plain(info);
let header_line = output.lines().find(|l| l.contains("[feature-a]")).unwrap();
assert!(
header_line.ends_with("[feature-a]"),
"expected no indicator after ], got: {}",
header_line
);
}
fn branch(name: &str, tip: u8, remote: Option<RemoteStatus>) -> BranchInfo {
BranchInfo {
name: name.to_string(),
tip_oid: oid(tip),
remote,
}
}
fn three_level_stack() -> RepoInfo {
let mut info = base_info();
info.commits = vec![
commit(0x10, "X1", None),
commit(5, "C1", Some(4)),
commit(4, "B2", Some(3)),
commit(3, "B1", Some(2)),
commit(2, "A2", Some(1)),
commit(1, "A1", None),
];
info.branches = vec![
branch("feature-x", 0x10, None),
branch("feature-c", 5, None),
branch("feature-b", 4, None),
branch("feature-a", 2, None),
];
info
}
#[test]
fn stack_parent_follows_tip_adjacency() {
let info = three_level_stack();
assert_eq!(graph::stack_parent(&info, "feature-a"), None);
assert_eq!(
graph::stack_parent(&info, "feature-b"),
Some("feature-a".to_string())
);
assert_eq!(
graph::stack_parent(&info, "feature-c"),
Some("feature-b".to_string())
);
assert_eq!(graph::stack_parent(&info, "feature-x"), None);
assert_eq!(graph::stack_parent(&info, "unknown"), None);
}
#[test]
fn stack_parent_is_none_above_a_loose_commit() {
let mut info = base_info();
info.commits = vec![commit(2, "A1", Some(1)), commit(1, "L1", None)];
info.branches = vec![branch("feature-a", 2, None)];
assert_eq!(graph::stack_parent(&info, "feature-a"), None);
assert_eq!(graph::downstack(&info, "feature-a"), vec!["feature-a"]);
}
#[test]
fn stack_parent_is_none_for_an_empty_branch() {
let mut info = base_info();
info.commits = vec![commit(1, "A1", None)];
info.branches = vec![branch("feature-a", 1, None), branch("empty", 0xAA, None)];
assert_eq!(graph::stack_parent(&info, "empty"), None);
assert_eq!(graph::downstack(&info, "empty"), vec!["empty"]);
assert!(graph::upstack(&info, "empty").is_empty());
}
#[test]
fn downstack_runs_bottom_to_top() {
let info = three_level_stack();
assert_eq!(
graph::downstack(&info, "feature-c"),
vec!["feature-a", "feature-b", "feature-c"]
);
assert_eq!(
graph::downstack(&info, "feature-b"),
vec!["feature-a", "feature-b"]
);
assert_eq!(graph::downstack(&info, "feature-a"), vec!["feature-a"]);
assert_eq!(graph::downstack(&info, "feature-x"), vec!["feature-x"]);
}
#[test]
fn upstack_lists_nearest_first() {
let info = three_level_stack();
assert_eq!(
graph::upstack(&info, "feature-a"),
vec!["feature-b", "feature-c"]
);
assert_eq!(graph::upstack(&info, "feature-b"), vec!["feature-c"]);
assert!(graph::upstack(&info, "feature-c").is_empty());
assert!(graph::upstack(&info, "feature-x").is_empty());
}
#[test]
fn stack_helpers_use_canonical_name_for_colocated_tips() {
let mut info = base_info();
info.commits = vec![commit(2, "B1", Some(1)), commit(1, "A1", None)];
info.branches = vec![
branch("feature-b", 2, None),
branch("feature-a", 1, None),
branch("alias-a", 1, None),
];
assert_eq!(
graph::stack_parent(&info, "feature-b"),
Some("feature-a".to_string())
);
assert_eq!(graph::upstack(&info, "alias-a"), vec!["feature-b"]);
assert_eq!(graph::upstack(&info, "feature-a"), vec!["feature-b"]);
}
#[test]
fn commit_ids_share_one_fixed_width_column() {
let mut info = base_info();
let mut a2 = commit(2, "A2", Some(1));
a2.change_id = Some("I3ac7000000000000000000000000000000000000".to_string());
info.commits = vec![a2, commit(1, "A1", None)];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(2),
remote: None,
}];
let output = render_plain(info);
assert!(
output.contains("│● wpn 0000002 A2\n│● 01 0000001 A1\n"),
"{output}"
);
}
#[test]
fn an_oversized_commit_id_pushes_only_its_own_line() {
let mut info = base_info();
let mut a3 = commit(3, "A3", Some(2));
a3.change_id = Some("I3ac7000000000000000000000000000000000000".to_string());
let mut a2 = commit(2, "A2", Some(1));
a2.change_id = Some("I3ac7100000000000000000000000000000000000".to_string());
info.commits = vec![a3, a2, commit(1, "A1", None)];
info.branches = vec![BranchInfo {
name: "feature-a".to_string(),
tip_oid: oid(3),
remote: None,
}];
let output = render_plain(info);
assert!(
output.contains(
"│● wpnsz 0000003 A3
│● wpnsy 0000002 A2
│● 01 0000001 A1
"
),
"{output}"
);
}
#[test]
fn loose_commit_puts_the_hash_before_the_subject() {
let mut info = base_info();
let mut c = commit(2, "Fix typo", Some(1));
c.change_id = Some("Ib710000000000000000000000000000000000000".to_string());
info.commits = vec![c];
let output = render_plain(info);
assert!(output.contains("● osy 0000002 Fix typo\n"), "{output}");
}
#[test]
fn stack_parents_maps_every_stacked_name() {
let mut info = base_info();
info.commits = vec![
commit(0x05, "e", Some(0x04)),
commit(0x04, "d", Some(0x03)),
commit(0x03, "c", Some(0x02)),
commit(0x02, "b", Some(0x01)),
commit(0x01, "a", Some(0xAA)),
];
info.branches = vec![
BranchInfo {
name: "feature-bottom".to_string(),
tip_oid: oid(0x02),
remote: None,
},
BranchInfo {
name: "feature-bottom-v2".to_string(),
tip_oid: oid(0x02),
remote: None,
},
BranchInfo {
name: "feature-middle".to_string(),
tip_oid: oid(0x04),
remote: None,
},
BranchInfo {
name: "feature-top".to_string(),
tip_oid: oid(0x05),
remote: None,
},
BranchInfo {
name: "feature-base".to_string(),
tip_oid: oid(0xAA),
remote: None,
},
];
let parents = graph::stack_parents(&info);
let expected: std::collections::HashMap<String, String> = [
("feature-bottom", "feature-base"),
("feature-bottom-v2", "feature-base"),
("feature-middle", "feature-bottom"),
("feature-top", "feature-middle"),
]
.into_iter()
.map(|(b, p)| (b.to_string(), p.to_string()))
.collect();
assert_eq!(parents, expected);
for b in &info.branches {
assert_eq!(
parents.get(&b.name).cloned(),
graph::stack_parent(&info, &b.name),
"{}",
b.name
);
}
}