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::index::Index;
10use crate::inventory::{self, InventoryFile, InventoryStore, LoopMemo};
11
12/// Inventory update produced by one `open_loops` call: `(common-dir hash, file)`.
13type InvUpdate = (String, InventoryFile);
14
15/// Options controlling a scan (light phase always; heavy phase optional + memoised).
16#[derive(Debug, Clone, Default)]
17pub struct ScanOptions {
18    /// Whether to compute ahead/behind counts (heavy phase via `rev-list`).
19    pub need_ahead_behind: bool,
20    /// When true, skip any cached inventory memo and recompute `rev-list`.
21    pub fresh: bool,
22    /// Directory for the inventory JSON files. `None` disables memoisation.
23    pub inventory_dir: Option<PathBuf>,
24    /// Seconds before a cached entry expires; 0 = SHA-only validation.
25    pub inventory_ttl_secs: u64,
26}
27
28/// Runs a git subcommand in `repo` and returns trimmed stdout.
29///
30/// # Errors
31///
32/// Returns `Err` if git is not in PATH or if the command fails.
33pub(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
51/// Default branch: origin/HEAD's target if it resolves locally; otherwise main;
52/// otherwise master.
53///
54/// # Errors
55///
56/// Returns `Err` if no default branch is found.
57pub fn default_branch(repo: &Path) -> Result<String> {
58    let (name, _) = default_branch_and_sha(repo)?;
59    Ok(name)
60}
61
62/// Default branch name and its SHA, resolved in a single rev-parse call.
63/// Used internally to avoid redundant git calls in the heavy phase.
64///
65/// origin/HEAD only wins when its target branch exists locally: a stale or
66/// `--single-branch` origin/HEAD can name a branch with no local ref, and we
67/// must fall through to main/master rather than hide the whole repo.
68///
69/// # Errors
70///
71/// Returns `Err` if no default branch is found.
72fn 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            // Only honour origin/HEAD when its branch resolves locally; otherwise
79            // fall through so a stale pointer doesn't make the repo disappear.
80            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/// A git repository discovered under a configured root (deduped by common-dir).
100#[derive(Debug, Clone)]
101pub struct RepoCandidate {
102    pub path: PathBuf,
103    /// Canonical repo name from `--git-common-dir` (computed once during dedup).
104    pub repo_name: String,
105}
106
107/// An open loop: an unmerged branch with its own commits.
108#[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    /// Canonical key used in resume/ignore: "root-label/repo/branch".
122    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
137/// Derives a stable repo name from the absolute git common-dir (§5 of Spec Fase A).
138pub 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
153/// Absolute path of the git common-dir for `path` (bare store / `.git` dir).
154///
155/// # Errors
156///
157/// Returns `Err` when `path` is not inside a git repository.
158pub 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
166/// Cheap fingerprint of a repo's refs: the MAX mtime (unix nanoseconds since the
167/// epoch) of `<common_dir>/HEAD`, `<common_dir>/packed-refs` (when present), the
168/// newest entry anywhere under the `<common_dir>/refs` tree, and the newest entry
169/// anywhere under the `<common_dir>/worktrees/` tree. Missing files or directories
170/// contribute 0.
171///
172/// This is the refs-fingerprint gate (#13): when it is unchanged since the last
173/// index write, the cached loops are still valid and the heavy git phase
174/// (`for-each-ref`, `branch --merged`, per-branch `rev-list`) can be skipped
175/// entirely.
176///
177/// Precision note: the brief specified whole-second mtimes, but a branch created
178/// (or advanced) within the same wall-clock second as the previous index write
179/// would then leave the fingerprint unchanged and silently serve stale loops —
180/// e.g. a brand-new branch would not appear. Using nanosecond precision closes
181/// that window so a new/advanced ref is always detected, regardless of timing.
182/// `i64` nanos-since-epoch overflow only in the year 2262, so the range is safe.
183/// On filesystems that expose only second-granularity mtimes the sub-second part
184/// is simply 0 — the gate then degrades to whole-second behaviour, never worse.
185///
186/// Other notes:
187/// - the gate is additionally paired with `default_sha` in `cached_loops`, so a
188///   moved default branch invalidates even if mtimes somehow collide.
189/// - `git gc`/repacking rewrites `packed-refs`, which bumps the fingerprint
190///   without a semantic change. That is acceptable — it only forces one
191///   recompute, never stale data.
192/// - `git worktree add`/`remove` mutates `<common_dir>/worktrees/`, which is now
193///   covered: any worktree change bumps the fingerprint → gate invalidates →
194///   recompute → fresh `worktree_path` values are served.
195pub 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
204/// Unix mtime of a single path in nanoseconds since the epoch, or 0 when it is
205/// missing / unreadable. Saturates at `i64::MAX` (year 2262) rather than wrap.
206fn 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
215/// Newest mtime (unix nanos) found by walking `dir` recursively (files and the
216/// directories themselves). Returns 0 for a missing/unreadable tree.
217///
218/// Loose refs live as files under `refs/`; a brand-new branch creates a new file
219/// (and bumps the containing directory's mtime), and advancing a branch rewrites
220/// its ref file — both raise this value, which is exactly the signal the gate
221/// wants. Packed refs are covered separately via `packed-refs`.
222fn 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// PERF-1: git_common_dir is called twice per repo — once in dedup_candidates
238// (computed but not stored), and again in open_loops. Reusing the value from
239// dedup would require changing RepoCandidate or open_loops's public signature.
240// Both are internal, but threading the common_dir through without altering the
241// public API would require wrapping it in a private helper that cli.rs doesn't call.
242// Current cost: negligible (one extra git call per repo per scan), acceptable
243// trade-off for keeping the public signature stable. Revisit if scan latency
244// becomes dominated by this call (measure: `time loops scan --fresh`).
245
246/// One entry from `git worktree list --porcelain`.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct WorktreeEntry {
249    pub path: PathBuf,
250    /// Short branch name (`refs/heads/` stripped). `None` when detached or bare.
251    pub branch: Option<String>,
252    pub bare: bool,
253    pub prunable: bool,
254}
255
256/// Parses `git worktree list --porcelain` into entries.
257///
258/// Pure over the git output: a new entry starts at each `worktree ` line; the
259/// `HEAD`/`detached`/`locked` lines leave `branch` as `None`. Tolerant — unknown
260/// or blank lines are ignored, never panics.
261pub 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
295/// Maps each checked-out branch to the absolute path of its worktree.
296///
297/// Bare and detached entries are dropped (no branch to key on). git proscribes
298/// the same branch in two worktrees, so the map is 1:1.
299///
300/// # Errors
301///
302/// Returns `Err` if `git worktree list` fails.
303pub 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
312/// Walks roots up to `scan_depth` looking for git repo candidates, then
313/// deduplicates by absolute `--git-common-dir`.
314pub fn find_repos(roots: &[PathBuf], scan_depth: usize) -> (Vec<RepoCandidate>, Vec<String>) {
315    find_repos_cached(roots, scan_depth, None)
316}
317
318/// Like [`find_repos`] but optionally consults `index` to skip `git rev-parse`
319/// for already-known paths (resolves #17).
320pub 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
332/// Like `dedup_candidates` but optionally uses `index` to cache/reuse
333/// `--git-common-dir` results, skipping the git shell-out on cache hits.
334fn 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        // Try index cache first (hit = skip shell-out).
343        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            // Cache miss: call git and store the result back.
348            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
400/// Path-based repo name guess when `git rev-parse --git-common-dir` fails.
401/// Primary naming comes from common-dir during dedup; this is the error fallback only.
402pub 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
410/// Returns all unmerged branches (except default) in a repo, optionally reading
411/// and updating the inventory memo for ahead/behind.
412///
413/// Light phase (default branch, merged set, `for-each-ref`) always runs. The
414/// heavy phase (`rev-list` for ahead/behind) runs only when
415/// `opts.need_ahead_behind` is true, and consults the inventory memo unless
416/// `opts.fresh` is set.
417///
418/// Returns the open loops and, when memoisation is active, the updated
419/// `(hash, InventoryFile)` pair for write-through by the caller.
420///
421/// # Errors
422///
423/// Returns `Err` if git fails or if the default branch is not found.
424pub 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
432/// Like [`open_loops`] but optionally consults a SQLite `index` for the
433/// refs-fingerprint gate (#13).
434///
435/// When `index` is `Some` and `opts.fresh` is false, the gate is checked first:
436/// if the repo's `refs_fingerprint` and `default_sha` are unchanged since the
437/// last index write, the cached loops are returned and the heavy git phase
438/// (`for-each-ref`, `branch --merged`, per-branch `rev-list`) is skipped
439/// entirely. On a miss (or `opts.fresh`), the full logic runs and the result is
440/// written through to the index for the next call.
441///
442/// When `index` is `None`, the behaviour is byte-for-byte identical to the
443/// pre-index code path.
444///
445/// Index errors never abort a scan: git is the source of truth and the index is
446/// disposable, so a degraded index simply forces a recompute.
447///
448/// # Errors
449///
450/// Returns `Err` if git fails or if the default branch is not found.
451pub 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    // Resolve default branch and its SHA once (PERF-2: avoid duplicate rev-parse).
458    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    // -- Refs-fingerprint gate (#13) ---------------------------------------
464    // Compute the fingerprint once; reused for both the read gate and the
465    // write-through below.
466    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                // A hit must serve what the caller needs: if ahead/behind were
473                // requested but the cached rows lack them, treat as a miss and
474                // recompute (never hand back None to a caller that asked).
475                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                    // Cache hit returns no inventory update: the heavy phase did
491                    // not run, so there is nothing new to memoise.
492                    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    // Determine whether to use the inventory memo for this scan.
521    let use_inventory = opts.need_ahead_behind && opts.inventory_dir.is_some();
522
523    // Robustness: if default_sha is empty, skip memoisation to avoid poisoning the cache.
524    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    // Load the existing inventory file unless `--fresh` was requested.
533    // Destructure inventory_dir once to avoid .unwrap() landmine.
534    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    // -- Write-through to the index (#13) ----------------------------------
644    // A miss (or `--fresh`) just recomputed everything: persist it so the next
645    // unchanged-refs scan hits the gate and skips the heavy git phase. Index
646    // errors are swallowed inside `put_loops` (git is the source of truth).
647    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
674/// Scans all repos found under the roots in parallel.
675///
676/// `repo_filter`, when set, retains only repos whose canonical name (from dedup)
677/// matches before `open_loops` runs. Individual repo failures become warnings and
678/// never abort the scan.
679///
680/// Returns `(loops, warnings, inventory_updates)` where `inventory_updates` is a
681/// vec of `(hash, file)` pairs ready for write-through by the caller.
682pub 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
692/// Cheap per-repo git values computed once, in parallel, for the gate.
693///
694/// All three are derived from git subprocess calls (`default_branch_and_sha`,
695/// `git_common_dir`) plus a recursive ref-tree stat (`refs_fingerprint`). They
696/// are computed exactly ONCE per repo and threaded through both the gate read
697/// and the write-through so the git work never runs more than once per cold repo.
698struct GateInputs {
699    default: String,
700    default_sha: String,
701    common_dir: PathBuf,
702    refs_fp: i64,
703    gate_hash: String,
704}
705
706/// Like [`scan`] but optionally consults a SQLite `index` for the
707/// refs-fingerprint gate (#13) and to cache `--git-common-dir` during repo
708/// discovery.
709///
710/// `rusqlite::Connection` is `Send` but `!Sync`, so a single `&Index` cannot be
711/// shared across the parallel scan threads. We therefore keep the cheap SQLite
712/// reads/writes on the calling thread while running ALL git work in parallel —
713/// the same parallelism the pre-index `scan` had. The shape is three phases:
714///
715/// 1. **Parallel git probes** (`thread::scope`): for every repo, compute the
716///    cheap gate inputs (`default_branch_and_sha`, `git_common_dir`,
717///    `refs_fingerprint`) ONCE. This replaces the old serial `git` fan-out that
718///    serialized ~2 subprocess spawns per repo on the calling thread.
719/// 2. **Sequential SQLite gate** on the calling thread: one indexed
720///    `cached_loops` read per repo, using the precomputed inputs. Hits are
721///    served directly; misses are deferred.
722/// 3. **Parallel recompute** of the misses (`thread::scope`), followed by a
723///    sequential write-through that REUSES the already-computed gate inputs
724///    (no third re-shell of the git values).
725///
726/// `--fresh` runs phase 1 (so the write-through still has its inputs) but skips
727/// the phase-2 gate read, treating every repo as a miss — the gate is bypassed
728/// yet the index is still refreshed, matching the pre-index behaviour.
729///
730/// The `None` path is byte-for-byte identical to the pre-index `scan`: it skips
731/// phases 1–2 entirely and recomputes every repo in parallel with no gate and
732/// no write-through.
733pub 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    // No index: the gate is inert. Recompute everything in parallel exactly like
751    // the pre-index `scan` (the `None`-path contract — no gate inputs, no
752    // write-through).
753    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    // Phase 1: compute the cheap gate inputs for every repo IN PARALLEL. This is
762    // the git work the old code ran serially in `try_gate`. Results are
763    // positionally aligned with `repos`; `Err` is a fatal git error reported
764    // once. The inputs are reused for both the gate read and the write-through.
765    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    // Phase 2: sequential SQLite gate read on the calling thread (skipped under
783    // `--fresh`, which still recomputes and writes through). Hits are served
784    // from cache; misses (and their precomputed inputs) are deferred.
785    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            // Propagate the git error so the repo is reported once, not retried.
791            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        // `--fresh` bypasses the gate read but keeps the write-through below.
798        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    // Phase 3: recompute the misses in parallel and write them through using the
813    // gate inputs already computed in phase 1 (no re-shell of the git values).
814    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
829/// Recomputes `misses` in parallel (heavy git phase) and, when an index is
830/// present, writes each result through using the matching precomputed
831/// `gate_inputs` (positionally aligned with `misses`; empty when there is no
832/// index / `--fresh`, in which case write-through is skipped). Appends loops to
833/// `all`, warnings to `warnings`, and returns the inventory updates.
834fn 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                // Write the freshly computed loops through to the index using the
866                // gate inputs already computed in phase 1 (no re-shell).
867                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
883/// Computes the cheap per-repo gate inputs once (called in parallel, phase 1).
884///
885/// # Errors
886///
887/// Returns `Err` if the default branch or common-dir cannot be resolved.
888fn 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
902/// Sequential SQLite gate read for one repo using its precomputed inputs.
903/// `Some(loops)` is a hit the caller can use directly; `None` is a cache miss to
904/// be recomputed in parallel. No git work happens here — only one indexed read.
905fn 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    // Serve only if the cached rows satisfy the caller's ahead/behind need.
913    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
933/// Persists freshly recomputed `loops` for `repo` to the index after a miss,
934/// reusing the gate inputs already computed in phase 1 (no re-shell of git).
935fn 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
959/// Branch-exclusive commits relative to the default (for the distillation prompt).
960///
961/// # Errors
962///
963/// Returns `Err` if git fails.
964pub fn git_log(repo: &Path, default: &str, branch: &str) -> Result<String> {
965    git(repo, &["log", "--oneline", &format!("{default}..{branch}")])
966}
967
968/// Diffstat of the branch against the base (for the distillation prompt).
969///
970/// # Errors
971///
972/// Returns `Err` if git fails.
973pub fn diffstat(repo: &Path, default: &str, branch: &str) -> Result<String> {
974    git(repo, &["diff", "--stat", &format!("{default}...{branch}")])
975}
976
977/// Time window of the branch-exclusive commits.
978///
979/// Used to filter out AI sessions that predate the branch work.
980///
981/// # Errors
982///
983/// Returns `Err` if git fails or if there are no commits on the branch.
984pub 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        // branch has no exclusive commit: fall back to its latest commit
1000        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    /// Helper: call `open_loops` without inventory, returning only the loops vec.
1023    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    /// Helper: call `scan` without inventory, returning only `(loops, warnings)`.
1036    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); // main + commit
1070        testutil::git(&repo, &["branch", "develop"]); // local develop exists
1071        testutil::git(
1072            &repo,
1073            &[
1074                "symbolic-ref",
1075                "refs/remotes/origin/HEAD",
1076                "refs/remotes/origin/develop",
1077            ],
1078        );
1079        // origin/HEAD wins over main because its target resolves locally.
1080        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); // main + commit, no local "ghost"
1088        testutil::git(
1089            &repo,
1090            &[
1091                "symbolic-ref",
1092                "refs/remotes/origin/HEAD",
1093                "refs/remotes/origin/ghost",
1094            ],
1095        );
1096        // Stale origin/HEAD target → fall through to main, not an error.
1097        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        // directory is not a git repo
1104        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"]); // points to main => merged
1213
1214        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        // feat/y exists in the store but is NOT checked out in any worktree
1247        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"); // checks out feat/x then back to main
1263        let loops = open_loops_simple(&repo, "root", true);
1264        assert_eq!(loops[0].branch, "feat/x");
1265        assert_eq!(loops[0].repo_path, repo); // not checked out in a worktree → fallback
1266    }
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        // First call: no cache → runs rev-list and writes inventory.
1310        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        // Second call: memo present → cache hit; ahead/behind same.
1320        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        // inventory update is still returned (for write-through)
1325        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        // Pre-seed inventory with wrong ahead/behind values to detect if it's
1337        // being used.
1338        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, // <-- bypass cache
1360            inventory_dir: Some(inv_dir.clone()),
1361            inventory_ttl_secs: 0,
1362        };
1363        let (loops, _) = open_loops(&repo, "root", &opts).unwrap();
1364        // real values, not the stubbed 99/99
1365        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        // lowercase filter must match a mixed-case repo dir (both sides lowered)
1400        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        // truly broken repo: no commits, so default_branch fails
1434        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        // no commits: refs/heads/main and refs/heads/master do not exist
1478        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")); // slash preserved
1522        assert_eq!(entries[2].branch, None); // detached
1523        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); // main worktree at container/main
1542        testutil::add_named_worktree(&container, "dev", "dev"); // dev worktree at container/dev
1543
1544        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        // the `.bare` entry is filtered out (no branch / bare)
1548        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        // a plain directory is not a git repo → git worktree list fails
1555        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    // -----------------------------------------------------------------------
1590    // Task 2: cached find_repos / dedup tests
1591    // -----------------------------------------------------------------------
1592
1593    /// (a) find_repos_cached with a fresh in-memory index populates repos.common_dir.
1594    #[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        // The index must now have an entry for this path.
1607        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    /// (b) Second dedup_candidates_cached call reads from cache (SENTINEL proves
1618    ///     git_common_dir was NOT called again).
1619    ///
1620    /// We pre-seed the index with a fake/sentinel common_dir_hash for the repo
1621    /// path. If the cache is consulted, we get the sentinel back; if git is
1622    /// called instead, we get the real hash — different values prove the path
1623    /// taken.
1624    #[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        // Pre-seed the index with a SENTINEL hash and a real-looking common_dir
1633        // so dedup can still build a RepoCandidate (repo_name_from_common_dir
1634        // only needs the path shape, not a real dir).
1635        let sentinel_hash = "sentinel_hash_no_git";
1636        let sentinel_cd = repo.join(".git"); // same shape as reality
1637        index.put_repo_common_dir(&repo, sentinel_hash, &sentinel_cd);
1638
1639        // Now call dedup; it should hit the cache and return the sentinel.
1640        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        // The cached entry must still be the sentinel (git was not called to overwrite it).
1646        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    /// (c) N worktrees of the same repo → 1 RepoCandidate on the cached path.
1656    #[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        // Both the container and the dev worktree point to the same common_dir.
1667        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    // -----------------------------------------------------------------------
1679    // Task 3: refs-fingerprint gate (#13)
1680    // -----------------------------------------------------------------------
1681
1682    /// Helper: indexed open_loops with ahead/behind, returning only the loops.
1683    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    /// (a) ZERO rev-list proof. After warming the cache with the real value
1697    /// (ahead=1), we overwrite the *cached* ahead/behind with an impossible
1698    /// sentinel (999/888) while keeping the SAME refs_fingerprint + default_sha.
1699    /// A second indexed scan returns the sentinel — which the live git repo can
1700    /// never produce — proving the gate served cached data and `rev-list` (and
1701    /// for-each-ref / branch --merged) were not re-run.
1702    #[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        // First scan: miss → real compute (ahead=1) + write-through.
1712        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        // Capture the live fingerprint/hash/default_sha, then poison the cached
1718        // row's ahead/behind with a value the repo cannot produce.
1719        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        // Second scan: refs unchanged → gate HIT → returns the sentinel.
1743        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    /// (b) Advancing HEAD changes the fingerprint → recompute → fresh values.
1754    #[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"); // ahead=1
1760
1761        let index = Index::open_in_memory();
1762
1763        // Warm + poison with a sentinel under the OLD fingerprint.
1764        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        // Add a second commit on feat/x → new loose ref mtime → fingerprint bumps.
1789        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        // Second scan: fingerprint differs → MISS → real recompute (ahead=2).
1802        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    /// (c) Changing the default-branch SHA invalidates the cache even when the
1812    /// branch's own refs and the gross fingerprint match the stored default_sha.
1813    #[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        // Store a poisoned row under a STALE default_sha but the live fingerprint.
1830        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        // The live default_sha != stored stale default_sha → MISS → recompute.
1849        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    /// (d) `fresh: true` bypasses the gate even when a (poisoned) cache exists.
1858    #[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        // Poison the cache under the live fingerprint/default_sha.
1869        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        // fresh=true must IGNORE the poisoned cache and recompute real values.
1892        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    /// (e) A brand-new branch after caching → fingerprint changes → it appears.
1902    #[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        // Add a brand-new unmerged branch.
1914        testutil::add_branch_with_commit(&repo, "feat/y", "y.txt");
1915
1916        // Fingerprint must have changed (new loose ref under refs/heads).
1917        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    /// A cache HIT whose rows lack ahead/behind must NOT be served to a caller
1924    /// that needs them — it degrades to a recompute (correctness guard).
1925    #[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        // Warm the index with a LIGHT-phase scan (no ahead/behind) so the cached
1934        // rows have NULL ahead/behind under the live fingerprint.
1935        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        // Now ask WITH ahead/behind: the NULL-ahead cache must not be served.
1945        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    /// `scan_indexed(None)` is identical to `scan`: the gate is inert.
1955    #[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    /// Adding a worktree for an ALREADY-EXISTING branch (so no new loose ref is
1975    /// written under `refs/`) must still bump the fingerprint, because
1976    /// `<common_dir>/worktrees/<name>/` is created. This guards against
1977    /// `git worktree remove` leaving a stale `worktree_path` in the index that
1978    /// points at a deleted directory.
1979    ///
1980    /// We assert on ADD (not remove) because creating a new directory entry
1981    /// reliably bumps the parent dir mtime even on coarse filesystems, whereas
1982    /// remove leaves a gap the OS may or may not fill before the next read.
1983    #[test]
1984    fn adding_worktree_for_existing_branch_bumps_fingerprint() {
1985        let tmp = tempfile::tempdir().unwrap();
1986        let container = tmp.path().join("proj");
1987        // init_bare_worktree_container gives us: .bare/ (common_dir), a `main`
1988        // worktree, and one commit — a branch ref already exists.
1989        testutil::init_bare_worktree_container(&container);
1990
1991        let common = git_common_dir(&container).unwrap();
1992        let fp_before = refs_fingerprint(&common);
1993
1994        // Add a worktree for the existing `main` branch ref reusing `-b` on a
1995        // fresh name so no new ref is created (we just add a worktrees/<name>/ entry).
1996        // `add_named_worktree` uses `git worktree add -b <branch> <path>`, which
1997        // creates a brand-new branch. To avoid creating a new ref we use
1998        // `git worktree add --detach` on an existing commit instead.
1999        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    /// End-to-end through `scan_indexed`: a warm scan with an index serves the
2019    /// poisoned cache (gate hit on the sequential path), proving the heavy phase
2020    /// was skipped at the scan level too.
2021    #[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        // Cold scan warms the index (write-through).
2035        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        // Poison the cache under the live gate.
2047        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        // Warm scan: gate hit on the sequential path → sentinel served.
2070        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}