use std::fs;
use std::path::Path;
use std::time::SystemTime;
use crate::error::Error;
use crate::paths::Tree;
use crate::state::is_sha;
use crate::{git, swap};
pub fn prune(tree: &Tree, keep: usize, recorded: &[&str]) -> 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(Error::at(&releases))?;
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 git_dir = tree.git();
let mut removed = Vec::new();
let mut failure = None;
let spared: Vec<Option<&str>> = live
.as_deref()
.into_iter()
.chain(recorded.iter().copied())
.map(Some)
.collect();
for sha in doomed(&found, keep, &spared) {
match git::worktree_remove(&git_dir, &tree.release(&sha)) {
Ok(()) => {
let _ = fs::remove_file(tree.completion(&sha));
removed.push(sha);
}
Err(err) => failure = failure.or(Some(err)),
}
}
let pruned = git::worktree_prune(&git_dir);
sweep_markers(tree, &releases);
match failure.or(pruned.err()) {
Some(err) => Err(err),
None => Ok(removed),
}
}
fn sweep_markers(tree: &Tree, releases: &Path) {
let Ok(markers) = fs::read_dir(tree.completions()) else {
return;
};
for marker in markers.flatten() {
if !releases.join(marker.file_name()).exists() {
let _ = fs::remove_file(marker.path());
}
}
}
fn names_a_release(entry: &fs::DirEntry, name: &str) -> bool {
entry.file_type().is_ok_and(|kind| kind.is_dir()) && is_sha(name)
}
fn doomed(releases: &[(String, SystemTime)], keep: usize, spared: &[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)
.filter(|(name, _)| !spared.contains(&Some(name.as_str())))
.map(|(name, _)| name.clone())
.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 = fixtures::empty_checkout();
for i in 0..n {
let name = format!("file-{i}.txt");
fixtures::commit(origin.path(), &[(&name, "x")], &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");
}
#[test]
fn the_recorded_release_is_spared_like_the_live_one() {
let all = releases(&["newest", "recorded", "older", "ancient"]);
assert_eq!(
doomed(&all, 1, &[Some("newest"), Some("recorded")]),
vec!["older", "ancient"]
);
}
#[test]
fn a_reclaimed_release_takes_its_completion_marker_with_it() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "web");
let origin = fixtures::checkout(&[("f", "x")]);
let sha = fixtures::head_of(origin.path());
git::init_bare(&tree.git()).expect("bare");
git::fetch(
&tree.git(),
origin.path().to_str().expect("utf-8"),
fixtures::TEST_BUDGET,
)
.expect("fetch");
fs::create_dir_all(tree.releases()).expect("releases");
let doomed_sha = sha.clone();
git::worktree_add(&tree.git(), &tree.release(&doomed_sha), &sha).expect("worktree");
at_second(&tree.release(&doomed_sha), 1);
for (n, stand_in) in ["1".repeat(40), "2".repeat(40)].iter().enumerate() {
fs::create_dir_all(tree.release(stand_in)).expect("stand-in");
at_second(&tree.release(stand_in), 10 + n as u64);
}
let markers = tree
.completion(&doomed_sha)
.parent()
.expect("complete/")
.to_owned();
fs::create_dir_all(&markers).expect("complete dir");
fs::write(tree.completion(&doomed_sha), b"").expect("marker");
let orphan = tree.completion(&"3".repeat(40));
fs::write(&orphan, b"").expect("a marker with no release");
let removed = prune(&tree, 2, &[]).expect("prunes");
assert_eq!(removed, vec![doomed_sha.clone()]);
assert!(
!tree.completion(&doomed_sha).exists(),
"the reclaimed release's marker must go with it"
);
assert!(!orphan.exists(), "a marker with no release must be swept");
}
}