use std::collections::BTreeSet;
use camino::{Utf8Path, Utf8PathBuf};
use crate::branch::BranchInstance;
use crate::checkpoint::CheckpointLog;
use crate::error::{NewgitError, Result};
use crate::materializer::workspace_marker_path;
use crate::resource::Ownership;
use crate::store::{MetadataStore, read_subdirs_sorted};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CleanupOutcome {
pub dry_run: bool,
pub finalized: Vec<FinalizedInstance>,
pub orphan_workspaces: Vec<Utf8PathBuf>,
pub dead_state: Vec<Utf8PathBuf>,
pub purged_checkpoints: Vec<PurgedCheckpoints>,
pub pruned: Vec<PrunedRev>,
pub pinned_by_checkpoints: usize,
pub pinned_by_archived: usize,
pub warnings: Vec<String>,
}
impl CleanupOutcome {
pub fn is_empty(&self) -> bool {
self.finalized.is_empty()
&& self.orphan_workspaces.is_empty()
&& self.dead_state.is_empty()
&& self.purged_checkpoints.is_empty()
&& self.pruned.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchivedCheckpoints {
Keep,
Purge,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PurgedCheckpoints {
pub slug: String,
pub checkpoints: usize,
pub source_refs: usize,
pub dir: Utf8PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FinalizedInstance {
pub name: String,
pub workspace: Utf8PathBuf,
pub hooks: Vec<HookOutcome>,
pub archived_record: Option<Utf8PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookOutcome {
pub resource: String,
pub ownership: Ownership,
pub detail: HookDetail,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookDetail {
Ran {
command: String,
ok: bool,
log: Utf8PathBuf,
},
WouldRun(String),
SkippedOwnership,
NoHook,
SkippedUnresolved {
command: String,
placeholder: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrunedRev {
pub tracker: String,
pub rev: String,
pub path: Utf8PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaneRev {
pub tracker: String,
pub rev: String,
pub path: Utf8PathBuf,
pub is_staging: bool,
}
pub fn may_tear_down(ownership: Ownership) -> bool {
ownership.per_branch_teardown_may_touch()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SnapshotRoots {
pub bindings: BTreeSet<(String, String)>,
pub checkpoints: BTreeSet<(String, String)>,
pub archived_checkpoints: BTreeSet<(String, String)>,
pub lane_heads: BTreeSet<(String, String)>,
}
impl SnapshotRoots {
pub fn collect(
store: &MetadataStore,
branches: &[BranchInstance],
archived: ArchivedCheckpoints,
) -> Result<Self> {
let mut roots = Self::default();
for branch in branches {
for (tracker, binding) in &branch.trackers {
if let Some(rev) = &binding.content_rev {
roots.bindings.insert((tracker.clone(), rev.clone()));
}
}
}
let live_slugs: BTreeSet<&str> =
branches.iter().map(|branch| branch.slug.as_str()).collect();
let mut live_claims: BTreeSet<(String, String)> = BTreeSet::new();
let mut archived_claims: BTreeSet<(String, String)> = BTreeSet::new();
for slug in store.checkpointed_slugs()? {
let claims = if live_slugs.contains(slug.as_str()) {
&mut live_claims
} else if archived == ArchivedCheckpoints::Purge {
continue;
} else {
&mut archived_claims
};
let log = CheckpointLog::new(store.checkpoint_dir(&slug), &slug);
for record in log.list()? {
for state in &record.tracker_states {
if let Some(rev) = &state.content_rev {
claims.insert((state.name.clone(), rev.clone()));
}
}
for state in &record.resource_states {
if let Some((tracker, rev)) =
state.state_ref.as_deref().and_then(parse_tracker_state_ref)
{
claims.insert((tracker.to_owned(), rev.to_owned()));
}
}
}
}
roots.archived_checkpoints = archived_claims.difference(&live_claims).cloned().collect();
roots.checkpoints = live_claims.union(&archived_claims).cloned().collect();
for lane in lane_names(&store.paths().snapshots)? {
if let Some(head) =
crate::lane::TrackerLane::new(&store.paths().snapshots, &lane).latest()
{
roots.lane_heads.insert((lane, head));
}
}
Ok(roots)
}
pub fn contains(&self, tracker: &str, rev: &str) -> bool {
let key = (tracker.to_owned(), rev.to_owned());
self.bindings.contains(&key)
|| self.checkpoints.contains(&key)
|| self.lane_heads.contains(&key)
}
pub fn pinned_only_by_checkpoints(&self) -> impl Iterator<Item = &(String, String)> {
self.checkpoints
.iter()
.filter(|key| !self.bindings.contains(*key) && !self.lane_heads.contains(*key))
}
pub fn pinned_only_by_archived_checkpoints(&self) -> impl Iterator<Item = &(String, String)> {
self.pinned_only_by_checkpoints()
.filter(|key| self.archived_checkpoints.contains(*key))
}
}
fn parse_tracker_state_ref(state_ref: &str) -> Option<(&str, &str)> {
state_ref.strip_prefix("tracker:")?.split_once('@')
}
pub fn lane_names(snapshots_root: &Utf8Path) -> Result<Vec<String>> {
Ok(read_subdirs_sorted(snapshots_root)?
.iter()
.filter_map(|dir| dir.file_name().map(ToOwned::to_owned))
.collect())
}
pub fn lane_revs(snapshots_root: &Utf8Path) -> Result<Vec<LaneRev>> {
let mut revs = Vec::new();
for tracker in lane_names(snapshots_root)? {
for dir in read_subdirs_sorted(&snapshots_root.join(&tracker))? {
let Some(name) = dir.file_name() else {
continue;
};
let (rev, is_staging) = match name.strip_suffix(".tmp") {
Some(rev) => (rev.to_owned(), true),
None => (name.to_owned(), false),
};
revs.push(LaneRev {
tracker: tracker.clone(),
rev,
path: dir,
is_staging,
});
}
}
Ok(revs)
}
pub fn orphan_workspaces(
workspace_root: &Utf8Path,
branches: &[BranchInstance],
) -> Result<(Vec<Utf8PathBuf>, Vec<String>)> {
let claimed: BTreeSet<&Utf8Path> = branches
.iter()
.map(|branch| branch.workspace_path.as_path())
.collect();
let mut orphans = Vec::new();
let mut warnings = Vec::new();
for dir in read_subdirs_sorted(workspace_root)? {
if claimed.contains(dir.as_path()) {
continue;
}
if workspace_marker_path(&dir).is_file() || is_empty_dir(&dir)? {
orphans.push(dir);
} else {
warnings.push(format!(
"{dir} sits under the workspace root but has no binding record and no newgit \
workspace marker, so newgit did not remove it — delete it yourself if it is junk"
));
}
}
Ok((orphans, warnings))
}
fn is_empty_dir(path: &Utf8Path) -> Result<bool> {
let mut entries = std::fs::read_dir(path).map_err(|source| NewgitError::io(path, source))?;
Ok(entries.next().is_none())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tracker_state_refs_parse_and_others_are_ignored() {
assert_eq!(
parse_tracker_state_ref("tracker:db-snapshots@77e10b2c4451"),
Some(("db-snapshots", "77e10b2c4451"))
);
assert_eq!(parse_tracker_state_ref("hash:9921aa04d2e1"), None);
assert_eq!(parse_tracker_state_ref("pv_9"), None);
}
#[test]
fn ownership_gates_per_branch_teardown() {
assert!(may_tear_down(Ownership::Branch));
assert!(may_tear_down(Ownership::Workspace));
assert!(may_tear_down(Ownership::External));
assert!(!may_tear_down(Ownership::Project));
assert!(!may_tear_down(Ownership::User));
}
#[test]
fn lane_revs_flag_staging_directories() {
let temp = tempfile::tempdir().expect("tempdir");
let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
std::fs::create_dir_all(root.join("runtime-env/abc123")).expect("mkdir");
std::fs::create_dir_all(root.join("runtime-env/def456.tmp")).expect("mkdir");
std::fs::write(root.join("runtime-env/LATEST"), "abc123\n").expect("write");
let revs = lane_revs(&root).expect("lane revs");
assert_eq!(revs.len(), 2, "LATEST is a file, not a rev");
assert_eq!(revs[0].rev, "abc123");
assert!(!revs[0].is_staging);
assert_eq!(revs[1].rev, "def456");
assert!(revs[1].is_staging);
}
}