1use anyhow::{bail, Context, Result};
5use chrono::{DateTime, Utc};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use crate::inventory::{self, InventoryFile, InventoryStore, LoopMemo};
10
11type InvUpdate = (String, InventoryFile);
13
14#[derive(Debug, Clone, Default)]
16pub struct ScanOptions {
17 pub need_ahead_behind: bool,
19 pub fresh: bool,
21 pub inventory_dir: Option<PathBuf>,
23 pub inventory_ttl_secs: u64,
25}
26
27pub(crate) fn git(repo: &Path, args: &[&str]) -> Result<String> {
33 let out = Command::new("git")
34 .arg("-C")
35 .arg(repo)
36 .args(args)
37 .output()
38 .context("git not found in PATH — install git")?;
39 if !out.status.success() {
40 bail!(
41 "git {:?} failed in {}: {}",
42 args,
43 repo.display(),
44 String::from_utf8_lossy(&out.stderr).trim()
45 );
46 }
47 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
48}
49
50pub fn default_branch(repo: &Path) -> Result<String> {
57 let (name, _) = default_branch_and_sha(repo)?;
58 Ok(name)
59}
60
61fn default_branch_and_sha(repo: &Path) -> Result<(String, String)> {
72 if let Ok(sym) = git(
73 repo,
74 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
75 ) {
76 if let Some(branch) = sym.strip_prefix("origin/") {
77 if let Ok(sha) = git(repo, &["rev-parse", &format!("refs/heads/{branch}")]) {
80 return Ok((branch.to_string(), sha));
81 }
82 }
83 }
84 for candidate in ["main", "master"] {
85 if let Ok(sha) = git(
86 repo,
87 &["rev-parse", "--verify", &format!("refs/heads/{candidate}")],
88 ) {
89 return Ok((candidate.to_string(), sha));
90 }
91 }
92 bail!(
93 "couldn't find the default branch in {} (expected origin/HEAD, main or master)",
94 repo.display()
95 )
96}
97
98#[derive(Debug, Clone)]
100pub struct RepoCandidate {
101 pub path: PathBuf,
102 pub repo_name: String,
104}
105
106#[derive(Debug, Clone)]
108pub struct OpenLoop {
109 pub root_label: String,
110 pub repo_name: String,
111 pub repo_path: PathBuf,
112 pub branch: String,
113 pub head_sha: String,
114 pub last_commit: DateTime<Utc>,
115 pub ahead: Option<u32>,
116 pub behind: Option<u32>,
117}
118
119impl OpenLoop {
120 pub fn key(&self) -> String {
122 format!("{}/{}/{}", self.root_label, self.repo_name, self.branch)
123 }
124}
125
126const SKIP_DIRS: [&str; 2] = ["node_modules", "target"];
127
128fn looks_like_bare(dir: &Path) -> bool {
129 dir.join("HEAD").is_file() && dir.join("objects").is_dir() && dir.join("refs").is_dir()
130}
131
132fn is_repo_candidate(dir: &Path) -> bool {
133 dir.join(".git").exists() || looks_like_bare(dir)
134}
135
136pub fn repo_name_from_common_dir(common_dir: &Path) -> String {
138 let base = common_dir
139 .file_name()
140 .map(|n| n.to_string_lossy().into_owned())
141 .unwrap_or_default();
142 if base == ".git" || base == ".bare" {
143 return common_dir
144 .parent()
145 .and_then(|p| p.file_name())
146 .map(|n| n.to_string_lossy().into_owned())
147 .unwrap_or(base);
148 }
149 base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
150}
151
152pub fn git_common_dir(path: &Path) -> Result<PathBuf> {
158 let raw = git(
159 path,
160 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
161 )?;
162 Ok(PathBuf::from(raw))
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct WorktreeEntry {
177 pub path: PathBuf,
178 pub branch: Option<String>,
180 pub bare: bool,
181 pub prunable: bool,
182}
183
184pub fn parse_worktree_porcelain(out: &str) -> Vec<WorktreeEntry> {
190 let mut entries = Vec::new();
191 let mut current: Option<WorktreeEntry> = None;
192 for line in out.lines() {
193 if let Some(p) = line.strip_prefix("worktree ") {
194 if let Some(e) = current.take() {
195 entries.push(e);
196 }
197 current = Some(WorktreeEntry {
198 path: PathBuf::from(p),
199 branch: None,
200 bare: false,
201 prunable: false,
202 });
203 } else if let Some(e) = current.as_mut() {
204 if let Some(b) = line.strip_prefix("branch ") {
205 e.branch = Some(b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
206 } else if line == "bare" {
207 e.bare = true;
208 } else if line == "prunable" || line.starts_with("prunable ") {
209 e.prunable = true;
210 }
211 }
212 }
213 if let Some(e) = current.take() {
214 entries.push(e);
215 }
216 entries
217}
218
219fn normalize_path(path: PathBuf) -> PathBuf {
220 std::fs::canonicalize(&path).unwrap_or(path)
221}
222
223pub fn worktree_map(repo: &Path) -> Result<std::collections::HashMap<String, PathBuf>> {
232 let raw = git(repo, &["worktree", "list", "--porcelain"])?;
233 Ok(parse_worktree_porcelain(&raw)
234 .into_iter()
235 .filter(|e| !e.bare)
236 .filter_map(|e| e.branch.map(|b| (b, normalize_path(e.path))))
237 .collect())
238}
239
240pub fn find_repos(roots: &[PathBuf], scan_depth: usize) -> (Vec<RepoCandidate>, Vec<String>) {
243 let mut candidates = Vec::new();
244 for root in roots {
245 walk(root, 0, scan_depth, &mut candidates);
246 }
247 dedup_candidates(candidates)
248}
249
250fn dedup_candidates(candidates: Vec<PathBuf>) -> (Vec<RepoCandidate>, Vec<String>) {
251 use std::collections::HashMap;
252 let mut by_common: HashMap<PathBuf, RepoCandidate> = HashMap::new();
253 let mut warnings = Vec::new();
254 for candidate in candidates {
255 match git_common_dir(&candidate) {
256 Ok(common) => {
257 let repo_name = repo_name_from_common_dir(&common);
258 by_common.entry(common).or_insert(RepoCandidate {
259 path: candidate,
260 repo_name,
261 });
262 }
263 Err(e) => {
264 warnings.push(format!("{}: {e:#}", candidate.display()));
265 }
266 }
267 }
268 let mut repos: Vec<RepoCandidate> = by_common.into_values().collect();
269 repos.sort_by(|a, b| a.path.cmp(&b.path));
270 (repos, warnings)
271}
272
273fn walk(dir: &Path, depth: usize, scan_depth: usize, candidates: &mut Vec<PathBuf>) {
274 if is_repo_candidate(dir) {
275 candidates.push(dir.to_path_buf());
276 return;
277 }
278 if depth >= scan_depth {
279 return;
280 }
281 let Ok(entries) = std::fs::read_dir(dir) else {
282 return;
283 };
284 for entry in entries.flatten() {
285 let path = entry.path();
286 let name = entry.file_name();
287 let name = name.to_string_lossy();
288 if !path.is_dir() || name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
289 continue;
290 }
291 walk(&path, depth + 1, scan_depth, candidates);
292 }
293}
294
295pub fn repo_name_hint(path: &Path) -> String {
298 let base = path
299 .file_name()
300 .map(|n| n.to_string_lossy().into_owned())
301 .unwrap_or_default();
302 base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
303}
304
305pub fn open_loops(
320 repo: &Path,
321 root_label: &str,
322 opts: &ScanOptions,
323) -> Result<(Vec<OpenLoop>, Option<InvUpdate>)> {
324 let (default, default_sha) = default_branch_and_sha(repo)?;
326
327 let common_dir = git_common_dir(repo)?;
328 let repo_name = repo_name_from_common_dir(&common_dir);
329 let worktrees = worktree_map(repo).unwrap_or_else(|e| {
330 eprintln!(
331 "warning: git worktree list failed in {}: {e:#}; session matching falls back to the repo path",
332 repo.display()
333 );
334 std::collections::HashMap::new()
335 });
336 let merged: std::collections::HashSet<String> = git(
337 repo,
338 &["branch", "--merged", &default, "--format=%(refname:short)"],
339 )?
340 .lines()
341 .map(|s| s.trim().to_string())
342 .collect();
343 let raw = git(
344 repo,
345 &[
346 "for-each-ref",
347 "refs/heads",
348 "--format=%(refname:short)%09%(objectname)%09%(committerdate:iso8601-strict)",
349 ],
350 )?;
351
352 let use_inventory = opts.need_ahead_behind && opts.inventory_dir.is_some();
354
355 let use_inventory = use_inventory && !default_sha.is_empty();
357
358 let hash = if use_inventory {
359 inventory::common_dir_hash(&common_dir)
360 } else {
361 String::new()
362 };
363
364 let existing: Option<InventoryFile> = if use_inventory && !opts.fresh {
367 if let Some(inv_dir) = &opts.inventory_dir {
368 let store = InventoryStore {
369 dir: inv_dir.clone(),
370 };
371 store.load(&hash)
372 } else {
373 None
374 }
375 } else {
376 None
377 };
378
379 let now = Utc::now();
380 let repo_canonical = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
381 let mut new_memos: Vec<LoopMemo> = Vec::new();
382 let mut result = Vec::new();
383
384 for line in raw.lines() {
385 let mut parts = line.split('\t');
386 let (Some(branch), Some(sha), Some(date)) = (parts.next(), parts.next(), parts.next())
387 else {
388 eprintln!("warning: unexpected line from git for-each-ref ignored: {line:?}");
389 continue;
390 };
391 if branch == default || merged.contains(branch) {
392 continue;
393 }
394
395 let (ahead, behind) = if opts.need_ahead_behind {
396 let cached = if use_inventory {
397 existing.as_ref().and_then(|f| {
398 inventory::lookup_ahead_behind(
399 f,
400 branch,
401 sha,
402 &default_sha,
403 opts.inventory_ttl_secs,
404 now,
405 )
406 })
407 } else {
408 None
409 };
410
411 let (a, b) = if let Some(hit) = cached {
412 hit
413 } else {
414 let counts = git(
415 repo,
416 &[
417 "rev-list",
418 "--left-right",
419 "--count",
420 &format!("{default}...{branch}"),
421 ],
422 )?;
423 let mut c = counts.split_whitespace();
424 let behind_val: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
425 let ahead_val: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
426 (ahead_val, behind_val)
427 };
428
429 if use_inventory {
430 new_memos.push(LoopMemo {
431 branch: branch.to_string(),
432 head_sha: sha.to_string(),
433 ab_base_sha: default_sha.clone(),
434 ahead: a,
435 behind: b,
436 });
437 }
438 (Some(a), Some(b))
439 } else {
440 (None, None)
441 };
442
443 let last_commit = DateTime::parse_from_rfc3339(date)
444 .with_context(|| format!("invalid date from git: {date}"))?
445 .with_timezone(&Utc);
446 let repo_path = worktrees
447 .get(branch)
448 .cloned()
449 .unwrap_or_else(|| repo.to_path_buf());
450 result.push(OpenLoop {
451 root_label: root_label.to_string(),
452 repo_name: repo_name.clone(),
453 repo_path,
454 branch: branch.to_string(),
455 head_sha: sha.to_string(),
456 last_commit,
457 ahead,
458 behind,
459 });
460 }
461
462 let inventory_update = if use_inventory {
463 Some((
464 hash,
465 InventoryFile {
466 repo_path: repo_canonical,
467 indexed_at: now,
468 loops: new_memos,
469 },
470 ))
471 } else {
472 None
473 };
474
475 Ok((result, inventory_update))
476}
477
478pub fn scan(
487 roots: &[PathBuf],
488 labels: &[(PathBuf, String)],
489 scan_depth: usize,
490 opts: &ScanOptions,
491 repo_filter: Option<&str>,
492) -> (Vec<OpenLoop>, Vec<String>, Vec<InvUpdate>) {
493 let (mut repos, mut warnings) = find_repos(roots, scan_depth);
494 if let Some(filter) = repo_filter {
495 let needle = filter.to_lowercase();
496 repos.retain(|r| r.repo_name.to_lowercase().contains(&needle));
497 }
498 let results: Vec<Result<(Vec<OpenLoop>, Option<InvUpdate>)>> = std::thread::scope(|s| {
499 let handles: Vec<_> = repos
500 .iter()
501 .map(|repo| {
502 let label = crate::config::label_for_repo(labels, &repo.path);
503 let path = repo.path.clone();
504 s.spawn(move || open_loops(&path, &label, opts))
505 })
506 .collect();
507 handles
508 .into_iter()
509 .map(|h| {
510 h.join()
511 .unwrap_or_else(|_| Err(anyhow::anyhow!("panic while scanning repository")))
512 })
513 .collect()
514 });
515 let mut all = Vec::new();
516 let mut inventory_updates = Vec::new();
517 for (repo, res) in repos.iter().zip(results) {
518 match res {
519 Ok((mut loops, inv)) => {
520 all.append(&mut loops);
521 if let Some(update) = inv {
522 inventory_updates.push(update);
523 }
524 }
525 Err(e) => warnings.push(format!("{}: {e:#}", repo.path.display())),
526 }
527 }
528 (all, warnings, inventory_updates)
529}
530
531pub fn git_log(repo: &Path, default: &str, branch: &str) -> Result<String> {
537 git(repo, &["log", "--oneline", &format!("{default}..{branch}")])
538}
539
540pub fn diffstat(repo: &Path, default: &str, branch: &str) -> Result<String> {
546 git(repo, &["diff", "--stat", &format!("{default}...{branch}")])
547}
548
549pub fn commit_window(
557 repo: &Path,
558 default: &str,
559 branch: &str,
560) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
561 let raw = git(
562 repo,
563 &["log", "--format=%cI", &format!("{default}..{branch}")],
564 )?;
565 let mut dates: Vec<DateTime<Utc>> = raw
566 .lines()
567 .filter_map(|l| DateTime::parse_from_rfc3339(l.trim()).ok())
568 .map(|d| d.with_timezone(&Utc))
569 .collect();
570 if dates.is_empty() {
571 let head = git(repo, &["log", "-1", "--format=%cI", branch])?;
573 dates.push(DateTime::parse_from_rfc3339(head.trim())?.with_timezone(&Utc));
574 }
575 let min = dates
576 .iter()
577 .min()
578 .copied()
579 .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
580 let max = dates
581 .iter()
582 .max()
583 .copied()
584 .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
585 Ok((min, max))
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::testutil;
592
593 fn open_loops_simple(
595 repo: &std::path::Path,
596 root_label: &str,
597 need_ahead_behind: bool,
598 ) -> Vec<OpenLoop> {
599 let opts = ScanOptions {
600 need_ahead_behind,
601 ..ScanOptions::default()
602 };
603 open_loops(repo, root_label, &opts).unwrap().0
604 }
605
606 fn scan_simple(
608 roots: &[PathBuf],
609 labels: &[(PathBuf, String)],
610 depth: usize,
611 need_ahead_behind: bool,
612 filter: Option<&str>,
613 ) -> (Vec<OpenLoop>, Vec<String>) {
614 let opts = ScanOptions {
615 need_ahead_behind,
616 ..ScanOptions::default()
617 };
618 let (loops, warnings, _inv) = scan(roots, labels, depth, &opts, filter);
619 (loops, warnings)
620 }
621
622 fn assert_same_path(actual: &std::path::Path, expected: &std::path::Path) {
623 let a = std::fs::canonicalize(actual).unwrap_or_else(|_| actual.to_path_buf());
624 let b = std::fs::canonicalize(expected).unwrap_or_else(|_| expected.to_path_buf());
625 assert_eq!(a, b);
626 }
627
628 #[test]
629 fn default_branch_detects_main() {
630 let tmp = tempfile::tempdir().unwrap();
631 let repo = tmp.path().join("app");
632 testutil::init_repo(&repo);
633 assert_eq!(default_branch(&repo).unwrap(), "main");
634 }
635
636 #[test]
637 fn default_branch_honours_origin_head_when_target_is_local() {
638 let tmp = tempfile::tempdir().unwrap();
639 let repo = tmp.path().join("app");
640 testutil::init_repo(&repo); testutil::git(&repo, &["branch", "develop"]); testutil::git(
643 &repo,
644 &[
645 "symbolic-ref",
646 "refs/remotes/origin/HEAD",
647 "refs/remotes/origin/develop",
648 ],
649 );
650 assert_eq!(default_branch(&repo).unwrap(), "develop");
652 }
653
654 #[test]
655 fn default_branch_falls_back_when_origin_head_target_missing() {
656 let tmp = tempfile::tempdir().unwrap();
657 let repo = tmp.path().join("app");
658 testutil::init_repo(&repo); testutil::git(
660 &repo,
661 &[
662 "symbolic-ref",
663 "refs/remotes/origin/HEAD",
664 "refs/remotes/origin/ghost",
665 ],
666 );
667 assert_eq!(default_branch(&repo).unwrap(), "main");
669 }
670
671 #[test]
672 fn git_fails_with_contextual_message() {
673 let tmp = tempfile::tempdir().unwrap();
674 let err = git(tmp.path(), &["status"]).unwrap_err();
676 assert!(err.to_string().contains(&tmp.path().display().to_string()));
677 }
678
679 #[test]
680 fn find_repos_dedups_container_and_worktrees() {
681 let tmp = tempfile::tempdir().unwrap();
682 let container = tmp.path().join("my-app");
683 testutil::init_bare_worktree_container(&container);
684 let dev = container.join("dev");
685 testutil::add_named_worktree(&container, "dev", "dev");
686 let (repos, warnings) = find_repos(&[container.clone(), dev], 4);
687 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
688 assert_eq!(repos.len(), 1);
689 assert_eq!(repos[0].path, container);
690 }
691
692 #[test]
693 fn find_repos_respects_scan_depth_and_skips_hidden() {
694 let tmp = tempfile::tempdir().unwrap();
695 testutil::init_repo(&tmp.path().join("a/b/c/repo-deep"));
696 testutil::init_repo(&tmp.path().join("a/b/repo-mid"));
697 testutil::init_repo(&tmp.path().join("repo-shallow"));
698 testutil::init_repo(&tmp.path().join(".hidden/repo3"));
699
700 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
701 let names: Vec<_> = repos
702 .iter()
703 .filter_map(|r| r.path.file_name())
704 .map(|n| n.to_string_lossy().into_owned())
705 .collect();
706 assert!(names.contains(&"repo-deep".to_string()));
707 assert!(names.contains(&"repo-mid".to_string()));
708 assert!(names.contains(&"repo-shallow".to_string()));
709 assert!(!names.contains(&"repo3".to_string()));
710
711 let (shallow, _) = find_repos(&[tmp.path().to_path_buf()], 2);
712 let shallow_names: Vec<_> = shallow
713 .iter()
714 .filter_map(|r| r.path.file_name())
715 .map(|n| n.to_string_lossy().into_owned())
716 .collect();
717 assert!(!shallow_names.contains(&"repo-deep".to_string()));
718 assert!(shallow_names.contains(&"repo-shallow".to_string()));
719 }
720
721 #[test]
722 fn find_repos_finds_normal_git_dir_repo() {
723 let tmp = tempfile::tempdir().unwrap();
724 testutil::init_repo(&tmp.path().join("app"));
725 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
726 assert_eq!(repos.len(), 1);
727 }
728
729 #[test]
730 fn find_repos_finds_bare_worktree_container_via_git_file() {
731 let tmp = tempfile::tempdir().unwrap();
732 let container = tmp.path().join("my-app");
733 testutil::init_bare_worktree_container(&container);
734 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
735 assert_eq!(repos.len(), 1);
736 assert_eq!(repos[0].path, container);
737 }
738
739 #[test]
740 fn find_repos_finds_pure_bare_repo() {
741 let tmp = tempfile::tempdir().unwrap();
742 let bare = tmp.path().join("foo.git");
743 testutil::init_bare_repo(&bare);
744 testutil::seed_bare_main(&bare);
745 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
746 assert_eq!(repos.len(), 1);
747 assert_eq!(repos[0].path, bare);
748 }
749
750 #[test]
751 fn open_loops_uses_common_dir_repo_name_in_bare_layout() {
752 let tmp = tempfile::tempdir().unwrap();
753 let container = tmp.path().join("my-app");
754 testutil::init_bare_worktree_container(&container);
755 testutil::add_named_worktree(&container, "dev", "dev");
756 testutil::add_branch_on_bare(&container.join(".bare"), "feat/x", "x.txt");
757
758 let loops = open_loops_simple(&container, "root", true);
759 assert_eq!(loops.len(), 1);
760 assert_eq!(loops[0].repo_name, "my-app");
761 assert_eq!(loops[0].branch, "feat/x");
762 assert_eq!(loops[0].key(), "root/my-app/feat/x");
763 }
764
765 #[test]
766 fn open_loops_bare_root_repo_name_strips_dot_git_suffix() {
767 let tmp = tempfile::tempdir().unwrap();
768 let bare = tmp.path().join("foo.git");
769 testutil::init_bare_repo(&bare);
770 testutil::seed_bare_main(&bare);
771 testutil::add_branch_on_bare(&bare, "feat/y", "y.txt");
772
773 let loops = open_loops_simple(&bare, "r", true);
774 assert_eq!(loops[0].repo_name, "foo");
775 }
776
777 #[test]
778 fn open_loops_finds_unmerged_ignores_merged_and_default() {
779 let tmp = tempfile::tempdir().unwrap();
780 let repo = tmp.path().join("app");
781 testutil::init_repo(&repo);
782 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
783 testutil::git(&repo, &["branch", "merged"]); let loops = open_loops_simple(&repo, "root", true);
786 assert_eq!(loops.len(), 1);
787 let l = &loops[0];
788 assert_eq!(l.branch, "feat/x");
789 assert_eq!(l.repo_name, "app");
790 assert_eq!(l.root_label, "root");
791 assert_eq!(l.key(), "root/app/feat/x");
792 assert_eq!(l.ahead, Some(1));
793 assert_eq!(l.behind, Some(0));
794 assert_eq!(l.head_sha.len(), 40);
795 }
796
797 #[test]
798 fn open_loops_sets_repo_path_to_worktree_when_branch_checked_out() {
799 let tmp = tempfile::tempdir().unwrap();
800 let container = tmp.path().join("my-app");
801 testutil::init_bare_worktree_container(&container);
802 testutil::add_worktree_with_commit(&container, "feat-x", "feat/x", "x.txt");
803
804 let loops = open_loops_simple(&container, "root", true);
805 let lp = loops
806 .iter()
807 .find(|l| l.branch == "feat/x")
808 .expect("feat/x loop");
809 assert_same_path(&lp.repo_path, &container.join("feat-x"));
810 }
811
812 #[test]
813 fn open_loops_falls_back_to_container_when_branch_has_no_worktree() {
814 let tmp = tempfile::tempdir().unwrap();
815 let container = tmp.path().join("my-app");
816 testutil::init_bare_worktree_container(&container);
817 testutil::add_branch_on_bare(&container.join(".bare"), "feat/y", "y.txt");
819
820 let loops = open_loops_simple(&container, "root", true);
821 let lp = loops
822 .iter()
823 .find(|l| l.branch == "feat/y")
824 .expect("feat/y loop");
825 assert_eq!(lp.repo_path, container);
826 }
827
828 #[test]
829 fn open_loops_normal_repo_keeps_repo_path_as_repo_dir() {
830 let tmp = tempfile::tempdir().unwrap();
831 let repo = tmp.path().join("app");
832 testutil::init_repo(&repo);
833 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt"); let loops = open_loops_simple(&repo, "root", true);
835 assert_eq!(loops[0].branch, "feat/x");
836 assert_eq!(loops[0].repo_path, repo); }
838
839 #[test]
840 fn open_loops_skips_rev_list_when_need_ahead_behind_false() {
841 let tmp = tempfile::tempdir().unwrap();
842 let repo = tmp.path().join("app");
843 testutil::init_repo(&repo);
844 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
845
846 let loops = open_loops_simple(&repo, "root", false);
847 assert_eq!(loops.len(), 1);
848 assert_eq!(loops[0].ahead, None);
849 assert_eq!(loops[0].behind, None);
850 }
851
852 #[test]
853 fn open_loops_computes_ahead_behind_when_need_ahead_behind_true() {
854 let tmp = tempfile::tempdir().unwrap();
855 let repo = tmp.path().join("app");
856 testutil::init_repo(&repo);
857 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
858
859 let loops = open_loops_simple(&repo, "root", true);
860 assert_eq!(loops.len(), 1);
861 assert_eq!(loops[0].ahead, Some(1));
862 assert_eq!(loops[0].behind, Some(0));
863 }
864
865 #[test]
866 fn open_loops_reuses_inventory_memo_on_repeated_scan() {
867 let tmp = tempfile::tempdir().unwrap();
868 let repo = tmp.path().join("app");
869 testutil::init_repo(&repo);
870 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
871 let inv_dir = tmp.path().join("inv");
872
873 let opts = ScanOptions {
874 need_ahead_behind: true,
875 fresh: false,
876 inventory_dir: Some(inv_dir.clone()),
877 inventory_ttl_secs: 0,
878 };
879
880 let (loops1, inv1) = open_loops(&repo, "root", &opts).unwrap();
882 assert_eq!(loops1.len(), 1);
883 assert_eq!(loops1[0].ahead, Some(1));
884 let (hash, file) = inv1.unwrap();
885 let store = InventoryStore {
886 dir: inv_dir.clone(),
887 };
888 store.save(&hash, &file).unwrap();
889
890 let (loops2, inv2) = open_loops(&repo, "root", &opts).unwrap();
892 assert_eq!(loops2.len(), 1);
893 assert_eq!(loops2[0].ahead, Some(1));
894 assert_eq!(loops2[0].behind, Some(0));
895 assert!(inv2.is_some());
897 }
898
899 #[test]
900 fn open_loops_fresh_ignores_inventory_memo() {
901 let tmp = tempfile::tempdir().unwrap();
902 let repo = tmp.path().join("app");
903 testutil::init_repo(&repo);
904 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
905 let inv_dir = tmp.path().join("inv");
906
907 let common = git_common_dir(&repo).unwrap();
910 let hash = crate::inventory::common_dir_hash(&common);
911 let store = InventoryStore {
912 dir: inv_dir.clone(),
913 };
914 let fake_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
915 let stub_file = InventoryFile {
916 repo_path: repo.clone(),
917 indexed_at: chrono::Utc::now(),
918 loops: vec![LoopMemo {
919 branch: "feat/x".to_string(),
920 head_sha: fake_sha.to_string(),
921 ab_base_sha: fake_sha.to_string(),
922 ahead: 99,
923 behind: 99,
924 }],
925 };
926 store.save(&hash, &stub_file).unwrap();
927
928 let opts = ScanOptions {
929 need_ahead_behind: true,
930 fresh: true, inventory_dir: Some(inv_dir.clone()),
932 inventory_ttl_secs: 0,
933 };
934 let (loops, _) = open_loops(&repo, "root", &opts).unwrap();
935 assert_eq!(loops[0].ahead, Some(1));
937 assert_eq!(loops[0].behind, Some(0));
938 }
939
940 #[test]
941 fn scan_repo_filter_pushdown_skips_non_matching_repos() {
942 let tmp = tempfile::tempdir().unwrap();
943 let api = tmp.path().join("api-service");
944 let web = tmp.path().join("web-app");
945 testutil::init_repo(&api);
946 testutil::init_repo(&web);
947 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
948 testutil::add_branch_with_commit(&web, "feat/web", "w.txt");
949
950 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
951 let (loops, _) = scan_simple(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
952 assert_eq!(loops.len(), 1);
953 assert_eq!(loops[0].repo_name, "api-service");
954 assert_eq!(loops[0].branch, "feat/api");
955 }
956
957 #[test]
958 fn repo_name_hint_strips_dot_git_suffix() {
959 assert_eq!(repo_name_hint(std::path::Path::new("/srv/foo.git")), "foo");
960 }
961
962 #[test]
963 fn scan_repo_filter_is_case_insensitive() {
964 let tmp = tempfile::tempdir().unwrap();
965 let api = tmp.path().join("API-Service");
966 testutil::init_repo(&api);
967 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
968
969 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
970 let (loops, _) = scan_simple(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
972 assert_eq!(loops.len(), 1);
973 assert_eq!(loops[0].repo_name, "API-Service");
974 }
975
976 #[test]
977 fn scan_repo_filter_matching_nothing_yields_no_loops() {
978 let tmp = tempfile::tempdir().unwrap();
979 let api = tmp.path().join("api-service");
980 testutil::init_repo(&api);
981 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
982
983 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
984 let (loops, warnings) = scan_simple(
985 &[tmp.path().to_path_buf()],
986 &labels,
987 4,
988 false,
989 Some("zzz-nope"),
990 );
991 assert!(loops.is_empty());
992 assert!(
993 warnings.is_empty(),
994 "filtered-out repos must not warn: {warnings:?}"
995 );
996 }
997
998 #[test]
999 fn scan_aggregates_repos_and_reports_warning_without_aborting() {
1000 let tmp = tempfile::tempdir().unwrap();
1001 let good = tmp.path().join("good");
1002 testutil::init_repo(&good);
1003 testutil::add_branch_with_commit(&good, "feat/ok", "ok.txt");
1004 let empty = tmp.path().join("empty");
1006 std::fs::create_dir_all(&empty).unwrap();
1007 testutil::git(&empty, &["init", "-b", "main"]);
1008
1009 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
1010 let (loops, warnings) = scan_simple(&[tmp.path().to_path_buf()], &labels, 4, true, None);
1011 assert_eq!(loops.len(), 1);
1012 assert_eq!(loops[0].key(), "r/good/feat/ok");
1013 assert_eq!(warnings.len(), 1);
1014 assert!(warnings[0].contains("empty"));
1015 }
1016
1017 #[test]
1018 fn context_helpers_return_commits_and_window() {
1019 let tmp = tempfile::tempdir().unwrap();
1020 let repo = tmp.path().join("app");
1021 testutil::init_repo(&repo);
1022 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
1023
1024 let log = git_log(&repo, "main", "feat/x").unwrap();
1025 assert!(log.contains("wip feat/x"));
1026 let stat = diffstat(&repo, "main", "feat/x").unwrap();
1027 assert!(stat.contains("x.txt"));
1028 let (start, end) = commit_window(&repo, "main", "feat/x").unwrap();
1029 assert!(start <= end);
1030 }
1031
1032 #[test]
1033 fn default_branch_detects_master_fallback() {
1034 let tmp = tempfile::tempdir().unwrap();
1035 let repo = tmp.path();
1036 testutil::git(repo, &["init", "-b", "master"]);
1037 std::fs::write(repo.join("a.txt"), "a").unwrap();
1038 testutil::git(repo, &["add", "."]);
1039 testutil::git(repo, &["commit", "-m", "init"]);
1040 assert_eq!(default_branch(repo).unwrap(), "master");
1041 }
1042
1043 #[test]
1044 fn default_branch_errors_without_main_or_master() {
1045 let tmp = tempfile::tempdir().unwrap();
1046 let repo = tmp.path();
1047 testutil::git(repo, &["init", "-b", "trunk"]);
1048 let err = default_branch(repo).unwrap_err();
1050 assert!(err.to_string().contains("couldn't find the default branch"));
1051 }
1052
1053 #[test]
1054 fn git_common_dir_resolves_normal_and_bare_pointer() {
1055 let tmp = tempfile::tempdir().unwrap();
1056 let normal = tmp.path().join("app");
1057 testutil::init_repo(&normal);
1058 let normal_common = git_common_dir(&normal).unwrap();
1059 assert!(normal_common.ends_with(".git"));
1060
1061 let container = tmp.path().join("container");
1062 testutil::init_bare_worktree_container(&container);
1063 let bare_common = git_common_dir(&container).unwrap();
1064 assert!(bare_common.ends_with(".bare"));
1065 }
1066
1067 #[test]
1068 fn parse_worktree_porcelain_extracts_branches_and_flags() {
1069 let out = "\
1070worktree /home/u/app/main
1071HEAD aaaaaaaa
1072branch refs/heads/main
1073
1074worktree /home/u/app/feat-x
1075HEAD bbbbbbbb
1076branch refs/heads/feat/x
1077
1078worktree /home/u/app/detached
1079HEAD cccccccc
1080detached
1081
1082worktree /home/u/app/.bare
1083bare
1084";
1085 let entries = parse_worktree_porcelain(out);
1086 assert_eq!(entries.len(), 4);
1087 assert_eq!(entries[0].branch.as_deref(), Some("main"));
1088 assert_eq!(
1089 entries[0].path,
1090 std::path::PathBuf::from("/home/u/app/main")
1091 );
1092 assert_eq!(entries[1].branch.as_deref(), Some("feat/x")); assert_eq!(entries[2].branch, None); assert!(entries[3].bare);
1095 assert_eq!(entries[3].branch, None);
1096 }
1097
1098 #[test]
1099 fn parse_worktree_porcelain_marks_prunable_and_handles_empty() {
1100 assert!(parse_worktree_porcelain("").is_empty());
1101 let out = "worktree /gone\nprunable gitdir file points to non-existent location\n";
1102 let entries = parse_worktree_porcelain(out);
1103 assert_eq!(entries.len(), 1);
1104 assert!(entries[0].prunable);
1105 assert_eq!(entries[0].branch, None);
1106 }
1107
1108 #[test]
1109 fn worktree_map_maps_checked_out_branches_to_paths() {
1110 let tmp = tempfile::tempdir().unwrap();
1111 let container = tmp.path().join("my-app");
1112 testutil::init_bare_worktree_container(&container); testutil::add_named_worktree(&container, "dev", "dev"); let map = worktree_map(&container).unwrap();
1116 assert_same_path(map.get("main").unwrap(), &container.join("main"));
1117 assert_same_path(map.get("dev").unwrap(), &container.join("dev"));
1118 assert!(!map.values().any(|p| p.ends_with(".bare")));
1120 }
1121
1122 #[test]
1123 fn worktree_map_errors_on_non_git_dir() {
1124 let tmp = tempfile::tempdir().unwrap();
1125 assert!(worktree_map(tmp.path()).is_err());
1127 }
1128
1129 #[test]
1130 fn parse_worktree_porcelain_ignores_lines_before_first_worktree() {
1131 let out = "branch refs/heads/orphan\nHEAD deadbeef\nworktree /home/u/app/main\nbranch refs/heads/main\n";
1132 let entries = parse_worktree_porcelain(out);
1133 assert_eq!(entries.len(), 1);
1134 assert_eq!(
1135 entries[0].path,
1136 std::path::PathBuf::from("/home/u/app/main")
1137 );
1138 assert_eq!(entries[0].branch.as_deref(), Some("main"));
1139 }
1140
1141 #[test]
1142 fn repo_name_from_common_dir_table() {
1143 use std::path::Path;
1144
1145 let cases: &[(&str, &str)] = &[
1146 ("/home/u/my-app/.bare", "my-app"),
1147 ("/home/u/app/.git", "app"),
1148 ("/srv/git/foo.git", "foo"),
1149 ("/srv/git/myproject", "myproject"),
1150 ];
1151 for (common, want) in cases {
1152 assert_eq!(
1153 repo_name_from_common_dir(Path::new(common)),
1154 *want,
1155 "common_dir={common}"
1156 );
1157 }
1158 }
1159}