Skip to main content

open_loops/
scanner.rs

1//! Repository and unmerged-branch discovery via git shell-out.
2//! Design decision: shell-out (not git2/gix) — simple and debuggable;
3//! the product performance bottleneck is the LLM, not git.
4use 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
11/// Inventory update produced by one `open_loops` call: `(common-dir hash, file)`.
12type InvUpdate = (String, InventoryFile);
13
14/// Options controlling a scan (light phase always; heavy phase optional + memoised).
15#[derive(Debug, Clone, Default)]
16pub struct ScanOptions {
17    /// Whether to compute ahead/behind counts (heavy phase via `rev-list`).
18    pub need_ahead_behind: bool,
19    /// When true, skip any cached inventory memo and recompute `rev-list`.
20    pub fresh: bool,
21    /// Directory for the inventory JSON files. `None` disables memoisation.
22    pub inventory_dir: Option<PathBuf>,
23    /// Seconds before a cached entry expires; 0 = SHA-only validation.
24    pub inventory_ttl_secs: u64,
25}
26
27/// Runs a git subcommand in `repo` and returns trimmed stdout.
28///
29/// # Errors
30///
31/// Returns `Err` if git is not in PATH or if the command fails.
32pub(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
50/// Default branch: origin/HEAD's target if it resolves locally; otherwise main;
51/// otherwise master.
52///
53/// # Errors
54///
55/// Returns `Err` if no default branch is found.
56pub fn default_branch(repo: &Path) -> Result<String> {
57    let (name, _) = default_branch_and_sha(repo)?;
58    Ok(name)
59}
60
61/// Default branch name and its SHA, resolved in a single rev-parse call.
62/// Used internally to avoid redundant git calls in the heavy phase.
63///
64/// origin/HEAD only wins when its target branch exists locally: a stale or
65/// `--single-branch` origin/HEAD can name a branch with no local ref, and we
66/// must fall through to main/master rather than hide the whole repo.
67///
68/// # Errors
69///
70/// Returns `Err` if no default branch is found.
71fn 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            // Only honour origin/HEAD when its branch resolves locally; otherwise
78            // fall through so a stale pointer doesn't make the repo disappear.
79            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/// A git repository discovered under a configured root (deduped by common-dir).
99#[derive(Debug, Clone)]
100pub struct RepoCandidate {
101    pub path: PathBuf,
102    /// Canonical repo name from `--git-common-dir` (computed once during dedup).
103    pub repo_name: String,
104}
105
106/// An open loop: an unmerged branch with its own commits.
107#[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    /// Canonical key used in resume/ignore: "root-label/repo/branch".
121    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
136/// Derives a stable repo name from the absolute git common-dir (§5 of Spec Fase A).
137pub 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
152/// Absolute path of the git common-dir for `path` (bare store / `.git` dir).
153///
154/// # Errors
155///
156/// Returns `Err` when `path` is not inside a git repository.
157pub 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// PERF-1: git_common_dir is called twice per repo — once in dedup_candidates
166// (computed but not stored), and again in open_loops. Reusing the value from
167// dedup would require changing RepoCandidate or open_loops's public signature.
168// Both are internal, but threading the common_dir through without altering the
169// public API would require wrapping it in a private helper that cli.rs doesn't call.
170// Current cost: negligible (one extra git call per repo per scan), acceptable
171// trade-off for keeping the public signature stable. Revisit if scan latency
172// becomes dominated by this call (measure: `time loops scan --fresh`).
173
174/// One entry from `git worktree list --porcelain`.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct WorktreeEntry {
177    pub path: PathBuf,
178    /// Short branch name (`refs/heads/` stripped). `None` when detached or bare.
179    pub branch: Option<String>,
180    pub bare: bool,
181    pub prunable: bool,
182}
183
184/// Parses `git worktree list --porcelain` into entries.
185///
186/// Pure over the git output: a new entry starts at each `worktree ` line; the
187/// `HEAD`/`detached`/`locked` lines leave `branch` as `None`. Tolerant — unknown
188/// or blank lines are ignored, never panics.
189pub 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
223/// Maps each checked-out branch to the absolute path of its worktree.
224///
225/// Bare and detached entries are dropped (no branch to key on). git proscribes
226/// the same branch in two worktrees, so the map is 1:1.
227///
228/// # Errors
229///
230/// Returns `Err` if `git worktree list` fails.
231pub 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
240/// Walks roots up to `scan_depth` looking for git repo candidates, then
241/// deduplicates by absolute `--git-common-dir`.
242pub 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
295/// Path-based repo name guess when `git rev-parse --git-common-dir` fails.
296/// Primary naming comes from common-dir during dedup; this is the error fallback only.
297pub 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
305/// Returns all unmerged branches (except default) in a repo, optionally reading
306/// and updating the inventory memo for ahead/behind.
307///
308/// Light phase (default branch, merged set, `for-each-ref`) always runs. The
309/// heavy phase (`rev-list` for ahead/behind) runs only when
310/// `opts.need_ahead_behind` is true, and consults the inventory memo unless
311/// `opts.fresh` is set.
312///
313/// Returns the open loops and, when memoisation is active, the updated
314/// `(hash, InventoryFile)` pair for write-through by the caller.
315///
316/// # Errors
317///
318/// Returns `Err` if git fails or if the default branch is not found.
319pub fn open_loops(
320    repo: &Path,
321    root_label: &str,
322    opts: &ScanOptions,
323) -> Result<(Vec<OpenLoop>, Option<InvUpdate>)> {
324    // Resolve default branch and its SHA once (PERF-2: avoid duplicate rev-parse).
325    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    // Determine whether to use the inventory memo for this scan.
353    let use_inventory = opts.need_ahead_behind && opts.inventory_dir.is_some();
354
355    // Robustness: if default_sha is empty, skip memoisation to avoid poisoning the cache.
356    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    // Load the existing inventory file unless `--fresh` was requested.
365    // Destructure inventory_dir once to avoid .unwrap() landmine.
366    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
478/// Scans all repos found under the roots in parallel.
479///
480/// `repo_filter`, when set, retains only repos whose canonical name (from dedup)
481/// matches before `open_loops` runs. Individual repo failures become warnings and
482/// never abort the scan.
483///
484/// Returns `(loops, warnings, inventory_updates)` where `inventory_updates` is a
485/// vec of `(hash, file)` pairs ready for write-through by the caller.
486pub 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
531/// Branch-exclusive commits relative to the default (for the distillation prompt).
532///
533/// # Errors
534///
535/// Returns `Err` if git fails.
536pub fn git_log(repo: &Path, default: &str, branch: &str) -> Result<String> {
537    git(repo, &["log", "--oneline", &format!("{default}..{branch}")])
538}
539
540/// Diffstat of the branch against the base (for the distillation prompt).
541///
542/// # Errors
543///
544/// Returns `Err` if git fails.
545pub fn diffstat(repo: &Path, default: &str, branch: &str) -> Result<String> {
546    git(repo, &["diff", "--stat", &format!("{default}...{branch}")])
547}
548
549/// Time window of the branch-exclusive commits.
550///
551/// Used to filter out AI sessions that predate the branch work.
552///
553/// # Errors
554///
555/// Returns `Err` if git fails or if there are no commits on the branch.
556pub 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        // branch has no exclusive commit: fall back to its latest commit
572        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    /// Helper: call `open_loops` without inventory, returning only the loops vec.
594    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    /// Helper: call `scan` without inventory, returning only `(loops, warnings)`.
607    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); // main + commit
641        testutil::git(&repo, &["branch", "develop"]); // local develop exists
642        testutil::git(
643            &repo,
644            &[
645                "symbolic-ref",
646                "refs/remotes/origin/HEAD",
647                "refs/remotes/origin/develop",
648            ],
649        );
650        // origin/HEAD wins over main because its target resolves locally.
651        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); // main + commit, no local "ghost"
659        testutil::git(
660            &repo,
661            &[
662                "symbolic-ref",
663                "refs/remotes/origin/HEAD",
664                "refs/remotes/origin/ghost",
665            ],
666        );
667        // Stale origin/HEAD target → fall through to main, not an error.
668        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        // directory is not a git repo
675        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"]); // points to main => merged
784
785        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        // feat/y exists in the store but is NOT checked out in any worktree
818        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"); // checks out feat/x then back to main
834        let loops = open_loops_simple(&repo, "root", true);
835        assert_eq!(loops[0].branch, "feat/x");
836        assert_eq!(loops[0].repo_path, repo); // not checked out in a worktree → fallback
837    }
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        // First call: no cache → runs rev-list and writes inventory.
881        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        // Second call: memo present → cache hit; ahead/behind same.
891        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        // inventory update is still returned (for write-through)
896        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        // Pre-seed inventory with wrong ahead/behind values to detect if it's
908        // being used.
909        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, // <-- bypass cache
931            inventory_dir: Some(inv_dir.clone()),
932            inventory_ttl_secs: 0,
933        };
934        let (loops, _) = open_loops(&repo, "root", &opts).unwrap();
935        // real values, not the stubbed 99/99
936        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        // lowercase filter must match a mixed-case repo dir (both sides lowered)
971        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        // truly broken repo: no commits, so default_branch fails
1005        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        // no commits: refs/heads/main and refs/heads/master do not exist
1049        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")); // slash preserved
1093        assert_eq!(entries[2].branch, None); // detached
1094        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); // main worktree at container/main
1113        testutil::add_named_worktree(&container, "dev", "dev"); // dev worktree at container/dev
1114
1115        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        // the `.bare` entry is filtered out (no branch / bare)
1119        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        // a plain directory is not a git repo → git worktree list fails
1126        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}