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}