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;
};
if !names_a_release(&entry, &name) {
continue;
}
found.push((name, modified));
}
let mut removed = Vec::new();
let mut failure = None;
for sha in doomed(&found, keep, live.as_deref()) {
match git::worktree_remove(&tree.git(), &tree.release(&sha)) {
Ok(()) => removed.push(sha),
Err(err) => failure = failure.or(Some(err)),
}
}
git::worktree_prune(&tree.git())?;
match failure {
Some(err) => Err(err),
None => Ok(removed),
}
}
fn names_a_release(entry: &fs::DirEntry, name: &str) -> bool {
entry.file_type().is_ok_and(|kind| kind.is_dir())
&& matches!(name.len(), 40 | 64)
&& name.bytes().all(|byte| byte.is_ascii_hexdigit())
}
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 crate::fixtures;
use super::*;
use std::process::Command;
use std::time::Duration;
fn at_second(path: &Path, seconds: u64) {
let when = SystemTime::UNIX_EPOCH + Duration::from_secs(seconds);
fs::File::open(path)
.expect("open for set_times")
.set_times(fs::FileTimes::new().set_modified(when))
.expect("pin the mtime");
}
#[test]
fn a_stray_file_under_releases_cannot_push_a_release_out() {
let (tree, shas) = fixture_tree_with_releases(3);
let stray = tree.releases().join("notes.txt");
fs::write(&stray, "not a release").expect("stray");
at_second(&tree.release(&shas[0]), 10);
at_second(&tree.release(&shas[1]), 20);
at_second(&tree.release(&shas[2]), 30);
at_second(&stray, 40);
prune(&tree, 2).expect("prunes");
assert!(
tree.release(&shas[1]).is_dir(),
"the second newest release is the rollback target and must survive"
);
}
#[test]
fn one_release_that_will_not_go_does_not_strand_the_others() {
let (tree, shas) = fixture_tree_with_releases(4);
let never_a_worktree = tree.releases().join("0".repeat(40));
fs::create_dir(&never_a_worktree).expect("a release-shaped directory");
at_second(&tree.release(&shas[0]), 10);
at_second(&tree.release(&shas[1]), 20);
at_second(&never_a_worktree, 30);
at_second(&tree.release(&shas[2]), 40);
at_second(&tree.release(&shas[3]), 50);
let err = prune(&tree, 2).expect_err("the failure must still be reported");
assert!(
!tree.release(&shas[0]).exists() && !tree.release(&shas[1]).exists(),
"the rest of the cycle must run past the failure: {err}"
);
}
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 fixture_tree_with_releases(n: u32) -> (Tree, Vec<String>) {
let origin = tempfile::tempdir().expect("tempdir");
fixtures::run_git(origin.path(), &["init", "-q", "-b", "main"]);
fixtures::run_git(origin.path(), &["config", "user.email", "test@example.com"]);
fixtures::run_git(origin.path(), &["config", "user.name", "test"]);
for i in 0..n {
fs::write(origin.path().join(format!("file-{i}.txt")), "x").expect("write");
fixtures::run_git(origin.path(), &["add", "."]);
fixtures::run_git(
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");
fixtures::run_git(&tree.git(), &["init", "-q", "--bare"]);
let remote = origin.path().to_str().expect("utf-8 path").to_owned();
git::fetch(&tree.git(), &remote, fixtures::TEST_BUDGET).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_eq!(doomed(&all, 1, Some("ancient")), vec!["old", "older"]);
}
#[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");
}
}