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