use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::index::Index;
use crate::inventory::{self, InventoryFile, InventoryStore, LoopMemo};
type InvUpdate = (String, InventoryFile);
#[derive(Debug, Clone, Default)]
pub struct ScanOptions {
pub need_ahead_behind: bool,
pub fresh: bool,
pub inventory_dir: Option<PathBuf>,
pub inventory_ttl_secs: u64,
}
pub(crate) fn git(repo: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.context("git not found in PATH — install git")?;
if !out.status.success() {
bail!(
"git {:?} failed in {}: {}",
args,
repo.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn default_branch(repo: &Path) -> Result<String> {
let (name, _) = default_branch_and_sha(repo)?;
Ok(name)
}
fn default_branch_and_sha(repo: &Path) -> Result<(String, String)> {
if let Ok(sym) = git(
repo,
&["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
) {
if let Some(branch) = sym.strip_prefix("origin/") {
if let Ok(sha) = git(repo, &["rev-parse", &format!("refs/heads/{branch}")]) {
return Ok((branch.to_string(), sha));
}
}
}
for candidate in ["main", "master"] {
if let Ok(sha) = git(
repo,
&["rev-parse", "--verify", &format!("refs/heads/{candidate}")],
) {
return Ok((candidate.to_string(), sha));
}
}
bail!(
"couldn't find the default branch in {} (expected origin/HEAD, main or master)",
repo.display()
)
}
#[derive(Debug, Clone)]
pub struct RepoCandidate {
pub path: PathBuf,
pub repo_name: String,
}
#[derive(Debug, Clone)]
pub struct OpenLoop {
pub root_label: String,
pub repo_name: String,
pub repo_path: PathBuf,
pub branch: String,
pub head_sha: String,
pub last_commit: DateTime<Utc>,
pub ahead: Option<u32>,
pub behind: Option<u32>,
}
impl OpenLoop {
pub fn key(&self) -> String {
format!("{}/{}/{}", self.root_label, self.repo_name, self.branch)
}
}
const SKIP_DIRS: [&str; 2] = ["node_modules", "target"];
fn looks_like_bare(dir: &Path) -> bool {
dir.join("HEAD").is_file() && dir.join("objects").is_dir() && dir.join("refs").is_dir()
}
fn is_repo_candidate(dir: &Path) -> bool {
dir.join(".git").exists() || looks_like_bare(dir)
}
pub fn repo_name_from_common_dir(common_dir: &Path) -> String {
let base = common_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if base == ".git" || base == ".bare" {
return common_dir
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or(base);
}
base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
}
pub fn git_common_dir(path: &Path) -> Result<PathBuf> {
let raw = git(
path,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)?;
Ok(PathBuf::from(raw))
}
pub fn refs_fingerprint(common_dir: &Path) -> i64 {
let mut max = 0_i64;
max = max.max(file_mtime_nanos(&common_dir.join("HEAD")));
max = max.max(file_mtime_nanos(&common_dir.join("packed-refs")));
max = max.max(newest_mtime_in_tree(&common_dir.join("refs")));
max = max.max(newest_mtime_in_tree(&common_dir.join("worktrees")));
max
}
fn file_mtime_nanos(path: &Path) -> i64 {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
.unwrap_or(0)
}
fn newest_mtime_in_tree(dir: &Path) -> i64 {
let mut max = file_mtime_nanos(dir);
let Ok(entries) = std::fs::read_dir(dir) else {
return max;
};
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(ft) if ft.is_dir() => max = max.max(newest_mtime_in_tree(&path)),
_ => max = max.max(file_mtime_nanos(&path)),
}
}
max
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeEntry {
pub path: PathBuf,
pub branch: Option<String>,
pub bare: bool,
pub prunable: bool,
}
pub fn parse_worktree_porcelain(out: &str) -> Vec<WorktreeEntry> {
let mut entries = Vec::new();
let mut current: Option<WorktreeEntry> = None;
for line in out.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
if let Some(e) = current.take() {
entries.push(e);
}
current = Some(WorktreeEntry {
path: PathBuf::from(p),
branch: None,
bare: false,
prunable: false,
});
} else if let Some(e) = current.as_mut() {
if let Some(b) = line.strip_prefix("branch ") {
e.branch = Some(b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
} else if line == "bare" {
e.bare = true;
} else if line == "prunable" || line.starts_with("prunable ") {
e.prunable = true;
}
}
}
if let Some(e) = current.take() {
entries.push(e);
}
entries
}
fn normalize_path(path: PathBuf) -> PathBuf {
std::fs::canonicalize(&path).unwrap_or(path)
}
pub fn worktree_map(repo: &Path) -> Result<std::collections::HashMap<String, PathBuf>> {
let raw = git(repo, &["worktree", "list", "--porcelain"])?;
Ok(parse_worktree_porcelain(&raw)
.into_iter()
.filter(|e| !e.bare)
.filter_map(|e| e.branch.map(|b| (b, normalize_path(e.path))))
.collect())
}
pub fn find_repos(roots: &[PathBuf], scan_depth: usize) -> (Vec<RepoCandidate>, Vec<String>) {
find_repos_cached(roots, scan_depth, None)
}
pub fn find_repos_cached(
roots: &[PathBuf],
scan_depth: usize,
index: Option<&Index>,
) -> (Vec<RepoCandidate>, Vec<String>) {
let mut candidates = Vec::new();
for root in roots {
walk(root, 0, scan_depth, &mut candidates);
}
dedup_candidates_cached(candidates, index)
}
fn dedup_candidates_cached(
candidates: Vec<PathBuf>,
index: Option<&Index>,
) -> (Vec<RepoCandidate>, Vec<String>) {
use std::collections::HashMap;
let mut by_common: HashMap<PathBuf, RepoCandidate> = HashMap::new();
let mut warnings = Vec::new();
for candidate in candidates {
let cached = index.and_then(|idx| idx.cached_common_dir(&candidate));
let common_result = if let Some((_hash, common_dir)) = cached {
Ok(common_dir)
} else {
match git_common_dir(&candidate) {
Ok(common) => {
if let Some(idx) = index {
let hash = crate::inventory::common_dir_hash(&common);
idx.put_repo_common_dir(&candidate, &hash, &common);
}
Ok(common)
}
Err(e) => Err(e),
}
};
match common_result {
Ok(common) => {
let repo_name = repo_name_from_common_dir(&common);
by_common.entry(common).or_insert(RepoCandidate {
path: candidate,
repo_name,
});
}
Err(e) => {
warnings.push(format!("{}: {e:#}", candidate.display()));
}
}
}
let mut repos: Vec<RepoCandidate> = by_common.into_values().collect();
repos.sort_by(|a, b| a.path.cmp(&b.path));
(repos, warnings)
}
fn walk(dir: &Path, depth: usize, scan_depth: usize, candidates: &mut Vec<PathBuf>) {
if is_repo_candidate(dir) {
candidates.push(dir.to_path_buf());
return;
}
if depth >= scan_depth {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if !path.is_dir() || name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
walk(&path, depth + 1, scan_depth, candidates);
}
}
pub fn repo_name_hint(path: &Path) -> String {
let base = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
}
pub fn open_loops(
repo: &Path,
root_label: &str,
opts: &ScanOptions,
) -> Result<(Vec<OpenLoop>, Option<InvUpdate>)> {
open_loops_indexed(repo, root_label, opts, None)
}
pub fn open_loops_indexed(
repo: &Path,
root_label: &str,
opts: &ScanOptions,
index: Option<&Index>,
) -> Result<(Vec<OpenLoop>, Option<InvUpdate>)> {
let (default, default_sha) = default_branch_and_sha(repo)?;
let common_dir = git_common_dir(repo)?;
let repo_name = repo_name_from_common_dir(&common_dir);
let refs_fp = refs_fingerprint(&common_dir);
let gate_hash = inventory::common_dir_hash(&common_dir);
if let Some(idx) = index {
if !opts.fresh {
if let Some(rows) = idx.cached_loops(&gate_hash, refs_fp, &default_sha) {
let serves = !opts.need_ahead_behind || rows.iter().all(|r| r.ahead.is_some());
if serves {
let loops = rows
.into_iter()
.map(|r| OpenLoop {
root_label: root_label.to_string(),
repo_name: repo_name.clone(),
repo_path: r.worktree_path,
branch: r.branch,
head_sha: r.head_sha,
last_commit: r.last_commit,
ahead: r.ahead,
behind: r.behind,
})
.collect();
return Ok((loops, None));
}
}
}
}
let worktrees = worktree_map(repo).unwrap_or_else(|e| {
eprintln!(
"warning: git worktree list failed in {}: {e:#}; session matching falls back to the repo path",
repo.display()
);
std::collections::HashMap::new()
});
let merged: std::collections::HashSet<String> = git(
repo,
&["branch", "--merged", &default, "--format=%(refname:short)"],
)?
.lines()
.map(|s| s.trim().to_string())
.collect();
let raw = git(
repo,
&[
"for-each-ref",
"refs/heads",
"--format=%(refname:short)%09%(objectname)%09%(committerdate:iso8601-strict)",
],
)?;
let use_inventory = opts.need_ahead_behind && opts.inventory_dir.is_some();
let use_inventory = use_inventory && !default_sha.is_empty();
let hash = if use_inventory {
inventory::common_dir_hash(&common_dir)
} else {
String::new()
};
let existing: Option<InventoryFile> = if use_inventory && !opts.fresh {
if let Some(inv_dir) = &opts.inventory_dir {
let store = InventoryStore {
dir: inv_dir.clone(),
};
store.load(&hash)
} else {
None
}
} else {
None
};
let now = Utc::now();
let repo_canonical = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
let mut new_memos: Vec<LoopMemo> = Vec::new();
let mut result = Vec::new();
for line in raw.lines() {
let mut parts = line.split('\t');
let (Some(branch), Some(sha), Some(date)) = (parts.next(), parts.next(), parts.next())
else {
eprintln!("warning: unexpected line from git for-each-ref ignored: {line:?}");
continue;
};
if branch == default || merged.contains(branch) {
continue;
}
let (ahead, behind) = if opts.need_ahead_behind {
let cached = if use_inventory {
existing.as_ref().and_then(|f| {
inventory::lookup_ahead_behind(
f,
branch,
sha,
&default_sha,
opts.inventory_ttl_secs,
now,
)
})
} else {
None
};
let (a, b) = if let Some(hit) = cached {
hit
} else {
let counts = git(
repo,
&[
"rev-list",
"--left-right",
"--count",
&format!("{default}...{branch}"),
],
)?;
let mut c = counts.split_whitespace();
let behind_val: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
let ahead_val: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
(ahead_val, behind_val)
};
if use_inventory {
new_memos.push(LoopMemo {
branch: branch.to_string(),
head_sha: sha.to_string(),
ab_base_sha: default_sha.clone(),
ahead: a,
behind: b,
});
}
(Some(a), Some(b))
} else {
(None, None)
};
let last_commit = DateTime::parse_from_rfc3339(date)
.with_context(|| format!("invalid date from git: {date}"))?
.with_timezone(&Utc);
let repo_path = worktrees
.get(branch)
.cloned()
.unwrap_or_else(|| repo.to_path_buf());
result.push(OpenLoop {
root_label: root_label.to_string(),
repo_name: repo_name.clone(),
repo_path,
branch: branch.to_string(),
head_sha: sha.to_string(),
last_commit,
ahead,
behind,
});
}
let inventory_update = if use_inventory {
Some((
hash,
InventoryFile {
repo_path: repo_canonical,
indexed_at: now,
loops: new_memos,
},
))
} else {
None
};
if let Some(idx) = index {
let rows: Vec<crate::index::LoopRow> = result
.iter()
.map(|l| crate::index::LoopRow {
branch: l.branch.clone(),
head_sha: l.head_sha.clone(),
base_sha: default_sha.clone(),
ahead: l.ahead,
behind: l.behind,
last_commit: l.last_commit,
worktree_path: l.repo_path.clone(),
})
.collect();
idx.put_loops(
&gate_hash,
repo,
&common_dir,
&default,
&default_sha,
refs_fp,
&rows,
);
}
Ok((result, inventory_update))
}
pub fn scan(
roots: &[PathBuf],
labels: &[(PathBuf, String)],
scan_depth: usize,
opts: &ScanOptions,
repo_filter: Option<&str>,
) -> (Vec<OpenLoop>, Vec<String>, Vec<InvUpdate>) {
scan_indexed(roots, labels, scan_depth, opts, repo_filter, None)
}
struct GateInputs {
default: String,
default_sha: String,
common_dir: PathBuf,
refs_fp: i64,
gate_hash: String,
}
pub fn scan_indexed(
roots: &[PathBuf],
labels: &[(PathBuf, String)],
scan_depth: usize,
opts: &ScanOptions,
repo_filter: Option<&str>,
index: Option<&Index>,
) -> (Vec<OpenLoop>, Vec<String>, Vec<InvUpdate>) {
let (mut repos, mut warnings) = find_repos_cached(roots, scan_depth, index);
if let Some(filter) = repo_filter {
let needle = filter.to_lowercase();
repos.retain(|r| r.repo_name.to_lowercase().contains(&needle));
}
let mut all = Vec::new();
let mut inventory_updates = Vec::new();
let Some(idx) = index else {
let misses: Vec<&RepoCandidate> = repos.iter().collect();
recompute_misses(&misses, &[], labels, opts, None, &mut all, &mut warnings)
.into_iter()
.for_each(|u| inventory_updates.push(u));
return (all, warnings, inventory_updates);
};
let gate_inputs: Vec<Result<GateInputs>> = std::thread::scope(|s| {
let handles: Vec<_> = repos
.iter()
.map(|repo| {
let path = repo.path.clone();
s.spawn(move || compute_gate_inputs(&path))
})
.collect();
handles
.into_iter()
.map(|h| {
h.join()
.unwrap_or_else(|_| Err(anyhow::anyhow!("panic while probing repository")))
})
.collect()
});
let mut misses: Vec<&RepoCandidate> = Vec::new();
let mut miss_inputs: Vec<GateInputs> = Vec::new();
for (repo, inputs) in repos.iter().zip(gate_inputs) {
let inputs = match inputs {
Ok(i) => i,
Err(e) => {
warnings.push(format!("{}: {e:#}", repo.path.display()));
continue;
}
};
let label = crate::config::label_for_repo(labels, &repo.path);
let hit = if opts.fresh {
None
} else {
gate_lookup(&label, opts, idx, &inputs)
};
match hit {
Some(mut loops) => all.append(&mut loops),
None => {
misses.push(repo);
miss_inputs.push(inputs);
}
}
}
recompute_misses(
&misses,
&miss_inputs,
labels,
opts,
index,
&mut all,
&mut warnings,
)
.into_iter()
.for_each(|u| inventory_updates.push(u));
(all, warnings, inventory_updates)
}
fn recompute_misses(
misses: &[&RepoCandidate],
gate_inputs: &[GateInputs],
labels: &[(PathBuf, String)],
opts: &ScanOptions,
index: Option<&Index>,
all: &mut Vec<OpenLoop>,
warnings: &mut Vec<String>,
) -> Vec<InvUpdate> {
let mut inventory_updates = Vec::new();
let results: Vec<Result<(Vec<OpenLoop>, Option<InvUpdate>)>> = std::thread::scope(|s| {
let handles: Vec<_> = misses
.iter()
.map(|repo| {
let label = crate::config::label_for_repo(labels, &repo.path);
let path = repo.path.clone();
s.spawn(move || open_loops(&path, &label, opts))
})
.collect();
handles
.into_iter()
.map(|h| {
h.join()
.unwrap_or_else(|_| Err(anyhow::anyhow!("panic while scanning repository")))
})
.collect()
});
for (i, (repo, res)) in misses.iter().zip(results).enumerate() {
match res {
Ok((loops, inv)) => {
if let Some(idx) = index {
if let Some(inputs) = gate_inputs.get(i) {
write_through(&repo.path, &loops, idx, inputs);
}
}
all.extend(loops);
if let Some(update) = inv {
inventory_updates.push(update);
}
}
Err(e) => warnings.push(format!("{}: {e:#}", repo.path.display())),
}
}
inventory_updates
}
fn compute_gate_inputs(repo: &Path) -> Result<GateInputs> {
let (default, default_sha) = default_branch_and_sha(repo)?;
let common_dir = git_common_dir(repo)?;
let refs_fp = refs_fingerprint(&common_dir);
let gate_hash = inventory::common_dir_hash(&common_dir);
Ok(GateInputs {
default,
default_sha,
common_dir,
refs_fp,
gate_hash,
})
}
fn gate_lookup(
label: &str,
opts: &ScanOptions,
idx: &Index,
inputs: &GateInputs,
) -> Option<Vec<OpenLoop>> {
let rows = idx.cached_loops(&inputs.gate_hash, inputs.refs_fp, &inputs.default_sha)?;
if opts.need_ahead_behind && !rows.iter().all(|r| r.ahead.is_some()) {
return None;
}
let repo_name = repo_name_from_common_dir(&inputs.common_dir);
let loops = rows
.into_iter()
.map(|r| OpenLoop {
root_label: label.to_string(),
repo_name: repo_name.clone(),
repo_path: r.worktree_path,
branch: r.branch,
head_sha: r.head_sha,
last_commit: r.last_commit,
ahead: r.ahead,
behind: r.behind,
})
.collect();
Some(loops)
}
fn write_through(repo: &Path, loops: &[OpenLoop], idx: &Index, inputs: &GateInputs) {
let rows: Vec<crate::index::LoopRow> = loops
.iter()
.map(|l| crate::index::LoopRow {
branch: l.branch.clone(),
head_sha: l.head_sha.clone(),
base_sha: inputs.default_sha.clone(),
ahead: l.ahead,
behind: l.behind,
last_commit: l.last_commit,
worktree_path: l.repo_path.clone(),
})
.collect();
idx.put_loops(
&inputs.gate_hash,
repo,
&inputs.common_dir,
&inputs.default,
&inputs.default_sha,
inputs.refs_fp,
&rows,
);
}
pub fn git_log(repo: &Path, default: &str, branch: &str) -> Result<String> {
git(repo, &["log", "--oneline", &format!("{default}..{branch}")])
}
pub fn diffstat(repo: &Path, default: &str, branch: &str) -> Result<String> {
git(repo, &["diff", "--stat", &format!("{default}...{branch}")])
}
pub fn commit_window(
repo: &Path,
default: &str,
branch: &str,
) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
let raw = git(
repo,
&["log", "--format=%cI", &format!("{default}..{branch}")],
)?;
let mut dates: Vec<DateTime<Utc>> = raw
.lines()
.filter_map(|l| DateTime::parse_from_rfc3339(l.trim()).ok())
.map(|d| d.with_timezone(&Utc))
.collect();
if dates.is_empty() {
let head = git(repo, &["log", "-1", "--format=%cI", branch])?;
dates.push(DateTime::parse_from_rfc3339(head.trim())?.with_timezone(&Utc));
}
let min = dates
.iter()
.min()
.copied()
.ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
let max = dates
.iter()
.max()
.copied()
.ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
Ok((min, max))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::Index;
use crate::testutil;
fn open_loops_simple(
repo: &std::path::Path,
root_label: &str,
need_ahead_behind: bool,
) -> Vec<OpenLoop> {
let opts = ScanOptions {
need_ahead_behind,
..ScanOptions::default()
};
open_loops(repo, root_label, &opts).unwrap().0
}
fn scan_simple(
roots: &[PathBuf],
labels: &[(PathBuf, String)],
depth: usize,
need_ahead_behind: bool,
filter: Option<&str>,
) -> (Vec<OpenLoop>, Vec<String>) {
let opts = ScanOptions {
need_ahead_behind,
..ScanOptions::default()
};
let (loops, warnings, _inv) = scan(roots, labels, depth, &opts, filter);
(loops, warnings)
}
fn assert_same_path(actual: &std::path::Path, expected: &std::path::Path) {
let a = std::fs::canonicalize(actual).unwrap_or_else(|_| actual.to_path_buf());
let b = std::fs::canonicalize(expected).unwrap_or_else(|_| expected.to_path_buf());
assert_eq!(a, b);
}
#[test]
fn default_branch_detects_main() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
assert_eq!(default_branch(&repo).unwrap(), "main");
}
#[test]
fn default_branch_honours_origin_head_when_target_is_local() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo); testutil::git(&repo, &["branch", "develop"]); testutil::git(
&repo,
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/develop",
],
);
assert_eq!(default_branch(&repo).unwrap(), "develop");
}
#[test]
fn default_branch_falls_back_when_origin_head_target_missing() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo); testutil::git(
&repo,
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/ghost",
],
);
assert_eq!(default_branch(&repo).unwrap(), "main");
}
#[test]
fn git_fails_with_contextual_message() {
let tmp = tempfile::tempdir().unwrap();
let err = git(tmp.path(), &["status"]).unwrap_err();
assert!(err.to_string().contains(&tmp.path().display().to_string()));
}
#[test]
fn find_repos_dedups_container_and_worktrees() {
let tmp = tempfile::tempdir().unwrap();
let container = tmp.path().join("my-app");
testutil::init_bare_worktree_container(&container);
let dev = container.join("dev");
testutil::add_named_worktree(&container, "dev", "dev");
let (repos, warnings) = find_repos(&[container.clone(), dev], 4);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].path, container);
}
#[test]
fn find_repos_respects_scan_depth_and_skips_hidden() {
let tmp = tempfile::tempdir().unwrap();
testutil::init_repo(&tmp.path().join("a/b/c/repo-deep"));
testutil::init_repo(&tmp.path().join("a/b/repo-mid"));
testutil::init_repo(&tmp.path().join("repo-shallow"));
testutil::init_repo(&tmp.path().join(".hidden/repo3"));
let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
let names: Vec<_> = repos
.iter()
.filter_map(|r| r.path.file_name())
.map(|n| n.to_string_lossy().into_owned())
.collect();
assert!(names.contains(&"repo-deep".to_string()));
assert!(names.contains(&"repo-mid".to_string()));
assert!(names.contains(&"repo-shallow".to_string()));
assert!(!names.contains(&"repo3".to_string()));
let (shallow, _) = find_repos(&[tmp.path().to_path_buf()], 2);
let shallow_names: Vec<_> = shallow
.iter()
.filter_map(|r| r.path.file_name())
.map(|n| n.to_string_lossy().into_owned())
.collect();
assert!(!shallow_names.contains(&"repo-deep".to_string()));
assert!(shallow_names.contains(&"repo-shallow".to_string()));
}
#[test]
fn find_repos_finds_normal_git_dir_repo() {
let tmp = tempfile::tempdir().unwrap();
testutil::init_repo(&tmp.path().join("app"));
let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
assert_eq!(repos.len(), 1);
}
#[test]
fn find_repos_finds_bare_worktree_container_via_git_file() {
let tmp = tempfile::tempdir().unwrap();
let container = tmp.path().join("my-app");
testutil::init_bare_worktree_container(&container);
let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].path, container);
}
#[test]
fn find_repos_finds_pure_bare_repo() {
let tmp = tempfile::tempdir().unwrap();
let bare = tmp.path().join("foo.git");
testutil::init_bare_repo(&bare);
testutil::seed_bare_main(&bare);
let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].path, bare);
}
#[test]
fn open_loops_uses_common_dir_repo_name_in_bare_layout() {
let tmp = tempfile::tempdir().unwrap();
let container = tmp.path().join("my-app");
testutil::init_bare_worktree_container(&container);
testutil::add_named_worktree(&container, "dev", "dev");
testutil::add_branch_on_bare(&container.join(".bare"), "feat/x", "x.txt");
let loops = open_loops_simple(&container, "root", true);
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].repo_name, "my-app");
assert_eq!(loops[0].branch, "feat/x");
assert_eq!(loops[0].key(), "root/my-app/feat/x");
}
#[test]
fn open_loops_bare_root_repo_name_strips_dot_git_suffix() {
let tmp = tempfile::tempdir().unwrap();
let bare = tmp.path().join("foo.git");
testutil::init_bare_repo(&bare);
testutil::seed_bare_main(&bare);
testutil::add_branch_on_bare(&bare, "feat/y", "y.txt");
let loops = open_loops_simple(&bare, "r", true);
assert_eq!(loops[0].repo_name, "foo");
}
#[test]
fn open_loops_finds_unmerged_ignores_merged_and_default() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
testutil::git(&repo, &["branch", "merged"]);
let loops = open_loops_simple(&repo, "root", true);
assert_eq!(loops.len(), 1);
let l = &loops[0];
assert_eq!(l.branch, "feat/x");
assert_eq!(l.repo_name, "app");
assert_eq!(l.root_label, "root");
assert_eq!(l.key(), "root/app/feat/x");
assert_eq!(l.ahead, Some(1));
assert_eq!(l.behind, Some(0));
assert_eq!(l.head_sha.len(), 40);
}
#[test]
fn open_loops_sets_repo_path_to_worktree_when_branch_checked_out() {
let tmp = tempfile::tempdir().unwrap();
let container = tmp.path().join("my-app");
testutil::init_bare_worktree_container(&container);
testutil::add_worktree_with_commit(&container, "feat-x", "feat/x", "x.txt");
let loops = open_loops_simple(&container, "root", true);
let lp = loops
.iter()
.find(|l| l.branch == "feat/x")
.expect("feat/x loop");
assert_same_path(&lp.repo_path, &container.join("feat-x"));
}
#[test]
fn open_loops_falls_back_to_container_when_branch_has_no_worktree() {
let tmp = tempfile::tempdir().unwrap();
let container = tmp.path().join("my-app");
testutil::init_bare_worktree_container(&container);
testutil::add_branch_on_bare(&container.join(".bare"), "feat/y", "y.txt");
let loops = open_loops_simple(&container, "root", true);
let lp = loops
.iter()
.find(|l| l.branch == "feat/y")
.expect("feat/y loop");
assert_eq!(lp.repo_path, container);
}
#[test]
fn open_loops_normal_repo_keeps_repo_path_as_repo_dir() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt"); let loops = open_loops_simple(&repo, "root", true);
assert_eq!(loops[0].branch, "feat/x");
assert_eq!(loops[0].repo_path, repo); }
#[test]
fn open_loops_skips_rev_list_when_need_ahead_behind_false() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let loops = open_loops_simple(&repo, "root", false);
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].ahead, None);
assert_eq!(loops[0].behind, None);
}
#[test]
fn open_loops_computes_ahead_behind_when_need_ahead_behind_true() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let loops = open_loops_simple(&repo, "root", true);
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].ahead, Some(1));
assert_eq!(loops[0].behind, Some(0));
}
#[test]
fn open_loops_reuses_inventory_memo_on_repeated_scan() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let inv_dir = tmp.path().join("inv");
let opts = ScanOptions {
need_ahead_behind: true,
fresh: false,
inventory_dir: Some(inv_dir.clone()),
inventory_ttl_secs: 0,
};
let (loops1, inv1) = open_loops(&repo, "root", &opts).unwrap();
assert_eq!(loops1.len(), 1);
assert_eq!(loops1[0].ahead, Some(1));
let (hash, file) = inv1.unwrap();
let store = InventoryStore {
dir: inv_dir.clone(),
};
store.save(&hash, &file).unwrap();
let (loops2, inv2) = open_loops(&repo, "root", &opts).unwrap();
assert_eq!(loops2.len(), 1);
assert_eq!(loops2[0].ahead, Some(1));
assert_eq!(loops2[0].behind, Some(0));
assert!(inv2.is_some());
}
#[test]
fn open_loops_fresh_ignores_inventory_memo() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let inv_dir = tmp.path().join("inv");
let common = git_common_dir(&repo).unwrap();
let hash = crate::inventory::common_dir_hash(&common);
let store = InventoryStore {
dir: inv_dir.clone(),
};
let fake_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let stub_file = InventoryFile {
repo_path: repo.clone(),
indexed_at: chrono::Utc::now(),
loops: vec![LoopMemo {
branch: "feat/x".to_string(),
head_sha: fake_sha.to_string(),
ab_base_sha: fake_sha.to_string(),
ahead: 99,
behind: 99,
}],
};
store.save(&hash, &stub_file).unwrap();
let opts = ScanOptions {
need_ahead_behind: true,
fresh: true, inventory_dir: Some(inv_dir.clone()),
inventory_ttl_secs: 0,
};
let (loops, _) = open_loops(&repo, "root", &opts).unwrap();
assert_eq!(loops[0].ahead, Some(1));
assert_eq!(loops[0].behind, Some(0));
}
#[test]
fn scan_repo_filter_pushdown_skips_non_matching_repos() {
let tmp = tempfile::tempdir().unwrap();
let api = tmp.path().join("api-service");
let web = tmp.path().join("web-app");
testutil::init_repo(&api);
testutil::init_repo(&web);
testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
testutil::add_branch_with_commit(&web, "feat/web", "w.txt");
let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
let (loops, _) = scan_simple(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].repo_name, "api-service");
assert_eq!(loops[0].branch, "feat/api");
}
#[test]
fn repo_name_hint_strips_dot_git_suffix() {
assert_eq!(repo_name_hint(std::path::Path::new("/srv/foo.git")), "foo");
}
#[test]
fn scan_repo_filter_is_case_insensitive() {
let tmp = tempfile::tempdir().unwrap();
let api = tmp.path().join("API-Service");
testutil::init_repo(&api);
testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
let (loops, _) = scan_simple(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].repo_name, "API-Service");
}
#[test]
fn scan_repo_filter_matching_nothing_yields_no_loops() {
let tmp = tempfile::tempdir().unwrap();
let api = tmp.path().join("api-service");
testutil::init_repo(&api);
testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
let (loops, warnings) = scan_simple(
&[tmp.path().to_path_buf()],
&labels,
4,
false,
Some("zzz-nope"),
);
assert!(loops.is_empty());
assert!(
warnings.is_empty(),
"filtered-out repos must not warn: {warnings:?}"
);
}
#[test]
fn scan_aggregates_repos_and_reports_warning_without_aborting() {
let tmp = tempfile::tempdir().unwrap();
let good = tmp.path().join("good");
testutil::init_repo(&good);
testutil::add_branch_with_commit(&good, "feat/ok", "ok.txt");
let empty = tmp.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
testutil::git(&empty, &["init", "-b", "main"]);
let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
let (loops, warnings) = scan_simple(&[tmp.path().to_path_buf()], &labels, 4, true, None);
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].key(), "r/good/feat/ok");
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("empty"));
}
#[test]
fn context_helpers_return_commits_and_window() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let log = git_log(&repo, "main", "feat/x").unwrap();
assert!(log.contains("wip feat/x"));
let stat = diffstat(&repo, "main", "feat/x").unwrap();
assert!(stat.contains("x.txt"));
let (start, end) = commit_window(&repo, "main", "feat/x").unwrap();
assert!(start <= end);
}
#[test]
fn default_branch_detects_master_fallback() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
testutil::git(repo, &["init", "-b", "master"]);
std::fs::write(repo.join("a.txt"), "a").unwrap();
testutil::git(repo, &["add", "."]);
testutil::git(repo, &["commit", "-m", "init"]);
assert_eq!(default_branch(repo).unwrap(), "master");
}
#[test]
fn default_branch_errors_without_main_or_master() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
testutil::git(repo, &["init", "-b", "trunk"]);
let err = default_branch(repo).unwrap_err();
assert!(err.to_string().contains("couldn't find the default branch"));
}
#[test]
fn git_common_dir_resolves_normal_and_bare_pointer() {
let tmp = tempfile::tempdir().unwrap();
let normal = tmp.path().join("app");
testutil::init_repo(&normal);
let normal_common = git_common_dir(&normal).unwrap();
assert!(normal_common.ends_with(".git"));
let container = tmp.path().join("container");
testutil::init_bare_worktree_container(&container);
let bare_common = git_common_dir(&container).unwrap();
assert!(bare_common.ends_with(".bare"));
}
#[test]
fn parse_worktree_porcelain_extracts_branches_and_flags() {
let out = "\
worktree /home/u/app/main
HEAD aaaaaaaa
branch refs/heads/main
worktree /home/u/app/feat-x
HEAD bbbbbbbb
branch refs/heads/feat/x
worktree /home/u/app/detached
HEAD cccccccc
detached
worktree /home/u/app/.bare
bare
";
let entries = parse_worktree_porcelain(out);
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].branch.as_deref(), Some("main"));
assert_eq!(
entries[0].path,
std::path::PathBuf::from("/home/u/app/main")
);
assert_eq!(entries[1].branch.as_deref(), Some("feat/x")); assert_eq!(entries[2].branch, None); assert!(entries[3].bare);
assert_eq!(entries[3].branch, None);
}
#[test]
fn parse_worktree_porcelain_marks_prunable_and_handles_empty() {
assert!(parse_worktree_porcelain("").is_empty());
let out = "worktree /gone\nprunable gitdir file points to non-existent location\n";
let entries = parse_worktree_porcelain(out);
assert_eq!(entries.len(), 1);
assert!(entries[0].prunable);
assert_eq!(entries[0].branch, None);
}
#[test]
fn worktree_map_maps_checked_out_branches_to_paths() {
let tmp = tempfile::tempdir().unwrap();
let container = tmp.path().join("my-app");
testutil::init_bare_worktree_container(&container); testutil::add_named_worktree(&container, "dev", "dev");
let map = worktree_map(&container).unwrap();
assert_same_path(map.get("main").unwrap(), &container.join("main"));
assert_same_path(map.get("dev").unwrap(), &container.join("dev"));
assert!(!map.values().any(|p| p.ends_with(".bare")));
}
#[test]
fn worktree_map_errors_on_non_git_dir() {
let tmp = tempfile::tempdir().unwrap();
assert!(worktree_map(tmp.path()).is_err());
}
#[test]
fn parse_worktree_porcelain_ignores_lines_before_first_worktree() {
let out = "branch refs/heads/orphan\nHEAD deadbeef\nworktree /home/u/app/main\nbranch refs/heads/main\n";
let entries = parse_worktree_porcelain(out);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].path,
std::path::PathBuf::from("/home/u/app/main")
);
assert_eq!(entries[0].branch.as_deref(), Some("main"));
}
#[test]
fn repo_name_from_common_dir_table() {
use std::path::Path;
let cases: &[(&str, &str)] = &[
("/home/u/my-app/.bare", "my-app"),
("/home/u/app/.git", "app"),
("/srv/git/foo.git", "foo"),
("/srv/git/myproject", "myproject"),
];
for (common, want) in cases {
assert_eq!(
repo_name_from_common_dir(Path::new(common)),
*want,
"common_dir={common}"
);
}
}
#[test]
fn find_repos_cached_populates_index() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
let index = Index::open_in_memory();
let (repos, warnings) = find_repos_cached(&[tmp.path().to_path_buf()], 4, Some(&index));
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
assert_eq!(repos.len(), 1);
let (hash, cd) = index
.cached_common_dir(&repo)
.expect("index should have cached the common_dir after find_repos_cached");
assert!(!hash.is_empty());
assert!(
cd.ends_with(".git"),
"common_dir should end with .git, got: {cd:?}"
);
}
#[test]
fn dedup_candidates_cached_uses_index_on_second_call() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
let index = Index::open_in_memory();
let sentinel_hash = "sentinel_hash_no_git";
let sentinel_cd = repo.join(".git"); index.put_repo_common_dir(&repo, sentinel_hash, &sentinel_cd);
let (repos, warnings) = dedup_candidates_cached(vec![repo.clone()], Some(&index));
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
assert_eq!(repos.len(), 1);
let (got_hash, _) = index
.cached_common_dir(&repo)
.expect("index entry must still exist");
assert_eq!(
got_hash, sentinel_hash,
"sentinel hash changed — git was called instead of using cache"
);
}
#[test]
fn dedup_cached_n_worktrees_yields_one_repo() {
let tmp = tempfile::tempdir().unwrap();
let container = tmp.path().join("my-app");
testutil::init_bare_worktree_container(&container);
let dev = container.join("dev");
testutil::add_named_worktree(&container, "dev", "dev");
let index = Index::open_in_memory();
let candidates = vec![container.clone(), dev.clone()];
let (repos, warnings) = dedup_candidates_cached(candidates, Some(&index));
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
assert_eq!(
repos.len(),
1,
"N worktrees must dedup to 1 repo, got: {repos:?}"
);
}
fn open_loops_indexed_simple(
repo: &std::path::Path,
idx: Option<&Index>,
fresh: bool,
) -> Vec<OpenLoop> {
let opts = ScanOptions {
need_ahead_behind: true,
fresh,
..ScanOptions::default()
};
open_loops_indexed(repo, "root", &opts, idx).unwrap().0
}
#[test]
fn warm_scan_unchanged_refs_skips_rev_list() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let index = Index::open_in_memory();
let first = open_loops_indexed_simple(&repo, Some(&index), false);
assert_eq!(first.len(), 1);
assert_eq!(first[0].ahead, Some(1));
assert_eq!(first[0].behind, Some(0));
let common = git_common_dir(&repo).unwrap();
let hash = crate::inventory::common_dir_hash(&common);
let refs_fp = refs_fingerprint(&common);
let (default, default_sha) = default_branch_and_sha(&repo).unwrap();
let poisoned = vec![crate::index::LoopRow {
branch: "feat/x".into(),
head_sha: first[0].head_sha.clone(),
base_sha: default_sha.clone(),
ahead: Some(999),
behind: Some(888),
last_commit: first[0].last_commit,
worktree_path: first[0].repo_path.clone(),
}];
index.put_loops(
&hash,
&repo,
&common,
&default,
&default_sha,
refs_fp,
&poisoned,
);
let second = open_loops_indexed_simple(&repo, Some(&index), false);
assert_eq!(second.len(), 1);
assert_eq!(
second[0].ahead,
Some(999),
"gate must serve cached ahead — git was re-run if this is 1"
);
assert_eq!(second[0].behind, Some(888));
}
#[test]
fn advancing_head_invalidates_and_recomputes() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let index = Index::open_in_memory();
let first = open_loops_indexed_simple(&repo, Some(&index), false);
assert_eq!(first[0].ahead, Some(1));
let common = git_common_dir(&repo).unwrap();
let hash = crate::inventory::common_dir_hash(&common);
let old_fp = refs_fingerprint(&common);
let (default, default_sha) = default_branch_and_sha(&repo).unwrap();
index.put_loops(
&hash,
&repo,
&common,
&default,
&default_sha,
old_fp,
&[crate::index::LoopRow {
branch: "feat/x".into(),
head_sha: first[0].head_sha.clone(),
base_sha: default_sha.clone(),
ahead: Some(999),
behind: Some(888),
last_commit: first[0].last_commit,
worktree_path: first[0].repo_path.clone(),
}],
);
testutil::git(&repo, &["checkout", "feat/x"]);
std::fs::write(repo.join("x2.txt"), "x2").unwrap();
testutil::git(&repo, &["add", "."]);
testutil::git(&repo, &["commit", "-m", "wip more"]);
testutil::git(&repo, &["checkout", "main"]);
let new_fp = refs_fingerprint(&common);
assert!(
new_fp >= old_fp,
"fingerprint must not go backwards: {old_fp} -> {new_fp}"
);
let second = open_loops_indexed_simple(&repo, Some(&index), false);
assert_eq!(
second[0].ahead,
Some(2),
"must recompute after HEAD advance"
);
assert_eq!(second[0].behind, Some(0));
}
#[test]
fn default_sha_change_invalidates() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let index = Index::open_in_memory();
let first = open_loops_indexed_simple(&repo, Some(&index), false);
assert_eq!(first[0].ahead, Some(1));
let common = git_common_dir(&repo).unwrap();
let hash = crate::inventory::common_dir_hash(&common);
let refs_fp = refs_fingerprint(&common);
let (default, _real_sha) = default_branch_and_sha(&repo).unwrap();
index.put_loops(
&hash,
&repo,
&common,
&default,
"stale_default_sha_0000000000000000000000",
refs_fp,
&[crate::index::LoopRow {
branch: "feat/x".into(),
head_sha: first[0].head_sha.clone(),
base_sha: "stale_default_sha_0000000000000000000000".into(),
ahead: Some(999),
behind: Some(888),
last_commit: first[0].last_commit,
worktree_path: first[0].repo_path.clone(),
}],
);
let second = open_loops_indexed_simple(&repo, Some(&index), false);
assert_eq!(
second[0].ahead,
Some(1),
"stale default_sha must force recompute, not serve 999"
);
}
#[test]
fn fresh_bypasses_the_gate() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let index = Index::open_in_memory();
let first = open_loops_indexed_simple(&repo, Some(&index), false);
let common = git_common_dir(&repo).unwrap();
let hash = crate::inventory::common_dir_hash(&common);
let refs_fp = refs_fingerprint(&common);
let (default, default_sha) = default_branch_and_sha(&repo).unwrap();
index.put_loops(
&hash,
&repo,
&common,
&default,
&default_sha,
refs_fp,
&[crate::index::LoopRow {
branch: "feat/x".into(),
head_sha: first[0].head_sha.clone(),
base_sha: default_sha.clone(),
ahead: Some(999),
behind: Some(888),
last_commit: first[0].last_commit,
worktree_path: first[0].repo_path.clone(),
}],
);
let fresh = open_loops_indexed_simple(&repo, Some(&index), true);
assert_eq!(
fresh[0].ahead,
Some(1),
"fresh must recompute, not serve 999"
);
assert_eq!(fresh[0].behind, Some(0));
}
#[test]
fn new_branch_after_caching_appears() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let index = Index::open_in_memory();
let first = open_loops_indexed_simple(&repo, Some(&index), false);
assert_eq!(first.len(), 1);
testutil::add_branch_with_commit(&repo, "feat/y", "y.txt");
let second = open_loops_indexed_simple(&repo, Some(&index), false);
let mut names: Vec<_> = second.iter().map(|l| l.branch.clone()).collect();
names.sort();
assert_eq!(names, vec!["feat/x".to_string(), "feat/y".to_string()]);
}
#[test]
fn hit_with_null_ahead_behind_recomputes_when_needed() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let index = Index::open_in_memory();
let light_opts = ScanOptions {
need_ahead_behind: false,
..ScanOptions::default()
};
let light = open_loops_indexed(&repo, "root", &light_opts, Some(&index))
.unwrap()
.0;
assert_eq!(light[0].ahead, None);
let full = open_loops_indexed_simple(&repo, Some(&index), false);
assert_eq!(
full[0].ahead,
Some(1),
"must recompute when cached rows lack the requested ahead/behind"
);
assert_eq!(full[0].behind, Some(0));
}
#[test]
fn scan_indexed_none_matches_scan() {
let tmp = tempfile::tempdir().unwrap();
let api = tmp.path().join("api-service");
testutil::init_repo(&api);
testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
let opts = ScanOptions {
need_ahead_behind: true,
..ScanOptions::default()
};
let (loops, warnings, _) =
scan_indexed(&[tmp.path().to_path_buf()], &labels, 4, &opts, None, None);
assert!(warnings.is_empty(), "warnings: {warnings:?}");
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].branch, "feat/api");
assert_eq!(loops[0].ahead, Some(1));
}
#[test]
fn adding_worktree_for_existing_branch_bumps_fingerprint() {
let tmp = tempfile::tempdir().unwrap();
let container = tmp.path().join("proj");
testutil::init_bare_worktree_container(&container);
let common = git_common_dir(&container).unwrap();
let fp_before = refs_fingerprint(&common);
let wt_path = container.join("extra");
testutil::git(
&container,
&[
"worktree",
"add",
"--detach",
wt_path.to_str().unwrap(),
"HEAD",
],
);
let fp_after = refs_fingerprint(&common);
assert!(
fp_after > fp_before,
"fingerprint must increase after git worktree add (before={fp_before}, after={fp_after})"
);
}
#[test]
fn scan_indexed_warm_serves_cache() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("app");
testutil::init_repo(&repo);
testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
let labels = vec![(tmp.path().to_path_buf(), "root".to_string())];
let opts = ScanOptions {
need_ahead_behind: true,
..ScanOptions::default()
};
let index = Index::open_in_memory();
let (cold, _, _) = scan_indexed(
&[tmp.path().to_path_buf()],
&labels,
4,
&opts,
None,
Some(&index),
);
assert_eq!(cold.len(), 1);
assert_eq!(cold[0].ahead, Some(1));
let common = git_common_dir(&repo).unwrap();
let hash = crate::inventory::common_dir_hash(&common);
let refs_fp = refs_fingerprint(&common);
let (default, default_sha) = default_branch_and_sha(&repo).unwrap();
index.put_loops(
&hash,
&repo,
&common,
&default,
&default_sha,
refs_fp,
&[crate::index::LoopRow {
branch: "feat/x".into(),
head_sha: cold[0].head_sha.clone(),
base_sha: default_sha.clone(),
ahead: Some(999),
behind: Some(888),
last_commit: cold[0].last_commit,
worktree_path: cold[0].repo_path.clone(),
}],
);
let (warm, warnings, _) = scan_indexed(
&[tmp.path().to_path_buf()],
&labels,
4,
&opts,
None,
Some(&index),
);
assert!(warnings.is_empty(), "warnings: {warnings:?}");
assert_eq!(warm.len(), 1);
assert_eq!(
warm[0].ahead,
Some(999),
"scan_indexed warm path must serve cached loops, not re-run git"
);
}
}