gwm-cli 1.6.1

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
429
430
//! Unit tests for `gwm clean` (issue #313) — the pure disk-reclaim layer.
//!
//! The scan / size / delete / report functions take plain paths and need no
//! git repo, so they are fully testable against a `tempfile::TempDir`. Sizes
//! are summed from the logical length of regular files written by the test,
//! which is deterministic across filesystems (CLAUDE.md env-independence).

use gwm::clean::{
  default_patterns, delete_reclaim, format_report, human_size, remove_dir_all_tolerant, resolve_clean_dirs,
  scan_worktree, WorktreeReclaim,
};
use gwm::config::{CleanConfig, CleanProfile};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use tempfile::TempDir;

/// Create `dir/<rel>` and drop one `blob` of `bytes` length inside it.
fn make_artifact(root: &Path, rel: &str, bytes: usize) {
  let d = root.join(rel);
  fs::create_dir_all(&d).unwrap();
  fs::write(d.join("blob.bin"), vec![0u8; bytes]).unwrap();
}

/// Build a [`CleanConfig`] with the given `[clean.profiles.*]` entries.
fn clean_cfg(profiles: &[(&str, &[&str])]) -> CleanConfig {
  let mut map = BTreeMap::new();
  for (name, dirs) in profiles {
    map.insert(
      (*name).to_string(),
      CleanProfile {
        dirs: dirs.iter().map(|s| s.to_string()).collect(),
      },
    );
  }
  CleanConfig { profiles: map }
}

#[test]
fn human_size_formats_units() {
  assert_eq!(human_size(0), "0 B");
  assert_eq!(human_size(512), "512 B");
  assert_eq!(human_size(1024), "1.0 KiB");
  assert_eq!(human_size(1536), "1.5 KiB");
  assert_eq!(human_size(1024 * 1024), "1.0 MiB");
  assert_eq!(human_size(1024 * 1024 * 1024), "1.0 GiB");
}

#[test]
fn default_patterns_cover_the_common_build_dirs() {
  let p = default_patterns();
  for expected in ["target", "node_modules", "dist", "build"] {
    assert!(
      p.iter().any(|d| d == expected),
      "default patterns should include {expected}: {p:?}"
    );
  }
}

#[test]
fn scan_finds_default_artifact_dirs_and_sums_sizes() {
  let dir = TempDir::new().unwrap();
  let wt = dir.path();
  make_artifact(wt, "target", 2048);
  make_artifact(wt, "node_modules", 1024);
  // Not an artifact: must be ignored and excluded from the total.
  make_artifact(wt, "src", 4096);

  let r = scan_worktree("feat-1", wt, &default_patterns());

  assert_eq!(r.name, "feat-1");
  let mut found: Vec<&str> = r.artifacts.iter().map(|a| a.rel.as_str()).collect();
  found.sort_unstable();
  assert_eq!(found, vec!["node_modules", "target"]);
  assert_eq!(r.total_bytes, 2048 + 1024);
}

#[test]
fn scan_returns_empty_when_no_artifacts_present() {
  let dir = TempDir::new().unwrap();
  make_artifact(dir.path(), "src", 4096);

  let r = scan_worktree("clean-wt", dir.path(), &default_patterns());

  assert!(r.artifacts.is_empty());
  assert_eq!(r.total_bytes, 0);
}

#[cfg(unix)]
#[test]
fn scan_does_not_follow_symlinks_inside_artifacts() {
  use std::os::unix::fs::symlink;
  let dir = TempDir::new().unwrap();
  let wt = dir.path();
  // Heavy content that a followed symlink would wrongly pull into the total.
  let outside = TempDir::new().unwrap();
  make_artifact(outside.path(), "heavy", 100_000);

  make_artifact(wt, "target", 100);
  symlink(outside.path(), wt.join("target").join("link_out")).unwrap();

  let r = scan_worktree("wt", wt, &default_patterns());

  assert_eq!(
    r.total_bytes, 100,
    "a symlink inside target/ must not be followed or counted (only the real 100-byte file)"
  );
}

#[cfg(unix)]
#[test]
fn scan_skips_a_symlinked_artifact_root() {
  use std::os::unix::fs::symlink;
  let dir = TempDir::new().unwrap();
  let wt = dir.path();
  // An external dir the symlinked root would wrongly pull in if followed.
  let outside = TempDir::new().unwrap();
  make_artifact(outside.path(), "heavy", 100_000);
  // `target` itself is a symlink to the external dir.
  symlink(outside.path(), wt.join("target")).unwrap();

  let r = scan_worktree("wt", wt, &default_patterns());

  assert!(
    r.artifacts.is_empty(),
    "a symlinked artifact root must not be scanned: {:?}",
    r.artifacts
  );
  assert_eq!(r.total_bytes, 0);
}

#[test]
fn scan_counts_nested_files_recursively() {
  let dir = TempDir::new().unwrap();
  let wt = dir.path();
  make_artifact(wt, "target", 100);
  make_artifact(wt, "target/debug/deps", 250);

  let r = scan_worktree("nested", wt, &default_patterns());

  assert_eq!(r.total_bytes, 350, "size should sum files at every depth under target/");
}

#[test]
fn delete_reclaim_removes_artifact_dirs_only() {
  let dir = TempDir::new().unwrap();
  let wt = dir.path();
  make_artifact(wt, "target", 2048);
  make_artifact(wt, "src", 4096);

  let r = scan_worktree("feat-1", wt, &default_patterns());
  let freed = delete_reclaim(&r).unwrap();

  assert_eq!(freed, 2048);
  assert!(!wt.join("target").exists(), "target/ should be deleted");
  assert!(wt.join("src").exists(), "src/ must be preserved");
}

// --- ENOTEMPTY race tolerance (issue #440) ----------------------------------

#[test]
fn delete_reclaim_tolerates_an_artifact_already_gone() {
  // Another process (or a parallel `gwm clean`) reclaimed target/ between the
  // scan and the delete — a NotFound is a success, not a wholesale failure.
  let dir = TempDir::new().unwrap();
  let wt = dir.path();
  make_artifact(wt, "target", 2048);
  let r = scan_worktree("feat-1", wt, &default_patterns());
  fs::remove_dir_all(wt.join("target")).unwrap();

  let freed = delete_reclaim(&r).expect("an already-missing artifact must not fail the command");
  assert_eq!(freed, 2048, "freed still reports the scanned size");
}

#[test]
fn retry_recovers_when_a_concurrent_writer_races_the_removal() {
  // Simulates rust-analyzer recreating a file inside target/ mid-removal:
  // the first passes hit ENOTEMPTY, the next pass deletes what was rewritten.
  let dir = TempDir::new().unwrap();
  let target = dir.path().join("target");
  make_artifact(dir.path(), "target", 64);
  let mut calls = 0u32;

  let res = remove_dir_all_tolerant(&target, |p| {
    calls += 1;
    if calls < 3 {
      Err(std::io::Error::new(std::io::ErrorKind::DirectoryNotEmpty, "ENOTEMPTY"))
    } else {
      fs::remove_dir_all(p)
    }
  });

  assert!(res.is_ok(), "a transient ENOTEMPTY must be retried: {res:?}");
  assert_eq!(calls, 3, "two failed passes then the successful one");
  assert!(!target.exists());
}

#[test]
fn retry_gives_up_after_bounded_attempts() {
  // A writer that never stops must not spin the command forever.
  let dir = TempDir::new().unwrap();
  let target = dir.path().join("target");
  let mut calls = 0u32;

  let res = remove_dir_all_tolerant(&target, |_| {
    calls += 1;
    Err(std::io::Error::new(std::io::ErrorKind::DirectoryNotEmpty, "ENOTEMPTY"))
  });

  let err = res.expect_err("a persistent ENOTEMPTY must surface after the retries");
  assert_eq!(err.kind(), std::io::ErrorKind::DirectoryNotEmpty);
  assert_eq!(calls, 3, "the retry budget is bounded");
}

#[test]
fn retry_does_not_mask_other_errors() {
  // Only the ENOTEMPTY race is transient; anything else propagates first hit.
  let dir = TempDir::new().unwrap();
  let target = dir.path().join("target");
  let mut calls = 0u32;

  let res = remove_dir_all_tolerant(&target, |_| {
    calls += 1;
    Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "EACCES"))
  });

  let err = res.expect_err("a non-ENOTEMPTY error must propagate");
  assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
  assert_eq!(calls, 1, "no retry on non-transient errors");
}

#[test]
fn an_already_missing_dir_counts_as_reclaimed_without_retry() {
  let dir = TempDir::new().unwrap();
  let target = dir.path().join("target");
  let mut calls = 0u32;

  let res = remove_dir_all_tolerant(&target, |_| {
    calls += 1;
    Err(std::io::Error::new(std::io::ErrorKind::NotFound, "ENOENT"))
  });

  assert!(res.is_ok(), "NotFound means someone else reclaimed it: {res:?}");
  assert_eq!(calls, 1);
}

#[test]
fn format_report_lists_each_worktree_and_a_grand_total() {
  let reclaims = vec![
    WorktreeReclaim {
      name: "feat-1".into(),
      path: "/x/feat-1".into(),
      artifacts: vec![gwm::clean::Artifact {
        rel: "target".into(),
        bytes: 1024 * 1024,
      }],
      total_bytes: 1024 * 1024,
    },
    WorktreeReclaim {
      name: "fix-2".into(),
      path: "/x/fix-2".into(),
      artifacts: vec![],
      total_bytes: 0,
    },
  ];

  let report = format_report(&reclaims);

  assert!(report.contains("feat-1"), "report should name worktrees with artifacts");
  assert!(report.contains("target"), "report should name the artifact dir");
  assert!(report.contains("1.0 MiB"), "report should render human sizes");
  // Grand total of reclaimable space across all worktrees.
  assert!(report.contains("1.0 MiB"), "report should carry a grand total");
}

// --- profile resolution (issue #324) ----------------------------------------

#[test]
fn resolve_dirs_falls_back_to_builtins_without_profile_or_default() {
  // No `--profile` and no `[clean.profiles.default]` ⇒ the built-in set.
  let cfg = clean_cfg(&[]);
  let dirs = resolve_clean_dirs(None, &cfg).expect("builtin fallback");
  assert_eq!(dirs, default_patterns());
}

#[test]
fn resolve_dirs_uses_the_default_profile_when_present() {
  // `gwm clean` without `--profile` prefers `[clean.profiles.default]`.
  let cfg = clean_cfg(&[("default", &["target", "node_modules", "coverage", ".turbo"])]);
  let dirs = resolve_clean_dirs(None, &cfg).expect("default profile");
  assert_eq!(dirs, vec!["target", "node_modules", "coverage", ".turbo"]);
  assert_ne!(dirs, default_patterns(), "the default profile overrides the built-ins");
}

#[test]
fn resolve_dirs_uses_a_named_profile() {
  let cfg = clean_cfg(&[("deep", &["target", ".cache", ".venv"])]);
  let dirs = resolve_clean_dirs(Some("deep"), &cfg).expect("named profile");
  assert_eq!(dirs, vec!["target", ".cache", ".venv"]);
}

#[test]
fn resolve_dirs_named_profile_is_a_complete_set_not_additive() {
  // A profile's `dirs` REPLACES the built-ins — `build`/`dist` are absent
  // from the resolved set unless the profile lists them.
  let cfg = clean_cfg(&[("rust", &["target"])]);
  let dirs = resolve_clean_dirs(Some("rust"), &cfg).expect("named profile");
  assert_eq!(dirs, vec!["target"]);
  assert!(!dirs.contains(&"node_modules".to_string()), "built-ins are not added");
  assert!(!dirs.contains(&"build".to_string()), "built-ins are not added");
}

#[test]
fn resolve_dirs_rejects_an_unknown_profile() {
  let cfg = clean_cfg(&[("deep", &["target"])]);
  let err = resolve_clean_dirs(Some("nope"), &cfg).expect_err("unknown profile must error");
  assert!(
    err.to_string().contains("nope") && err.to_string().contains("profile"),
    "error should name the missing profile: {err}"
  );
}

#[test]
fn resolve_dirs_rejects_an_absolute_profile_dir() {
  // `worktree.join("/")` resolves to the FS root — a dry-run scan would walk
  // the whole filesystem. Reject absolute entries at resolution (exit 1).
  let cfg = clean_cfg(&[("evil", &["/"])]);
  let err = resolve_clean_dirs(Some("evil"), &cfg).expect_err("absolute dir must error");
  assert!(
    err.to_string().contains("absolute"),
    "error should flag the absolute path: {err}"
  );
}

#[test]
fn resolve_dirs_rejects_a_parent_traversal_profile_dir() {
  let cfg = clean_cfg(&[("evil", &["../sibling"])]);
  let err = resolve_clean_dirs(Some("evil"), &cfg).expect_err("`..` dir must error");
  assert!(err.to_string().contains(".."), "error should flag the traversal: {err}");
}

#[test]
fn resolve_dirs_rejects_an_empty_profile_dir() {
  let cfg = clean_cfg(&[("evil", &[""])]);
  let err = resolve_clean_dirs(Some("evil"), &cfg).expect_err("empty dir must error");
  assert!(
    err.to_string().contains("empty"),
    "error should flag the empty entry: {err}"
  );
}

#[test]
fn resolve_dirs_validates_the_default_profile_too() {
  // The escape guard also covers the implicit `default` profile (no --profile).
  let cfg = clean_cfg(&[("default", &[".."])]);
  let err = resolve_clean_dirs(None, &cfg).expect_err("default profile is validated");
  assert!(err.to_string().contains(".."), "error should flag the traversal: {err}");
}

#[test]
fn resolve_dirs_rejects_git_pathspec_metacharacters() {
  // A name with glob magic (`* ? [ ]`) or a leading `:` would be misread as a
  // pathspec by the git safety checks — reject it up-front. A leading `-` is
  // fine (the `--` delimiter handles it).
  for evil in ["foo[bar]", "a*", "b?", ":magic"] {
    let cfg = clean_cfg(&[("evil", &[evil])]);
    let err = resolve_clean_dirs(Some("evil"), &cfg).unwrap_err();
    assert!(
      err.to_string().contains("pathspec metacharacters"),
      "`{evil}` should be rejected: {err}"
    );
  }
  // A leading-dash name is allowed (not pathspec magic).
  let ok = clean_cfg(&[("dash", &["-cache"])]);
  assert_eq!(resolve_clean_dirs(Some("dash"), &ok).unwrap(), vec!["-cache"]);
}

#[test]
fn resolve_dirs_rejects_a_nested_path() {
  // A nested path is refused for 1.0: an intermediate component could be a
  // symlink that scan/delete would follow out of the worktree. Dirs are
  // restricted to single directory names.
  for nested in ["packages/app/node_modules", "target/debug"] {
    let cfg = clean_cfg(&[("nested", &[nested])]);
    let err = resolve_clean_dirs(Some("nested"), &cfg).unwrap_err();
    assert!(
      err.to_string().contains("single directory name"),
      "`{nested}` should be rejected as nested: {err}"
    );
  }
}

#[test]
fn resolve_dirs_rejects_a_dot_profile_dir() {
  // `"."` / `"./."` resolve to the worktree root (no Normal component) —
  // scanning the root is as dangerous as an empty entry.
  for evil in [".", "./."] {
    let cfg = clean_cfg(&[("evil", &[evil])]);
    let err = resolve_clean_dirs(Some("evil"), &cfg).unwrap_err();
    assert!(
      err.to_string().contains("worktree root"),
      "`{evil}` should be rejected as the worktree root: {err}"
    );
  }
}

#[test]
fn resolve_dirs_dedups_exact_duplicate_entries() {
  let cfg = clean_cfg(&[("dup", &["target", "node_modules", "target"])]);
  let dirs = resolve_clean_dirs(Some("dup"), &cfg).expect("duplicates collapse");
  assert_eq!(dirs, vec!["target", "node_modules"], "an exact duplicate is dropped");
}

#[test]
fn resolve_dirs_normalizes_syntactic_aliases_before_dedup() {
  // `target`, `target/`, and `./target` are the same directory — normalize to
  // the bare component name so dedup folds them to one (no double scan/delete).
  let cfg = clean_cfg(&[("alias", &["target", "target/", "./target", "node_modules"])]);
  let dirs = resolve_clean_dirs(Some("alias"), &cfg).expect("aliases normalize and dedup");
  assert_eq!(dirs, vec!["target", "node_modules"]);
}

#[test]
fn resolve_dirs_keeps_distinct_single_names_in_declared_order() {
  // With dirs pinned to single names, distinct entries pass through in order
  // (only exact duplicates are dropped — covered above).
  let cfg = clean_cfg(&[("multi", &["target", "node_modules", "dist"])]);
  let dirs = resolve_clean_dirs(Some("multi"), &cfg).expect("distinct names");
  assert_eq!(dirs, vec!["target", "node_modules", "dist"]);
}