use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use serde::Serialize;
use crate::output::CommandReport;
use crate::paths::{git as git_paths, state as state_paths};
use crate::repo::marker as repo_marker;
use crate::repo::registry as repo_registry;
#[derive(Serialize)]
pub struct GcReport {
command: &'static str,
ok: bool,
path: String,
workspace_state_root: String,
clone_state_root: String,
marker_present: bool,
removed_workspace_states: Vec<String>,
removed_clone_states: Vec<String>,
stale_repo_overlays: Vec<StaleRepoOverlay>,
}
#[derive(Serialize)]
pub struct StaleRepoOverlay {
profile: String,
project_id: String,
locality_id: String,
path: String,
registry_path: String,
}
impl CommandReport for GcReport {
fn exit_code(&self) -> ExitCode {
ExitCode::SUCCESS
}
fn render_text(&self) {
if self.removed_workspace_states.is_empty() {
println!("No workspace-local garbage removed.");
} else {
println!(
"Removed {} workspace-local state path(s).",
self.removed_workspace_states.len()
);
for path in &self.removed_workspace_states {
println!("- {path}");
}
}
if self.stale_repo_overlays.is_empty() {
println!("No stale repo overlays detected.");
return;
}
println!();
println!("Stale repo overlays (reported only, not deleted):");
for overlay in &self.stale_repo_overlays {
println!(
"- profile={} project_id={} overlay={} missing={}",
overlay.profile, overlay.project_id, overlay.path, overlay.registry_path
);
}
}
}
pub fn run(repo_root: &Path) -> Result<GcReport> {
let git_ccd_root = git_paths::ccd_dir(repo_root)?;
let ccd_root = state_paths::default_ccd_root()?;
let marker_present = repo_marker::load(repo_root)?.is_some();
let clone_state_root = git_ccd_root.join("profiles");
let removed_clone_states = prune_clone_state(&git_ccd_root, &ccd_root, marker_present)?;
let stale_repo_overlays = find_stale_repo_overlays(&ccd_root)?;
Ok(GcReport {
command: "gc",
ok: true,
path: repo_root.display().to_string(),
workspace_state_root: clone_state_root.display().to_string(),
clone_state_root: clone_state_root.display().to_string(),
marker_present,
removed_workspace_states: removed_clone_states.clone(),
removed_clone_states,
stale_repo_overlays,
})
}
fn prune_clone_state(
git_ccd_root: &Path,
ccd_root: &Path,
marker_present: bool,
) -> Result<Vec<String>> {
let clone_profiles_root = git_ccd_root.join("profiles");
let mut removed = Vec::new();
for profile_dir in sorted_child_dirs(&clone_profiles_root)? {
let profile_name = profile_dir
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
let durable_profile_root = ccd_root.join("profiles").join(&profile_name);
let should_remove = !marker_present || !durable_profile_root.is_dir();
if !should_remove {
continue;
}
fs::remove_dir_all(&profile_dir)
.with_context(|| format!("failed to remove {}", profile_dir.display()))?;
removed.push(profile_dir.display().to_string());
}
remove_if_empty(&clone_profiles_root)?;
remove_if_empty(git_ccd_root)?;
Ok(removed)
}
fn find_stale_repo_overlays(ccd_root: &Path) -> Result<Vec<StaleRepoOverlay>> {
let profiles_root = ccd_root.join("profiles");
let mut stale = Vec::new();
for profile_root in sorted_child_dirs(&profiles_root)? {
let profile = profile_root
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
let repo_overlays_root = profile_root.join("repos");
for overlay_root in sorted_child_dirs(&repo_overlays_root)? {
let locality_id = overlay_root
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
let registry_path = ccd_root
.join("repos")
.join(&locality_id)
.join(repo_registry::REPO_METADATA_FILE);
if registry_path.is_file() {
continue;
}
stale.push(StaleRepoOverlay {
profile: profile.clone(),
project_id: locality_id.clone(),
locality_id,
path: overlay_root.display().to_string(),
registry_path: registry_path.display().to_string(),
});
}
}
Ok(stale)
}
fn sorted_child_dirs(root: &Path) -> Result<Vec<PathBuf>> {
let Ok(read_dir) = fs::read_dir(root) else {
return Ok(Vec::new());
};
let mut entries = read_dir
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let Ok(file_type) = entry.file_type() else {
return None;
};
if file_type.is_dir() {
Some(entry.path())
} else {
None
}
})
.collect::<Vec<_>>();
entries.sort();
Ok(entries)
}
fn remove_if_empty(path: &Path) -> Result<()> {
if !path.is_dir() {
return Ok(());
}
let mut entries = fs::read_dir(path)
.with_context(|| format!("failed to read directory {}", path.display()))?;
if entries.next().is_some() {
return Ok(());
}
fs::remove_dir(path).with_context(|| format!("failed to remove {}", path.display()))?;
Ok(())
}