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).
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}