use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::config::wtconfig::{self, WtMeta};
use crate::error::Result;
use crate::git::cli::GitCli;
use crate::git::discover::Repo;
use crate::git::{
CommitInfo, Upstream, abbrev_len, ahead_behind, branch_ref, commit_info, default_branch,
enumerate, is_ancestor, local_branches, recent_commits, resolve_hex, status_of, upstream_of,
};
use crate::model::{Commit, MergeState, Pr, PrState, Worktree};
use crate::slug::slugify;
use crate::time::iso8601;
fn same_path(a: &Path, b: &Path) -> bool {
let ca = std::fs::canonicalize(a).unwrap_or_else(|_| a.to_path_buf());
let cb = std::fs::canonicalize(b).unwrap_or_else(|_| b.to_path_buf());
ca == cb
}
pub(crate) fn enumerate_worktrees(repo: &Repo, git: &dyn GitCli) -> Result<Vec<Worktree>> {
let dir = repo.current_workdir().unwrap_or_else(|| repo.git_dir());
let raws = enumerate(git, &dir)?;
let current = repo.current_workdir();
let mut out = Vec::with_capacity(raws.len());
for raw in raws {
if raw.is_bare {
continue;
}
let mut wt = Worktree::new(raw.path.clone());
wt.is_main = raw.is_main;
wt.is_missing = raw.is_missing;
wt.is_detached = raw.is_detached;
wt.branch = raw.branch.clone();
wt.slug = raw.branch.as_deref().map(slugify);
wt.is_current = current
.as_deref()
.is_some_and(|cur| same_path(cur, &raw.path));
out.push(wt);
}
Ok(out)
}
pub(crate) fn enrich_worktree(repo: &Repo, git: &dyn GitCli, abbrev: usize, wt: &mut Worktree) {
if let Some(branch) = wt.branch.clone() {
let meta = wtconfig::read_meta(repo.gix(), &branch);
wt.base_ref = meta.base_ref.clone();
wt.pr = build_pr(&meta);
wt.pr_url = meta.pr_url.clone();
let upstream = upstream_of(repo.gix(), &branch);
if let Some(up) = &upstream {
wt.upstream = Some(up.display.clone());
fill_ahead_behind(git, wt, &branch, up);
}
if !wt.is_missing {
let state = compute_merge_state(repo, wt, &branch, upstream.as_ref());
wt.merge_state = state;
}
}
if wt.is_missing {
return;
}
if let Ok(status) = status_of(git, &wt.path) {
wt.dirty = Some(status.dirty);
wt.has_untracked = Some(status.has_untracked);
}
fill_commits(repo, abbrev, wt);
}
fn fill_ahead_behind(git: &dyn GitCli, wt: &mut Worktree, branch: &str, upstream: &Upstream) {
if !wt.is_missing
&& !upstream.is_gone
&& let Ok((ahead, behind)) =
ahead_behind(git, &wt.path, &upstream.tracking_ref, &branch_ref(branch))
{
wt.ahead = Some(ahead);
wt.behind = Some(behind);
}
}
fn fill_commits(repo: &Repo, abbrev: usize, wt: &mut Worktree) {
let Some(oid) = tip_oid(repo, wt) else {
return;
};
if let Ok(info) = commit_info(repo.gix(), &oid, abbrev) {
wt.commit = Some(to_commit(info));
}
wt.recent_commits = recent_commits(repo.gix(), &oid, abbrev, 5)
.into_iter()
.map(to_commit)
.collect();
}
fn to_commit(info: CommitInfo) -> Commit {
Commit {
hash: info.hash,
subject: info.subject,
author: info.author,
timestamp: iso8601(info.timestamp_unix),
}
}
fn tip_oid(repo: &Repo, wt: &Worktree) -> Option<String> {
match &wt.branch {
Some(branch) => resolve_hex(repo.gix(), &branch_ref(branch)),
None => gix::open(&wt.path)
.ok()
.and_then(|r| r.head_id().ok().map(|id| id.detach().to_string())),
}
}
fn compute_merge_state(
repo: &Repo,
wt: &Worktree,
branch: &str,
upstream: Option<&Upstream>,
) -> Option<MergeState> {
if let Some(up) = upstream
&& !up.is_gone
{
return Some(MergeState::Tracked);
}
let full_ref = branch_ref(branch);
if resolve_hex(repo.gix(), &full_ref).is_some() {
let mut tried: Vec<String> = Vec::new();
for target in [wt.base_ref.clone(), default_branch(repo.gix())]
.into_iter()
.flatten()
{
if target == branch || tried.contains(&target) {
continue;
}
if is_ancestor(repo.gix(), &full_ref, &target) {
return Some(MergeState::Merged { into: Some(target) });
}
tried.push(target);
}
}
if wt.pr.as_ref().map(|pr| pr.state) == Some(PrState::Merged) {
return Some(MergeState::Merged { into: None });
}
if upstream.is_some() {
return Some(MergeState::UpstreamGone);
}
Some(MergeState::NoUpstreamLocal)
}
fn build_pr(meta: &WtMeta) -> Option<Pr> {
let number = meta.pr_number?;
let state = meta
.pr_state
.as_deref()
.and_then(PrState::parse)
.unwrap_or(PrState::Open);
let title = meta.pr_title.clone().unwrap_or_default();
Some(Pr {
number,
state,
title,
})
}
pub(crate) fn build_worktrees(repo: &Repo, git: &dyn GitCli) -> Result<Vec<Worktree>> {
let abbrev = abbrev_len(repo.gix());
let mut worktrees = enumerate_worktrees(repo, git)?;
for wt in &mut worktrees {
enrich_worktree(repo, git, abbrev, wt);
}
Ok(worktrees)
}
fn branch_row_path(branch: &str) -> PathBuf {
PathBuf::from(format!("branch://{branch}"))
}
fn branchless_local_branches(repo: &Repo, worktrees: &[Worktree]) -> Vec<String> {
let checked_out: HashSet<&str> = worktrees
.iter()
.filter_map(|w| w.branch.as_deref())
.collect();
local_branches(repo.gix())
.unwrap_or_default()
.into_iter()
.filter(|name| !checked_out.contains(name.as_str()))
.collect()
}
fn branch_row(branch: &str) -> Worktree {
let mut row = Worktree::new(branch_row_path(branch));
row.has_worktree = false;
row.branch = Some(branch.to_string());
row.slug = Some(slugify(branch));
row
}
fn enrich_branch_row(repo: &Repo, git: &dyn GitCli, abbrev: usize, row: &mut Worktree) {
let Some(branch) = row.branch.clone() else {
return;
};
let meta = wtconfig::read_meta(repo.gix(), &branch);
row.base_ref = meta.base_ref.clone();
row.pr = build_pr(&meta);
row.pr_url = meta.pr_url.clone();
if let Some(up) = upstream_of(repo.gix(), &branch) {
row.upstream = Some(up.display);
}
let full_ref = branch_ref(&branch);
let dir = repo.current_workdir().unwrap_or_else(|| repo.git_dir());
if let Some(base) = row.base_ref.clone().or_else(|| default_branch(repo.gix()))
&& base != branch
&& resolve_hex(repo.gix(), &base).is_some()
&& let Ok((ahead, behind)) = ahead_behind(git, &dir, &base, &full_ref)
{
row.ahead = Some(ahead);
row.behind = Some(behind);
}
if let Some(oid) = resolve_hex(repo.gix(), &full_ref) {
if let Ok(info) = commit_info(repo.gix(), &oid, abbrev) {
row.commit = Some(Commit {
hash: info.hash,
subject: info.subject,
author: info.author,
timestamp: iso8601(info.timestamp_unix),
});
}
row.recent_commits = recent_commits(repo.gix(), &oid, abbrev, 5)
.into_iter()
.map(|info| Commit {
hash: info.hash,
subject: info.subject,
author: info.author,
timestamp: iso8601(info.timestamp_unix),
})
.collect();
}
}
pub(crate) fn enumerate_rows(repo: &Repo, git: &dyn GitCli) -> Result<Vec<Worktree>> {
let mut rows = enumerate_worktrees(repo, git)?;
let names = branchless_local_branches(repo, &rows);
rows.extend(names.iter().map(|name| branch_row(name)));
Ok(rows)
}
pub(crate) fn build_rows(repo: &Repo, git: &dyn GitCli) -> Result<Vec<Worktree>> {
let mut rows = build_worktrees(repo, git)?;
let abbrev = abbrev_len(repo.gix());
for name in branchless_local_branches(repo, &rows) {
let mut row = branch_row(&name);
enrich_branch_row(repo, git, abbrev, &mut row);
rows.push(row);
}
Ok(rows)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum SortValue {
Text(String),
Num(i64),
}
fn sort_value(worktree: &Worktree, key: crate::model::SortKey) -> Option<SortValue> {
use crate::model::SortKey;
match key {
SortKey::Branch => worktree.branch.clone().map(SortValue::Text),
SortKey::Path => Some(SortValue::Text(
worktree.path.to_string_lossy().into_owned(),
)),
SortKey::Ahead => worktree.ahead.map(|a| SortValue::Num(i64::from(a))),
SortKey::Behind => worktree.behind.map(|b| SortValue::Num(i64::from(b))),
SortKey::Dirty => dirty_rank(worktree).map(SortValue::Num),
SortKey::Activity => worktree
.commit
.as_ref()
.and_then(|c| crate::time::parse_iso8601(&c.timestamp))
.map(|unix| SortValue::Num(-unix)),
}
}
fn dirty_rank(worktree: &Worktree) -> Option<i64> {
match worktree.dirty {
None => None,
Some(true) => Some(0),
Some(false) => Some(if worktree.has_untracked == Some(true) {
1
} else {
2
}),
}
}
pub(crate) fn sort_worktrees(worktrees: &mut [Worktree], spec: crate::model::SortSpec) {
worktrees.sort_by(|a, b| {
let ka = sort_value(a, spec.key);
let kb = sort_value(b, spec.key);
match (ka, kb) {
(Some(x), Some(y)) => {
if spec.descending {
y.cmp(&x)
} else {
x.cmp(&y)
}
}
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
pub(crate) fn sort_worktrees_base_first(worktrees: &mut [Worktree], spec: crate::model::SortSpec) {
sort_worktrees(worktrees, spec);
worktrees.sort_by_key(|w| !w.has_worktree);
if let Some(pos) = worktrees.iter().position(|w| w.is_main) {
worktrees[..=pos].rotate_right(1);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GuardStatus {
pub(crate) dirty: bool,
pub(crate) unpushed: bool,
}
impl GuardStatus {
pub(crate) fn blocks(self) -> bool {
self.dirty || self.unpushed
}
}
pub(crate) fn guard_status(worktree: &Worktree, untracked_blocks: bool) -> GuardStatus {
let dirty = worktree.dirty.unwrap_or(!worktree.is_missing)
|| (untracked_blocks && worktree.has_untracked.unwrap_or(false));
let unpushed = worktree.ahead.is_none_or(|ahead| ahead > 0);
GuardStatus { dirty, unpushed }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::cli::RealGit;
use crate::model::Worktree;
use crate::testutil::TestRepo;
use std::path::PathBuf;
#[test]
fn builds_rows_for_main_and_linked() {
let repo = TestRepo::init();
repo.add_worktree("feature/x", "../wt-x");
let r = Repo::discover(repo.root()).unwrap();
let worktrees = build_worktrees(&r, &RealGit).unwrap();
assert_eq!(worktrees.len(), 2);
let main = worktrees.iter().find(|w| w.is_main).unwrap();
assert_eq!(main.branch.as_deref(), Some("main"));
assert!(main.is_current);
assert_eq!(main.dirty, Some(false));
assert!(main.commit.is_some());
assert_eq!(main.commit.as_ref().unwrap().subject, "init");
assert_eq!(main.recent_commits.len(), 1);
assert_eq!(main.recent_commits[0].subject, "init");
let feat = worktrees
.iter()
.find(|w| w.branch.as_deref() == Some("feature/x"))
.unwrap();
assert_eq!(feat.slug.as_deref(), Some("feature-x"));
assert!(!feat.is_current);
}
#[test]
fn detached_worktree_tip_resolved_from_its_own_head() {
let repo = TestRepo::init();
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["worktree", "add", "--detach", "../wt-det", &head]);
let r = Repo::discover(repo.root()).unwrap();
let worktrees = build_worktrees(&r, &RealGit).unwrap();
let det = worktrees
.iter()
.find(|w| w.is_detached)
.expect("detached worktree row");
assert!(det.branch.is_none());
let commit = det.commit.as_ref().expect("tip commit filled from HEAD");
assert_eq!(commit.subject, "init");
assert!(head.starts_with(&commit.hash));
}
#[test]
fn dirty_and_untracked_are_distinguished() {
let repo = TestRepo::init();
repo.write("README.md", "changed\n");
repo.write("scratch.txt", "x\n");
let r = Repo::discover(repo.root()).unwrap();
let worktrees = build_worktrees(&r, &RealGit).unwrap();
let main = &worktrees[0];
assert_eq!(main.dirty, Some(true));
assert_eq!(main.has_untracked, Some(true));
}
#[test]
fn ahead_behind_and_upstream_populated() {
let repo = TestRepo::init();
let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/main", &base]);
repo.git(&["config", "branch.main.remote", "origin"]);
repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
repo.write("a.txt", "1\n");
repo.commit_all("c1");
let r = Repo::discover(repo.root()).unwrap();
let worktrees = build_worktrees(&r, &RealGit).unwrap();
let main = &worktrees[0];
assert_eq!(main.upstream.as_deref(), Some("origin/main"));
assert_eq!(main.ahead, Some(1));
assert_eq!(main.behind, Some(0));
}
#[test]
fn base_ref_and_pr_from_wt_config() {
let repo = TestRepo::init();
wtconfig::write_base_ref(&RealGit, repo.root(), "main", "develop").unwrap();
wtconfig::write_pr(&RealGit, repo.root(), "main", 42, "open", "Add login").unwrap();
let r = Repo::discover(repo.root()).unwrap();
let worktrees = build_worktrees(&r, &RealGit).unwrap();
let main = &worktrees[0];
assert_eq!(main.base_ref.as_deref(), Some("develop"));
let pr = main.pr.as_ref().unwrap();
assert_eq!(pr.number, 42);
assert_eq!(pr.state, PrState::Open);
assert_eq!(pr.title, "Add login");
}
#[test]
fn missing_worktree_keeps_only_admin_fields() {
let repo = TestRepo::init();
repo.add_worktree("gone", "../wt-gone");
wtconfig::write_base_ref(&RealGit, repo.root(), "gone", "main").unwrap();
let linked = repo.root().parent().unwrap().join("wt-gone");
std::fs::remove_dir_all(&linked).unwrap();
let r = Repo::discover(repo.root()).unwrap();
let worktrees = build_worktrees(&r, &RealGit).unwrap();
let gone = worktrees
.iter()
.find(|w| w.branch.as_deref() == Some("gone"))
.unwrap();
assert!(gone.is_missing);
assert_eq!(gone.base_ref.as_deref(), Some("main")); assert!(gone.dirty.is_none());
assert!(gone.ahead.is_none());
assert!(gone.commit.is_none());
assert!(gone.merge_state.is_none());
}
fn merge_wt(
repo: &TestRepo,
branch: &str,
base: Option<&str>,
pr: Option<PrState>,
) -> Worktree {
let mut wt = Worktree::new(repo.root().to_path_buf());
wt.branch = Some(branch.to_string());
wt.base_ref = base.map(str::to_string);
wt.pr = pr.map(|state| Pr {
number: 1,
state,
title: String::new(),
});
wt
}
fn divergent_branch(repo: &TestRepo, branch: &str) {
repo.git(&["checkout", "-q", "-b", branch]);
repo.write(&format!("{branch}.txt"), "x\n");
repo.commit_all("diverge");
repo.git(&["checkout", "-q", "main"]);
}
#[test]
fn merge_state_tracked_with_live_upstream() {
let repo = TestRepo::init();
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
repo.git(&["config", "branch.main.remote", "origin"]);
repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
let r = Repo::discover(repo.root()).unwrap();
let up = upstream_of(r.gix(), "main");
let wt = merge_wt(&repo, "main", None, None);
assert_eq!(
compute_merge_state(&r, &wt, "main", up.as_ref()),
Some(MergeState::Tracked)
);
}
#[test]
fn merge_state_merged_into_base() {
let repo = TestRepo::init();
repo.git(&["branch", "feat"]); let r = Repo::discover(repo.root()).unwrap();
let wt = merge_wt(&repo, "feat", Some("main"), None);
assert_eq!(
compute_merge_state(&r, &wt, "feat", None),
Some(MergeState::Merged {
into: Some("main".into())
})
);
}
#[test]
fn merge_state_merged_into_default_branch() {
let repo = TestRepo::init();
repo.git(&["branch", "feat"]); let r = Repo::discover(repo.root()).unwrap();
let wt = merge_wt(&repo, "feat", None, None);
assert_eq!(
compute_merge_state(&r, &wt, "feat", None),
Some(MergeState::Merged {
into: Some("main".into())
})
);
}
#[test]
fn merge_state_merged_via_pr_when_not_ancestor() {
let repo = TestRepo::init();
divergent_branch(&repo, "feat"); let r = Repo::discover(repo.root()).unwrap();
let wt = merge_wt(&repo, "feat", Some("main"), Some(PrState::Merged));
assert_eq!(
compute_merge_state(&r, &wt, "feat", None),
Some(MergeState::Merged { into: None })
);
}
#[test]
fn merge_state_upstream_gone() {
let repo = TestRepo::init();
divergent_branch(&repo, "feat");
repo.git(&["config", "branch.feat.remote", "origin"]);
repo.git(&["config", "branch.feat.merge", "refs/heads/feat"]);
let r = Repo::discover(repo.root()).unwrap();
let up = upstream_of(r.gix(), "feat");
assert!(up.as_ref().unwrap().is_gone);
let wt = merge_wt(&repo, "feat", Some("main"), None);
assert_eq!(
compute_merge_state(&r, &wt, "feat", up.as_ref()),
Some(MergeState::UpstreamGone)
);
}
#[test]
fn merge_state_no_upstream_local() {
let repo = TestRepo::init();
divergent_branch(&repo, "feat");
let r = Repo::discover(repo.root()).unwrap();
let wt = merge_wt(&repo, "feat", Some("main"), None);
assert_eq!(
compute_merge_state(&r, &wt, "feat", None),
Some(MergeState::NoUpstreamLocal)
);
}
#[test]
fn merge_state_never_reports_branch_merged_into_itself() {
let repo = TestRepo::init();
let r = Repo::discover(repo.root()).unwrap();
let wt = merge_wt(&repo, "main", None, None);
assert_eq!(
compute_merge_state(&r, &wt, "main", None),
Some(MergeState::NoUpstreamLocal)
);
}
#[test]
fn merge_state_ancestry_wins_over_merged_pr() {
let repo = TestRepo::init();
repo.git(&["branch", "feat"]); let r = Repo::discover(repo.root()).unwrap();
let wt = merge_wt(&repo, "feat", Some("main"), Some(PrState::Merged));
assert_eq!(
compute_merge_state(&r, &wt, "feat", None),
Some(MergeState::Merged {
into: Some("main".into())
})
);
}
#[test]
fn bare_repo_lists_no_hub_row() {
let repo = TestRepo::init_bare();
let r = Repo::discover(repo.root()).unwrap();
let worktrees = build_worktrees(&r, &RealGit).unwrap();
assert!(worktrees.is_empty());
}
#[test]
fn build_rows_includes_branchless_branches_with_ahead_behind() {
let repo = TestRepo::init();
divergent_branch(&repo, "feat");
let r = Repo::discover(repo.root()).unwrap();
let rows = build_rows(&r, &RealGit).unwrap();
let main = rows.iter().find(|w| w.is_main).unwrap();
assert!(main.has_worktree);
let feat = rows
.iter()
.find(|w| w.branch.as_deref() == Some("feat"))
.unwrap();
assert!(!feat.has_worktree);
assert_eq!(feat.slug.as_deref(), Some("feat"));
assert_eq!(feat.ahead, Some(1));
assert_eq!(feat.behind, Some(0));
assert_eq!(feat.commit.as_ref().unwrap().subject, "diverge");
}
#[test]
fn build_rows_excludes_checked_out_branches() {
let repo = TestRepo::init();
repo.add_worktree("feature/x", "../wt-x");
let r = Repo::discover(repo.root()).unwrap();
let rows = build_rows(&r, &RealGit).unwrap();
let matches: Vec<_> = rows
.iter()
.filter(|w| w.branch.as_deref() == Some("feature/x"))
.collect();
assert_eq!(matches.len(), 1);
assert!(matches[0].has_worktree);
}
#[test]
fn branch_row_ahead_behind_uses_recorded_base_not_default() {
let repo = TestRepo::init(); repo.git(&["branch", "develop"]); repo.write("m.txt", "1\n");
repo.commit_all("c1"); repo.git(&["checkout", "-q", "-b", "topic", "develop"]); repo.write("t.txt", "x\n");
repo.commit_all("t1"); repo.git(&["checkout", "-q", "main"]);
wtconfig::write_base_ref(&RealGit, repo.root(), "topic", "develop").unwrap();
let r = Repo::discover(repo.root()).unwrap();
let rows = build_rows(&r, &RealGit).unwrap();
let topic = rows
.iter()
.find(|w| w.branch.as_deref() == Some("topic"))
.unwrap();
assert_eq!(topic.base_ref.as_deref(), Some("develop"));
assert_eq!(topic.ahead, Some(1));
assert_eq!(topic.behind, Some(0));
}
#[test]
fn enumerate_rows_adds_unenriched_branch_rows() {
let repo = TestRepo::init();
repo.git(&["branch", "lonely"]);
let r = Repo::discover(repo.root()).unwrap();
let rows = enumerate_rows(&r, &RealGit).unwrap();
let lonely = rows
.iter()
.find(|w| w.branch.as_deref() == Some("lonely"))
.unwrap();
assert!(!lonely.has_worktree);
assert_eq!(lonely.slug.as_deref(), Some("lonely"));
assert!(lonely.ahead.is_none());
assert!(lonely.commit.is_none());
}
#[test]
fn sort_base_first_groups_branch_rows_below_worktrees() {
use crate::model::{SortKey, SortSpec};
let mut main = wt_named("main");
main.is_main = true;
let zebra = wt_named("zebra"); let mut br_a = wt_named("aaa-branch");
br_a.has_worktree = false;
let mut br_z = wt_named("zzz-branch");
br_z.has_worktree = false;
let mut rows = vec![br_z, zebra, br_a, main];
sort_worktrees_base_first(
&mut rows,
SortSpec {
key: SortKey::Branch,
descending: false,
},
);
assert_eq!(
branches(&rows),
vec!["main", "zebra", "aaa-branch", "zzz-branch"]
);
}
fn guard_wt(dirty: Option<bool>, untracked: Option<bool>, ahead: Option<u32>) -> Worktree {
let mut w = Worktree::new(PathBuf::from("/r"));
w.dirty = dirty;
w.has_untracked = untracked;
w.ahead = ahead;
w
}
#[test]
fn sort_by_branch_and_direction() {
use crate::model::{SortKey, SortSpec};
let mut worktrees = vec![wt_named("zebra"), wt_named("alpha"), wt_named("mango")];
sort_worktrees(
&mut worktrees,
SortSpec {
key: SortKey::Branch,
descending: false,
},
);
assert_eq!(branches(&worktrees), vec!["alpha", "mango", "zebra"]);
sort_worktrees(
&mut worktrees,
SortSpec {
key: SortKey::Branch,
descending: true,
},
);
assert_eq!(branches(&worktrees), vec!["zebra", "mango", "alpha"]);
}
#[test]
fn sort_nulls_last_regardless_of_direction() {
use crate::model::{SortKey, SortSpec};
let mut a = wt_named("has-upstream");
a.ahead = Some(5);
let b = wt_named("no-upstream"); let mut worktrees = vec![b.clone(), a.clone()];
sort_worktrees(
&mut worktrees,
SortSpec {
key: SortKey::Ahead,
descending: false,
},
);
assert_eq!(branches(&worktrees), vec!["has-upstream", "no-upstream"]);
sort_worktrees(
&mut worktrees,
SortSpec {
key: SortKey::Ahead,
descending: true,
},
);
assert_eq!(branches(&worktrees), vec!["has-upstream", "no-upstream"]);
}
#[test]
fn sort_dirty_ranks_modified_first() {
use crate::model::{SortKey, SortSpec};
let mut modified = wt_named("modified");
modified.dirty = Some(true);
let mut untracked = wt_named("untracked");
untracked.dirty = Some(false);
untracked.has_untracked = Some(true);
let mut clean = wt_named("clean");
clean.dirty = Some(false);
clean.has_untracked = Some(false);
let mut worktrees = vec![clean, untracked, modified];
sort_worktrees(
&mut worktrees,
SortSpec {
key: SortKey::Dirty,
descending: false,
},
);
assert_eq!(branches(&worktrees), vec!["modified", "untracked", "clean"]);
}
#[test]
fn base_first_pins_primary_regardless_of_sort_direction() {
use crate::model::{SortKey, SortSpec};
let mut base = wt_named("main");
base.is_main = true;
let mut worktrees = vec![wt_named("zebra"), base, wt_named("alpha")];
sort_worktrees_base_first(
&mut worktrees,
SortSpec {
key: SortKey::Branch,
descending: false,
},
);
assert_eq!(branches(&worktrees), vec!["main", "alpha", "zebra"]);
sort_worktrees_base_first(
&mut worktrees,
SortSpec {
key: SortKey::Branch,
descending: true,
},
);
assert_eq!(branches(&worktrees), vec!["main", "zebra", "alpha"]);
}
#[test]
fn base_first_is_plain_sort_when_no_primary() {
use crate::model::{SortKey, SortSpec};
let spec = SortSpec {
key: SortKey::Branch,
descending: false,
};
let mut pinned = vec![wt_named("zebra"), wt_named("alpha"), wt_named("mango")];
let mut plain = pinned.clone();
sort_worktrees_base_first(&mut pinned, spec);
sort_worktrees(&mut plain, spec);
assert_eq!(branches(&pinned), branches(&plain));
assert_eq!(branches(&pinned), vec!["alpha", "mango", "zebra"]);
}
fn wt_named(branch: &str) -> Worktree {
let mut w = Worktree::new(PathBuf::from(format!("/r/{branch}")));
w.branch = Some(branch.to_string());
w
}
fn branches(worktrees: &[Worktree]) -> Vec<&str> {
worktrees
.iter()
.filter_map(|w| w.branch.as_deref())
.collect()
}
#[test]
fn guards_dirty_and_unpushed() {
let g = guard_status(&guard_wt(Some(false), Some(false), Some(0)), false);
assert!(!g.dirty && !g.unpushed && !g.blocks());
assert!(guard_status(&guard_wt(Some(true), Some(false), Some(0)), false).dirty);
assert!(!guard_status(&guard_wt(Some(false), Some(true), Some(0)), false).dirty);
assert!(guard_status(&guard_wt(Some(false), Some(true), Some(0)), true).dirty);
assert!(guard_status(&guard_wt(Some(false), Some(false), Some(3)), false).unpushed);
assert!(guard_status(&guard_wt(Some(false), Some(false), None), false).unpushed);
}
#[test]
fn guard_unknown_dirty_fails_safe_for_present_worktree() {
let mut wt = guard_wt(None, None, Some(0));
assert!(guard_status(&wt, false).dirty);
wt.is_missing = true;
assert!(!guard_status(&wt, false).dirty);
}
}