use crate::core::config::{Language, ResolvedCrateConfig};
use anyhow::Context as _;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
const UNFILTERED_TS_ROOTS: [&str; 2] = ["packages/wasm", "packages/typescript"];
pub fn generate_sweep_roots(
languages: &[Language],
filtered: bool,
config: &ResolvedCrateConfig,
base_dir: &Path,
) -> Vec<PathBuf> {
let mut roots: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
for &lang in languages {
roots.insert(base_dir.join(config.package_dir(lang)));
if let Some(out) = config.output_for(&lang.to_string()) {
roots.insert(base_dir.join(out));
}
}
if !filtered {
for root in UNFILTERED_TS_ROOTS {
roots.insert(base_dir.join(root));
}
}
roots.into_iter().collect()
}
pub fn targeted_e2e_sweep_roots(
output_paths: &[PathBuf],
e2e_output_root: &Path,
snippet_output_root: Option<&Path>,
) -> Vec<PathBuf> {
let mut roots = std::collections::BTreeSet::new();
for path in output_paths {
if snippet_output_root.is_some_and(|snippet_root| path.starts_with(snippet_root)) {
continue;
}
if let Ok(relative) = path.strip_prefix(e2e_output_root) {
let mut components = relative.components();
let Some(language) = components.next() else {
continue;
};
let Some(owned_subtree) = components.next() else {
continue;
};
if components.next().is_none() {
continue;
}
roots.insert(
e2e_output_root
.join(language.as_os_str())
.join(owned_subtree.as_os_str()),
);
}
}
roots.into_iter().collect()
}
pub fn sweep_orphans(
roots: &[std::path::PathBuf],
keep: &std::collections::HashSet<std::path::PathBuf>,
) -> anyhow::Result<usize> {
let mut removed = 0usize;
let mut touched_dirs: std::collections::BTreeSet<std::path::PathBuf> = std::collections::BTreeSet::new();
for root in roots {
if !root.exists() {
continue;
}
let mut stack = vec![root.clone()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if file_type.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if matches!(
name,
".git"
| "target"
| "node_modules"
| "vendor"
| "_build"
| "deps"
| ".venv"
| "venv"
| "build"
| "dist"
| "Pods"
) {
continue;
}
stack.push(path);
continue;
}
if !file_type.is_file() {
continue;
}
if keep.contains(&path) {
continue;
}
if !path_is_alef_owned(&path) {
continue;
}
if let Err(err) = std::fs::remove_file(&path) {
debug!(" sweep skip (remove failed): {} ({err})", path.display());
continue;
}
debug!(" swept orphan: {}", path.display());
if let Some(parent) = path.parent() {
touched_dirs.insert(parent.to_path_buf());
}
removed += 1;
}
}
}
let mut dirs: Vec<_> = touched_dirs.into_iter().collect();
dirs.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
for dir in dirs {
let _ = std::fs::remove_dir(&dir);
}
if removed > 0 {
info!("Swept {removed} orphan generated file(s)");
}
Ok(removed)
}
pub fn sweep_manifest_orphans(
previous_paths: &[PathBuf],
keep: &std::collections::HashSet<PathBuf>,
allowed_roots: &[PathBuf],
disk_scan_roots: &[PathBuf],
) -> anyhow::Result<usize> {
let mut removed = 0;
for path in previous_paths {
if keep.contains(path) || !allowed_roots.iter().any(|root| path.starts_with(root)) || !path.is_file() {
continue;
}
if !path_is_reclaimable(path) {
continue;
}
std::fs::remove_file(path).with_context(|| format!("failed to remove orphan {}", path.display()))?;
debug!(" swept manifest orphan: {}", path.display());
removed += 1;
removed += reclaim_lockfile_siblings(path, keep)?;
}
for root in disk_scan_roots {
if !root.exists() {
continue;
}
let manifest_entries_under_root = previous_paths.iter().filter(|path| path.starts_with(root)).count();
let keep_entries_under_root = keep.iter().filter(|path| path.starts_with(root)).count();
if manifest_entries_under_root == 0 || keep_entries_under_root == 0 {
tracing::warn!(
"disk-scan orphan reclaim skipped for {}: {manifest_entries_under_root} manifest entry(s) and \
{keep_entries_under_root} keep entry(s) recorded under this root -- a backend whose own \
bookkeeping has never vouched for anything under its output root cannot be trusted to tell a \
real orphan from output it simply never recorded; a stale file here is left in place until that \
backend's own path tracking is fixed",
root.display()
);
continue;
}
let Some(tracked) = git_tracked_paths_under(root) else {
tracing::warn!(
"disk-scan orphan reclaim skipped for {}: could not determine which files under this root are \
git-tracked (not inside a git work tree, or `git` is unavailable) -- an alef marker alone cannot \
distinguish real generated output from a build tool's staged copy of it, so tracked-ness is \
required before disk-scan deletion is safe",
root.display()
);
continue;
};
let candidates: Vec<_> = collect_alef_headered_paths(root)
.into_iter()
.filter(|path| !keep.contains(path) && tracked.contains(path) && path_is_alef_owned(path))
.collect();
if !candidates.is_empty() {
report_disk_scan_candidates(root, &candidates);
}
}
for root in allowed_roots {
if disk_scan_roots.contains(root) {
continue;
}
let manifest_entries_under_root = previous_paths.iter().filter(|path| path.starts_with(root)).count();
if manifest_entries_under_root > 0 {
continue;
}
let keep_entries_under_root = keep.iter().filter(|path| path.starts_with(root)).count();
if keep_entries_under_root == 0 {
continue;
}
tracing::warn!(
"orphan-reclaim bookkeeping gap for {}: this run recorded {keep_entries_under_root} file(s) as \
kept output under this root, but the previous-run manifest recorded none. An output root with \
kept files and no manifest entries is never legitimate -- it means orphan reclaim can never run \
here (nothing to compare `keep` against), so a file a backend stops emitting under this root \
would never be removed. This root's manifest bookkeeping needs attention regardless of the \
cause",
root.display()
);
}
if removed > 0 {
info!("Swept {removed} manifest orphan(s)");
}
Ok(removed)
}
fn path_is_reclaimable(path: &Path) -> bool {
let has_marker = std::fs::read_to_string(path).is_ok_and(|content| content_is_alef_owned(&content));
has_marker || is_unmarkable_alef_manifest(path)
}
const UNMARKABLE_ALEF_MANIFESTS: &[&str] = &["composer.json", "package.json"];
pub(super) fn is_unmarkable_alef_manifest(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| UNMARKABLE_ALEF_MANIFESTS.contains(&name))
}
const MANIFEST_LOCKFILE_SIBLINGS: &[(&str, &[&str])] = &[
("composer.json", &["composer.lock"]),
("package.json", &["package-lock.json", "pnpm-lock.yaml", "yarn.lock"]),
];
fn reclaim_lockfile_siblings(manifest_path: &Path, keep: &std::collections::HashSet<PathBuf>) -> anyhow::Result<usize> {
let Some(manifest_name) = manifest_path.file_name().and_then(|name| name.to_str()) else {
return Ok(0);
};
let Some(dir) = manifest_path.parent() else {
return Ok(0);
};
let mut removed = 0;
for entry in MANIFEST_LOCKFILE_SIBLINGS {
if entry.0 != manifest_name {
continue;
}
for &lockfile_name in entry.1 {
let lockfile_path = dir.join(lockfile_name);
if keep.contains(&lockfile_path) || !lockfile_path.is_file() {
continue;
}
std::fs::remove_file(&lockfile_path)
.with_context(|| format!("failed to remove orphan lockfile {}", lockfile_path.display()))?;
removed += 1;
}
}
Ok(removed)
}
pub fn collect_alef_headered_paths(root: &std::path::Path) -> std::collections::HashSet<std::path::PathBuf> {
fn is_alef_owned(path: &std::path::Path) -> bool {
let Ok(content) = std::fs::read_to_string(path) else {
return false;
};
crate::core::hash::content_has_alef_marker(&content)
}
let mut paths = std::collections::HashSet::new();
if !root.exists() {
return paths;
}
let visible = crate::cli::git::IgnoreFilter::for_root(root);
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(ft) = entry.file_type() else { continue };
if ft.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if matches!(
name,
".git"
| "target"
| "node_modules"
| "vendor"
| "_build"
| "deps"
| ".venv"
| "venv"
| "build"
| "dist"
| "Pods"
) || !visible.allows(&path)
{
continue;
}
stack.push(path);
} else if ft.is_file() && visible.allows(&path) && is_alef_owned(&path) {
paths.insert(path);
}
}
}
paths
}
fn path_is_alef_owned(path: &Path) -> bool {
std::fs::read_to_string(path).is_ok_and(|content| content_is_alef_owned(&content))
}
fn report_disk_scan_candidates(root: &Path, candidates: &[PathBuf]) {
tracing::warn!(
"{} alef-marked, git-tracked file(s) under {} are not in this run's recorded output. These \
are NOT deleted: a file missing from that record is not proven an orphan (the emitter may \
have stopped emitting it, failed to emit it, emit it only when absent, or simply not \
recorded it). Review each and remove by hand if genuinely stale:",
candidates.len(),
root.display()
);
for path in candidates {
tracing::warn!(" unrecorded alef-marked file: {}", path.display());
}
}
fn git_tracked_paths_under(root: &Path) -> Option<std::collections::HashSet<PathBuf>> {
crate::cli::git::tracked_paths_under(root)
}
fn content_is_alef_owned(content: &str) -> bool {
crate::core::hash::content_has_alef_marker(content) && crate::core::hash::extract_hash(content).is_some()
}
#[cfg(test)]
#[path = "orphans/tests.rs"]
mod sweep_roots_tests;