Skip to main content

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}