use crate::workspace::Workspace;
use ostraka_runtime::{index, worktree};
type Outcome = Result<bool, Box<dyn std::error::Error>>;
#[derive(Debug, Clone)]
pub struct Leftover {
pub repo: std::path::PathBuf,
pub path: std::path::PathBuf,
}
pub struct Survey {
pub worktrees: Vec<Leftover>,
pub removable: Vec<Leftover>,
pub unfinished: Vec<String>,
}
pub fn leftovers(workspace: &Workspace) -> Result<Vec<Leftover>, Box<dyn std::error::Error>> {
Ok(survey(workspace)?.removable)
}
pub fn survey(workspace: &Workspace) -> Result<Survey, Box<dyn std::error::Error>> {
let config = workspace.config()?;
let base = workspace.worktrees(&config);
let mut worktrees: Vec<Leftover> = Vec::new();
for repo in workspace.repositories() {
for path in worktree::list(&repo.path, &base)? {
worktrees.push(Leftover {
repo: repo.path.clone(),
path,
});
}
}
let runs = index::list(&workspace.records())?;
let unfinished: Vec<&str> = runs
.iter()
.filter(|r| r.outcome.is_none())
.map(|r| r.run_id.as_str())
.collect();
let unfinished: Vec<String> = unfinished.into_iter().map(str::to_string).collect();
let removable: Vec<Leftover> = worktrees
.iter()
.filter(|l| {
let name = l.path.file_name().unwrap_or_default().to_string_lossy();
!unfinished.iter().any(|id| *id == name)
})
.cloned()
.collect();
Ok(Survey {
worktrees,
removable,
unfinished,
})
}
pub fn remove(leftovers: &[Leftover]) -> (usize, Vec<String>) {
let mut removed = 0usize;
let mut failed = Vec::new();
for l in leftovers {
match worktree::release_path(&l.repo, &l.path) {
Ok(()) => removed += 1,
Err(e) => failed.push(format!("could not remove {}: {e}", l.path.display())),
}
}
(removed, failed)
}
pub fn run(workspace: &Workspace, apply: bool, json: bool) -> Outcome {
let Survey {
worktrees,
removable,
unfinished,
} = survey(workspace)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"worktrees": worktrees.len(),
"removable": removable.iter().map(|l| l.path.display().to_string()).collect::<Vec<_>>(),
"kept_because_unfinished": unfinished,
"applied": apply,
}))?
);
}
if removable.is_empty() {
if !json {
println!("nothing to prune");
}
return Ok(true);
}
if !apply {
if !json {
println!("{} worktree(s) can be removed:", removable.len());
for l in &removable {
let name = l.path.strip_prefix(&workspace.root).unwrap_or(&l.path);
println!(" {}", name.display());
}
println!("\nBranches and run records are untouched either way.");
println!("Re-run with --apply to remove them.");
}
return Ok(true);
}
let (removed, failed) = remove(&removable);
for said in &failed {
eprintln!("{said}");
}
if !json {
println!("removed {removed} worktree(s); branches and records untouched");
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ostraka-prune-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ostraka/adapters")).expect("adapters");
std::fs::write(
dir.join(".ostraka/ostraka.toml"),
"[gate]\nchecks = []\n\n[gate.review]\nmust_differ_from_author = true\n",
)
.expect("config");
dir
}
#[test]
fn a_survey_reports_what_it_looked_at_as_well_as_what_can_go() {
let dir = fixture("survey");
let workspace = Workspace::at(&dir);
let survey = survey(&workspace).expect("surveys");
assert!(survey.worktrees.is_empty());
assert!(survey.removable.is_empty());
assert!(survey.unfinished.is_empty());
assert_eq!(
leftovers(&workspace).expect("leftovers").len(),
survey.removable.len()
);
let _ = std::fs::remove_dir_all(&dir);
}
}