Skip to main content

git_worktree_manager/
scope.rs

1//! cwd-based scope discovery for gw.
2//!
3//! Determines which worktrees a gw command should act on based on the
4//! invocation directory. Two cases:
5//!
6//! 1. cwd inside a git worktree → return that worktree's family (main repo +
7//!    all linked worktrees).
8//! 2. cwd outside any repo → walk down (depth ≤ 4, skipping dotdirs and
9//!    common build dirs) and gather every distinct repo family found.
10
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use crate::error::{CwError, Result};
15use crate::git;
16
17#[derive(Debug, Clone)]
18pub struct ScopedWorktree {
19    /// Basename of the worktree directory.
20    pub name: String,
21    /// Short branch name. `None` for detached HEAD.
22    pub branch: Option<String>,
23    /// Absolute path to the worktree directory.
24    pub path: PathBuf,
25    /// Main repo root this worktree belongs to. Canonicalized (symlinks resolved)
26    /// for stable equality comparisons.
27    pub repo_root: PathBuf,
28    /// True if this worktree is itself the main repo of its family.
29    pub is_main: bool,
30}
31
32#[derive(Debug, Clone, Default)]
33pub struct Scope {
34    worktrees: Vec<ScopedWorktree>,
35}
36
37impl Scope {
38    pub fn worktrees(&self) -> &[ScopedWorktree] {
39        &self.worktrees
40    }
41
42    pub fn is_empty(&self) -> bool {
43        self.worktrees.is_empty()
44    }
45}
46
47const MAX_WALK_DEPTH: usize = 4;
48
49pub fn discover_scope(cwd: &Path) -> Result<Scope> {
50    if let Ok(repo_root) = git::get_main_repo_root(Some(cwd)) {
51        let worktrees = collect_family(&repo_root)?;
52        return Ok(Scope { worktrees });
53    }
54
55    let mut all = Vec::new();
56    let mut seen_roots: HashSet<PathBuf> = HashSet::new();
57    walk_for_repos(cwd, 0, &mut all, &mut seen_roots)?;
58
59    if all.is_empty() {
60        return Err(CwError::Other(
61            "no worktrees in scope (cwd is not in a git repo and no repos found below)".into(),
62        ));
63    }
64    Ok(Scope { worktrees: all })
65}
66
67fn collect_family(repo_root: &Path) -> Result<Vec<ScopedWorktree>> {
68    let raw = git::parse_worktrees(repo_root)?;
69    let main_canon = git::canonicalize_or(repo_root);
70    let repo_root_canon = main_canon.clone();
71    let mut out = Vec::with_capacity(raw.len());
72    for (branch_raw, path) in raw {
73        let normalized = git::normalize_branch_name(&branch_raw);
74        let branch = if normalized == "(detached)" {
75            None
76        } else {
77            Some(normalized.to_string())
78        };
79        let name = path
80            .file_name()
81            .map(|n| n.to_string_lossy().into_owned())
82            .unwrap_or_default();
83        let is_main = git::canonicalize_or(&path) == main_canon;
84        out.push(ScopedWorktree {
85            name,
86            branch,
87            path,
88            repo_root: repo_root_canon.clone(),
89            is_main,
90        });
91    }
92    Ok(out)
93}
94
95fn walk_for_repos(
96    dir: &Path,
97    depth: usize,
98    out: &mut Vec<ScopedWorktree>,
99    seen: &mut HashSet<PathBuf>,
100) -> Result<()> {
101    if depth > MAX_WALK_DEPTH {
102        return Ok(());
103    }
104    let entries = match std::fs::read_dir(dir) {
105        Ok(e) => e,
106        Err(_) => return Ok(()),
107    };
108    for entry in entries.flatten() {
109        let path = entry.path();
110        let file_type = match entry.file_type() {
111            Ok(ft) => ft,
112            Err(_) => continue,
113        };
114        if file_type.is_symlink() {
115            continue;
116        }
117        if !file_type.is_dir() {
118            continue;
119        }
120        if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
121            if name.starts_with('.') && name != ".git" {
122                continue;
123            }
124            if name == "node_modules" || name == "target" {
125                continue;
126            }
127        }
128        if let Ok(repo) = git::get_main_repo_root(Some(&path)) {
129            if seen.insert(git::canonicalize_or(&repo)) {
130                out.extend(collect_family(&repo)?);
131            }
132            continue;
133        }
134        walk_for_repos(&path, depth + 1, out, seen)?;
135    }
136    Ok(())
137}