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).
212///
213/// **Ungated.** This does NOT apply the [`dir_is_safe_to_clean`] gate — it will
214/// happily size a `dist/` that holds a force-added tracked file. Every
215/// production caller MUST go through [`scan_worktree_safe`], which wraps this
216/// and drops the unsafe artifacts. This raw entry point is public only so the
217/// unit tests in `tests/clean_tests.rs` can exercise the sizing logic in
218/// isolation; treat it as `pub(crate)` by convention (tightening the actual
219/// visibility is tracked with the broader library-surface review in #342).
220pub fn scan_worktree(name: &str, path: &Path, patterns: &[String]) -> WorktreeReclaim {
221 let mut artifacts = Vec::new();
222 let mut total = 0u64;
223 for pat in patterns {
224 let candidate = path.join(pat);
225 // Skip a symlinked artifact *root*: `Path::is_dir` follows the link, so
226 // `dir_size` would walk (and `clean --yes` could reach) a tree outside
227 // the worktree. `symlink_metadata` reports on the link itself.
228 let Ok(meta) = std::fs::symlink_metadata(&candidate) else {
229 continue;
230 };
231 if meta.file_type().is_symlink() {
232 continue;
233 }
234 if meta.is_dir() {
235 let bytes = dir_size(&candidate);
236 total = total.saturating_add(bytes);
237 artifacts.push(Artifact {
238 rel: pat.clone(),
239 bytes,
240 });
241 }
242 }
243 WorktreeReclaim {
244 name: name.to_string(),
245 path: path.to_path_buf(),
246 artifacts,
247 total_bytes: total,
248 }
249}
250
251/// Delete every scanned artifact directory of `reclaim`, returning the number
252/// of bytes freed (the sum of the scanned sizes). Only the directories named
253/// in `reclaim.artifacts` are touched — anything else in the worktree is left
254/// untouched.
255///
256/// **Ungated — trusts its input.** This re-runs no safety check; it deletes
257/// exactly the artifacts in `reclaim`. The caller MUST build `reclaim` from
258/// [`scan_worktree_safe`] (all real callers — `gwm clean` and the TUI clean
259/// overlay, #325 — do), never a raw [`scan_worktree`], or a tracked
260/// force-added file could be destroyed. Public only for the `tests/clean_tests.rs`
261/// round-trip; treat as `pub(crate)` by convention (see #342 for the surface
262/// review).
263///
264/// There is a narrow **TOCTOU** window between the gate check inside
265/// `scan_worktree_safe` and the `remove_dir_all` here: a path that was
266/// git-ignored-and-untracked at scan time could be `git add`-ed (or replaced)
267/// in the interval and still be deleted. The exposure is bounded — `gwm clean`
268/// is user-initiated against the user's own build dirs, not a privileged or
269/// adversarial context — so the window is documented rather than closed
270/// (closing it would need re-checking the gate under a lock immediately before
271/// each delete, disproportionate to the threat).
272pub fn delete_reclaim(reclaim: &WorktreeReclaim) -> Result<u64> {
273 let mut freed = 0u64;
274 for a in &reclaim.artifacts {
275 let target = reclaim.path.join(&a.rel);
276 remove_dir_all_tolerant(&target, |p| std::fs::remove_dir_all(p))?;
277 freed = freed.saturating_add(a.bytes);
278 }
279 Ok(freed)
280}
281
282/// Bounded-retry wrapper around a recursive directory removal (issue #440).
283///
284/// `gwm clean --yes` races concurrent writers: a watcher such as
285/// rust-analyzer can recreate a file inside `target/` after `remove_dir_all`
286/// emptied a subdirectory but before it removed the parent, and the whole
287/// command then fails with ENOTEMPTY even though the reclaim mostly worked.
288/// One more pass deletes the freshly written files, so `DirectoryNotEmpty`
289/// is retried (bounded, so a writer that never stops cannot spin the command
290/// forever). A `NotFound` means someone else already reclaimed the directory
291/// — that is a success, not an error. Every other error propagates on the
292/// first hit.
293///
294/// The removal primitive is injected so `tests/clean_tests.rs` can pin the
295/// retry contract deterministically (the real race is timing dependent);
296/// production callers go through [`delete_reclaim`], which passes
297/// `std::fs::remove_dir_all`. Public for that test seam only — treat as
298/// `pub(crate)` by convention (see #342 for the surface review).
299pub fn remove_dir_all_tolerant<F>(target: &Path, mut remove: F) -> std::io::Result<()>
300where
301 F: FnMut(&Path) -> std::io::Result<()>,
302{
303 const REMOVE_ATTEMPTS: u32 = 3;
304 let mut attempt = 0;
305 loop {
306 attempt += 1;
307 match remove(target) {
308 Ok(()) => return Ok(()),
309 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
310 Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty && attempt < REMOVE_ATTEMPTS => continue,
311 Err(e) => return Err(e),
312 }
313 }
314}
315
316/// Format `bytes` as a human-readable size with a binary unit (`B`, `KiB`,
317/// `MiB`, `GiB`), one decimal place above 1 KiB.
318pub fn human_size(bytes: u64) -> String {
319 const KIB: u64 = 1024;
320 const MIB: u64 = 1024 * KIB;
321 const GIB: u64 = 1024 * MIB;
322 if bytes >= GIB {
323 format!("{:.1} GiB", bytes as f64 / GIB as f64)
324 } else if bytes >= MIB {
325 format!("{:.1} MiB", bytes as f64 / MIB as f64)
326 } else if bytes >= KIB {
327 format!("{:.1} KiB", bytes as f64 / KIB as f64)
328 } else {
329 format!("{} B", bytes)
330 }
331}
332
333/// Render the per-worktree report plus a grand total. Worktrees with no
334/// reclaimable artifacts are omitted from the body (they would be noise), but
335/// still count toward the total — which is zero when everything is clean.
336pub fn format_report(reclaims: &[WorktreeReclaim]) -> String {
337 let mut out = String::new();
338 let grand: u64 = reclaims.iter().map(|r| r.total_bytes).sum();
339 for r in reclaims {
340 if r.artifacts.is_empty() {
341 continue;
342 }
343 out.push_str(&format!("{} ({})\n", r.name, human_size(r.total_bytes)));
344 for a in &r.artifacts {
345 out.push_str(&format!(" {:<14} {}\n", a.rel, human_size(a.bytes)));
346 }
347 }
348 out.push_str(&format!("\nTotal reclaimable: {}\n", human_size(grand)));
349 out
350}
351
352/// The safety gate for deleting an artifact directory: a directory is safe
353/// to delete only when git treats `rel` (relative to `worktree`) as ignored
354/// AND it holds no tracked files. The ignore check alone is not enough — git
355/// tracks files, not directories, so a force-added `dist/index.html` can
356/// survive under a `dist/` ignore rule, and `remove_dir_all` would otherwise
357/// destroy that tracked (possibly edited) file. Every failure path resolves
358/// conservatively to "not safe" (preserve).
359///
360/// Shared by `gwm clean` (CLI) and the TUI clean overlay (issue #325) so both
361/// honour the identical contract — a raw [`scan_worktree`] + [`delete_reclaim`]
362/// would bypass this gate.
363pub fn dir_is_safe_to_clean(worktree: &Path, rel: &str) -> bool {
364 dir_is_git_ignored(worktree, rel) && !dir_has_tracked_files(worktree, rel)
365}
366
367/// Whether git considers `rel` (relative to `worktree`) ignored. Shells out to
368/// `git check-ignore -q -- <rel>` (exit 0 = ignored). Any failure (git missing,
369/// not a repo) is treated as "not ignored" so the default is to preserve.
370///
371/// The `--` delimiter is required: with a config-supplied `[clean.profiles]`
372/// dir, a name like `-cache` would otherwise be parsed by git as an option and
373/// the directory would always read as "not ignored" (skipped). `ls-files`
374/// below already passes `--` for the same reason.
375fn dir_is_git_ignored(worktree: &Path, rel: &str) -> bool {
376 std::process::Command::new("git")
377 .arg("-C")
378 .arg(worktree)
379 .args(["check-ignore", "-q", "--", rel])
380 .status()
381 .map(|s| s.success())
382 .unwrap_or(false)
383}
384
385/// Whether any tracked file lives under `rel`. `git ls-files -- <rel>` prints
386/// one line per tracked path; non-empty stdout ⇒ tracked content present. On
387/// any error we assume `true` (tracked) so the safety gate errs toward
388/// preserving the directory.
389fn dir_has_tracked_files(worktree: &Path, rel: &str) -> bool {
390 std::process::Command::new("git")
391 .arg("-C")
392 .arg(worktree)
393 .args(["ls-files", "--", rel])
394 .output()
395 .map(|o| !o.status.success() || !o.stdout.is_empty())
396 .unwrap_or(true)
397}
398
399/// Scan `path` for `patterns`, then keep only the artifacts that pass
400/// [`dir_is_safe_to_clean`]. Returns the gated [`WorktreeReclaim`] (its
401/// `total_bytes` reflecting only the deletable artifacts) plus the rejected
402/// directory names, so the caller can report what it preserved.
403///
404/// This is the safe entry point both `gwm clean` and the TUI clean overlay
405/// (issue #325) use — a raw [`scan_worktree`] feeding [`delete_reclaim`] would
406/// bypass the gate and could destroy tracked files.
407pub fn scan_worktree_safe(name: &str, path: &Path, patterns: &[String]) -> (WorktreeReclaim, Vec<String>) {
408 let scan = scan_worktree(name, path, patterns);
409 let mut deletable = Vec::new();
410 let mut skipped = Vec::new();
411 for a in scan.artifacts {
412 if dir_is_safe_to_clean(path, &a.rel) {
413 deletable.push(a);
414 } else {
415 skipped.push(a.rel);
416 }
417 }
418 let total_bytes = deletable.iter().map(|a| a.bytes).sum();
419 (
420 WorktreeReclaim {
421 name: name.to_string(),
422 path: path.to_path_buf(),
423 artifacts: deletable,
424 total_bytes,
425 },
426 skipped,
427 )
428}