|
|
|
@@ -0,0 +1,319 @@
|
|
|
|
|
//! Store of retained worktrees (#78): worktrees the daemon created whose
|
|
|
|
|
//! session is gone.
|
|
|
|
|
//!
|
|
|
|
|
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
|
|
|
|
|
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
|
|
|
|
|
//! missing or malformed files tolerated on read. The store survives a daemon
|
|
|
|
|
//! restart; it is the recovery path for everything else in the worktree
|
|
|
|
|
//! lifecycle.
|
|
|
|
|
//!
|
|
|
|
|
//! It is not an ownership record: it must never be consulted to decide whether
|
|
|
|
|
//! a path may be adopted. Its only job is to remember what the daemon is
|
|
|
|
|
//! holding so it can be listed and reclaimed.
|
|
|
|
|
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
|
|
|
pub enum RetainedStoreError {
|
|
|
|
|
#[error("io: {0}")]
|
|
|
|
|
Io(#[from] std::io::Error),
|
|
|
|
|
#[error("json: {0}")]
|
|
|
|
|
Json(#[from] serde_json::Error),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Why a worktree outlived its session. Kebab-case on the wire; new variants
|
|
|
|
|
/// are additive but clients should render unknown reasons generically.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
|
|
|
#[serde(rename_all = "kebab-case")]
|
|
|
|
|
pub enum RetainedReason {
|
|
|
|
|
/// The session was killed with `--force` while its worktree held unsaved
|
|
|
|
|
/// work.
|
|
|
|
|
ForceKilled,
|
|
|
|
|
/// The session ended by itself and its worktree held unsaved work.
|
|
|
|
|
ExitedWithUnsavedWork,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
|
|
|
pub struct RetainedWorktree {
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
pub path: PathBuf,
|
|
|
|
|
pub base_repo: PathBuf,
|
|
|
|
|
pub branch: String,
|
|
|
|
|
pub reason: RetainedReason,
|
|
|
|
|
pub retained_at: DateTime<Utc>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct RetainedStore {
|
|
|
|
|
dir: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RetainedStore {
|
|
|
|
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
|
|
|
|
Self { dir: dir.into() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn dir(&self) -> &Path {
|
|
|
|
|
&self.dir
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn path_for(&self, id: Uuid) -> PathBuf {
|
|
|
|
|
self.dir.join(format!("{id}.json"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Record a retained worktree and return the entry that was written.
|
|
|
|
|
pub fn retain(
|
|
|
|
|
&self,
|
|
|
|
|
path: &Path,
|
|
|
|
|
base_repo: &Path,
|
|
|
|
|
branch: &str,
|
|
|
|
|
reason: RetainedReason,
|
|
|
|
|
) -> Result<RetainedWorktree, RetainedStoreError> {
|
|
|
|
|
let entry = RetainedWorktree {
|
|
|
|
|
id: Uuid::new_v4(),
|
|
|
|
|
path: path.to_path_buf(),
|
|
|
|
|
base_repo: base_repo.to_path_buf(),
|
|
|
|
|
branch: branch.to_string(),
|
|
|
|
|
reason,
|
|
|
|
|
retained_at: Utc::now(),
|
|
|
|
|
};
|
|
|
|
|
self.overwrite(&entry)?;
|
|
|
|
|
Ok(entry)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Atomically write `entry`. Creates the directory if it does not exist.
|
|
|
|
|
pub fn overwrite(&self, entry: &RetainedWorktree) -> Result<(), RetainedStoreError> {
|
|
|
|
|
std::fs::create_dir_all(&self.dir)?;
|
|
|
|
|
let final_path = self.path_for(entry.id);
|
|
|
|
|
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
|
|
|
|
|
let json = serde_json::to_vec_pretty(entry)?;
|
|
|
|
|
std::fs::write(&tmp, json)?;
|
|
|
|
|
std::fs::rename(&tmp, &final_path)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
|
|
|
|
|
/// store already lost (e.g. pruned) must not fail.
|
|
|
|
|
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
|
|
|
|
|
match std::fs::remove_file(self.path_for(id)) {
|
|
|
|
|
Ok(()) => Ok(()),
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
|
|
|
Err(e) => Err(e.into()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Read every entry. Files that are missing or malformed are skipped
|
|
|
|
|
/// silently: a corrupt record must not hide the others, and an empty or
|
|
|
|
|
/// absent directory simply means nothing is retained.
|
|
|
|
|
pub fn load_all(&self) -> Result<Vec<RetainedWorktree>, RetainedStoreError> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
let entries = match std::fs::read_dir(&self.dir) {
|
|
|
|
|
Ok(e) => e,
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
|
|
|
|
Err(e) => return Err(e.into()),
|
|
|
|
|
};
|
|
|
|
|
for entry in entries {
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Ok(bytes) = std::fs::read(&path) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktree>(&bytes) {
|
|
|
|
|
out.push(worktree);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out.sort_by_key(|w| w.retained_at);
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drop entries whose worktree directory no longer exists — somebody
|
|
|
|
|
/// removed it by hand. Returns the ids that were pruned.
|
|
|
|
|
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
|
|
|
|
|
let mut pruned = Vec::new();
|
|
|
|
|
for entry in self.load_all()? {
|
|
|
|
|
if !entry.path.exists() {
|
|
|
|
|
self.remove(entry.id)?;
|
|
|
|
|
pruned.push(entry.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(pruned)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use chrono::Duration;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
fn tmp_dir() -> tempfile::TempDir {
|
|
|
|
|
tempfile::tempdir().expect("tempdir")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
|
|
|
|
|
let p = root.join(name);
|
|
|
|
|
std::fs::create_dir_all(&p).expect("mkdir");
|
|
|
|
|
p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn retain_then_load_round_trips_every_field() {
|
|
|
|
|
let dir = tmp_dir();
|
|
|
|
|
let store = RetainedStore::new(dir.path());
|
|
|
|
|
let wt = sample_path(dir.path(), "wt");
|
|
|
|
|
let repo = sample_path(dir.path(), "repo");
|
|
|
|
|
|
|
|
|
|
let entry = store
|
|
|
|
|
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
|
|
|
|
|
let all = store.load_all().expect("load_all");
|
|
|
|
|
assert_eq!(all, vec![entry.clone()]);
|
|
|
|
|
assert_eq!(entry.path, wt);
|
|
|
|
|
assert_eq!(entry.base_repo, repo);
|
|
|
|
|
assert_eq!(entry.branch, "feature/login");
|
|
|
|
|
assert_eq!(entry.reason, RetainedReason::ForceKilled);
|
|
|
|
|
let age = Utc::now() - entry.retained_at;
|
|
|
|
|
assert!(age >= Duration::zero() && age < Duration::minutes(1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn reason_serializes_kebab_case() {
|
|
|
|
|
let json =
|
|
|
|
|
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
|
|
|
|
|
assert_eq!(json, r#""exited-with-unsaved-work""#);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn entries_survive_a_restart() {
|
|
|
|
|
let dir = tmp_dir();
|
|
|
|
|
let wt = sample_path(dir.path(), "wt");
|
|
|
|
|
|
|
|
|
|
let first = RetainedStore::new(dir.path());
|
|
|
|
|
let entry = first
|
|
|
|
|
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
|
|
|
|
|
// A fresh store over the same state_dir — what a daemon restart sees.
|
|
|
|
|
let second = RetainedStore::new(dir.path());
|
|
|
|
|
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn missing_store_reads_as_empty() {
|
|
|
|
|
let dir = tmp_dir();
|
|
|
|
|
let store = RetainedStore::new(dir.path().join("does-not-exist"));
|
|
|
|
|
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn malformed_entries_are_skipped_silently() {
|
|
|
|
|
let dir = tmp_dir();
|
|
|
|
|
let store = RetainedStore::new(dir.path());
|
|
|
|
|
let wt = sample_path(dir.path(), "wt");
|
|
|
|
|
store
|
|
|
|
|
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
|
|
|
|
|
|
|
|
|
|
let all = store.load_all().expect("load_all");
|
|
|
|
|
assert_eq!(all.len(), 1);
|
|
|
|
|
assert_eq!(all[0].branch, "kept");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ids_are_unique() {
|
|
|
|
|
let dir = tmp_dir();
|
|
|
|
|
let store = RetainedStore::new(dir.path());
|
|
|
|
|
let a = store
|
|
|
|
|
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
let b = store
|
|
|
|
|
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
assert_ne!(a.id, b.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn remove_drops_the_entry() {
|
|
|
|
|
let dir = tmp_dir();
|
|
|
|
|
let store = RetainedStore::new(dir.path());
|
|
|
|
|
let wt = sample_path(dir.path(), "wt");
|
|
|
|
|
let entry = store
|
|
|
|
|
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
|
|
|
|
|
store.remove(entry.id).expect("remove");
|
|
|
|
|
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
|
|
|
|
|
|
|
|
|
// Removing an unknown id is a no-op, not an error.
|
|
|
|
|
store.remove(entry.id).expect("remove again");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_all_is_sorted_by_time() {
|
|
|
|
|
let dir = tmp_dir();
|
|
|
|
|
let store = RetainedStore::new(dir.path());
|
|
|
|
|
let mut first = store
|
|
|
|
|
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
first.retained_at -= Duration::hours(1);
|
|
|
|
|
store.overwrite(&first).expect("rewrite");
|
|
|
|
|
|
|
|
|
|
let second = store
|
|
|
|
|
.retain(
|
|
|
|
|
dir.path(),
|
|
|
|
|
dir.path(),
|
|
|
|
|
"second",
|
|
|
|
|
RetainedReason::ForceKilled,
|
|
|
|
|
)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
|
|
|
|
|
let branches: Vec<_> = store
|
|
|
|
|
.load_all()
|
|
|
|
|
.expect("load_all")
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|e| e.branch)
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(branches, vec!["first", "second"]);
|
|
|
|
|
let _ = second;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prune_missing_drops_gone_directories_only() {
|
|
|
|
|
let dir = tmp_dir();
|
|
|
|
|
let store = RetainedStore::new(dir.path());
|
|
|
|
|
let alive = sample_path(dir.path(), "alive");
|
|
|
|
|
let gone = sample_path(dir.path(), "gone");
|
|
|
|
|
let keep = store
|
|
|
|
|
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
let drop = store
|
|
|
|
|
.retain(
|
|
|
|
|
&gone,
|
|
|
|
|
dir.path(),
|
|
|
|
|
"drop",
|
|
|
|
|
RetainedReason::ExitedWithUnsavedWork,
|
|
|
|
|
)
|
|
|
|
|
.expect("retain");
|
|
|
|
|
std::fs::remove_dir_all(&gone).expect("rm");
|
|
|
|
|
|
|
|
|
|
let pruned = store.prune_missing().expect("prune_missing");
|
|
|
|
|
assert_eq!(pruned, vec![drop.id]);
|
|
|
|
|
|
|
|
|
|
let branches: Vec<_> = store
|
|
|
|
|
.load_all()
|
|
|
|
|
.expect("load_all")
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|e| e.branch)
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(branches, vec!["keep"]);
|
|
|
|
|
let _ = keep;
|
|
|
|
|
}
|
|
|
|
|
}
|