use anyhow::Result;
use clap::Parser;
use std::path::Path;
use tracing::info;
#[derive(Parser, Debug)]
pub struct Args {
#[arg(long)]
all: bool,
#[arg(long, value_name = "DAYS")]
older_than: Option<u64>,
#[arg(long)]
artifacts: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
teams: bool,
}
pub(crate) async fn cleanup_team_states(
teams_dir: &Path,
older_than: Option<u64>,
dry_run: bool,
) -> Result<(usize, u64)> {
let mut removed = 0;
let mut freed: u64 = 0;
if !teams_dir.exists() {
return Ok((removed, freed));
}
let mut entries = tokio::fs::read_dir(teams_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if should_remove(&path, older_than).await? {
let size = dir_size(&path).await?;
if dry_run {
println!(
"Would remove: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
} else {
tokio::fs::remove_dir_all(&path).await?;
info!(path = %path.display(), "Removed team state");
println!(
"✓ Removed: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
}
removed += 1;
freed += size;
}
}
Ok((removed, freed))
}
pub(crate) async fn run(args: Args) -> Result<()> {
let state_dir = crate::runtime::config::state_dir();
if args.teams {
let teams_dir = state_dir.join("team");
let (removed, freed) =
cleanup_team_states(&teams_dir, args.older_than, args.dry_run).await?;
println!();
if args.dry_run {
println!(
"Would remove {removed} team state directories ({:.1} MB)",
freed as f64 / 1_048_576.0
);
} else {
println!(
"Removed {removed} team state directories ({:.1} MB freed)",
freed as f64 / 1_048_576.0
);
}
return Ok(());
}
if args.artifacts {
let mut removed = 0;
let mut freed: u64 = 0;
let artifacts_dir = crate::runtime::config::data_dir().join("artifacts");
if artifacts_dir.exists() {
let mut entries = tokio::fs::read_dir(&artifacts_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if should_remove(&path, args.older_than).await? {
let size = dir_size(&path).await?;
if args.dry_run {
println!(
"Would remove: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
} else {
tokio::fs::remove_dir_all(&path).await?;
info!(path = %path.display(), "Removed artifacts");
println!(
"✓ Removed: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
}
removed += 1;
freed += size;
}
}
}
let logs_dir = crate::runtime::config::state_dir().join("logs");
if logs_dir.exists() {
let mut entries = tokio::fs::read_dir(&logs_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if should_remove(&path, args.older_than).await? {
let size = entry.metadata().await?.len();
if args.dry_run {
println!(
"Would remove: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
} else {
tokio::fs::remove_file(&path).await?;
info!(path = %path.display(), "Removed log file");
println!(
"✓ Removed: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
}
removed += 1;
freed += size;
}
}
}
println!();
if args.dry_run {
println!(
"Would remove {removed} artifact directories/log files ({:.1} MB)",
freed as f64 / 1_048_576.0
);
} else {
println!(
"Removed {removed} artifact directories/log files ({:.1} MB freed)",
freed as f64 / 1_048_576.0
);
}
return Ok(());
}
if args.all {
println!("This will remove ALL omk state.");
if !args.dry_run {
println!("Are you sure? Type 'yes' to confirm: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim() != "yes" {
println!("Aborted.");
return Ok(());
}
tokio::fs::remove_dir_all(&state_dir).await?;
tokio::fs::create_dir_all(&state_dir).await?;
println!("✓ All state removed");
return Ok(());
} else {
println!("Would remove: {}", state_dir.display());
return Ok(());
}
}
let mut removed = 0;
let mut freed: u64 = 0;
let teams_dir = state_dir.join("team");
if teams_dir.exists() {
let (r, f) = cleanup_team_states(&teams_dir, args.older_than, args.dry_run).await?;
removed += r;
freed += f;
}
let autopilot_dir = state_dir.join("autopilot");
if autopilot_dir.exists() {
let mut entries = tokio::fs::read_dir(&autopilot_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if should_remove(&path, args.older_than).await? {
let size = dir_size(&path).await?;
if args.dry_run {
println!(
"Would remove: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
} else {
tokio::fs::remove_dir_all(&path).await?;
info!(path = %path.display(), "Removed autopilot state");
println!(
"✓ Removed: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
}
removed += 1;
freed += size;
}
}
}
let ralph_dir = state_dir.join("ralph");
if ralph_dir.exists() {
let mut entries = tokio::fs::read_dir(&ralph_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if should_remove(&path, args.older_than).await? {
let size = dir_size(&path).await?;
if args.dry_run {
println!(
"Would remove: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
} else {
tokio::fs::remove_dir_all(&path).await?;
info!(path = %path.display(), "Removed ralph state");
println!(
"✓ Removed: {} ({:.1} MB)",
path.display(),
size as f64 / 1_048_576.0
);
}
removed += 1;
freed += size;
}
}
}
println!();
if args.dry_run {
println!(
"Would remove {removed} state directories ({:.1} MB)",
freed as f64 / 1_048_576.0
);
} else {
println!(
"Removed {removed} state directories ({:.1} MB freed)",
freed as f64 / 1_048_576.0
);
}
Ok(())
}
async fn should_remove(path: &Path, older_than: Option<u64>) -> Result<bool> {
if let Some(days) = older_than {
let metadata = tokio::fs::metadata(path).await?;
let modified = metadata.modified()?;
let age = modified.elapsed()?.as_secs() / 86400;
return Ok(age >= days);
}
Ok(true)
}
async fn dir_size(path: &Path) -> Result<u64> {
let mut total = 0u64;
let mut stack = vec![path.to_path_buf()];
while let Some(current) = stack.pop() {
let mut entries = tokio::fs::read_dir(¤t).await?;
while let Some(entry) = entries.next_entry().await? {
let metadata = entry.metadata().await?;
if metadata.is_dir() {
stack.push(entry.path());
} else {
total += metadata.len();
}
}
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_cleanup_team_states_dry_run() {
let dir = tempfile::tempdir().unwrap();
let teams_dir = dir.path().join("team");
tokio::fs::create_dir_all(&teams_dir).await.unwrap();
for name in ["team-a", "team-b"] {
let team_dir = teams_dir.join(name);
tokio::fs::create_dir_all(&team_dir).await.unwrap();
tokio::fs::write(team_dir.join("team-state.json"), r#"{"name":"test"}"#)
.await
.unwrap();
}
let (removed, freed) = cleanup_team_states(&teams_dir, None, true).await.unwrap();
assert_eq!(removed, 2);
assert!(freed > 0);
assert!(teams_dir.join("team-a").exists());
assert!(teams_dir.join("team-b").exists());
}
#[tokio::test]
async fn test_cleanup_team_states_removes_all() {
let dir = tempfile::tempdir().unwrap();
let teams_dir = dir.path().join("team");
tokio::fs::create_dir_all(&teams_dir).await.unwrap();
for name in ["team-a", "team-b"] {
let team_dir = teams_dir.join(name);
tokio::fs::create_dir_all(&team_dir).await.unwrap();
tokio::fs::write(team_dir.join("team-state.json"), r#"{"name":"test"}"#)
.await
.unwrap();
}
let (removed, freed) = cleanup_team_states(&teams_dir, None, false).await.unwrap();
assert_eq!(removed, 2);
assert!(freed > 0);
assert!(!teams_dir.join("team-a").exists());
assert!(!teams_dir.join("team-b").exists());
}
#[tokio::test]
async fn test_cleanup_team_states_age_filter_zero() {
let dir = tempfile::tempdir().unwrap();
let teams_dir = dir.path().join("team");
tokio::fs::create_dir_all(&teams_dir).await.unwrap();
for name in ["team-a", "team-b"] {
let team_dir = teams_dir.join(name);
tokio::fs::create_dir_all(&team_dir).await.unwrap();
tokio::fs::write(team_dir.join("team-state.json"), r#"{"name":"test"}"#)
.await
.unwrap();
}
let (removed, _freed) = cleanup_team_states(&teams_dir, Some(0), false)
.await
.unwrap();
assert_eq!(removed, 2);
}
#[tokio::test]
async fn test_cleanup_team_states_age_filter_future() {
let dir = tempfile::tempdir().unwrap();
let teams_dir = dir.path().join("team");
tokio::fs::create_dir_all(&teams_dir).await.unwrap();
for name in ["team-a", "team-b"] {
let team_dir = teams_dir.join(name);
tokio::fs::create_dir_all(&team_dir).await.unwrap();
tokio::fs::write(team_dir.join("team-state.json"), r#"{"name":"test"}"#)
.await
.unwrap();
}
let (removed, _freed) = cleanup_team_states(&teams_dir, Some(100000), false)
.await
.unwrap();
assert_eq!(removed, 0);
assert!(teams_dir.join("team-a").exists());
assert!(teams_dir.join("team-b").exists());
}
}