use std::fs;
use std::path::Path;
use std::time::SystemTime;
use crate::error::Error;
use crate::paths::Tree;
use crate::{git, swap};
pub fn prune(tree: &Tree, keep: usize) -> Result<Vec<String>, Error> {
let live = swap::resolve(&tree.current())?;
let live = live.as_deref().and_then(sha_of);
let mut found = Vec::new();
let releases = tree.releases();
let entries = match fs::read_dir(&releases) {
Ok(entries) => entries,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => {
return Err(Error::Io {
path: releases,
source,
});
}
};
for entry in entries {
let entry = entry.map_err(|source| Error::Io {
path: releases.clone(),
source,
})?;
let (Some(name), Ok(modified)) = (
entry.file_name().to_str().map(str::to_owned),
entry.metadata().and_then(|meta| meta.modified()),
) else {
continue;
};
found.push((name, modified));
}
let mut removed = Vec::new();
for sha in doomed(&found, keep, live.as_deref()) {
git::worktree_remove(&tree.git(), &tree.release(&sha))?;
removed.push(sha);
}
git::worktree_prune(&tree.git())?;
Ok(removed)
}
fn doomed(releases: &[(String, SystemTime)], keep: usize, live: Option<&str>) -> Vec<String> {
let mut ordered: Vec<&(String, SystemTime)> = releases.iter().collect();
ordered.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
ordered
.into_iter()
.skip(keep)
.map(|(name, _)| name.clone())
.filter(|name| Some(name.as_str()) != live)
.collect()
}
fn sha_of(release: &Path) -> Option<String> {
release
.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::time::Duration;
fn releases(names: &[&str]) -> Vec<(String, SystemTime)> {
let base = SystemTime::UNIX_EPOCH;
names
.iter()
.enumerate()
.map(|(age, name)| {
(
(*name).to_owned(),
base + Duration::from_secs(1000 - age as u64),
)
})
.collect()
}
fn run(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn fixture_tree_with_releases(n: u32) -> (Tree, Vec<String>) {
let origin = tempfile::tempdir().expect("tempdir");
run(origin.path(), &["init", "-q", "-b", "main"]);
run(origin.path(), &["config", "user.email", "test@example.com"]);
run(origin.path(), &["config", "user.name", "test"]);
for i in 0..n {
fs::write(origin.path().join(format!("file-{i}.txt")), "x").expect("write");
run(origin.path(), &["add", "."]);
run(
origin.path(),
&["commit", "-q", "-m", &format!("commit {i}")],
);
}
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "web");
fs::create_dir_all(tree.git()).expect("create git dir");
run(&tree.git(), &["init", "-q", "--bare"]);
let remote = origin.path().to_str().expect("utf-8 path").to_owned();
git::fetch(&tree.git(), &remote).expect("fetch");
let log = Command::new("git")
.current_dir(tree.git())
.args(["log", "--reverse", "--format=%H", "main"])
.output()
.expect("log");
let shas: Vec<String> = String::from_utf8(log.stdout)
.expect("utf-8 log")
.lines()
.map(str::to_owned)
.collect();
for sha in &shas {
let release = tree.release(sha);
git::worktree_add(&tree.git(), &release, sha).expect("worktree add");
fs::write(release.join("built.txt"), "built").expect("dirty the release");
}
swap::point_at(
&tree.current(),
&tree.release(shas.last().expect("at least one")),
)
.expect("point current");
let _ = origin.keep();
let _ = home.keep();
(tree, shas)
}
#[test]
fn the_newest_releases_survive() {
let all = releases(&["new", "old", "older", "ancient"]);
assert_eq!(doomed(&all, 2, None), vec!["older", "ancient"]);
}
#[test]
fn a_release_removed_by_hand_is_deregistered_even_when_nothing_is_pruned() {
let (tree, shas) = fixture_tree_with_releases(2);
let orphan = tree.release(&shas[0]);
fs::remove_dir_all(&orphan).expect("remove the release by hand");
let removed = prune(&tree, 5).expect("prune");
assert!(removed.is_empty(), "nothing was past the keep count");
git::worktree_add(&tree.git(), &orphan, &shas[0])
.expect("the sha redeploys after prune cleared its registration");
assert!(orphan.is_dir(), "the release is back on disk");
}
#[test]
fn the_live_release_is_never_pruned_whatever_its_age() {
let all = releases(&["new", "old", "older", "ancient"]);
assert_eq!(doomed(&all, 2, Some("ancient")), vec!["older"]);
assert!(!doomed(&all, 1, Some("ancient")).contains(&"ancient".to_owned()));
}
#[test]
fn keeping_two_leaves_a_rollback_target() {
let all = releases(&["new", "old", "older"]);
let survivors: Vec<String> = all
.iter()
.map(|(name, _)| name.clone())
.filter(|name| !doomed(&all, 2, None).contains(name))
.collect();
assert_eq!(survivors, vec!["new", "old"]);
}
#[test]
fn a_young_tree_loses_nothing() {
assert!(doomed(&releases(&["new", "old"]), 5, None).is_empty());
assert!(doomed(&[], 5, None).is_empty());
}
#[test]
fn prune_removes_real_worktrees_and_leaves_the_live_one() {
let (tree, shas) = fixture_tree_with_releases(4);
let removed = prune(&tree, 2).expect("prunes");
assert_eq!(removed.len(), 2);
assert!(tree.release(&shas[3]).exists(), "the newest survives");
assert!(
tree.release(&shas[2]).exists(),
"so does the rollback target"
);
assert!(!tree.release(&shas[0]).exists(), "the oldest is gone");
}
}