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
9/// Runs a git subcommand in `repo` and returns trimmed stdout.
10///
11/// # Errors
12///
13/// Returns `Err` if git is not in PATH or if the command fails.
14pub(crate) fn git(repo: &Path, args: &[&str]) -> Result<String> {
15    let out = Command::new("git")
16        .arg("-C")
17        .arg(repo)
18        .args(args)
19        .output()
20        .context("git not found in PATH — install git")?;
21    if !out.status.success() {
22        bail!(
23            "git {:?} failed in {}: {}",
24            args,
25            repo.display(),
26            String::from_utf8_lossy(&out.stderr).trim()
27        );
28    }
29    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
30}
31
32/// Default branch: origin/HEAD if it exists; otherwise main; otherwise master.
33///
34/// # Errors
35///
36/// Returns `Err` if no default branch is found.
37pub fn default_branch(repo: &Path) -> Result<String> {
38    if let Ok(sym) = git(
39        repo,
40        &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
41    ) {
42        if let Some(branch) = sym.strip_prefix("origin/") {
43            return Ok(branch.to_string());
44        }
45    }
46    for candidate in ["main", "master"] {
47        if git(
48            repo,
49            &["rev-parse", "--verify", &format!("refs/heads/{candidate}")],
50        )
51        .is_ok()
52        {
53            return Ok(candidate.to_string());
54        }
55    }
56    bail!(
57        "couldn't find the default branch in {} (expected origin/HEAD, main or master)",
58        repo.display()
59    )
60}
61
62/// A git repository discovered under a configured root (deduped by common-dir).
63#[derive(Debug, Clone)]
64pub struct RepoCandidate {
65    pub path: PathBuf,
66    /// Canonical repo name from `--git-common-dir` (computed once during dedup).
67    pub repo_name: String,
68}
69
70/// An open loop: an unmerged branch with its own commits.
71#[derive(Debug, Clone)]
72pub struct OpenLoop {
73    pub root_label: String,
74    pub repo_name: String,
75    pub repo_path: PathBuf,
76    pub branch: String,
77    pub head_sha: String,
78    pub last_commit: DateTime<Utc>,
79    pub ahead: Option<u32>,
80    pub behind: Option<u32>,
81}
82
83impl OpenLoop {
84    /// Canonical key used in resume/ignore: "root-label/repo/branch".
85    pub fn key(&self) -> String {
86        format!("{}/{}/{}", self.root_label, self.repo_name, self.branch)
87    }
88}
89
90const SKIP_DIRS: [&str; 2] = ["node_modules", "target"];
91
92fn looks_like_bare(dir: &Path) -> bool {
93    dir.join("HEAD").is_file() && dir.join("objects").is_dir() && dir.join("refs").is_dir()
94}
95
96fn is_repo_candidate(dir: &Path) -> bool {
97    dir.join(".git").exists() || looks_like_bare(dir)
98}
99
100/// Derives a stable repo name from the absolute git common-dir (§5 of Spec Fase A).
101pub fn repo_name_from_common_dir(common_dir: &Path) -> String {
102    let base = common_dir
103        .file_name()
104        .map(|n| n.to_string_lossy().into_owned())
105        .unwrap_or_default();
106    if base == ".git" || base == ".bare" {
107        return common_dir
108            .parent()
109            .and_then(|p| p.file_name())
110            .map(|n| n.to_string_lossy().into_owned())
111            .unwrap_or(base);
112    }
113    base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
114}
115
116/// Absolute path of the git common-dir for `path` (bare store / `.git` dir).
117///
118/// # Errors
119///
120/// Returns `Err` when `path` is not inside a git repository.
121pub fn git_common_dir(path: &Path) -> Result<PathBuf> {
122    let raw = git(
123        path,
124        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
125    )?;
126    Ok(PathBuf::from(raw))
127}
128
129/// One entry from `git worktree list --porcelain`.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct WorktreeEntry {
132    pub path: PathBuf,
133    /// Short branch name (`refs/heads/` stripped). `None` when detached or bare.
134    pub branch: Option<String>,
135    pub bare: bool,
136    pub prunable: bool,
137}
138
139/// Parses `git worktree list --porcelain` into entries.
140///
141/// Pure over the git output: a new entry starts at each `worktree ` line; the
142/// `HEAD`/`detached`/`locked` lines leave `branch` as `None`. Tolerant — unknown
143/// or blank lines are ignored, never panics.
144pub fn parse_worktree_porcelain(out: &str) -> Vec<WorktreeEntry> {
145    let mut entries = Vec::new();
146    let mut current: Option<WorktreeEntry> = None;
147    for line in out.lines() {
148        if let Some(p) = line.strip_prefix("worktree ") {
149            if let Some(e) = current.take() {
150                entries.push(e);
151            }
152            current = Some(WorktreeEntry {
153                path: PathBuf::from(p),
154                branch: None,
155                bare: false,
156                prunable: false,
157            });
158        } else if let Some(e) = current.as_mut() {
159            if let Some(b) = line.strip_prefix("branch ") {
160                e.branch = Some(b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
161            } else if line == "bare" {
162                e.bare = true;
163            } else if line == "prunable" || line.starts_with("prunable ") {
164                e.prunable = true;
165            }
166        }
167    }
168    if let Some(e) = current.take() {
169        entries.push(e);
170    }
171    entries
172}
173
174fn normalize_path(path: PathBuf) -> PathBuf {
175    std::fs::canonicalize(&path).unwrap_or(path)
176}
177
178/// Maps each checked-out branch to the absolute path of its worktree.
179///
180/// Bare and detached entries are dropped (no branch to key on). git proscribes
181/// the same branch in two worktrees, so the map is 1:1.
182///
183/// # Errors
184///
185/// Returns `Err` if `git worktree list` fails.
186pub fn worktree_map(repo: &Path) -> Result<std::collections::HashMap<String, PathBuf>> {
187    let raw = git(repo, &["worktree", "list", "--porcelain"])?;
188    Ok(parse_worktree_porcelain(&raw)
189        .into_iter()
190        .filter(|e| !e.bare)
191        .filter_map(|e| e.branch.map(|b| (b, normalize_path(e.path))))
192        .collect())
193}
194
195/// Walks roots up to `scan_depth` looking for git repo candidates, then
196/// deduplicates by absolute `--git-common-dir`.
197pub fn find_repos(roots: &[PathBuf], scan_depth: usize) -> (Vec<RepoCandidate>, Vec<String>) {
198    let mut candidates = Vec::new();
199    for root in roots {
200        walk(root, 0, scan_depth, &mut candidates);
201    }
202    dedup_candidates(candidates)
203}
204
205fn dedup_candidates(candidates: Vec<PathBuf>) -> (Vec<RepoCandidate>, Vec<String>) {
206    use std::collections::HashMap;
207    let mut by_common: HashMap<PathBuf, RepoCandidate> = HashMap::new();
208    let mut warnings = Vec::new();
209    for candidate in candidates {
210        match git_common_dir(&candidate) {
211            Ok(common) => {
212                let repo_name = repo_name_from_common_dir(&common);
213                by_common.entry(common).or_insert(RepoCandidate {
214                    path: candidate,
215                    repo_name,
216                });
217            }
218            Err(e) => {
219                warnings.push(format!("{}: {e:#}", candidate.display()));
220            }
221        }
222    }
223    let mut repos: Vec<RepoCandidate> = by_common.into_values().collect();
224    repos.sort_by(|a, b| a.path.cmp(&b.path));
225    (repos, warnings)
226}
227
228fn walk(dir: &Path, depth: usize, scan_depth: usize, candidates: &mut Vec<PathBuf>) {
229    if is_repo_candidate(dir) {
230        candidates.push(dir.to_path_buf());
231        return;
232    }
233    if depth >= scan_depth {
234        return;
235    }
236    let Ok(entries) = std::fs::read_dir(dir) else {
237        return;
238    };
239    for entry in entries.flatten() {
240        let path = entry.path();
241        let name = entry.file_name();
242        let name = name.to_string_lossy();
243        if !path.is_dir() || name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
244            continue;
245        }
246        walk(&path, depth + 1, scan_depth, candidates);
247    }
248}
249
250/// Path-based repo name guess when `git rev-parse --git-common-dir` fails.
251/// Primary naming comes from common-dir during dedup; this is the error fallback only.
252pub fn repo_name_hint(path: &Path) -> String {
253    let base = path
254        .file_name()
255        .map(|n| n.to_string_lossy().into_owned())
256        .unwrap_or_default();
257    base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
258}
259
260/// Returns all unmerged branches (except default) in a repo.
261///
262/// Light phase (default branch, merged set, for-each-ref) always runs. The heavy
263/// phase (`rev-list` for ahead/behind) runs only when `need_ahead_behind` is true.
264///
265/// # Errors
266///
267/// Returns `Err` if git fails or if the default branch is not found.
268pub fn open_loops(repo: &Path, root_label: &str, need_ahead_behind: bool) -> Result<Vec<OpenLoop>> {
269    let default = default_branch(repo)?;
270    let common_dir = git_common_dir(repo)?;
271    let repo_name = repo_name_from_common_dir(&common_dir);
272    let worktrees = worktree_map(repo).unwrap_or_else(|e| {
273        eprintln!(
274            "warning: git worktree list failed in {}: {e:#}; session matching falls back to the repo path",
275            repo.display()
276        );
277        std::collections::HashMap::new()
278    });
279    let merged: std::collections::HashSet<String> = git(
280        repo,
281        &["branch", "--merged", &default, "--format=%(refname:short)"],
282    )?
283    .lines()
284    .map(|s| s.trim().to_string())
285    .collect();
286    let raw = git(
287        repo,
288        &[
289            "for-each-ref",
290            "refs/heads",
291            "--format=%(refname:short)%09%(objectname)%09%(committerdate:iso8601-strict)",
292        ],
293    )?;
294    let mut result = Vec::new();
295    for line in raw.lines() {
296        let mut parts = line.split('\t');
297        let (Some(branch), Some(sha), Some(date)) = (parts.next(), parts.next(), parts.next())
298        else {
299            eprintln!("warning: unexpected line from git for-each-ref ignored: {line:?}");
300            continue;
301        };
302        if branch == default || merged.contains(branch) {
303            continue;
304        }
305        let (ahead, behind) = if need_ahead_behind {
306            let counts = git(
307                repo,
308                &[
309                    "rev-list",
310                    "--left-right",
311                    "--count",
312                    &format!("{default}...{branch}"),
313                ],
314            )?;
315            let mut c = counts.split_whitespace();
316            let behind: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
317            let ahead: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
318            (Some(ahead), Some(behind))
319        } else {
320            (None, None)
321        };
322        let last_commit = DateTime::parse_from_rfc3339(date)
323            .with_context(|| format!("invalid date from git: {date}"))?
324            .with_timezone(&Utc);
325        let repo_path = worktrees
326            .get(branch)
327            .cloned()
328            .unwrap_or_else(|| repo.to_path_buf());
329        result.push(OpenLoop {
330            root_label: root_label.to_string(),
331            repo_name: repo_name.clone(),
332            repo_path,
333            branch: branch.to_string(),
334            head_sha: sha.to_string(),
335            last_commit,
336            ahead,
337            behind,
338        });
339    }
340    Ok(result)
341}
342
343/// Scans all repos found under the roots in parallel.
344///
345/// `repo_filter`, when set, retains only repos whose canonical name (from dedup)
346/// matches before `open_loops` runs. Individual repo failures become warnings and
347/// never abort the scan.
348pub fn scan(
349    roots: &[PathBuf],
350    labels: &[(PathBuf, String)],
351    scan_depth: usize,
352    need_ahead_behind: bool,
353    repo_filter: Option<&str>,
354) -> (Vec<OpenLoop>, Vec<String>) {
355    let (mut repos, mut warnings) = find_repos(roots, scan_depth);
356    if let Some(filter) = repo_filter {
357        let needle = filter.to_lowercase();
358        repos.retain(|r| r.repo_name.to_lowercase().contains(&needle));
359    }
360    let results: Vec<Result<Vec<OpenLoop>>> = std::thread::scope(|s| {
361        let handles: Vec<_> = repos
362            .iter()
363            .map(|repo| {
364                let label = crate::config::label_for_repo(labels, &repo.path);
365                let path = repo.path.clone();
366                s.spawn(move || open_loops(&path, &label, need_ahead_behind))
367            })
368            .collect();
369        handles
370            .into_iter()
371            .map(|h| {
372                h.join()
373                    .unwrap_or_else(|_| Err(anyhow::anyhow!("panic while scanning repository")))
374            })
375            .collect()
376    });
377    let mut all = Vec::new();
378    for (repo, res) in repos.iter().zip(results) {
379        match res {
380            Ok(mut loops) => all.append(&mut loops),
381            Err(e) => warnings.push(format!("{}: {e:#}", repo.path.display())),
382        }
383    }
384    (all, warnings)
385}
386
387/// Branch-exclusive commits relative to the default (for the distillation prompt).
388///
389/// # Errors
390///
391/// Returns `Err` if git fails.
392pub fn git_log(repo: &Path, default: &str, branch: &str) -> Result<String> {
393    git(repo, &["log", "--oneline", &format!("{default}..{branch}")])
394}
395
396/// Diffstat of the branch against the base (for the distillation prompt).
397///
398/// # Errors
399///
400/// Returns `Err` if git fails.
401pub fn diffstat(repo: &Path, default: &str, branch: &str) -> Result<String> {
402    git(repo, &["diff", "--stat", &format!("{default}...{branch}")])
403}
404
405/// Time window of the branch-exclusive commits.
406///
407/// Used to filter out AI sessions that predate the branch work.
408///
409/// # Errors
410///
411/// Returns `Err` if git fails or if there are no commits on the branch.
412pub fn commit_window(
413    repo: &Path,
414    default: &str,
415    branch: &str,
416) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
417    let raw = git(
418        repo,
419        &["log", "--format=%cI", &format!("{default}..{branch}")],
420    )?;
421    let mut dates: Vec<DateTime<Utc>> = raw
422        .lines()
423        .filter_map(|l| DateTime::parse_from_rfc3339(l.trim()).ok())
424        .map(|d| d.with_timezone(&Utc))
425        .collect();
426    if dates.is_empty() {
427        // branch has no exclusive commit: fall back to its latest commit
428        let head = git(repo, &["log", "-1", "--format=%cI", branch])?;
429        dates.push(DateTime::parse_from_rfc3339(head.trim())?.with_timezone(&Utc));
430    }
431    let min = dates
432        .iter()
433        .min()
434        .copied()
435        .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
436    let max = dates
437        .iter()
438        .max()
439        .copied()
440        .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
441    Ok((min, max))
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::testutil;
448
449    fn assert_same_path(actual: &std::path::Path, expected: &std::path::Path) {
450        let a = std::fs::canonicalize(actual).unwrap_or_else(|_| actual.to_path_buf());
451        let b = std::fs::canonicalize(expected).unwrap_or_else(|_| expected.to_path_buf());
452        assert_eq!(a, b);
453    }
454
455    #[test]
456    fn default_branch_detects_main() {
457        let tmp = tempfile::tempdir().unwrap();
458        let repo = tmp.path().join("app");
459        testutil::init_repo(&repo);
460        assert_eq!(default_branch(&repo).unwrap(), "main");
461    }
462
463    #[test]
464    fn git_fails_with_contextual_message() {
465        let tmp = tempfile::tempdir().unwrap();
466        // directory is not a git repo
467        let err = git(tmp.path(), &["status"]).unwrap_err();
468        assert!(err.to_string().contains(&tmp.path().display().to_string()));
469    }
470
471    #[test]
472    fn find_repos_dedups_container_and_worktrees() {
473        let tmp = tempfile::tempdir().unwrap();
474        let container = tmp.path().join("my-app");
475        testutil::init_bare_worktree_container(&container);
476        let dev = container.join("dev");
477        testutil::add_named_worktree(&container, "dev", "dev");
478        let (repos, warnings) = find_repos(&[container.clone(), dev], 4);
479        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
480        assert_eq!(repos.len(), 1);
481        assert_eq!(repos[0].path, container);
482    }
483
484    #[test]
485    fn find_repos_respects_scan_depth_and_skips_hidden() {
486        let tmp = tempfile::tempdir().unwrap();
487        testutil::init_repo(&tmp.path().join("a/b/c/repo-deep"));
488        testutil::init_repo(&tmp.path().join("a/b/repo-mid"));
489        testutil::init_repo(&tmp.path().join("repo-shallow"));
490        testutil::init_repo(&tmp.path().join(".hidden/repo3"));
491
492        let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
493        let names: Vec<_> = repos
494            .iter()
495            .filter_map(|r| r.path.file_name())
496            .map(|n| n.to_string_lossy().into_owned())
497            .collect();
498        assert!(names.contains(&"repo-deep".to_string()));
499        assert!(names.contains(&"repo-mid".to_string()));
500        assert!(names.contains(&"repo-shallow".to_string()));
501        assert!(!names.contains(&"repo3".to_string()));
502
503        let (shallow, _) = find_repos(&[tmp.path().to_path_buf()], 2);
504        let shallow_names: Vec<_> = shallow
505            .iter()
506            .filter_map(|r| r.path.file_name())
507            .map(|n| n.to_string_lossy().into_owned())
508            .collect();
509        assert!(!shallow_names.contains(&"repo-deep".to_string()));
510        assert!(shallow_names.contains(&"repo-shallow".to_string()));
511    }
512
513    #[test]
514    fn find_repos_finds_normal_git_dir_repo() {
515        let tmp = tempfile::tempdir().unwrap();
516        testutil::init_repo(&tmp.path().join("app"));
517        let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
518        assert_eq!(repos.len(), 1);
519    }
520
521    #[test]
522    fn find_repos_finds_bare_worktree_container_via_git_file() {
523        let tmp = tempfile::tempdir().unwrap();
524        let container = tmp.path().join("my-app");
525        testutil::init_bare_worktree_container(&container);
526        let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
527        assert_eq!(repos.len(), 1);
528        assert_eq!(repos[0].path, container);
529    }
530
531    #[test]
532    fn find_repos_finds_pure_bare_repo() {
533        let tmp = tempfile::tempdir().unwrap();
534        let bare = tmp.path().join("foo.git");
535        testutil::init_bare_repo(&bare);
536        testutil::seed_bare_main(&bare);
537        let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
538        assert_eq!(repos.len(), 1);
539        assert_eq!(repos[0].path, bare);
540    }
541
542    #[test]
543    fn open_loops_uses_common_dir_repo_name_in_bare_layout() {
544        let tmp = tempfile::tempdir().unwrap();
545        let container = tmp.path().join("my-app");
546        testutil::init_bare_worktree_container(&container);
547        testutil::add_named_worktree(&container, "dev", "dev");
548        testutil::add_branch_on_bare(&container.join(".bare"), "feat/x", "x.txt");
549
550        let loops = open_loops(&container, "root", true).unwrap();
551        assert_eq!(loops.len(), 1);
552        assert_eq!(loops[0].repo_name, "my-app");
553        assert_eq!(loops[0].branch, "feat/x");
554        assert_eq!(loops[0].key(), "root/my-app/feat/x");
555    }
556
557    #[test]
558    fn open_loops_bare_root_repo_name_strips_dot_git_suffix() {
559        let tmp = tempfile::tempdir().unwrap();
560        let bare = tmp.path().join("foo.git");
561        testutil::init_bare_repo(&bare);
562        testutil::seed_bare_main(&bare);
563        testutil::add_branch_on_bare(&bare, "feat/y", "y.txt");
564
565        let loops = open_loops(&bare, "r", true).unwrap();
566        assert_eq!(loops[0].repo_name, "foo");
567    }
568
569    #[test]
570    fn open_loops_finds_unmerged_ignores_merged_and_default() {
571        let tmp = tempfile::tempdir().unwrap();
572        let repo = tmp.path().join("app");
573        testutil::init_repo(&repo);
574        testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
575        testutil::git(&repo, &["branch", "merged"]); // points to main => merged
576
577        let loops = open_loops(&repo, "root", true).unwrap();
578        assert_eq!(loops.len(), 1);
579        let l = &loops[0];
580        assert_eq!(l.branch, "feat/x");
581        assert_eq!(l.repo_name, "app");
582        assert_eq!(l.root_label, "root");
583        assert_eq!(l.key(), "root/app/feat/x");
584        assert_eq!(l.ahead, Some(1));
585        assert_eq!(l.behind, Some(0));
586        assert_eq!(l.head_sha.len(), 40);
587    }
588
589    #[test]
590    fn open_loops_sets_repo_path_to_worktree_when_branch_checked_out() {
591        let tmp = tempfile::tempdir().unwrap();
592        let container = tmp.path().join("my-app");
593        testutil::init_bare_worktree_container(&container);
594        testutil::add_worktree_with_commit(&container, "feat-x", "feat/x", "x.txt");
595
596        let loops = open_loops(&container, "root", true).unwrap();
597        let lp = loops
598            .iter()
599            .find(|l| l.branch == "feat/x")
600            .expect("feat/x loop");
601        assert_same_path(&lp.repo_path, &container.join("feat-x"));
602    }
603
604    #[test]
605    fn open_loops_falls_back_to_container_when_branch_has_no_worktree() {
606        let tmp = tempfile::tempdir().unwrap();
607        let container = tmp.path().join("my-app");
608        testutil::init_bare_worktree_container(&container);
609        // feat/y exists in the store but is NOT checked out in any worktree
610        testutil::add_branch_on_bare(&container.join(".bare"), "feat/y", "y.txt");
611
612        let loops = open_loops(&container, "root", true).unwrap();
613        let lp = loops
614            .iter()
615            .find(|l| l.branch == "feat/y")
616            .expect("feat/y loop");
617        assert_eq!(lp.repo_path, container);
618    }
619
620    #[test]
621    fn open_loops_normal_repo_keeps_repo_path_as_repo_dir() {
622        let tmp = tempfile::tempdir().unwrap();
623        let repo = tmp.path().join("app");
624        testutil::init_repo(&repo);
625        testutil::add_branch_with_commit(&repo, "feat/x", "x.txt"); // checks out feat/x then back to main
626        let loops = open_loops(&repo, "root", true).unwrap();
627        assert_eq!(loops[0].branch, "feat/x");
628        assert_eq!(loops[0].repo_path, repo); // not checked out in a worktree → fallback
629    }
630
631    #[test]
632    fn open_loops_skips_rev_list_when_need_ahead_behind_false() {
633        let tmp = tempfile::tempdir().unwrap();
634        let repo = tmp.path().join("app");
635        testutil::init_repo(&repo);
636        testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
637
638        let loops = open_loops(&repo, "root", false).unwrap();
639        assert_eq!(loops.len(), 1);
640        assert_eq!(loops[0].ahead, None);
641        assert_eq!(loops[0].behind, None);
642    }
643
644    #[test]
645    fn open_loops_computes_ahead_behind_when_need_ahead_behind_true() {
646        let tmp = tempfile::tempdir().unwrap();
647        let repo = tmp.path().join("app");
648        testutil::init_repo(&repo);
649        testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
650
651        let loops = open_loops(&repo, "root", true).unwrap();
652        assert_eq!(loops.len(), 1);
653        assert_eq!(loops[0].ahead, Some(1));
654        assert_eq!(loops[0].behind, Some(0));
655    }
656
657    #[test]
658    fn scan_repo_filter_pushdown_skips_non_matching_repos() {
659        let tmp = tempfile::tempdir().unwrap();
660        let api = tmp.path().join("api-service");
661        let web = tmp.path().join("web-app");
662        testutil::init_repo(&api);
663        testutil::init_repo(&web);
664        testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
665        testutil::add_branch_with_commit(&web, "feat/web", "w.txt");
666
667        let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
668        let (loops, _) = scan(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
669        assert_eq!(loops.len(), 1);
670        assert_eq!(loops[0].repo_name, "api-service");
671        assert_eq!(loops[0].branch, "feat/api");
672    }
673
674    #[test]
675    fn repo_name_hint_strips_dot_git_suffix() {
676        assert_eq!(repo_name_hint(std::path::Path::new("/srv/foo.git")), "foo");
677    }
678
679    #[test]
680    fn scan_repo_filter_is_case_insensitive() {
681        let tmp = tempfile::tempdir().unwrap();
682        let api = tmp.path().join("API-Service");
683        testutil::init_repo(&api);
684        testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
685
686        let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
687        // lowercase filter must match a mixed-case repo dir (both sides lowered)
688        let (loops, _) = scan(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
689        assert_eq!(loops.len(), 1);
690        assert_eq!(loops[0].repo_name, "API-Service");
691    }
692
693    #[test]
694    fn scan_repo_filter_matching_nothing_yields_no_loops() {
695        let tmp = tempfile::tempdir().unwrap();
696        let api = tmp.path().join("api-service");
697        testutil::init_repo(&api);
698        testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
699
700        let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
701        let (loops, warnings) = scan(
702            &[tmp.path().to_path_buf()],
703            &labels,
704            4,
705            false,
706            Some("zzz-nope"),
707        );
708        assert!(loops.is_empty());
709        assert!(
710            warnings.is_empty(),
711            "filtered-out repos must not warn: {warnings:?}"
712        );
713    }
714
715    #[test]
716    fn scan_aggregates_repos_and_reports_warning_without_aborting() {
717        let tmp = tempfile::tempdir().unwrap();
718        let good = tmp.path().join("good");
719        testutil::init_repo(&good);
720        testutil::add_branch_with_commit(&good, "feat/ok", "ok.txt");
721        // truly broken repo: no commits, so default_branch fails
722        let empty = tmp.path().join("empty");
723        std::fs::create_dir_all(&empty).unwrap();
724        testutil::git(&empty, &["init", "-b", "main"]);
725
726        let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
727        let (loops, warnings) = scan(&[tmp.path().to_path_buf()], &labels, 4, true, None);
728        assert_eq!(loops.len(), 1);
729        assert_eq!(loops[0].key(), "r/good/feat/ok");
730        assert_eq!(warnings.len(), 1);
731        assert!(warnings[0].contains("empty"));
732    }
733
734    #[test]
735    fn context_helpers_return_commits_and_window() {
736        let tmp = tempfile::tempdir().unwrap();
737        let repo = tmp.path().join("app");
738        testutil::init_repo(&repo);
739        testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
740
741        let log = git_log(&repo, "main", "feat/x").unwrap();
742        assert!(log.contains("wip feat/x"));
743        let stat = diffstat(&repo, "main", "feat/x").unwrap();
744        assert!(stat.contains("x.txt"));
745        let (start, end) = commit_window(&repo, "main", "feat/x").unwrap();
746        assert!(start <= end);
747    }
748
749    #[test]
750    fn default_branch_detects_master_fallback() {
751        let tmp = tempfile::tempdir().unwrap();
752        let repo = tmp.path();
753        testutil::git(repo, &["init", "-b", "master"]);
754        std::fs::write(repo.join("a.txt"), "a").unwrap();
755        testutil::git(repo, &["add", "."]);
756        testutil::git(repo, &["commit", "-m", "init"]);
757        assert_eq!(default_branch(repo).unwrap(), "master");
758    }
759
760    #[test]
761    fn default_branch_errors_without_main_or_master() {
762        let tmp = tempfile::tempdir().unwrap();
763        let repo = tmp.path();
764        testutil::git(repo, &["init", "-b", "trunk"]);
765        // no commits: refs/heads/main and refs/heads/master do not exist
766        let err = default_branch(repo).unwrap_err();
767        assert!(err.to_string().contains("couldn't find the default branch"));
768    }
769
770    #[test]
771    fn git_common_dir_resolves_normal_and_bare_pointer() {
772        let tmp = tempfile::tempdir().unwrap();
773        let normal = tmp.path().join("app");
774        testutil::init_repo(&normal);
775        let normal_common = git_common_dir(&normal).unwrap();
776        assert!(normal_common.ends_with(".git"));
777
778        let container = tmp.path().join("container");
779        testutil::init_bare_worktree_container(&container);
780        let bare_common = git_common_dir(&container).unwrap();
781        assert!(bare_common.ends_with(".bare"));
782    }
783
784    #[test]
785    fn parse_worktree_porcelain_extracts_branches_and_flags() {
786        let out = "\
787worktree /home/u/app/main
788HEAD aaaaaaaa
789branch refs/heads/main
790
791worktree /home/u/app/feat-x
792HEAD bbbbbbbb
793branch refs/heads/feat/x
794
795worktree /home/u/app/detached
796HEAD cccccccc
797detached
798
799worktree /home/u/app/.bare
800bare
801";
802        let entries = parse_worktree_porcelain(out);
803        assert_eq!(entries.len(), 4);
804        assert_eq!(entries[0].branch.as_deref(), Some("main"));
805        assert_eq!(
806            entries[0].path,
807            std::path::PathBuf::from("/home/u/app/main")
808        );
809        assert_eq!(entries[1].branch.as_deref(), Some("feat/x")); // slash preserved
810        assert_eq!(entries[2].branch, None); // detached
811        assert!(entries[3].bare);
812        assert_eq!(entries[3].branch, None);
813    }
814
815    #[test]
816    fn parse_worktree_porcelain_marks_prunable_and_handles_empty() {
817        assert!(parse_worktree_porcelain("").is_empty());
818        let out = "worktree /gone\nprunable gitdir file points to non-existent location\n";
819        let entries = parse_worktree_porcelain(out);
820        assert_eq!(entries.len(), 1);
821        assert!(entries[0].prunable);
822        assert_eq!(entries[0].branch, None);
823    }
824
825    #[test]
826    fn worktree_map_maps_checked_out_branches_to_paths() {
827        let tmp = tempfile::tempdir().unwrap();
828        let container = tmp.path().join("my-app");
829        testutil::init_bare_worktree_container(&container); // main worktree at container/main
830        testutil::add_named_worktree(&container, "dev", "dev"); // dev worktree at container/dev
831
832        let map = worktree_map(&container).unwrap();
833        assert_same_path(map.get("main").unwrap(), &container.join("main"));
834        assert_same_path(map.get("dev").unwrap(), &container.join("dev"));
835        // the `.bare` entry is filtered out (no branch / bare)
836        assert!(!map.values().any(|p| p.ends_with(".bare")));
837    }
838
839    #[test]
840    fn worktree_map_errors_on_non_git_dir() {
841        let tmp = tempfile::tempdir().unwrap();
842        // a plain directory is not a git repo → git worktree list fails
843        assert!(worktree_map(tmp.path()).is_err());
844    }
845
846    #[test]
847    fn parse_worktree_porcelain_ignores_lines_before_first_worktree() {
848        let out = "branch refs/heads/orphan\nHEAD deadbeef\nworktree /home/u/app/main\nbranch refs/heads/main\n";
849        let entries = parse_worktree_porcelain(out);
850        assert_eq!(entries.len(), 1);
851        assert_eq!(
852            entries[0].path,
853            std::path::PathBuf::from("/home/u/app/main")
854        );
855        assert_eq!(entries[0].branch.as_deref(), Some("main"));
856    }
857
858    #[test]
859    fn repo_name_from_common_dir_table() {
860        use std::path::Path;
861
862        let cases: &[(&str, &str)] = &[
863            ("/home/u/my-app/.bare", "my-app"),
864            ("/home/u/app/.git", "app"),
865            ("/srv/git/foo.git", "foo"),
866            ("/srv/git/myproject", "myproject"),
867        ];
868        for (common, want) in cases {
869            assert_eq!(
870                repo_name_from_common_dir(Path::new(common)),
871                *want,
872                "common_dir={common}"
873            );
874        }
875    }
876}