1use anyhow::{bail, Context, Result};
5use chrono::{DateTime, Utc};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use crate::index::Index;
10use crate::inventory::{self, InventoryFile, InventoryStore, LoopMemo};
11
12type InvUpdate = (String, InventoryFile);
14
15#[derive(Debug, Clone, Default)]
17pub struct ScanOptions {
18 pub need_ahead_behind: bool,
20 pub fresh: bool,
22 pub inventory_dir: Option<PathBuf>,
24 pub inventory_ttl_secs: u64,
26}
27
28pub(crate) fn git(repo: &Path, args: &[&str]) -> Result<String> {
34 let out = Command::new("git")
35 .arg("-C")
36 .arg(repo)
37 .args(args)
38 .output()
39 .context("git not found in PATH — install git")?;
40 if !out.status.success() {
41 bail!(
42 "git {:?} failed in {}: {}",
43 args,
44 repo.display(),
45 String::from_utf8_lossy(&out.stderr).trim()
46 );
47 }
48 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
49}
50
51pub fn default_branch(repo: &Path) -> Result<String> {
58 let (name, _) = default_branch_and_sha(repo)?;
59 Ok(name)
60}
61
62fn default_branch_and_sha(repo: &Path) -> Result<(String, String)> {
73 if let Ok(sym) = git(
74 repo,
75 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
76 ) {
77 if let Some(branch) = sym.strip_prefix("origin/") {
78 if let Ok(sha) = git(repo, &["rev-parse", &format!("refs/heads/{branch}")]) {
81 return Ok((branch.to_string(), sha));
82 }
83 }
84 }
85 for candidate in ["main", "master"] {
86 if let Ok(sha) = git(
87 repo,
88 &["rev-parse", "--verify", &format!("refs/heads/{candidate}")],
89 ) {
90 return Ok((candidate.to_string(), sha));
91 }
92 }
93 bail!(
94 "couldn't find the default branch in {} (expected origin/HEAD, main or master)",
95 repo.display()
96 )
97}
98
99#[derive(Debug, Clone)]
101pub struct RepoCandidate {
102 pub path: PathBuf,
103 pub repo_name: String,
105}
106
107#[derive(Debug, Clone)]
109pub struct OpenLoop {
110 pub root_label: String,
111 pub repo_name: String,
112 pub repo_path: PathBuf,
113 pub branch: String,
114 pub head_sha: String,
115 pub last_commit: DateTime<Utc>,
116 pub ahead: Option<u32>,
117 pub behind: Option<u32>,
118}
119
120impl OpenLoop {
121 pub fn key(&self) -> String {
123 format!("{}/{}/{}", self.root_label, self.repo_name, self.branch)
124 }
125}
126
127const SKIP_DIRS: [&str; 2] = ["node_modules", "target"];
128
129fn looks_like_bare(dir: &Path) -> bool {
130 dir.join("HEAD").is_file() && dir.join("objects").is_dir() && dir.join("refs").is_dir()
131}
132
133fn is_repo_candidate(dir: &Path) -> bool {
134 dir.join(".git").exists() || looks_like_bare(dir)
135}
136
137pub fn repo_name_from_common_dir(common_dir: &Path) -> String {
139 let base = common_dir
140 .file_name()
141 .map(|n| n.to_string_lossy().into_owned())
142 .unwrap_or_default();
143 if base == ".git" || base == ".bare" {
144 return common_dir
145 .parent()
146 .and_then(|p| p.file_name())
147 .map(|n| n.to_string_lossy().into_owned())
148 .unwrap_or(base);
149 }
150 base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
151}
152
153pub fn git_common_dir(path: &Path) -> Result<PathBuf> {
159 let raw = git(
160 path,
161 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
162 )?;
163 Ok(PathBuf::from(raw))
164}
165
166pub fn refs_fingerprint(common_dir: &Path) -> i64 {
196 let mut max = 0_i64;
197 max = max.max(file_mtime_nanos(&common_dir.join("HEAD")));
198 max = max.max(file_mtime_nanos(&common_dir.join("packed-refs")));
199 max = max.max(newest_mtime_in_tree(&common_dir.join("refs")));
200 max = max.max(newest_mtime_in_tree(&common_dir.join("worktrees")));
201 max
202}
203
204fn file_mtime_nanos(path: &Path) -> i64 {
207 std::fs::metadata(path)
208 .and_then(|m| m.modified())
209 .ok()
210 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
211 .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
212 .unwrap_or(0)
213}
214
215fn newest_mtime_in_tree(dir: &Path) -> i64 {
223 let mut max = file_mtime_nanos(dir);
224 let Ok(entries) = std::fs::read_dir(dir) else {
225 return max;
226 };
227 for entry in entries.flatten() {
228 let path = entry.path();
229 match entry.file_type() {
230 Ok(ft) if ft.is_dir() => max = max.max(newest_mtime_in_tree(&path)),
231 _ => max = max.max(file_mtime_nanos(&path)),
232 }
233 }
234 max
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct WorktreeEntry {
249 pub path: PathBuf,
250 pub branch: Option<String>,
252 pub bare: bool,
253 pub prunable: bool,
254}
255
256pub fn parse_worktree_porcelain(out: &str) -> Vec<WorktreeEntry> {
262 let mut entries = Vec::new();
263 let mut current: Option<WorktreeEntry> = None;
264 for line in out.lines() {
265 if let Some(p) = line.strip_prefix("worktree ") {
266 if let Some(e) = current.take() {
267 entries.push(e);
268 }
269 current = Some(WorktreeEntry {
270 path: PathBuf::from(p),
271 branch: None,
272 bare: false,
273 prunable: false,
274 });
275 } else if let Some(e) = current.as_mut() {
276 if let Some(b) = line.strip_prefix("branch ") {
277 e.branch = Some(b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
278 } else if line == "bare" {
279 e.bare = true;
280 } else if line == "prunable" || line.starts_with("prunable ") {
281 e.prunable = true;
282 }
283 }
284 }
285 if let Some(e) = current.take() {
286 entries.push(e);
287 }
288 entries
289}
290
291fn normalize_path(path: PathBuf) -> PathBuf {
292 std::fs::canonicalize(&path).unwrap_or(path)
293}
294
295pub fn worktree_map(repo: &Path) -> Result<std::collections::HashMap<String, PathBuf>> {
304 let raw = git(repo, &["worktree", "list", "--porcelain"])?;
305 Ok(parse_worktree_porcelain(&raw)
306 .into_iter()
307 .filter(|e| !e.bare)
308 .filter_map(|e| e.branch.map(|b| (b, normalize_path(e.path))))
309 .collect())
310}
311
312pub fn find_repos(roots: &[PathBuf], scan_depth: usize) -> (Vec<RepoCandidate>, Vec<String>) {
315 find_repos_cached(roots, scan_depth, None)
316}
317
318pub fn find_repos_cached(
321 roots: &[PathBuf],
322 scan_depth: usize,
323 index: Option<&Index>,
324) -> (Vec<RepoCandidate>, Vec<String>) {
325 let mut candidates = Vec::new();
326 for root in roots {
327 walk(root, 0, scan_depth, &mut candidates);
328 }
329 dedup_candidates_cached(candidates, index)
330}
331
332fn dedup_candidates_cached(
335 candidates: Vec<PathBuf>,
336 index: Option<&Index>,
337) -> (Vec<RepoCandidate>, Vec<String>) {
338 use std::collections::HashMap;
339 let mut by_common: HashMap<PathBuf, RepoCandidate> = HashMap::new();
340 let mut warnings = Vec::new();
341 for candidate in candidates {
342 let cached = index.and_then(|idx| idx.cached_common_dir(&candidate));
344 let common_result = if let Some((_hash, common_dir)) = cached {
345 Ok(common_dir)
346 } else {
347 match git_common_dir(&candidate) {
349 Ok(common) => {
350 if let Some(idx) = index {
351 let hash = crate::inventory::common_dir_hash(&common);
352 idx.put_repo_common_dir(&candidate, &hash, &common);
353 }
354 Ok(common)
355 }
356 Err(e) => Err(e),
357 }
358 };
359
360 match common_result {
361 Ok(common) => {
362 let repo_name = repo_name_from_common_dir(&common);
363 by_common.entry(common).or_insert(RepoCandidate {
364 path: candidate,
365 repo_name,
366 });
367 }
368 Err(e) => {
369 warnings.push(format!("{}: {e:#}", candidate.display()));
370 }
371 }
372 }
373 let mut repos: Vec<RepoCandidate> = by_common.into_values().collect();
374 repos.sort_by(|a, b| a.path.cmp(&b.path));
375 (repos, warnings)
376}
377
378fn walk(dir: &Path, depth: usize, scan_depth: usize, candidates: &mut Vec<PathBuf>) {
379 if is_repo_candidate(dir) {
380 candidates.push(dir.to_path_buf());
381 return;
382 }
383 if depth >= scan_depth {
384 return;
385 }
386 let Ok(entries) = std::fs::read_dir(dir) else {
387 return;
388 };
389 for entry in entries.flatten() {
390 let path = entry.path();
391 let name = entry.file_name();
392 let name = name.to_string_lossy();
393 if !path.is_dir() || name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
394 continue;
395 }
396 walk(&path, depth + 1, scan_depth, candidates);
397 }
398}
399
400pub fn repo_name_hint(path: &Path) -> String {
403 let base = path
404 .file_name()
405 .map(|n| n.to_string_lossy().into_owned())
406 .unwrap_or_default();
407 base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
408}
409
410pub fn open_loops(
425 repo: &Path,
426 root_label: &str,
427 opts: &ScanOptions,
428) -> Result<(Vec<OpenLoop>, Option<InvUpdate>)> {
429 open_loops_indexed(repo, root_label, opts, None)
430}
431
432pub fn open_loops_indexed(
452 repo: &Path,
453 root_label: &str,
454 opts: &ScanOptions,
455 index: Option<&Index>,
456) -> Result<(Vec<OpenLoop>, Option<InvUpdate>)> {
457 let (default, default_sha) = default_branch_and_sha(repo)?;
459
460 let common_dir = git_common_dir(repo)?;
461 let repo_name = repo_name_from_common_dir(&common_dir);
462
463 let refs_fp = refs_fingerprint(&common_dir);
467 let gate_hash = inventory::common_dir_hash(&common_dir);
468
469 if let Some(idx) = index {
470 if !opts.fresh {
471 if let Some(rows) = idx.cached_loops(&gate_hash, refs_fp, &default_sha) {
472 let serves = !opts.need_ahead_behind || rows.iter().all(|r| r.ahead.is_some());
476 if serves {
477 let loops = rows
478 .into_iter()
479 .map(|r| OpenLoop {
480 root_label: root_label.to_string(),
481 repo_name: repo_name.clone(),
482 repo_path: r.worktree_path,
483 branch: r.branch,
484 head_sha: r.head_sha,
485 last_commit: r.last_commit,
486 ahead: r.ahead,
487 behind: r.behind,
488 })
489 .collect();
490 return Ok((loops, None));
493 }
494 }
495 }
496 }
497 let worktrees = worktree_map(repo).unwrap_or_else(|e| {
498 eprintln!(
499 "warning: git worktree list failed in {}: {e:#}; session matching falls back to the repo path",
500 repo.display()
501 );
502 std::collections::HashMap::new()
503 });
504 let merged: std::collections::HashSet<String> = git(
505 repo,
506 &["branch", "--merged", &default, "--format=%(refname:short)"],
507 )?
508 .lines()
509 .map(|s| s.trim().to_string())
510 .collect();
511 let raw = git(
512 repo,
513 &[
514 "for-each-ref",
515 "refs/heads",
516 "--format=%(refname:short)%09%(objectname)%09%(committerdate:iso8601-strict)",
517 ],
518 )?;
519
520 let use_inventory = opts.need_ahead_behind && opts.inventory_dir.is_some();
522
523 let use_inventory = use_inventory && !default_sha.is_empty();
525
526 let hash = if use_inventory {
527 inventory::common_dir_hash(&common_dir)
528 } else {
529 String::new()
530 };
531
532 let existing: Option<InventoryFile> = if use_inventory && !opts.fresh {
535 if let Some(inv_dir) = &opts.inventory_dir {
536 let store = InventoryStore {
537 dir: inv_dir.clone(),
538 };
539 store.load(&hash)
540 } else {
541 None
542 }
543 } else {
544 None
545 };
546
547 let now = Utc::now();
548 let repo_canonical = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
549 let mut new_memos: Vec<LoopMemo> = Vec::new();
550 let mut result = Vec::new();
551
552 for line in raw.lines() {
553 let mut parts = line.split('\t');
554 let (Some(branch), Some(sha), Some(date)) = (parts.next(), parts.next(), parts.next())
555 else {
556 eprintln!("warning: unexpected line from git for-each-ref ignored: {line:?}");
557 continue;
558 };
559 if branch == default || merged.contains(branch) {
560 continue;
561 }
562
563 let (ahead, behind) = if opts.need_ahead_behind {
564 let cached = if use_inventory {
565 existing.as_ref().and_then(|f| {
566 inventory::lookup_ahead_behind(
567 f,
568 branch,
569 sha,
570 &default_sha,
571 opts.inventory_ttl_secs,
572 now,
573 )
574 })
575 } else {
576 None
577 };
578
579 let (a, b) = if let Some(hit) = cached {
580 hit
581 } else {
582 let counts = git(
583 repo,
584 &[
585 "rev-list",
586 "--left-right",
587 "--count",
588 &format!("{default}...{branch}"),
589 ],
590 )?;
591 let mut c = counts.split_whitespace();
592 let behind_val: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
593 let ahead_val: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
594 (ahead_val, behind_val)
595 };
596
597 if use_inventory {
598 new_memos.push(LoopMemo {
599 branch: branch.to_string(),
600 head_sha: sha.to_string(),
601 ab_base_sha: default_sha.clone(),
602 ahead: a,
603 behind: b,
604 });
605 }
606 (Some(a), Some(b))
607 } else {
608 (None, None)
609 };
610
611 let last_commit = DateTime::parse_from_rfc3339(date)
612 .with_context(|| format!("invalid date from git: {date}"))?
613 .with_timezone(&Utc);
614 let repo_path = worktrees
615 .get(branch)
616 .cloned()
617 .unwrap_or_else(|| repo.to_path_buf());
618 result.push(OpenLoop {
619 root_label: root_label.to_string(),
620 repo_name: repo_name.clone(),
621 repo_path,
622 branch: branch.to_string(),
623 head_sha: sha.to_string(),
624 last_commit,
625 ahead,
626 behind,
627 });
628 }
629
630 let inventory_update = if use_inventory {
631 Some((
632 hash,
633 InventoryFile {
634 repo_path: repo_canonical,
635 indexed_at: now,
636 loops: new_memos,
637 },
638 ))
639 } else {
640 None
641 };
642
643 if let Some(idx) = index {
648 let rows: Vec<crate::index::LoopRow> = result
649 .iter()
650 .map(|l| crate::index::LoopRow {
651 branch: l.branch.clone(),
652 head_sha: l.head_sha.clone(),
653 base_sha: default_sha.clone(),
654 ahead: l.ahead,
655 behind: l.behind,
656 last_commit: l.last_commit,
657 worktree_path: l.repo_path.clone(),
658 })
659 .collect();
660 idx.put_loops(
661 &gate_hash,
662 repo,
663 &common_dir,
664 &default,
665 &default_sha,
666 refs_fp,
667 &rows,
668 );
669 }
670
671 Ok((result, inventory_update))
672}
673
674pub fn scan(
683 roots: &[PathBuf],
684 labels: &[(PathBuf, String)],
685 scan_depth: usize,
686 opts: &ScanOptions,
687 repo_filter: Option<&str>,
688) -> (Vec<OpenLoop>, Vec<String>, Vec<InvUpdate>) {
689 scan_indexed(roots, labels, scan_depth, opts, repo_filter, None)
690}
691
692struct GateInputs {
699 default: String,
700 default_sha: String,
701 common_dir: PathBuf,
702 refs_fp: i64,
703 gate_hash: String,
704}
705
706pub fn scan_indexed(
734 roots: &[PathBuf],
735 labels: &[(PathBuf, String)],
736 scan_depth: usize,
737 opts: &ScanOptions,
738 repo_filter: Option<&str>,
739 index: Option<&Index>,
740) -> (Vec<OpenLoop>, Vec<String>, Vec<InvUpdate>) {
741 let (mut repos, mut warnings) = find_repos_cached(roots, scan_depth, index);
742 if let Some(filter) = repo_filter {
743 let needle = filter.to_lowercase();
744 repos.retain(|r| r.repo_name.to_lowercase().contains(&needle));
745 }
746
747 let mut all = Vec::new();
748 let mut inventory_updates = Vec::new();
749
750 let Some(idx) = index else {
754 let misses: Vec<&RepoCandidate> = repos.iter().collect();
755 recompute_misses(&misses, &[], labels, opts, None, &mut all, &mut warnings)
756 .into_iter()
757 .for_each(|u| inventory_updates.push(u));
758 return (all, warnings, inventory_updates);
759 };
760
761 let gate_inputs: Vec<Result<GateInputs>> = std::thread::scope(|s| {
766 let handles: Vec<_> = repos
767 .iter()
768 .map(|repo| {
769 let path = repo.path.clone();
770 s.spawn(move || compute_gate_inputs(&path))
771 })
772 .collect();
773 handles
774 .into_iter()
775 .map(|h| {
776 h.join()
777 .unwrap_or_else(|_| Err(anyhow::anyhow!("panic while probing repository")))
778 })
779 .collect()
780 });
781
782 let mut misses: Vec<&RepoCandidate> = Vec::new();
786 let mut miss_inputs: Vec<GateInputs> = Vec::new();
787 for (repo, inputs) in repos.iter().zip(gate_inputs) {
788 let inputs = match inputs {
789 Ok(i) => i,
790 Err(e) => {
792 warnings.push(format!("{}: {e:#}", repo.path.display()));
793 continue;
794 }
795 };
796 let label = crate::config::label_for_repo(labels, &repo.path);
797 let hit = if opts.fresh {
799 None
800 } else {
801 gate_lookup(&label, opts, idx, &inputs)
802 };
803 match hit {
804 Some(mut loops) => all.append(&mut loops),
805 None => {
806 misses.push(repo);
807 miss_inputs.push(inputs);
808 }
809 }
810 }
811
812 recompute_misses(
815 &misses,
816 &miss_inputs,
817 labels,
818 opts,
819 index,
820 &mut all,
821 &mut warnings,
822 )
823 .into_iter()
824 .for_each(|u| inventory_updates.push(u));
825
826 (all, warnings, inventory_updates)
827}
828
829fn recompute_misses(
835 misses: &[&RepoCandidate],
836 gate_inputs: &[GateInputs],
837 labels: &[(PathBuf, String)],
838 opts: &ScanOptions,
839 index: Option<&Index>,
840 all: &mut Vec<OpenLoop>,
841 warnings: &mut Vec<String>,
842) -> Vec<InvUpdate> {
843 let mut inventory_updates = Vec::new();
844 let results: Vec<Result<(Vec<OpenLoop>, Option<InvUpdate>)>> = std::thread::scope(|s| {
845 let handles: Vec<_> = misses
846 .iter()
847 .map(|repo| {
848 let label = crate::config::label_for_repo(labels, &repo.path);
849 let path = repo.path.clone();
850 s.spawn(move || open_loops(&path, &label, opts))
851 })
852 .collect();
853 handles
854 .into_iter()
855 .map(|h| {
856 h.join()
857 .unwrap_or_else(|_| Err(anyhow::anyhow!("panic while scanning repository")))
858 })
859 .collect()
860 });
861
862 for (i, (repo, res)) in misses.iter().zip(results).enumerate() {
863 match res {
864 Ok((loops, inv)) => {
865 if let Some(idx) = index {
868 if let Some(inputs) = gate_inputs.get(i) {
869 write_through(&repo.path, &loops, idx, inputs);
870 }
871 }
872 all.extend(loops);
873 if let Some(update) = inv {
874 inventory_updates.push(update);
875 }
876 }
877 Err(e) => warnings.push(format!("{}: {e:#}", repo.path.display())),
878 }
879 }
880 inventory_updates
881}
882
883fn compute_gate_inputs(repo: &Path) -> Result<GateInputs> {
889 let (default, default_sha) = default_branch_and_sha(repo)?;
890 let common_dir = git_common_dir(repo)?;
891 let refs_fp = refs_fingerprint(&common_dir);
892 let gate_hash = inventory::common_dir_hash(&common_dir);
893 Ok(GateInputs {
894 default,
895 default_sha,
896 common_dir,
897 refs_fp,
898 gate_hash,
899 })
900}
901
902fn gate_lookup(
906 label: &str,
907 opts: &ScanOptions,
908 idx: &Index,
909 inputs: &GateInputs,
910) -> Option<Vec<OpenLoop>> {
911 let rows = idx.cached_loops(&inputs.gate_hash, inputs.refs_fp, &inputs.default_sha)?;
912 if opts.need_ahead_behind && !rows.iter().all(|r| r.ahead.is_some()) {
914 return None;
915 }
916 let repo_name = repo_name_from_common_dir(&inputs.common_dir);
917 let loops = rows
918 .into_iter()
919 .map(|r| OpenLoop {
920 root_label: label.to_string(),
921 repo_name: repo_name.clone(),
922 repo_path: r.worktree_path,
923 branch: r.branch,
924 head_sha: r.head_sha,
925 last_commit: r.last_commit,
926 ahead: r.ahead,
927 behind: r.behind,
928 })
929 .collect();
930 Some(loops)
931}
932
933fn write_through(repo: &Path, loops: &[OpenLoop], idx: &Index, inputs: &GateInputs) {
936 let rows: Vec<crate::index::LoopRow> = loops
937 .iter()
938 .map(|l| crate::index::LoopRow {
939 branch: l.branch.clone(),
940 head_sha: l.head_sha.clone(),
941 base_sha: inputs.default_sha.clone(),
942 ahead: l.ahead,
943 behind: l.behind,
944 last_commit: l.last_commit,
945 worktree_path: l.repo_path.clone(),
946 })
947 .collect();
948 idx.put_loops(
949 &inputs.gate_hash,
950 repo,
951 &inputs.common_dir,
952 &inputs.default,
953 &inputs.default_sha,
954 inputs.refs_fp,
955 &rows,
956 );
957}
958
959pub fn git_log(repo: &Path, default: &str, branch: &str) -> Result<String> {
965 git(repo, &["log", "--oneline", &format!("{default}..{branch}")])
966}
967
968pub fn diffstat(repo: &Path, default: &str, branch: &str) -> Result<String> {
974 git(repo, &["diff", "--stat", &format!("{default}...{branch}")])
975}
976
977pub fn commit_window(
985 repo: &Path,
986 default: &str,
987 branch: &str,
988) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
989 let raw = git(
990 repo,
991 &["log", "--format=%cI", &format!("{default}..{branch}")],
992 )?;
993 let mut dates: Vec<DateTime<Utc>> = raw
994 .lines()
995 .filter_map(|l| DateTime::parse_from_rfc3339(l.trim()).ok())
996 .map(|d| d.with_timezone(&Utc))
997 .collect();
998 if dates.is_empty() {
999 let head = git(repo, &["log", "-1", "--format=%cI", branch])?;
1001 dates.push(DateTime::parse_from_rfc3339(head.trim())?.with_timezone(&Utc));
1002 }
1003 let min = dates
1004 .iter()
1005 .min()
1006 .copied()
1007 .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
1008 let max = dates
1009 .iter()
1010 .max()
1011 .copied()
1012 .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
1013 Ok((min, max))
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019 use crate::index::Index;
1020 use crate::testutil;
1021
1022 fn open_loops_simple(
1024 repo: &std::path::Path,
1025 root_label: &str,
1026 need_ahead_behind: bool,
1027 ) -> Vec<OpenLoop> {
1028 let opts = ScanOptions {
1029 need_ahead_behind,
1030 ..ScanOptions::default()
1031 };
1032 open_loops(repo, root_label, &opts).unwrap().0
1033 }
1034
1035 fn scan_simple(
1037 roots: &[PathBuf],
1038 labels: &[(PathBuf, String)],
1039 depth: usize,
1040 need_ahead_behind: bool,
1041 filter: Option<&str>,
1042 ) -> (Vec<OpenLoop>, Vec<String>) {
1043 let opts = ScanOptions {
1044 need_ahead_behind,
1045 ..ScanOptions::default()
1046 };
1047 let (loops, warnings, _inv) = scan(roots, labels, depth, &opts, filter);
1048 (loops, warnings)
1049 }
1050
1051 fn assert_same_path(actual: &std::path::Path, expected: &std::path::Path) {
1052 let a = std::fs::canonicalize(actual).unwrap_or_else(|_| actual.to_path_buf());
1053 let b = std::fs::canonicalize(expected).unwrap_or_else(|_| expected.to_path_buf());
1054 assert_eq!(a, b);
1055 }
1056
1057 #[test]
1058 fn default_branch_detects_main() {
1059 let tmp = tempfile::tempdir().unwrap();
1060 let repo = tmp.path().join("app");
1061 testutil::init_repo(&repo);
1062 assert_eq!(default_branch(&repo).unwrap(), "main");
1063 }
1064
1065 #[test]
1066 fn default_branch_honours_origin_head_when_target_is_local() {
1067 let tmp = tempfile::tempdir().unwrap();
1068 let repo = tmp.path().join("app");
1069 testutil::init_repo(&repo); testutil::git(&repo, &["branch", "develop"]); testutil::git(
1072 &repo,
1073 &[
1074 "symbolic-ref",
1075 "refs/remotes/origin/HEAD",
1076 "refs/remotes/origin/develop",
1077 ],
1078 );
1079 assert_eq!(default_branch(&repo).unwrap(), "develop");
1081 }
1082
1083 #[test]
1084 fn default_branch_falls_back_when_origin_head_target_missing() {
1085 let tmp = tempfile::tempdir().unwrap();
1086 let repo = tmp.path().join("app");
1087 testutil::init_repo(&repo); testutil::git(
1089 &repo,
1090 &[
1091 "symbolic-ref",
1092 "refs/remotes/origin/HEAD",
1093 "refs/remotes/origin/ghost",
1094 ],
1095 );
1096 assert_eq!(default_branch(&repo).unwrap(), "main");
1098 }
1099
1100 #[test]
1101 fn git_fails_with_contextual_message() {
1102 let tmp = tempfile::tempdir().unwrap();
1103 let err = git(tmp.path(), &["status"]).unwrap_err();
1105 assert!(err.to_string().contains(&tmp.path().display().to_string()));
1106 }
1107
1108 #[test]
1109 fn find_repos_dedups_container_and_worktrees() {
1110 let tmp = tempfile::tempdir().unwrap();
1111 let container = tmp.path().join("my-app");
1112 testutil::init_bare_worktree_container(&container);
1113 let dev = container.join("dev");
1114 testutil::add_named_worktree(&container, "dev", "dev");
1115 let (repos, warnings) = find_repos(&[container.clone(), dev], 4);
1116 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1117 assert_eq!(repos.len(), 1);
1118 assert_eq!(repos[0].path, container);
1119 }
1120
1121 #[test]
1122 fn find_repos_respects_scan_depth_and_skips_hidden() {
1123 let tmp = tempfile::tempdir().unwrap();
1124 testutil::init_repo(&tmp.path().join("a/b/c/repo-deep"));
1125 testutil::init_repo(&tmp.path().join("a/b/repo-mid"));
1126 testutil::init_repo(&tmp.path().join("repo-shallow"));
1127 testutil::init_repo(&tmp.path().join(".hidden/repo3"));
1128
1129 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
1130 let names: Vec<_> = repos
1131 .iter()
1132 .filter_map(|r| r.path.file_name())
1133 .map(|n| n.to_string_lossy().into_owned())
1134 .collect();
1135 assert!(names.contains(&"repo-deep".to_string()));
1136 assert!(names.contains(&"repo-mid".to_string()));
1137 assert!(names.contains(&"repo-shallow".to_string()));
1138 assert!(!names.contains(&"repo3".to_string()));
1139
1140 let (shallow, _) = find_repos(&[tmp.path().to_path_buf()], 2);
1141 let shallow_names: Vec<_> = shallow
1142 .iter()
1143 .filter_map(|r| r.path.file_name())
1144 .map(|n| n.to_string_lossy().into_owned())
1145 .collect();
1146 assert!(!shallow_names.contains(&"repo-deep".to_string()));
1147 assert!(shallow_names.contains(&"repo-shallow".to_string()));
1148 }
1149
1150 #[test]
1151 fn find_repos_finds_normal_git_dir_repo() {
1152 let tmp = tempfile::tempdir().unwrap();
1153 testutil::init_repo(&tmp.path().join("app"));
1154 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
1155 assert_eq!(repos.len(), 1);
1156 }
1157
1158 #[test]
1159 fn find_repos_finds_bare_worktree_container_via_git_file() {
1160 let tmp = tempfile::tempdir().unwrap();
1161 let container = tmp.path().join("my-app");
1162 testutil::init_bare_worktree_container(&container);
1163 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
1164 assert_eq!(repos.len(), 1);
1165 assert_eq!(repos[0].path, container);
1166 }
1167
1168 #[test]
1169 fn find_repos_finds_pure_bare_repo() {
1170 let tmp = tempfile::tempdir().unwrap();
1171 let bare = tmp.path().join("foo.git");
1172 testutil::init_bare_repo(&bare);
1173 testutil::seed_bare_main(&bare);
1174 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
1175 assert_eq!(repos.len(), 1);
1176 assert_eq!(repos[0].path, bare);
1177 }
1178
1179 #[test]
1180 fn open_loops_uses_common_dir_repo_name_in_bare_layout() {
1181 let tmp = tempfile::tempdir().unwrap();
1182 let container = tmp.path().join("my-app");
1183 testutil::init_bare_worktree_container(&container);
1184 testutil::add_named_worktree(&container, "dev", "dev");
1185 testutil::add_branch_on_bare(&container.join(".bare"), "feat/x", "x.txt");
1186
1187 let loops = open_loops_simple(&container, "root", true);
1188 assert_eq!(loops.len(), 1);
1189 assert_eq!(loops[0].repo_name, "my-app");
1190 assert_eq!(loops[0].branch, "feat/x");
1191 assert_eq!(loops[0].key(), "root/my-app/feat/x");
1192 }
1193
1194 #[test]
1195 fn open_loops_bare_root_repo_name_strips_dot_git_suffix() {
1196 let tmp = tempfile::tempdir().unwrap();
1197 let bare = tmp.path().join("foo.git");
1198 testutil::init_bare_repo(&bare);
1199 testutil::seed_bare_main(&bare);
1200 testutil::add_branch_on_bare(&bare, "feat/y", "y.txt");
1201
1202 let loops = open_loops_simple(&bare, "r", true);
1203 assert_eq!(loops[0].repo_name, "foo");
1204 }
1205
1206 #[test]
1207 fn open_loops_finds_unmerged_ignores_merged_and_default() {
1208 let tmp = tempfile::tempdir().unwrap();
1209 let repo = tmp.path().join("app");
1210 testutil::init_repo(&repo);
1211 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1212 testutil::git(&repo, &["branch", "merged"]); let loops = open_loops_simple(&repo, "root", true);
1215 assert_eq!(loops.len(), 1);
1216 let l = &loops[0];
1217 assert_eq!(l.branch, "feat/x");
1218 assert_eq!(l.repo_name, "app");
1219 assert_eq!(l.root_label, "root");
1220 assert_eq!(l.key(), "root/app/feat/x");
1221 assert_eq!(l.ahead, Some(1));
1222 assert_eq!(l.behind, Some(0));
1223 assert_eq!(l.head_sha.len(), 40);
1224 }
1225
1226 #[test]
1227 fn open_loops_sets_repo_path_to_worktree_when_branch_checked_out() {
1228 let tmp = tempfile::tempdir().unwrap();
1229 let container = tmp.path().join("my-app");
1230 testutil::init_bare_worktree_container(&container);
1231 testutil::add_worktree_with_commit(&container, "feat-x", "feat/x", "x.txt");
1232
1233 let loops = open_loops_simple(&container, "root", true);
1234 let lp = loops
1235 .iter()
1236 .find(|l| l.branch == "feat/x")
1237 .expect("feat/x loop");
1238 assert_same_path(&lp.repo_path, &container.join("feat-x"));
1239 }
1240
1241 #[test]
1242 fn open_loops_falls_back_to_container_when_branch_has_no_worktree() {
1243 let tmp = tempfile::tempdir().unwrap();
1244 let container = tmp.path().join("my-app");
1245 testutil::init_bare_worktree_container(&container);
1246 testutil::add_branch_on_bare(&container.join(".bare"), "feat/y", "y.txt");
1248
1249 let loops = open_loops_simple(&container, "root", true);
1250 let lp = loops
1251 .iter()
1252 .find(|l| l.branch == "feat/y")
1253 .expect("feat/y loop");
1254 assert_eq!(lp.repo_path, container);
1255 }
1256
1257 #[test]
1258 fn open_loops_normal_repo_keeps_repo_path_as_repo_dir() {
1259 let tmp = tempfile::tempdir().unwrap();
1260 let repo = tmp.path().join("app");
1261 testutil::init_repo(&repo);
1262 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt"); let loops = open_loops_simple(&repo, "root", true);
1264 assert_eq!(loops[0].branch, "feat/x");
1265 assert_eq!(loops[0].repo_path, repo); }
1267
1268 #[test]
1269 fn open_loops_skips_rev_list_when_need_ahead_behind_false() {
1270 let tmp = tempfile::tempdir().unwrap();
1271 let repo = tmp.path().join("app");
1272 testutil::init_repo(&repo);
1273 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1274
1275 let loops = open_loops_simple(&repo, "root", false);
1276 assert_eq!(loops.len(), 1);
1277 assert_eq!(loops[0].ahead, None);
1278 assert_eq!(loops[0].behind, None);
1279 }
1280
1281 #[test]
1282 fn open_loops_computes_ahead_behind_when_need_ahead_behind_true() {
1283 let tmp = tempfile::tempdir().unwrap();
1284 let repo = tmp.path().join("app");
1285 testutil::init_repo(&repo);
1286 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1287
1288 let loops = open_loops_simple(&repo, "root", true);
1289 assert_eq!(loops.len(), 1);
1290 assert_eq!(loops[0].ahead, Some(1));
1291 assert_eq!(loops[0].behind, Some(0));
1292 }
1293
1294 #[test]
1295 fn open_loops_reuses_inventory_memo_on_repeated_scan() {
1296 let tmp = tempfile::tempdir().unwrap();
1297 let repo = tmp.path().join("app");
1298 testutil::init_repo(&repo);
1299 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1300 let inv_dir = tmp.path().join("inv");
1301
1302 let opts = ScanOptions {
1303 need_ahead_behind: true,
1304 fresh: false,
1305 inventory_dir: Some(inv_dir.clone()),
1306 inventory_ttl_secs: 0,
1307 };
1308
1309 let (loops1, inv1) = open_loops(&repo, "root", &opts).unwrap();
1311 assert_eq!(loops1.len(), 1);
1312 assert_eq!(loops1[0].ahead, Some(1));
1313 let (hash, file) = inv1.unwrap();
1314 let store = InventoryStore {
1315 dir: inv_dir.clone(),
1316 };
1317 store.save(&hash, &file).unwrap();
1318
1319 let (loops2, inv2) = open_loops(&repo, "root", &opts).unwrap();
1321 assert_eq!(loops2.len(), 1);
1322 assert_eq!(loops2[0].ahead, Some(1));
1323 assert_eq!(loops2[0].behind, Some(0));
1324 assert!(inv2.is_some());
1326 }
1327
1328 #[test]
1329 fn open_loops_fresh_ignores_inventory_memo() {
1330 let tmp = tempfile::tempdir().unwrap();
1331 let repo = tmp.path().join("app");
1332 testutil::init_repo(&repo);
1333 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1334 let inv_dir = tmp.path().join("inv");
1335
1336 let common = git_common_dir(&repo).unwrap();
1339 let hash = crate::inventory::common_dir_hash(&common);
1340 let store = InventoryStore {
1341 dir: inv_dir.clone(),
1342 };
1343 let fake_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1344 let stub_file = InventoryFile {
1345 repo_path: repo.clone(),
1346 indexed_at: chrono::Utc::now(),
1347 loops: vec![LoopMemo {
1348 branch: "feat/x".to_string(),
1349 head_sha: fake_sha.to_string(),
1350 ab_base_sha: fake_sha.to_string(),
1351 ahead: 99,
1352 behind: 99,
1353 }],
1354 };
1355 store.save(&hash, &stub_file).unwrap();
1356
1357 let opts = ScanOptions {
1358 need_ahead_behind: true,
1359 fresh: true, inventory_dir: Some(inv_dir.clone()),
1361 inventory_ttl_secs: 0,
1362 };
1363 let (loops, _) = open_loops(&repo, "root", &opts).unwrap();
1364 assert_eq!(loops[0].ahead, Some(1));
1366 assert_eq!(loops[0].behind, Some(0));
1367 }
1368
1369 #[test]
1370 fn scan_repo_filter_pushdown_skips_non_matching_repos() {
1371 let tmp = tempfile::tempdir().unwrap();
1372 let api = tmp.path().join("api-service");
1373 let web = tmp.path().join("web-app");
1374 testutil::init_repo(&api);
1375 testutil::init_repo(&web);
1376 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
1377 testutil::add_branch_with_commit(&web, "feat/web", "w.txt");
1378
1379 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
1380 let (loops, _) = scan_simple(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
1381 assert_eq!(loops.len(), 1);
1382 assert_eq!(loops[0].repo_name, "api-service");
1383 assert_eq!(loops[0].branch, "feat/api");
1384 }
1385
1386 #[test]
1387 fn repo_name_hint_strips_dot_git_suffix() {
1388 assert_eq!(repo_name_hint(std::path::Path::new("/srv/foo.git")), "foo");
1389 }
1390
1391 #[test]
1392 fn scan_repo_filter_is_case_insensitive() {
1393 let tmp = tempfile::tempdir().unwrap();
1394 let api = tmp.path().join("API-Service");
1395 testutil::init_repo(&api);
1396 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
1397
1398 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
1399 let (loops, _) = scan_simple(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
1401 assert_eq!(loops.len(), 1);
1402 assert_eq!(loops[0].repo_name, "API-Service");
1403 }
1404
1405 #[test]
1406 fn scan_repo_filter_matching_nothing_yields_no_loops() {
1407 let tmp = tempfile::tempdir().unwrap();
1408 let api = tmp.path().join("api-service");
1409 testutil::init_repo(&api);
1410 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
1411
1412 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
1413 let (loops, warnings) = scan_simple(
1414 &[tmp.path().to_path_buf()],
1415 &labels,
1416 4,
1417 false,
1418 Some("zzz-nope"),
1419 );
1420 assert!(loops.is_empty());
1421 assert!(
1422 warnings.is_empty(),
1423 "filtered-out repos must not warn: {warnings:?}"
1424 );
1425 }
1426
1427 #[test]
1428 fn scan_aggregates_repos_and_reports_warning_without_aborting() {
1429 let tmp = tempfile::tempdir().unwrap();
1430 let good = tmp.path().join("good");
1431 testutil::init_repo(&good);
1432 testutil::add_branch_with_commit(&good, "feat/ok", "ok.txt");
1433 let empty = tmp.path().join("empty");
1435 std::fs::create_dir_all(&empty).unwrap();
1436 testutil::git(&empty, &["init", "-b", "main"]);
1437
1438 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
1439 let (loops, warnings) = scan_simple(&[tmp.path().to_path_buf()], &labels, 4, true, None);
1440 assert_eq!(loops.len(), 1);
1441 assert_eq!(loops[0].key(), "r/good/feat/ok");
1442 assert_eq!(warnings.len(), 1);
1443 assert!(warnings[0].contains("empty"));
1444 }
1445
1446 #[test]
1447 fn context_helpers_return_commits_and_window() {
1448 let tmp = tempfile::tempdir().unwrap();
1449 let repo = tmp.path().join("app");
1450 testutil::init_repo(&repo);
1451 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1452
1453 let log = git_log(&repo, "main", "feat/x").unwrap();
1454 assert!(log.contains("wip feat/x"));
1455 let stat = diffstat(&repo, "main", "feat/x").unwrap();
1456 assert!(stat.contains("x.txt"));
1457 let (start, end) = commit_window(&repo, "main", "feat/x").unwrap();
1458 assert!(start <= end);
1459 }
1460
1461 #[test]
1462 fn default_branch_detects_master_fallback() {
1463 let tmp = tempfile::tempdir().unwrap();
1464 let repo = tmp.path();
1465 testutil::git(repo, &["init", "-b", "master"]);
1466 std::fs::write(repo.join("a.txt"), "a").unwrap();
1467 testutil::git(repo, &["add", "."]);
1468 testutil::git(repo, &["commit", "-m", "init"]);
1469 assert_eq!(default_branch(repo).unwrap(), "master");
1470 }
1471
1472 #[test]
1473 fn default_branch_errors_without_main_or_master() {
1474 let tmp = tempfile::tempdir().unwrap();
1475 let repo = tmp.path();
1476 testutil::git(repo, &["init", "-b", "trunk"]);
1477 let err = default_branch(repo).unwrap_err();
1479 assert!(err.to_string().contains("couldn't find the default branch"));
1480 }
1481
1482 #[test]
1483 fn git_common_dir_resolves_normal_and_bare_pointer() {
1484 let tmp = tempfile::tempdir().unwrap();
1485 let normal = tmp.path().join("app");
1486 testutil::init_repo(&normal);
1487 let normal_common = git_common_dir(&normal).unwrap();
1488 assert!(normal_common.ends_with(".git"));
1489
1490 let container = tmp.path().join("container");
1491 testutil::init_bare_worktree_container(&container);
1492 let bare_common = git_common_dir(&container).unwrap();
1493 assert!(bare_common.ends_with(".bare"));
1494 }
1495
1496 #[test]
1497 fn parse_worktree_porcelain_extracts_branches_and_flags() {
1498 let out = "\
1499worktree /home/u/app/main
1500HEAD aaaaaaaa
1501branch refs/heads/main
1502
1503worktree /home/u/app/feat-x
1504HEAD bbbbbbbb
1505branch refs/heads/feat/x
1506
1507worktree /home/u/app/detached
1508HEAD cccccccc
1509detached
1510
1511worktree /home/u/app/.bare
1512bare
1513";
1514 let entries = parse_worktree_porcelain(out);
1515 assert_eq!(entries.len(), 4);
1516 assert_eq!(entries[0].branch.as_deref(), Some("main"));
1517 assert_eq!(
1518 entries[0].path,
1519 std::path::PathBuf::from("/home/u/app/main")
1520 );
1521 assert_eq!(entries[1].branch.as_deref(), Some("feat/x")); assert_eq!(entries[2].branch, None); assert!(entries[3].bare);
1524 assert_eq!(entries[3].branch, None);
1525 }
1526
1527 #[test]
1528 fn parse_worktree_porcelain_marks_prunable_and_handles_empty() {
1529 assert!(parse_worktree_porcelain("").is_empty());
1530 let out = "worktree /gone\nprunable gitdir file points to non-existent location\n";
1531 let entries = parse_worktree_porcelain(out);
1532 assert_eq!(entries.len(), 1);
1533 assert!(entries[0].prunable);
1534 assert_eq!(entries[0].branch, None);
1535 }
1536
1537 #[test]
1538 fn worktree_map_maps_checked_out_branches_to_paths() {
1539 let tmp = tempfile::tempdir().unwrap();
1540 let container = tmp.path().join("my-app");
1541 testutil::init_bare_worktree_container(&container); testutil::add_named_worktree(&container, "dev", "dev"); let map = worktree_map(&container).unwrap();
1545 assert_same_path(map.get("main").unwrap(), &container.join("main"));
1546 assert_same_path(map.get("dev").unwrap(), &container.join("dev"));
1547 assert!(!map.values().any(|p| p.ends_with(".bare")));
1549 }
1550
1551 #[test]
1552 fn worktree_map_errors_on_non_git_dir() {
1553 let tmp = tempfile::tempdir().unwrap();
1554 assert!(worktree_map(tmp.path()).is_err());
1556 }
1557
1558 #[test]
1559 fn parse_worktree_porcelain_ignores_lines_before_first_worktree() {
1560 let out = "branch refs/heads/orphan\nHEAD deadbeef\nworktree /home/u/app/main\nbranch refs/heads/main\n";
1561 let entries = parse_worktree_porcelain(out);
1562 assert_eq!(entries.len(), 1);
1563 assert_eq!(
1564 entries[0].path,
1565 std::path::PathBuf::from("/home/u/app/main")
1566 );
1567 assert_eq!(entries[0].branch.as_deref(), Some("main"));
1568 }
1569
1570 #[test]
1571 fn repo_name_from_common_dir_table() {
1572 use std::path::Path;
1573
1574 let cases: &[(&str, &str)] = &[
1575 ("/home/u/my-app/.bare", "my-app"),
1576 ("/home/u/app/.git", "app"),
1577 ("/srv/git/foo.git", "foo"),
1578 ("/srv/git/myproject", "myproject"),
1579 ];
1580 for (common, want) in cases {
1581 assert_eq!(
1582 repo_name_from_common_dir(Path::new(common)),
1583 *want,
1584 "common_dir={common}"
1585 );
1586 }
1587 }
1588
1589 #[test]
1595 fn find_repos_cached_populates_index() {
1596 let tmp = tempfile::tempdir().unwrap();
1597 let repo = tmp.path().join("app");
1598 testutil::init_repo(&repo);
1599
1600 let index = Index::open_in_memory();
1601 let (repos, warnings) = find_repos_cached(&[tmp.path().to_path_buf()], 4, Some(&index));
1602
1603 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1604 assert_eq!(repos.len(), 1);
1605
1606 let (hash, cd) = index
1608 .cached_common_dir(&repo)
1609 .expect("index should have cached the common_dir after find_repos_cached");
1610 assert!(!hash.is_empty());
1611 assert!(
1612 cd.ends_with(".git"),
1613 "common_dir should end with .git, got: {cd:?}"
1614 );
1615 }
1616
1617 #[test]
1625 fn dedup_candidates_cached_uses_index_on_second_call() {
1626 let tmp = tempfile::tempdir().unwrap();
1627 let repo = tmp.path().join("app");
1628 testutil::init_repo(&repo);
1629
1630 let index = Index::open_in_memory();
1631
1632 let sentinel_hash = "sentinel_hash_no_git";
1636 let sentinel_cd = repo.join(".git"); index.put_repo_common_dir(&repo, sentinel_hash, &sentinel_cd);
1638
1639 let (repos, warnings) = dedup_candidates_cached(vec![repo.clone()], Some(&index));
1641
1642 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1643 assert_eq!(repos.len(), 1);
1644
1645 let (got_hash, _) = index
1647 .cached_common_dir(&repo)
1648 .expect("index entry must still exist");
1649 assert_eq!(
1650 got_hash, sentinel_hash,
1651 "sentinel hash changed — git was called instead of using cache"
1652 );
1653 }
1654
1655 #[test]
1657 fn dedup_cached_n_worktrees_yields_one_repo() {
1658 let tmp = tempfile::tempdir().unwrap();
1659 let container = tmp.path().join("my-app");
1660 testutil::init_bare_worktree_container(&container);
1661 let dev = container.join("dev");
1662 testutil::add_named_worktree(&container, "dev", "dev");
1663
1664 let index = Index::open_in_memory();
1665
1666 let candidates = vec![container.clone(), dev.clone()];
1668 let (repos, warnings) = dedup_candidates_cached(candidates, Some(&index));
1669
1670 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1671 assert_eq!(
1672 repos.len(),
1673 1,
1674 "N worktrees must dedup to 1 repo, got: {repos:?}"
1675 );
1676 }
1677
1678 fn open_loops_indexed_simple(
1684 repo: &std::path::Path,
1685 idx: Option<&Index>,
1686 fresh: bool,
1687 ) -> Vec<OpenLoop> {
1688 let opts = ScanOptions {
1689 need_ahead_behind: true,
1690 fresh,
1691 ..ScanOptions::default()
1692 };
1693 open_loops_indexed(repo, "root", &opts, idx).unwrap().0
1694 }
1695
1696 #[test]
1703 fn warm_scan_unchanged_refs_skips_rev_list() {
1704 let tmp = tempfile::tempdir().unwrap();
1705 let repo = tmp.path().join("app");
1706 testutil::init_repo(&repo);
1707 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1708
1709 let index = Index::open_in_memory();
1710
1711 let first = open_loops_indexed_simple(&repo, Some(&index), false);
1713 assert_eq!(first.len(), 1);
1714 assert_eq!(first[0].ahead, Some(1));
1715 assert_eq!(first[0].behind, Some(0));
1716
1717 let common = git_common_dir(&repo).unwrap();
1720 let hash = crate::inventory::common_dir_hash(&common);
1721 let refs_fp = refs_fingerprint(&common);
1722 let (default, default_sha) = default_branch_and_sha(&repo).unwrap();
1723 let poisoned = vec![crate::index::LoopRow {
1724 branch: "feat/x".into(),
1725 head_sha: first[0].head_sha.clone(),
1726 base_sha: default_sha.clone(),
1727 ahead: Some(999),
1728 behind: Some(888),
1729 last_commit: first[0].last_commit,
1730 worktree_path: first[0].repo_path.clone(),
1731 }];
1732 index.put_loops(
1733 &hash,
1734 &repo,
1735 &common,
1736 &default,
1737 &default_sha,
1738 refs_fp,
1739 &poisoned,
1740 );
1741
1742 let second = open_loops_indexed_simple(&repo, Some(&index), false);
1744 assert_eq!(second.len(), 1);
1745 assert_eq!(
1746 second[0].ahead,
1747 Some(999),
1748 "gate must serve cached ahead — git was re-run if this is 1"
1749 );
1750 assert_eq!(second[0].behind, Some(888));
1751 }
1752
1753 #[test]
1755 fn advancing_head_invalidates_and_recomputes() {
1756 let tmp = tempfile::tempdir().unwrap();
1757 let repo = tmp.path().join("app");
1758 testutil::init_repo(&repo);
1759 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt"); let index = Index::open_in_memory();
1762
1763 let first = open_loops_indexed_simple(&repo, Some(&index), false);
1765 assert_eq!(first[0].ahead, Some(1));
1766 let common = git_common_dir(&repo).unwrap();
1767 let hash = crate::inventory::common_dir_hash(&common);
1768 let old_fp = refs_fingerprint(&common);
1769 let (default, default_sha) = default_branch_and_sha(&repo).unwrap();
1770 index.put_loops(
1771 &hash,
1772 &repo,
1773 &common,
1774 &default,
1775 &default_sha,
1776 old_fp,
1777 &[crate::index::LoopRow {
1778 branch: "feat/x".into(),
1779 head_sha: first[0].head_sha.clone(),
1780 base_sha: default_sha.clone(),
1781 ahead: Some(999),
1782 behind: Some(888),
1783 last_commit: first[0].last_commit,
1784 worktree_path: first[0].repo_path.clone(),
1785 }],
1786 );
1787
1788 testutil::git(&repo, &["checkout", "feat/x"]);
1790 std::fs::write(repo.join("x2.txt"), "x2").unwrap();
1791 testutil::git(&repo, &["add", "."]);
1792 testutil::git(&repo, &["commit", "-m", "wip more"]);
1793 testutil::git(&repo, &["checkout", "main"]);
1794
1795 let new_fp = refs_fingerprint(&common);
1796 assert!(
1797 new_fp >= old_fp,
1798 "fingerprint must not go backwards: {old_fp} -> {new_fp}"
1799 );
1800
1801 let second = open_loops_indexed_simple(&repo, Some(&index), false);
1803 assert_eq!(
1804 second[0].ahead,
1805 Some(2),
1806 "must recompute after HEAD advance"
1807 );
1808 assert_eq!(second[0].behind, Some(0));
1809 }
1810
1811 #[test]
1814 fn default_sha_change_invalidates() {
1815 let tmp = tempfile::tempdir().unwrap();
1816 let repo = tmp.path().join("app");
1817 testutil::init_repo(&repo);
1818 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1819
1820 let index = Index::open_in_memory();
1821 let first = open_loops_indexed_simple(&repo, Some(&index), false);
1822 assert_eq!(first[0].ahead, Some(1));
1823
1824 let common = git_common_dir(&repo).unwrap();
1825 let hash = crate::inventory::common_dir_hash(&common);
1826 let refs_fp = refs_fingerprint(&common);
1827 let (default, _real_sha) = default_branch_and_sha(&repo).unwrap();
1828
1829 index.put_loops(
1831 &hash,
1832 &repo,
1833 &common,
1834 &default,
1835 "stale_default_sha_0000000000000000000000",
1836 refs_fp,
1837 &[crate::index::LoopRow {
1838 branch: "feat/x".into(),
1839 head_sha: first[0].head_sha.clone(),
1840 base_sha: "stale_default_sha_0000000000000000000000".into(),
1841 ahead: Some(999),
1842 behind: Some(888),
1843 last_commit: first[0].last_commit,
1844 worktree_path: first[0].repo_path.clone(),
1845 }],
1846 );
1847
1848 let second = open_loops_indexed_simple(&repo, Some(&index), false);
1850 assert_eq!(
1851 second[0].ahead,
1852 Some(1),
1853 "stale default_sha must force recompute, not serve 999"
1854 );
1855 }
1856
1857 #[test]
1859 fn fresh_bypasses_the_gate() {
1860 let tmp = tempfile::tempdir().unwrap();
1861 let repo = tmp.path().join("app");
1862 testutil::init_repo(&repo);
1863 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1864
1865 let index = Index::open_in_memory();
1866 let first = open_loops_indexed_simple(&repo, Some(&index), false);
1867
1868 let common = git_common_dir(&repo).unwrap();
1870 let hash = crate::inventory::common_dir_hash(&common);
1871 let refs_fp = refs_fingerprint(&common);
1872 let (default, default_sha) = default_branch_and_sha(&repo).unwrap();
1873 index.put_loops(
1874 &hash,
1875 &repo,
1876 &common,
1877 &default,
1878 &default_sha,
1879 refs_fp,
1880 &[crate::index::LoopRow {
1881 branch: "feat/x".into(),
1882 head_sha: first[0].head_sha.clone(),
1883 base_sha: default_sha.clone(),
1884 ahead: Some(999),
1885 behind: Some(888),
1886 last_commit: first[0].last_commit,
1887 worktree_path: first[0].repo_path.clone(),
1888 }],
1889 );
1890
1891 let fresh = open_loops_indexed_simple(&repo, Some(&index), true);
1893 assert_eq!(
1894 fresh[0].ahead,
1895 Some(1),
1896 "fresh must recompute, not serve 999"
1897 );
1898 assert_eq!(fresh[0].behind, Some(0));
1899 }
1900
1901 #[test]
1903 fn new_branch_after_caching_appears() {
1904 let tmp = tempfile::tempdir().unwrap();
1905 let repo = tmp.path().join("app");
1906 testutil::init_repo(&repo);
1907 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1908
1909 let index = Index::open_in_memory();
1910 let first = open_loops_indexed_simple(&repo, Some(&index), false);
1911 assert_eq!(first.len(), 1);
1912
1913 testutil::add_branch_with_commit(&repo, "feat/y", "y.txt");
1915
1916 let second = open_loops_indexed_simple(&repo, Some(&index), false);
1918 let mut names: Vec<_> = second.iter().map(|l| l.branch.clone()).collect();
1919 names.sort();
1920 assert_eq!(names, vec!["feat/x".to_string(), "feat/y".to_string()]);
1921 }
1922
1923 #[test]
1926 fn hit_with_null_ahead_behind_recomputes_when_needed() {
1927 let tmp = tempfile::tempdir().unwrap();
1928 let repo = tmp.path().join("app");
1929 testutil::init_repo(&repo);
1930 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1931
1932 let index = Index::open_in_memory();
1933 let light_opts = ScanOptions {
1936 need_ahead_behind: false,
1937 ..ScanOptions::default()
1938 };
1939 let light = open_loops_indexed(&repo, "root", &light_opts, Some(&index))
1940 .unwrap()
1941 .0;
1942 assert_eq!(light[0].ahead, None);
1943
1944 let full = open_loops_indexed_simple(&repo, Some(&index), false);
1946 assert_eq!(
1947 full[0].ahead,
1948 Some(1),
1949 "must recompute when cached rows lack the requested ahead/behind"
1950 );
1951 assert_eq!(full[0].behind, Some(0));
1952 }
1953
1954 #[test]
1956 fn scan_indexed_none_matches_scan() {
1957 let tmp = tempfile::tempdir().unwrap();
1958 let api = tmp.path().join("api-service");
1959 testutil::init_repo(&api);
1960 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
1961 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
1962 let opts = ScanOptions {
1963 need_ahead_behind: true,
1964 ..ScanOptions::default()
1965 };
1966 let (loops, warnings, _) =
1967 scan_indexed(&[tmp.path().to_path_buf()], &labels, 4, &opts, None, None);
1968 assert!(warnings.is_empty(), "warnings: {warnings:?}");
1969 assert_eq!(loops.len(), 1);
1970 assert_eq!(loops[0].branch, "feat/api");
1971 assert_eq!(loops[0].ahead, Some(1));
1972 }
1973
1974 #[test]
1984 fn adding_worktree_for_existing_branch_bumps_fingerprint() {
1985 let tmp = tempfile::tempdir().unwrap();
1986 let container = tmp.path().join("proj");
1987 testutil::init_bare_worktree_container(&container);
1990
1991 let common = git_common_dir(&container).unwrap();
1992 let fp_before = refs_fingerprint(&common);
1993
1994 let wt_path = container.join("extra");
2000 testutil::git(
2001 &container,
2002 &[
2003 "worktree",
2004 "add",
2005 "--detach",
2006 wt_path.to_str().unwrap(),
2007 "HEAD",
2008 ],
2009 );
2010
2011 let fp_after = refs_fingerprint(&common);
2012 assert!(
2013 fp_after > fp_before,
2014 "fingerprint must increase after git worktree add (before={fp_before}, after={fp_after})"
2015 );
2016 }
2017
2018 #[test]
2022 fn scan_indexed_warm_serves_cache() {
2023 let tmp = tempfile::tempdir().unwrap();
2024 let repo = tmp.path().join("app");
2025 testutil::init_repo(&repo);
2026 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
2027 let labels = vec![(tmp.path().to_path_buf(), "root".to_string())];
2028 let opts = ScanOptions {
2029 need_ahead_behind: true,
2030 ..ScanOptions::default()
2031 };
2032 let index = Index::open_in_memory();
2033
2034 let (cold, _, _) = scan_indexed(
2036 &[tmp.path().to_path_buf()],
2037 &labels,
2038 4,
2039 &opts,
2040 None,
2041 Some(&index),
2042 );
2043 assert_eq!(cold.len(), 1);
2044 assert_eq!(cold[0].ahead, Some(1));
2045
2046 let common = git_common_dir(&repo).unwrap();
2048 let hash = crate::inventory::common_dir_hash(&common);
2049 let refs_fp = refs_fingerprint(&common);
2050 let (default, default_sha) = default_branch_and_sha(&repo).unwrap();
2051 index.put_loops(
2052 &hash,
2053 &repo,
2054 &common,
2055 &default,
2056 &default_sha,
2057 refs_fp,
2058 &[crate::index::LoopRow {
2059 branch: "feat/x".into(),
2060 head_sha: cold[0].head_sha.clone(),
2061 base_sha: default_sha.clone(),
2062 ahead: Some(999),
2063 behind: Some(888),
2064 last_commit: cold[0].last_commit,
2065 worktree_path: cold[0].repo_path.clone(),
2066 }],
2067 );
2068
2069 let (warm, warnings, _) = scan_indexed(
2071 &[tmp.path().to_path_buf()],
2072 &labels,
2073 4,
2074 &opts,
2075 None,
2076 Some(&index),
2077 );
2078 assert!(warnings.is_empty(), "warnings: {warnings:?}");
2079 assert_eq!(warm.len(), 1);
2080 assert_eq!(
2081 warm[0].ahead,
2082 Some(999),
2083 "scan_indexed warm path must serve cached loops, not re-run git"
2084 );
2085 }
2086}