gwm-cli 1.6.0

git worktree manager — TUI + CLI, native libgit2, per-repo bootstrap
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! `gwm clean` (issue #313): report and reclaim heavy build artifacts across
//! worktrees.
//!
//! A worktree fleet accumulates gigabytes of regenerable build output
//! (`target/`, `node_modules/`, `dist/`, `build/`). This module scans each
//! worktree for those directories, sizes them, renders a report, and — only
//! when the caller opts in — deletes them. Everything here is pure path
//! plumbing (no git repo needed), so it is unit-tested directly.
//!
//! Deliberately **not** journaled into `gwm history` / `gwm undo` (#29): the
//! artifacts are regenerable, so a resurrection entry would be meaningless.

use crate::config::CleanConfig;
use crate::error::{GwmError, Result};
use std::path::{Path, PathBuf};

/// One reclaimable artifact directory inside a worktree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
  /// Directory name relative to the worktree root (e.g. `target`).
  pub rel: String,
  /// Total logical size of the files underneath it.
  pub bytes: u64,
}

/// The reclaimable artifacts found in a single worktree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeReclaim {
  pub name: String,
  pub path: PathBuf,
  pub artifacts: Vec<Artifact>,
  pub total_bytes: u64,
}

/// The build-artifact directory names cleaned by default.
///
/// Hardcoded for the MVP; a `[clean]` config block to tune the set per repo
/// is a deliberate follow-up (issue #313).
pub fn default_patterns() -> Vec<String> {
  ["target", "node_modules", "dist", "build"]
    .iter()
    .map(|s| s.to_string())
    .collect()
}

/// Validate a profile `dirs` entry: it must be a single worktree-relative
/// directory **name** — exactly one `Normal` path component.
///
/// Profile-supplied dirs are user-controlled and later fed to
/// [`scan_worktree`], which does `worktree.join(entry)` and a recursive
/// `dir_size` *before* the git safety gate runs. Anything other than a single
/// plain name can escape the worktree:
/// - absolute (`"/"`, a drive prefix) — `Path::join` resolves to the FS root;
/// - a `..` traversal — climbs out of the worktree;
/// - empty or a bare `.` / `./.` — resolves to the worktree root itself;
/// - a **nested** path (`a/b`) — an intermediate component (`a`) could be a
///   symlink that `scan_worktree` / `remove_dir_all` follow outside the tree
///   (the symlink skip only covers the artifact root, not its ancestors).
///
/// Restricting to a single name eliminates every one of these by construction
/// and matches the built-in [`default_patterns`] (which are trusted and bypass
/// this check). Nesting is a deliberate, additive post-1.0 extension (it would
/// need an explicit symlinked-ancestor guard). Anything else is a config error
/// (exit 1) at resolution time.
///
/// Each accepted entry is returned **normalized** to its bare component name
/// (`"target/"` and `"./target"` both become `"target"`) so syntactic aliases
/// of the same directory collapse under the exact-match [`dedup_dirs`] — a
/// raw-string dedup would otherwise keep `["target", "target/"]` and
/// double-scan / double-delete it.
fn normalized_profile_dirs(profile: &str, dirs: &[String]) -> Result<Vec<String>> {
  use std::path::Component;
  let mut out = Vec::with_capacity(dirs.len());
  for d in dirs {
    if d.is_empty() {
      return Err(GwmError::Config(format!(
        "clean: profile `{profile}` has an empty `dirs` entry — list single worktree-relative directory names"
      )));
    }
    let mut comps = Path::new(d).components().filter(|c| !matches!(c, Component::CurDir));
    let name = match (comps.next(), comps.next()) {
      // Exactly one plain directory name — the only safe shape.
      (Some(Component::Normal(n)), None) => n.to_string_lossy().into_owned(),
      (Some(Component::ParentDir), _) => {
        return Err(GwmError::Config(format!(
          "clean: profile `{profile}` dir `{d}` must not escape the worktree with `..`"
        )));
      }
      (Some(Component::RootDir | Component::Prefix(_)), _) => {
        return Err(GwmError::Config(format!(
          "clean: profile `{profile}` dir `{d}` must be relative to the worktree, not absolute"
        )));
      }
      // `.` / `./.` collapse to nothing — they resolve to the worktree root.
      (None, _) => {
        return Err(GwmError::Config(format!(
          "clean: profile `{profile}` dir `{d}` resolves to the worktree root — name a real subdirectory"
        )));
      }
      // Two or more components — a nested path like `a/b`.
      _ => {
        return Err(GwmError::Config(format!(
          "clean: profile `{profile}` dir `{d}` must be a single directory name (no `/`); nested paths are not supported"
        )));
      }
    };
    // Reject git pathspec magic. The safety gate feeds this name to
    // `git ls-files -- <name>` / `git check-ignore -- <name>`, which treat it
    // as a PATHSPEC, not a literal path: a glob char (`* ? [ ]`) or a leading
    // `:` (magic prefix) would make git match something other than the literal
    // directory `std::fs` deletes — e.g. `ls-files -- "foo[bar]"` misses a
    // force-tracked file inside a literal `foo[bar]/`, so the tracked-file
    // guard wrongly passes and `--yes` deletes tracked data. `check-ignore`
    // can't be made literal (it rejects `:(literal)` magic), so reject these
    // names up-front rather than silently mishandling them. A leading `-` is
    // fine — the `--` delimiter in the git calls already neutralises it.
    if name.starts_with(':') || name.contains(['*', '?', '[', ']']) {
      return Err(GwmError::Config(format!(
        "clean: profile `{profile}` dir `{d}` contains git pathspec metacharacters (`* ? [ ]` or a leading `:`) — name a literal directory"
      )));
    }
    out.push(name);
  }
  Ok(out)
}

/// Validate a `[clean.profiles.<name>]` entry's `dirs` without resolving them
/// — same rules as [`normalized_profile_dirs`], surfaced for the config
/// validation path so `gwm config validate` / `gwm doctor` reject what
/// `gwm clean` would (issue #324 review).
pub fn validate_clean_profile_dirs(profile: &str, dirs: &[String]) -> Result<()> {
  normalized_profile_dirs(profile, dirs).map(|_| ())
}

/// Drop exact duplicate entries (declared order kept), so a directory listed
/// twice isn't scanned and reclaimed twice. Inputs come from
/// [`normalized_profile_dirs`], so syntactic aliases (`target` vs `target/`)
/// have already been folded to the same string — exact equality is the only
/// overlap left.
fn dedup_dirs(dirs: &[String]) -> Vec<String> {
  let mut kept: Vec<String> = Vec::new();
  for d in dirs {
    if !kept.contains(d) {
      kept.push(d.clone());
    }
  }
  kept
}

/// Resolve the directory set `gwm clean` should scan and reclaim (issue #324).
///
/// - `--profile <name>` selects `[clean.profiles.<name>].dirs`, a **complete**
///   set that replaces the built-ins. A name absent from `[clean.profiles]`
///   is an error (exit 1).
/// - **No** `--profile` uses `[clean.profiles.default].dirs` when that profile
///   exists, else falls back to the built-in [`default_patterns`].
///
/// Profile-supplied dirs are validated and normalized to single worktree-
/// relative names (absolute, `..`, `.`/root, nested, or empty → exit 1), then
/// exact-deduped. Whatever set is returned, the caller still runs every
/// directory through the safety gate (git-ignored + no tracked files + skip
/// symlinks) before delete.
pub fn resolve_clean_dirs(profile: Option<&str>, cfg: &CleanConfig) -> Result<Vec<String>> {
  match profile {
    Some(name) => {
      let p = cfg
        .profiles
        .get(name)
        .ok_or_else(|| GwmError::Config(format!("clean: no profile named `{name}` in [clean.profiles]")))?;
      Ok(dedup_dirs(&normalized_profile_dirs(name, &p.dirs)?))
    }
    None => match cfg.profiles.get("default") {
      Some(p) => Ok(dedup_dirs(&normalized_profile_dirs("default", &p.dirs)?)),
      None => Ok(default_patterns()),
    },
  }
}

/// Sum the logical length of every regular file under `dir`, recursively.
///
/// Symlinks are not followed: the entry type is read via `DirEntry::file_type`
/// (which, unlike `DirEntry::metadata`, does *not* traverse the link), so a
/// symlink is skipped outright rather than recursed into or counted. Its
/// target may live outside the worktree — or form a loop — and must not be
/// attributed to it (nor, on delete, reached through it).
fn dir_size(dir: &Path) -> u64 {
  let mut total = 0u64;
  let Ok(entries) = std::fs::read_dir(dir) else {
    return 0;
  };
  for entry in entries.flatten() {
    let Ok(ft) = entry.file_type() else {
      continue;
    };
    if ft.is_symlink() {
      continue;
    }
    if ft.is_dir() {
      total = total.saturating_add(dir_size(&entry.path()));
    } else if ft.is_file() {
      if let Ok(meta) = entry.metadata() {
        total = total.saturating_add(meta.len());
      }
    }
  }
  total
}

/// Scan one worktree at `path` for each pattern directory, sizing the ones
/// that exist. Returns a [`WorktreeReclaim`] (possibly with no artifacts when
/// the worktree is already clean).
///
/// **Ungated.** This does NOT apply the [`dir_is_safe_to_clean`] gate — it will
/// happily size a `dist/` that holds a force-added tracked file. Every
/// production caller MUST go through [`scan_worktree_safe`], which wraps this
/// and drops the unsafe artifacts. This raw entry point is public only so the
/// unit tests in `tests/clean_tests.rs` can exercise the sizing logic in
/// isolation; treat it as `pub(crate)` by convention (tightening the actual
/// visibility is tracked with the broader library-surface review in #342).
pub fn scan_worktree(name: &str, path: &Path, patterns: &[String]) -> WorktreeReclaim {
  let mut artifacts = Vec::new();
  let mut total = 0u64;
  for pat in patterns {
    let candidate = path.join(pat);
    // Skip a symlinked artifact *root*: `Path::is_dir` follows the link, so
    // `dir_size` would walk (and `clean --yes` could reach) a tree outside
    // the worktree. `symlink_metadata` reports on the link itself.
    let Ok(meta) = std::fs::symlink_metadata(&candidate) else {
      continue;
    };
    if meta.file_type().is_symlink() {
      continue;
    }
    if meta.is_dir() {
      let bytes = dir_size(&candidate);
      total = total.saturating_add(bytes);
      artifacts.push(Artifact {
        rel: pat.clone(),
        bytes,
      });
    }
  }
  WorktreeReclaim {
    name: name.to_string(),
    path: path.to_path_buf(),
    artifacts,
    total_bytes: total,
  }
}

/// Delete every scanned artifact directory of `reclaim`, returning the number
/// of bytes freed (the sum of the scanned sizes). Only the directories named
/// in `reclaim.artifacts` are touched — anything else in the worktree is left
/// untouched.
///
/// **Ungated — trusts its input.** This re-runs no safety check; it deletes
/// exactly the artifacts in `reclaim`. The caller MUST build `reclaim` from
/// [`scan_worktree_safe`] (all real callers — `gwm clean` and the TUI clean
/// overlay, #325 — do), never a raw [`scan_worktree`], or a tracked
/// force-added file could be destroyed. Public only for the `tests/clean_tests.rs`
/// round-trip; treat as `pub(crate)` by convention (see #342 for the surface
/// review).
///
/// There is a narrow **TOCTOU** window between the gate check inside
/// `scan_worktree_safe` and the `remove_dir_all` here: a path that was
/// git-ignored-and-untracked at scan time could be `git add`-ed (or replaced)
/// in the interval and still be deleted. The exposure is bounded — `gwm clean`
/// is user-initiated against the user's own build dirs, not a privileged or
/// adversarial context — so the window is documented rather than closed
/// (closing it would need re-checking the gate under a lock immediately before
/// each delete, disproportionate to the threat).
pub fn delete_reclaim(reclaim: &WorktreeReclaim) -> Result<u64> {
  let mut freed = 0u64;
  for a in &reclaim.artifacts {
    let target = reclaim.path.join(&a.rel);
    remove_dir_all_tolerant(&target, |p| std::fs::remove_dir_all(p))?;
    freed = freed.saturating_add(a.bytes);
  }
  Ok(freed)
}

/// Bounded-retry wrapper around a recursive directory removal (issue #440).
///
/// `gwm clean --yes` races concurrent writers: a watcher such as
/// rust-analyzer can recreate a file inside `target/` after `remove_dir_all`
/// emptied a subdirectory but before it removed the parent, and the whole
/// command then fails with ENOTEMPTY even though the reclaim mostly worked.
/// One more pass deletes the freshly written files, so `DirectoryNotEmpty`
/// is retried (bounded, so a writer that never stops cannot spin the command
/// forever). A `NotFound` means someone else already reclaimed the directory
/// — that is a success, not an error. Every other error propagates on the
/// first hit.
///
/// The removal primitive is injected so `tests/clean_tests.rs` can pin the
/// retry contract deterministically (the real race is timing dependent);
/// production callers go through [`delete_reclaim`], which passes
/// `std::fs::remove_dir_all`. Public for that test seam only — treat as
/// `pub(crate)` by convention (see #342 for the surface review).
pub fn remove_dir_all_tolerant<F>(target: &Path, mut remove: F) -> std::io::Result<()>
where
  F: FnMut(&Path) -> std::io::Result<()>,
{
  const REMOVE_ATTEMPTS: u32 = 3;
  let mut attempt = 0;
  loop {
    attempt += 1;
    match remove(target) {
      Ok(()) => return Ok(()),
      Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
      Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty && attempt < REMOVE_ATTEMPTS => continue,
      Err(e) => return Err(e),
    }
  }
}

/// Format `bytes` as a human-readable size with a binary unit (`B`, `KiB`,
/// `MiB`, `GiB`), one decimal place above 1 KiB.
pub fn human_size(bytes: u64) -> String {
  const KIB: u64 = 1024;
  const MIB: u64 = 1024 * KIB;
  const GIB: u64 = 1024 * MIB;
  if bytes >= GIB {
    format!("{:.1} GiB", bytes as f64 / GIB as f64)
  } else if bytes >= MIB {
    format!("{:.1} MiB", bytes as f64 / MIB as f64)
  } else if bytes >= KIB {
    format!("{:.1} KiB", bytes as f64 / KIB as f64)
  } else {
    format!("{} B", bytes)
  }
}

/// Render the per-worktree report plus a grand total. Worktrees with no
/// reclaimable artifacts are omitted from the body (they would be noise), but
/// still count toward the total — which is zero when everything is clean.
pub fn format_report(reclaims: &[WorktreeReclaim]) -> String {
  let mut out = String::new();
  let grand: u64 = reclaims.iter().map(|r| r.total_bytes).sum();
  for r in reclaims {
    if r.artifacts.is_empty() {
      continue;
    }
    out.push_str(&format!("{} ({})\n", r.name, human_size(r.total_bytes)));
    for a in &r.artifacts {
      out.push_str(&format!("  {:<14} {}\n", a.rel, human_size(a.bytes)));
    }
  }
  out.push_str(&format!("\nTotal reclaimable: {}\n", human_size(grand)));
  out
}

/// The safety gate for deleting an artifact directory: a directory is safe
/// to delete only when git treats `rel` (relative to `worktree`) as ignored
/// AND it holds no tracked files. The ignore check alone is not enough — git
/// tracks files, not directories, so a force-added `dist/index.html` can
/// survive under a `dist/` ignore rule, and `remove_dir_all` would otherwise
/// destroy that tracked (possibly edited) file. Every failure path resolves
/// conservatively to "not safe" (preserve).
///
/// Shared by `gwm clean` (CLI) and the TUI clean overlay (issue #325) so both
/// honour the identical contract — a raw [`scan_worktree`] + [`delete_reclaim`]
/// would bypass this gate.
pub fn dir_is_safe_to_clean(worktree: &Path, rel: &str) -> bool {
  dir_is_git_ignored(worktree, rel) && !dir_has_tracked_files(worktree, rel)
}

/// Whether git considers `rel` (relative to `worktree`) ignored. Shells out to
/// `git check-ignore -q -- <rel>` (exit 0 = ignored). Any failure (git missing,
/// not a repo) is treated as "not ignored" so the default is to preserve.
///
/// The `--` delimiter is required: with a config-supplied `[clean.profiles]`
/// dir, a name like `-cache` would otherwise be parsed by git as an option and
/// the directory would always read as "not ignored" (skipped). `ls-files`
/// below already passes `--` for the same reason.
fn dir_is_git_ignored(worktree: &Path, rel: &str) -> bool {
  std::process::Command::new("git")
    .arg("-C")
    .arg(worktree)
    .args(["check-ignore", "-q", "--", rel])
    .status()
    .map(|s| s.success())
    .unwrap_or(false)
}

/// Whether any tracked file lives under `rel`. `git ls-files -- <rel>` prints
/// one line per tracked path; non-empty stdout ⇒ tracked content present. On
/// any error we assume `true` (tracked) so the safety gate errs toward
/// preserving the directory.
fn dir_has_tracked_files(worktree: &Path, rel: &str) -> bool {
  std::process::Command::new("git")
    .arg("-C")
    .arg(worktree)
    .args(["ls-files", "--", rel])
    .output()
    .map(|o| !o.status.success() || !o.stdout.is_empty())
    .unwrap_or(true)
}

/// Scan `path` for `patterns`, then keep only the artifacts that pass
/// [`dir_is_safe_to_clean`]. Returns the gated [`WorktreeReclaim`] (its
/// `total_bytes` reflecting only the deletable artifacts) plus the rejected
/// directory names, so the caller can report what it preserved.
///
/// This is the safe entry point both `gwm clean` and the TUI clean overlay
/// (issue #325) use — a raw [`scan_worktree`] feeding [`delete_reclaim`] would
/// bypass the gate and could destroy tracked files.
pub fn scan_worktree_safe(name: &str, path: &Path, patterns: &[String]) -> (WorktreeReclaim, Vec<String>) {
  let scan = scan_worktree(name, path, patterns);
  let mut deletable = Vec::new();
  let mut skipped = Vec::new();
  for a in scan.artifacts {
    if dir_is_safe_to_clean(path, &a.rel) {
      deletable.push(a);
    } else {
      skipped.push(a.rel);
    }
  }
  let total_bytes = deletable.iter().map(|a| a.bytes).sum();
  (
    WorktreeReclaim {
      name: name.to_string(),
      path: path.to_path_buf(),
      artifacts: deletable,
      total_bytes,
    },
    skipped,
  )
}