- Drop fields and functions nothing reads: job and proc tips, fetch time, PR url/author (and their GraphQL fields), spine summaries, diff::commit, unread data attributes, unused CSS and tokens. - One home each for state wording (naming main as the repo does), lane lookup, unseen, outdated, main's tip, the stop wait and the token header. - Narrow visibility; rename render::View to Filter; queue ranking moves to the CLI, its only user. - Token test covers every route; shared checkout snapshot in the fixture. - Client: errors outside Run/Send are toasts, history only after a good load, a drag stays in one file, the agent marker is a ring. - design.md, the skill and AGENTS.md describe what is built.
289 lines
9.0 KiB
Rust
289 lines
9.0 KiB
Rust
mod fixture;
|
|
|
|
use branchgraph::git;
|
|
use fixture::{Fixture, git as run, merge_tree_conflicts};
|
|
|
|
#[test]
|
|
fn every_lane_matches_the_git_cli() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
let repo = git::open(c).unwrap();
|
|
let graph = git::build(&repo).unwrap();
|
|
|
|
assert_eq!(graph.main, "main");
|
|
assert_eq!(
|
|
graph.main_tip.to_string(),
|
|
run(c, &["rev-parse", "origin/main"])
|
|
);
|
|
let names: Vec<&str> = graph.lanes.iter().map(|l| l.name.as_str()).collect();
|
|
assert_eq!(
|
|
names,
|
|
[
|
|
"agent/from-main",
|
|
"agent/x",
|
|
"deps/lock",
|
|
"feat/conflict",
|
|
"feat/long",
|
|
"feat/merged",
|
|
"feat/old",
|
|
"feat/rename",
|
|
"feat/synced",
|
|
"feat/unpushed"
|
|
]
|
|
);
|
|
|
|
for lane in &graph.lanes {
|
|
let tip = lane.tip.to_string();
|
|
let n = &lane.name;
|
|
assert_eq!(
|
|
lane.fork.to_string(),
|
|
run(c, &["merge-base", "origin/main", &tip]),
|
|
"{n} fork"
|
|
);
|
|
|
|
let counts = run(
|
|
c,
|
|
&[
|
|
"rev-list",
|
|
"--left-right",
|
|
"--count",
|
|
&format!("origin/main...{tip}"),
|
|
],
|
|
);
|
|
let (behind, ahead) = counts.split_once('\t').unwrap();
|
|
assert_eq!(lane.behind.to_string(), behind, "{n} behind");
|
|
assert_eq!(lane.ahead.to_string(), ahead, "{n} ahead");
|
|
|
|
let expected = run(
|
|
c,
|
|
&[
|
|
"rev-list",
|
|
"-n",
|
|
&git::SHOWN.to_string(),
|
|
&format!("origin/main..{tip}"),
|
|
],
|
|
);
|
|
let mut expected: Vec<&str> = expected.lines().collect();
|
|
expected.reverse();
|
|
let got: Vec<String> = lane.commits.iter().map(|c| c.id.to_string()).collect();
|
|
assert_eq!(got, expected, "{n} lane commits");
|
|
|
|
let conflicts = git::conflicts(&repo, graph.main_tip, lane.tip).unwrap();
|
|
assert_eq!(
|
|
conflicts,
|
|
merge_tree_conflicts(c, "origin/main", &tip),
|
|
"{n} conflicts"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `git diff --numstat -z -M base tip` as (path, renamed from, added, removed).
|
|
fn numstat(c: &std::path::Path, base: &str, tip: &str) -> Vec<(String, Option<String>, u32, u32)> {
|
|
let out = run(c, &["diff", "--numstat", "-z", "-M", base, tip]);
|
|
let mut fields = out.split('\0').filter(|f| !f.is_empty());
|
|
let mut rows = Vec::new();
|
|
while let Some(head) = fields.next() {
|
|
let mut parts = head.splitn(3, '\t');
|
|
let added = parts.next().unwrap().parse().unwrap();
|
|
let removed = parts.next().unwrap().parse().unwrap();
|
|
let rows_path = match parts.next().unwrap() {
|
|
"" => {
|
|
let from = fields.next().unwrap().to_owned();
|
|
(fields.next().unwrap().to_owned(), Some(from))
|
|
}
|
|
path => (path.to_owned(), None),
|
|
};
|
|
rows.push((rows_path.0, rows_path.1, added, removed));
|
|
}
|
|
rows.sort();
|
|
rows
|
|
}
|
|
|
|
#[test]
|
|
fn branch_stat_matches_git_numstat() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
let repo = git::open(c).unwrap();
|
|
let graph = git::build(&repo).unwrap();
|
|
for lane in &graph.lanes {
|
|
let stat = branchgraph::diff::stat(&repo, lane.fork, lane.tip).unwrap();
|
|
let mut got: Vec<_> = stat
|
|
.files
|
|
.iter()
|
|
.map(|f| {
|
|
let (a, r) = f.stat.unwrap();
|
|
(f.path.clone(), f.from.clone(), a, r)
|
|
})
|
|
.collect();
|
|
got.sort();
|
|
assert_eq!(
|
|
got,
|
|
numstat(c, &lane.fork.to_string(), &lane.tip.to_string()),
|
|
"{} stat",
|
|
lane.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lanes_carry_upstream_worktree_and_conflict_facts() {
|
|
let f = Fixture::new();
|
|
let repo = git::open(&f.clone).unwrap();
|
|
let graph = git::build(&repo).unwrap();
|
|
let lane = |n: &str| graph.lane(n).unwrap();
|
|
|
|
let agent = lane("agent/x");
|
|
assert_eq!((agent.remote, agent.unpushed), (None, 2));
|
|
assert!(agent.worktree.as_ref().unwrap().path.ends_with("wt-agent"));
|
|
|
|
let from_main = lane("agent/from-main");
|
|
assert_eq!(
|
|
(from_main.remote, from_main.unpushed),
|
|
(None, 1),
|
|
"tracking main is no upstream"
|
|
);
|
|
|
|
let unpushed = lane("feat/unpushed");
|
|
assert_eq!((unpushed.unpushed, unpushed.to_pull), (1, 0));
|
|
|
|
let long = lane("feat/long");
|
|
assert_eq!(
|
|
(long.commits.len(), long.folded()),
|
|
(git::SHOWN, 40 - git::SHOWN)
|
|
);
|
|
assert!(lane("feat/merged").is_merged());
|
|
|
|
let main_wt = graph
|
|
.lanes
|
|
.iter()
|
|
.filter_map(|l| l.worktree.as_ref())
|
|
.find(|w| w.primary);
|
|
assert!(
|
|
main_wt.is_none(),
|
|
"main is checked out in the clone, and main has no lane"
|
|
);
|
|
|
|
let lock = git::conflicts(&repo, graph.main_tip, lane("deps/lock").tip).unwrap();
|
|
assert!(git::lock_only(&lock), "{lock:?}");
|
|
let real = git::conflicts(&repo, graph.main_tip, lane("feat/conflict").tip).unwrap();
|
|
assert!(!git::lock_only(&real));
|
|
assert_eq!(
|
|
graph.unrelated,
|
|
["gh-pages"],
|
|
"an orphan branch is listed, not drawn"
|
|
);
|
|
|
|
// The spine holds the fork points and the merge, oldest first, ending at the tip.
|
|
assert_eq!(graph.spine.last().unwrap().id, graph.main_tip);
|
|
assert!(graph.spine.iter().any(|s| s.merge));
|
|
for l in graph.lanes.iter().filter(|l| !l.is_merged()) {
|
|
assert!(
|
|
graph.spine.iter().any(|s| s.id == l.fork),
|
|
"{} fork on spine",
|
|
l.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn conflict_check_writes_nothing_to_the_clone() {
|
|
let f = Fixture::new();
|
|
let objects = |p: &std::path::Path| run(p, &["count-objects", "-v"]);
|
|
let before = objects(&f.clone);
|
|
let repo = git::open(&f.clone).unwrap();
|
|
let graph = git::build(&repo).unwrap();
|
|
for l in &graph.lanes {
|
|
git::conflicts(&repo, graph.main_tip, l.tip).unwrap();
|
|
}
|
|
assert_eq!(objects(&f.clone), before);
|
|
}
|
|
|
|
#[test]
|
|
fn dirty_counts_match_git_status_and_write_nothing() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
assert_eq!(git::dirty(c).unwrap(), 0);
|
|
std::fs::write(c.join("app.txt"), "changed\n").unwrap();
|
|
std::fs::write(c.join("new.txt"), "untracked\n").unwrap();
|
|
std::fs::write(c.join("staged.txt"), "staged\n").unwrap();
|
|
run(c, &["add", "staged.txt"]);
|
|
let index = std::fs::read(c.join(".git/index")).unwrap();
|
|
|
|
let porcelain = run(c, &["status", "--porcelain", "--untracked-files=all"]);
|
|
assert_eq!(git::dirty(c).unwrap(), porcelain.lines().count());
|
|
assert_eq!(std::fs::read(c.join(".git/index")).unwrap(), index);
|
|
}
|
|
|
|
#[test]
|
|
fn rail_graph_draws_every_branch_attached_to_main_with_parents_below() {
|
|
let f = Fixture::new();
|
|
let repo = git::open(&f.clone).unwrap();
|
|
let graph = git::build(&repo).unwrap();
|
|
// All lanes, merged ones too, as "show merged" draws them.
|
|
let lanes: Vec<&git::Lane> = graph.lanes.iter().collect();
|
|
let rows = branchgraph::rail::layout(&repo, &graph, &lanes).unwrap();
|
|
let at: std::collections::HashMap<_, _> = rows
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, r)| (r.commit.id, i))
|
|
.collect();
|
|
|
|
let main = &rows[at[&graph.main_tip]];
|
|
assert_eq!(
|
|
(main.col, main.labels.first().map(String::as_str)),
|
|
(0, Some("main"))
|
|
);
|
|
for (i, r) in rows.iter().enumerate() {
|
|
for p in &r.parents {
|
|
assert!(at[p] > i, "{} drawn above its parent", r.commit.summary);
|
|
}
|
|
if let Some(next) = rows.get(i + 1) {
|
|
assert_eq!(
|
|
r.after,
|
|
next.before,
|
|
"columns continue from row {i} to {}",
|
|
i + 1
|
|
);
|
|
}
|
|
assert_eq!(
|
|
r.after.get(r.col).copied().flatten(),
|
|
r.parents.first().copied(),
|
|
"first parent continues the column"
|
|
);
|
|
}
|
|
for lane in &lanes {
|
|
let tip = &rows[at[&lane.tip]];
|
|
assert!(
|
|
tip.labels.contains(&lane.name),
|
|
"{} labelled at its tip",
|
|
lane.name
|
|
);
|
|
// Following first parents from the tip reaches a main row: no lane floats.
|
|
let mut r = tip;
|
|
let mut steps = 0;
|
|
while !r.lanes.is_empty() {
|
|
let p = r
|
|
.parents
|
|
.first()
|
|
.unwrap_or_else(|| panic!("{} ends without reaching main", lane.name));
|
|
r = &rows[at[p]];
|
|
steps += 1;
|
|
assert!(steps < 100);
|
|
}
|
|
}
|
|
// Two lanes fork at the same commit; both columns end there.
|
|
let old = lanes.iter().find(|l| l.name == "feat/old").unwrap();
|
|
let fork_row = &rows[at[&old.fork]];
|
|
assert!(
|
|
fork_row
|
|
.before
|
|
.iter()
|
|
.filter(|c| **c == Some(old.fork))
|
|
.count()
|
|
>= 2,
|
|
"both lanes wait for their shared fork point"
|
|
);
|
|
let long = lanes.iter().find(|l| l.name == "feat/long").unwrap();
|
|
assert_eq!(rows[at[&long.tip]].lanes, ["feat/long"]);
|
|
}
|