use crate::config::CleanConfig;
use crate::error::{GwmError, Result};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
pub rel: String,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeReclaim {
pub name: String,
pub path: PathBuf,
pub artifacts: Vec<Artifact>,
pub total_bytes: u64,
}
pub fn default_patterns() -> Vec<String> {
["target", "node_modules", "dist", "build"]
.iter()
.map(|s| s.to_string())
.collect()
}
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()) {
(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"
)));
}
(None, _) => {
return Err(GwmError::Config(format!(
"clean: profile `{profile}` dir `{d}` resolves to the worktree root — name a real subdirectory"
)));
}
_ => {
return Err(GwmError::Config(format!(
"clean: profile `{profile}` dir `{d}` must be a single directory name (no `/`); nested paths are not supported"
)));
}
};
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)
}
pub fn validate_clean_profile_dirs(profile: &str, dirs: &[String]) -> Result<()> {
normalized_profile_dirs(profile, dirs).map(|_| ())
}
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
}
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()),
},
}
}
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
}
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);
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,
}
}
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)
}
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),
}
}
}
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)
}
}
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
}
pub fn dir_is_safe_to_clean(worktree: &Path, rel: &str) -> bool {
dir_is_git_ignored(worktree, rel) && !dir_has_tracked_files(worktree, rel)
}
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)
}
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)
}
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,
)
}