use anyhow::{Context, Result, bail};
use clap::Args;
use serde_json::{Value, json};
use agentstatedeveloper_core::Engine;
use agentstatedeveloper_core::gc::{GC_CHECKPOINT_EVERY, GC_KEEP_RECENT, gc_policy};
use crate::config::Config;
const HISTORY_EXTRACT_BATCH: usize = 5_000;
#[derive(Debug, Args)]
pub struct GcArgs {
#[arg(long)]
pub sweep: bool,
#[arg(long)]
pub vacuum: bool,
#[arg(long)]
pub unpin_legacy: bool,
#[arg(long, default_value_t = GC_KEEP_RECENT)]
pub keep_recent: usize,
#[arg(long, default_value_t = GC_CHECKPOINT_EVERY)]
pub checkpoint_every: usize,
}
pub fn run(cfg: &Config, args: GcArgs) -> Result<()> {
let engine = Engine::open_sqlite(&cfg.db_path)?;
let repo = &engine.repo;
let distilled = repo
.extract_history(HISTORY_EXTRACT_BATCH)
.context("could not distil history before GC")?
.commits_processed;
let unpinned = if args.unpin_legacy {
Some(
repo.history_unpin_legacy_milestones()
.context("could not unpin legacy milestones")?,
)
} else {
None
};
let policy = gc_policy(args.keep_recent, args.checkpoint_every);
let result = if args.sweep {
repo.gc_sweep(policy, true, args.vacuum).context(
"sweep failed — if the database is locked, another ASD process \
(a git hook, asd-serve, an MCP server) is writing; retry when it finishes",
)?
} else if args.vacuum {
json!({ "vacuum": repo.gc_vacuum().context("vacuum failed")? })
} else {
repo.gc_sweep(policy, false, false)?
};
let out = json!({
"db": cfg.db_path.display().to_string(),
"distilled_now": distilled,
"unpinned_legacy_milestones": unpinned,
"result": result,
});
println!("{}", serde_json::to_string_pretty(&out)?);
eprintln!("{}", summary(&args, &result));
if result["refused"] == true {
bail!(
"sweep refused: {}",
result["reason"].as_str().unwrap_or("unsafe")
);
}
Ok(())
}
fn summary(args: &GcArgs, result: &Value) -> String {
let n = |v: &Value| v.as_i64().unwrap_or(0);
if result["dry_run"] == true {
let w = &result["would_reclaim"];
return format!(
"preview: a sweep would delete {} of {} objects ({:.1}%), keeping {} live{}. \
Nothing was deleted — re-run with --sweep to reclaim.",
n(&w["reclaimable_objects"]),
n(&w["total_objects"]),
w["reclaimable_pct"].as_f64().unwrap_or(0.0),
n(&w["live_objects"]),
if result["safe"] == true {
""
} else {
" (a sweep would currently be refused: history is not fully distilled)"
},
);
}
if result["mutated"] == true {
let mut line = format!(
"swept: deleted {} of {} objects, {} remain.",
n(&result["objects_deleted"]),
n(&result["objects_before"]),
n(&result["objects_after"]),
);
if let Some(v) = result.get("vacuum") {
line.push_str(&format!(
" vacuum reclaimed {} bytes on disk.",
n(&v["bytes_reclaimed"])
));
} else if !args.vacuum {
line.push_str(" The file only shrinks after --vacuum.");
}
return line;
}
if let Some(v) = result.get("vacuum") {
return format!(
"vacuumed: reclaimed {} bytes on disk.",
n(&v["bytes_reclaimed"])
);
}
"no change.".to_string()
}