Skip to main content

gwm/
workspace.rs

1//! Workspace mode (issue #36): a bird's-eye view across every git repo that
2//! sits one level below a workspace root (e.g. `~/Projects`).
3//!
4//! `gwm` is single-repo by default. Workspace mode is an orthogonal
5//! dimension layered on top: discover every direct-child git repo under a
6//! root, then merge their worktree listings into one table where each row
7//! remembers which repo it belongs to. `.gwm.toml` stays per-repo — there is
8//! no workspace-level config in this version of the feature.
9
10use crate::error::Result;
11use crate::worktree::{self, WorktreeInfo};
12use git2::Repository;
13use std::path::{Path, PathBuf};
14
15/// One git repo discovered directly under the workspace root.
16#[derive(Debug, Clone)]
17pub struct WorkspaceRepo {
18  /// Display name — the repo directory's basename.
19  pub name: String,
20  /// The repo's working directory (a direct child of the workspace root).
21  pub path: PathBuf,
22}
23
24/// The set of repos found under a workspace root.
25#[derive(Debug, Clone)]
26pub struct Workspace {
27  /// The root the user pointed `--workspace` at.
28  pub root: PathBuf,
29  /// Direct-child git repos, sorted alphabetically by name.
30  pub repos: Vec<WorkspaceRepo>,
31}
32
33impl Workspace {
34  /// True when no git repo was found directly under the root.
35  pub fn is_empty(&self) -> bool {
36    self.repos.is_empty()
37  }
38}
39
40/// A merged worktree row: the owning repo plus the worktree info itself.
41#[derive(Debug, Clone)]
42pub struct WorkspaceRow {
43  /// Display name of the repo this worktree belongs to.
44  pub repo_name: String,
45  /// Working directory of the owning repo (the workspace child dir).
46  pub repo_path: PathBuf,
47  /// The per-worktree listing, identical to single-repo `worktree::list`.
48  pub info: WorktreeInfo,
49}
50
51/// Walk one level deep under `root`, opening each direct-child directory as a
52/// git repo. Non-directories and non-repo directories are ignored; nested
53/// repos two levels down are *not* discovered (workspace mode is intentionally
54/// shallow). Repos are returned sorted by name for a stable listing.
55///
56/// Errors if `root` cannot be read (missing / not a directory / no
57/// permission). An existing but repo-free root is *not* an error — it yields
58/// an empty [`Workspace`]; callers decide whether that is worth surfacing.
59pub fn discover(root: &Path) -> Result<Workspace> {
60  let entries = std::fs::read_dir(root)?;
61
62  let mut repos: Vec<WorkspaceRepo> = Vec::new();
63  // Canonical main-workdir of each repo already admitted, so two entries that
64  // resolve to the same main repo (a main checkout and one of its linked
65  // worktrees, both under the root) collapse to a single row.
66  let mut seen: Vec<PathBuf> = Vec::new();
67  for entry in entries.flatten() {
68    let path = entry.path();
69    if !path.is_dir() {
70      continue;
71    }
72    // `Repository::open` (not `discover`) so a non-repo child can't make us
73    // walk *up* and latch onto an unrelated ancestor repo: the child dir
74    // itself must be the repo root.
75    let Ok(repo) = Repository::open(&path) else {
76      continue;
77    };
78    if repo.is_bare() {
79      continue;
80    }
81    // Resolve the entry to the *main* repo it belongs to: a normal repo is its
82    // own main; a linked worktree resolves to the main checkout that owns it
83    // (which may live outside the root). `None` ⇒ unresolvable, skip.
84    let Some(main_workdir) = main_workdir(&repo) else {
85      continue;
86    };
87    // A non-worktree entry must BE its own repo root. When `--workspace` points
88    // at a directory that is itself a repo, `read_dir` surfaces the root's own
89    // `.git/`; `Repository::open` succeeds on it but resolves to the *parent*
90    // repo (main workdir = root, not `root/.git`), so this drops that bogus
91    // `.git` row (Codex review #303 P2).
92    if !repo.is_worktree() && !paths_equal(&main_workdir, &path) {
93      continue;
94    }
95    // Dedupe by the resolved main workdir: a main checkout and a linked
96    // worktree of it that both sit under the root collapse to one row (the
97    // main repo's `worktree::list` already emits that worktree). A linked
98    // worktree whose owner is NOT under the root resolves to its owner and is
99    // still included — its checkout would otherwise be invisible (#304).
100    let canon = main_workdir.canonicalize().unwrap_or_else(|_| main_workdir.clone());
101    if seen.contains(&canon) {
102      continue;
103    }
104    seen.push(canon);
105    let name = main_workdir
106      .file_name()
107      .map(|n| n.to_string_lossy().to_string())
108      .unwrap_or_else(|| "repo".into());
109    repos.push(WorkspaceRepo {
110      name,
111      path: main_workdir,
112    });
113  }
114
115  // Sort by name, then by path as a stable tie-breaker so two repos that share
116  // a basename always order the same way across runs / filesystems — otherwise
117  // the `-N` suffixing below would assign `main` / `main-2` non-deterministically
118  // and `--repo main-2` could target a different physical repo per run (#304).
119  repos.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
120  // Display names come from the main workdir basename, which can collide for
121  // distinct repos (a linked worktree resolving to an owner outside the root,
122  // or symlinked children). `--repo <name>` and the TUI must address each repo
123  // unambiguously, so suffix any duplicate with the smallest free `-N` (#304).
124  let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
125  for r in &mut repos {
126    if used.insert(r.name.clone()) {
127      continue;
128    }
129    let mut n = 2;
130    loop {
131      let candidate = format!("{}-{}", r.name, n);
132      if used.insert(candidate.clone()) {
133        r.name = candidate;
134        break;
135      }
136      n += 1;
137    }
138  }
139  Ok(Workspace {
140    root: root.to_path_buf(),
141    repos,
142  })
143}
144
145/// The working directory of the *main* repo an opened entry belongs to. A
146/// normal repo is its own main (`workdir()`); a linked worktree resolves to
147/// the main checkout that owns it — its gitdir is
148/// `<main>/.git/worktrees/<id>/`, so three parents up is `<main>`, which we
149/// re-open to read its real workdir. `None` when the path layout can't be
150/// resolved or the repo has no workdir (bare).
151fn main_workdir(repo: &Repository) -> Option<PathBuf> {
152  if repo.is_worktree() {
153    let admin = repo.path();
154    let main = admin.parent()?.parent()?.parent()?;
155    Repository::open(main).ok()?.workdir().map(Path::to_path_buf)
156  } else {
157    repo.workdir().map(Path::to_path_buf)
158  }
159}
160
161/// Compare two paths for the same on-disk location, canonicalizing first so a
162/// trailing separator (libgit2 workdirs carry one) or a `/var`↔`/private/var`
163/// symlink (macOS tempdirs) doesn't make equal paths compare unequal. Falls
164/// back to the raw path when canonicalization fails (e.g. a not-yet-created
165/// path), which is the conservative "compare as-is" behaviour.
166fn paths_equal(a: &Path, b: &Path) -> bool {
167  let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
168  let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
169  ca == cb
170}
171
172/// Heuristic trigger for the auto-detect prompt (issue #36): when bare `gwm`
173/// is run in a directory that is *not* itself inside a git repo but *does*
174/// hold direct-child git repos, offer to open it as a workspace.
175///
176/// Returns the discovered [`Workspace`] when the heuristic fires, else `None`.
177/// The interactive prompt lives in the CLI layer — this is the pure decision
178/// so it can be tested without stdin. Being inside a repo (at `cwd` or any
179/// ancestor) always loses to single-repo mode.
180pub fn autodetect(cwd: &Path) -> Option<Workspace> {
181  // `discover` here is libgit2's repo discovery (walks up); an `Ok` means we
182  // are inside a repo, so single-repo mode wins and we never auto-workspace.
183  if Repository::discover(cwd).is_ok() {
184    return None;
185  }
186  let ws = discover(cwd).ok()?;
187  if ws.is_empty() {
188    None
189  } else {
190    Some(ws)
191  }
192}
193
194/// Merge every repo's worktree listing into one flat, repo-tagged table.
195///
196/// Rows are grouped by repo in `workspace.repos` (alphabetical) order; within
197/// a repo the order is `worktree::list`'s (main worktree first). A repo whose
198/// listing fails (corrupt `.git`, transient git error) is skipped rather than
199/// aborting the whole table — the bird's-eye view is best-effort across repos.
200pub fn merge_worktrees(workspace: &Workspace) -> Result<Vec<WorkspaceRow>> {
201  let mut rows: Vec<WorkspaceRow> = Vec::new();
202  for repo in &workspace.repos {
203    let Ok(handle) = Repository::open(&repo.path) else {
204      continue;
205    };
206    let Ok(trees) = worktree::list(&handle) else {
207      continue;
208    };
209    for info in trees {
210      rows.push(WorkspaceRow {
211        repo_name: repo.name.clone(),
212        repo_path: repo.path.clone(),
213        info,
214      });
215    }
216  }
217  Ok(rows)
218}