gwm/clean.rs
1//! `gwm clean` (issue #313): report and reclaim heavy build artifacts across
2//! worktrees.
3//!
4//! A worktree fleet accumulates gigabytes of regenerable build output
5//! (`target/`, `node_modules/`, `dist/`, `build/`). This module scans each
6//! worktree for those directories, sizes them, renders a report, and — only
7//! when the caller opts in — deletes them. Everything here is pure path
8//! plumbing (no git repo needed), so it is unit-tested directly.
9//!
10//! Deliberately **not** journaled into `gwm history` / `gwm undo` (#29): the
11//! artifacts are regenerable, so a resurrection entry would be meaningless.
12
13use crate::config::CleanConfig;
14use crate::error::{GwmError, Result};
15use std::path::{Path, PathBuf};
16
17/// One reclaimable artifact directory inside a worktree.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Artifact {
20 /// Directory name relative to the worktree root (e.g. `target`).
21 pub rel: String,
22 /// Total logical size of the files underneath it.
23 pub bytes: u64,
24}
25
26/// The reclaimable artifacts found in a single worktree.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct WorktreeReclaim {
29 pub name: String,
30 pub path: PathBuf,
31 pub artifacts: Vec<Artifact>,
32 pub total_bytes: u64,
33}
34
35/// The build-artifact directory names cleaned by default.
36///
37/// Hardcoded for the MVP; a `[clean]` config block to tune the set per repo
38/// is a deliberate follow-up (issue #313).
39pub fn default_patterns() -> Vec<String> {
40 ["target", "node_modules", "dist", "build"]
41 .iter()
42 .map(|s| s.to_string())
43 .collect()
44}
45
46/// Validate a profile `dirs` entry: it must be a single worktree-relative
47/// directory **name** — exactly one `Normal` path component.
48///
49/// Profile-supplied dirs are user-controlled and later fed to
50/// [`scan_worktree`], which does `worktree.join(entry)` and a recursive
51/// `dir_size` *before* the git safety gate runs. Anything other than a single
52/// plain name can escape the worktree:
53/// - absolute (`"/"`, a drive prefix) — `Path::join` resolves to the FS root;
54/// - a `..` traversal — climbs out of the worktree;
55/// - empty or a bare `.` / `./.` — resolves to the worktree root itself;
56/// - a **nested** path (`a/b`) — an intermediate component (`a`) could be a
57/// symlink that `scan_worktree` / `remove_dir_all` follow outside the tree
58/// (the symlink skip only covers the artifact root, not its ancestors).
59///
60/// Restricting to a single name eliminates every one of these by construction
61/// and matches the built-in [`default_patterns`] (which are trusted and bypass
62/// this check). Nesting is a deliberate, additive post-1.0 extension (it would
63/// need an explicit symlinked-ancestor guard). Anything else is a config error
64/// (exit 1) at resolution time.
65///
66/// Each accepted entry is returned **normalized** to its bare component name
67/// (`"target/"` and `"./target"` both become `"target"`) so syntactic aliases
68/// of the same directory collapse under the exact-match [`dedup_dirs`] — a
69/// raw-string dedup would otherwise keep `["target", "target/"]` and
70/// double-scan / double-delete it.
71fn normalized_profile_dirs(profile: &str, dirs: &[String]) -> Result<Vec<String>> {
72 use std::path::Component;
73 let mut out = Vec::with_capacity(dirs.len());
74 for d in dirs {
75 if d.is_empty() {
76 return Err(GwmError::Config(format!(
77 "clean: profile `{profile}` has an empty `dirs` entry — list single worktree-relative directory names"
78 )));
79 }
80 let mut comps = Path::new(d).components().filter(|c| !matches!(c, Component::CurDir));
81 let name = match (comps.next(), comps.next()) {
82 // Exactly one plain directory name — the only safe shape.
83 (Some(Component::Normal(n)), None) => n.to_string_lossy().into_owned(),
84 (Some(Component::ParentDir), _) => {
85 return Err(GwmError::Config(format!(
86 "clean: profile `{profile}` dir `{d}` must not escape the worktree with `..`"
87 )));
88 }
89 (Some(Component::RootDir | Component::Prefix(_)), _) => {
90 return Err(GwmError::Config(format!(
91 "clean: profile `{profile}` dir `{d}` must be relative to the worktree, not absolute"
92 )));
93 }
94 // `.` / `./.` collapse to nothing — they resolve to the worktree root.
95 (None, _) => {
96 return Err(GwmError::Config(format!(
97 "clean: profile `{profile}` dir `{d}` resolves to the worktree root — name a real subdirectory"
98 )));
99 }
100 // Two or more components — a nested path like `a/b`.
101 _ => {
102 return Err(GwmError::Config(format!(
103 "clean: profile `{profile}` dir `{d}` must be a single directory name (no `/`); nested paths are not supported"
104 )));
105 }
106 };
107 // Reject git pathspec magic. The safety gate feeds this name to
108 // `git ls-files -- <name>` / `git check-ignore -- <name>`, which treat it
109 // as a PATHSPEC, not a literal path: a glob char (`* ? [ ]`) or a leading
110 // `:` (magic prefix) would make git match something other than the literal
111 // directory `std::fs` deletes — e.g. `ls-files -- "foo[bar]"` misses a
112 // force-tracked file inside a literal `foo[bar]/`, so the tracked-file
113 // guard wrongly passes and `--yes` deletes tracked data. `check-ignore`
114 // can't be made literal (it rejects `:(literal)` magic), so reject these
115 // names up-front rather than silently mishandling them. A leading `-` is
116 // fine — the `--` delimiter in the git calls already neutralises it.
117 if name.starts_with(':') || name.contains(['*', '?', '[', ']']) {
118 return Err(GwmError::Config(format!(
119 "clean: profile `{profile}` dir `{d}` contains git pathspec metacharacters (`* ? [ ]` or a leading `:`) — name a literal directory"
120 )));
121 }
122 out.push(name);
123 }
124 Ok(out)
125}
126
127/// Validate a `[clean.profiles.<name>]` entry's `dirs` without resolving them
128/// — same rules as [`normalized_profile_dirs`], surfaced for the config
129/// validation path so `gwm config validate` / `gwm doctor` reject what
130/// `gwm clean` would (issue #324 review).
131pub fn validate_clean_profile_dirs(profile: &str, dirs: &[String]) -> Result<()> {
132 normalized_profile_dirs(profile, dirs).map(|_| ())
133}
134
135/// Drop exact duplicate entries (declared order kept), so a directory listed
136/// twice isn't scanned and reclaimed twice. Inputs come from
137/// [`normalized_profile_dirs`], so syntactic aliases (`target` vs `target/`)
138/// have already been folded to the same string — exact equality is the only
139/// overlap left.
140fn dedup_dirs(dirs: &[String]) -> Vec<String> {
141 let mut kept: Vec<String> = Vec::new();
142 for d in dirs {
143 if !kept.contains(d) {
144 kept.push(d.clone());
145 }
146 }
147 kept
148}
149
150/// Resolve the directory set `gwm clean` should scan and reclaim (issue #324).
151///
152/// - `--profile <name>` selects `[clean.profiles.<name>].dirs`, a **complete**
153/// set that replaces the built-ins. A name absent from `[clean.profiles]`
154/// is an error (exit 1).
155/// - **No** `--profile` uses `[clean.profiles.default].dirs` when that profile
156/// exists, else falls back to the built-in [`default_patterns`].
157///
158/// Profile-supplied dirs are validated and normalized to single worktree-
159/// relative names (absolute, `..`, `.`/root, nested, or empty → exit 1), then
160/// exact-deduped. Whatever set is returned, the caller still runs every
161/// directory through the safety gate (git-ignored + no tracked files + skip
162/// symlinks) before delete.
163pub fn resolve_clean_dirs(profile: Option<&str>, cfg: &CleanConfig) -> Result<Vec<String>> {
164 match profile {
165 Some(name) => {
166 let p = cfg
167 .profiles
168 .get(name)
169 .ok_or_else(|| GwmError::Config(format!("clean: no profile named `{name}` in [clean.profiles]")))?;
170 Ok(dedup_dirs(&normalized_profile_dirs(name, &p.dirs)?))
171 }
172 None => match cfg.profiles.get("default") {
173 Some(p) => Ok(dedup_dirs(&normalized_profile_dirs("default", &p.dirs)?)),
174 None => Ok(default_patterns()),
175 },
176 }
177}
178
179/// Sum the logical length of every regular file under `dir`, recursively.
180///
181/// Symlinks are not followed: the entry type is read via `DirEntry::file_type`
182/// (which, unlike `DirEntry::metadata`, does *not* traverse the link), so a
183/// symlink is skipped outright rather than recursed into or counted. Its
184/// target may live outside the worktree — or form a loop — and must not be
185/// attributed to it (nor, on delete, reached through it).
186fn dir_size(dir: &Path) -> u64 {
187 let mut total = 0u64;
188 let Ok(entries) = std::fs::read_dir(dir) else {
189 return 0;
190 };
191 for entry in entries.flatten() {
192 let Ok(ft) = entry.file_type() else {
193 continue;
194 };
195 if ft.is_symlink() {
196 continue;
197 }
198 if ft.is_dir() {
199 total = total.saturating_add(dir_size(&entry.path()));
200 } else if ft.is_file() {
201 if let Ok(meta) = entry.metadata() {
202 total = total.saturating_add(meta.len());
203 }
204 }
205 }
206 total
207}
208
209/// Scan one worktree at `path` for each pattern directory, sizing the ones
210/// that exist. Returns a [`WorktreeReclaim`] (possibly with no artifacts when
211/// the worktree is already clean).
212pub fn scan_worktree(name: &str, path: &Path, patterns: &[String]) -> WorktreeReclaim {
213 let mut artifacts = Vec::new();
214 let mut total = 0u64;
215 for pat in patterns {
216 let candidate = path.join(pat);
217 // Skip a symlinked artifact *root*: `Path::is_dir` follows the link, so
218 // `dir_size` would walk (and `clean --yes` could reach) a tree outside
219 // the worktree. `symlink_metadata` reports on the link itself.
220 let Ok(meta) = std::fs::symlink_metadata(&candidate) else {
221 continue;
222 };
223 if meta.file_type().is_symlink() {
224 continue;
225 }
226 if meta.is_dir() {
227 let bytes = dir_size(&candidate);
228 total = total.saturating_add(bytes);
229 artifacts.push(Artifact {
230 rel: pat.clone(),
231 bytes,
232 });
233 }
234 }
235 WorktreeReclaim {
236 name: name.to_string(),
237 path: path.to_path_buf(),
238 artifacts,
239 total_bytes: total,
240 }
241}
242
243/// Delete every scanned artifact directory of `reclaim`, returning the number
244/// of bytes freed (the sum of the scanned sizes). Only the directories named
245/// in `reclaim.artifacts` are touched — anything else in the worktree is left
246/// untouched.
247pub fn delete_reclaim(reclaim: &WorktreeReclaim) -> Result<u64> {
248 let mut freed = 0u64;
249 for a in &reclaim.artifacts {
250 let target = reclaim.path.join(&a.rel);
251 std::fs::remove_dir_all(&target)?;
252 freed = freed.saturating_add(a.bytes);
253 }
254 Ok(freed)
255}
256
257/// Format `bytes` as a human-readable size with a binary unit (`B`, `KiB`,
258/// `MiB`, `GiB`), one decimal place above 1 KiB.
259pub fn human_size(bytes: u64) -> String {
260 const KIB: u64 = 1024;
261 const MIB: u64 = 1024 * KIB;
262 const GIB: u64 = 1024 * MIB;
263 if bytes >= GIB {
264 format!("{:.1} GiB", bytes as f64 / GIB as f64)
265 } else if bytes >= MIB {
266 format!("{:.1} MiB", bytes as f64 / MIB as f64)
267 } else if bytes >= KIB {
268 format!("{:.1} KiB", bytes as f64 / KIB as f64)
269 } else {
270 format!("{} B", bytes)
271 }
272}
273
274/// Render the per-worktree report plus a grand total. Worktrees with no
275/// reclaimable artifacts are omitted from the body (they would be noise), but
276/// still count toward the total — which is zero when everything is clean.
277pub fn format_report(reclaims: &[WorktreeReclaim]) -> String {
278 let mut out = String::new();
279 let grand: u64 = reclaims.iter().map(|r| r.total_bytes).sum();
280 for r in reclaims {
281 if r.artifacts.is_empty() {
282 continue;
283 }
284 out.push_str(&format!("{} ({})\n", r.name, human_size(r.total_bytes)));
285 for a in &r.artifacts {
286 out.push_str(&format!(" {:<14} {}\n", a.rel, human_size(a.bytes)));
287 }
288 }
289 out.push_str(&format!("\nTotal reclaimable: {}\n", human_size(grand)));
290 out
291}
292
293/// The safety gate for deleting an artifact directory: a directory is safe
294/// to delete only when git treats `rel` (relative to `worktree`) as ignored
295/// AND it holds no tracked files. The ignore check alone is not enough — git
296/// tracks files, not directories, so a force-added `dist/index.html` can
297/// survive under a `dist/` ignore rule, and `remove_dir_all` would otherwise
298/// destroy that tracked (possibly edited) file. Every failure path resolves
299/// conservatively to "not safe" (preserve).
300///
301/// Shared by `gwm clean` (CLI) and the TUI clean overlay (issue #325) so both
302/// honour the identical contract — a raw [`scan_worktree`] + [`delete_reclaim`]
303/// would bypass this gate.
304pub fn dir_is_safe_to_clean(worktree: &Path, rel: &str) -> bool {
305 dir_is_git_ignored(worktree, rel) && !dir_has_tracked_files(worktree, rel)
306}
307
308/// Whether git considers `rel` (relative to `worktree`) ignored. Shells out to
309/// `git check-ignore -q -- <rel>` (exit 0 = ignored). Any failure (git missing,
310/// not a repo) is treated as "not ignored" so the default is to preserve.
311///
312/// The `--` delimiter is required: with a config-supplied `[clean.profiles]`
313/// dir, a name like `-cache` would otherwise be parsed by git as an option and
314/// the directory would always read as "not ignored" (skipped). `ls-files`
315/// below already passes `--` for the same reason.
316fn dir_is_git_ignored(worktree: &Path, rel: &str) -> bool {
317 std::process::Command::new("git")
318 .arg("-C")
319 .arg(worktree)
320 .args(["check-ignore", "-q", "--", rel])
321 .status()
322 .map(|s| s.success())
323 .unwrap_or(false)
324}
325
326/// Whether any tracked file lives under `rel`. `git ls-files -- <rel>` prints
327/// one line per tracked path; non-empty stdout ⇒ tracked content present. On
328/// any error we assume `true` (tracked) so the safety gate errs toward
329/// preserving the directory.
330fn dir_has_tracked_files(worktree: &Path, rel: &str) -> bool {
331 std::process::Command::new("git")
332 .arg("-C")
333 .arg(worktree)
334 .args(["ls-files", "--", rel])
335 .output()
336 .map(|o| !o.status.success() || !o.stdout.is_empty())
337 .unwrap_or(true)
338}
339
340/// Scan `path` for `patterns`, then keep only the artifacts that pass
341/// [`dir_is_safe_to_clean`]. Returns the gated [`WorktreeReclaim`] (its
342/// `total_bytes` reflecting only the deletable artifacts) plus the rejected
343/// directory names, so the caller can report what it preserved.
344///
345/// This is the safe entry point both `gwm clean` and the TUI clean overlay
346/// (issue #325) use — a raw [`scan_worktree`] feeding [`delete_reclaim`] would
347/// bypass the gate and could destroy tracked files.
348pub fn scan_worktree_safe(name: &str, path: &Path, patterns: &[String]) -> (WorktreeReclaim, Vec<String>) {
349 let scan = scan_worktree(name, path, patterns);
350 let mut deletable = Vec::new();
351 let mut skipped = Vec::new();
352 for a in scan.artifacts {
353 if dir_is_safe_to_clean(path, &a.rel) {
354 deletable.push(a);
355 } else {
356 skipped.push(a.rel);
357 }
358 }
359 let total_bytes = deletable.iter().map(|a| a.bytes).sum();
360 (
361 WorktreeReclaim {
362 name: name.to_string(),
363 path: path.to_path_buf(),
364 artifacts: deletable,
365 total_bytes,
366 },
367 skipped,
368 )
369}