mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
791 lines
32 KiB
Rust
791 lines
32 KiB
Rust
//! Per-workspace `REPOS` directory provisioning.
|
|
//!
|
|
//! The nest's `REPOS` directory is either a real directory (the default) or a
|
|
//! symlink to a user-configured `repos_dir`, letting agents work in existing
|
|
//! local checkouts instead of re-cloning. [`ensure_repos_symlink`] reconciles
|
|
//! `REPOS` with the configured path; [`validate_repos_dir`] guards the input.
|
|
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[cfg(unix)]
|
|
use crate::util::{create_symlink, symlink_points_to};
|
|
|
|
/// Validate a user-supplied `repos_dir`, returning the canonical target path.
|
|
///
|
|
/// Requires an **existing absolute directory**. Rejects relative paths,
|
|
/// `~`-prefixed paths (shell tilde is not expanded by `std::fs` — the FE
|
|
/// expands before save, so a `~` reaching here is a bug to surface loudly),
|
|
/// non-directories, and a path that is the nest itself or an ancestor of it
|
|
/// (symlinking `REPOS` into its own parent would create a cycle). Never
|
|
/// creates the target — a typo must not silently mint a stray directory.
|
|
pub fn validate_repos_dir(nest_root: &Path, repos_dir: &str) -> Result<PathBuf, String> {
|
|
let trimmed = repos_dir.trim();
|
|
if trimmed.starts_with('~') {
|
|
return Err(format!(
|
|
"repos dir must be an absolute path (got `{trimmed}`); use e.g. /Users/you/Development"
|
|
));
|
|
}
|
|
let target = Path::new(trimmed);
|
|
if !target.is_absolute() {
|
|
return Err(format!(
|
|
"repos dir must be an absolute path (got `{trimmed}`)"
|
|
));
|
|
}
|
|
// Resolve symlinks/`..` so the directory check and ancestor check both
|
|
// operate on the real location. Fails loudly on a missing or unreadable
|
|
// path rather than falling back to a real REPOS dir.
|
|
let canonical = target
|
|
.canonicalize()
|
|
.map_err(|e| format!("repos dir `{trimmed}` is not accessible: {e}"))?;
|
|
if !canonical.is_dir() {
|
|
return Err(format!("repos dir `{trimmed}` is not a directory"));
|
|
}
|
|
// Refuse the nest itself or any ancestor of it — pointing REPOS there
|
|
// would nest the symlink inside its own target.
|
|
if let Ok(nest_canonical) = nest_root.canonicalize() {
|
|
if nest_canonical == canonical || nest_canonical.starts_with(&canonical) {
|
|
return Err(format!(
|
|
"repos dir `{trimmed}` is the nest or an ancestor of it; choose a separate directory"
|
|
));
|
|
}
|
|
}
|
|
Ok(canonical)
|
|
}
|
|
|
|
/// Ensure `nest_root/REPOS` matches the configured `repos_dir`.
|
|
///
|
|
/// - **`repos_dir` = `None`/empty** → ensure `REPOS` is a real in-nest
|
|
/// directory (the default). A pre-existing symlink (from a prior
|
|
/// `repos_dir`) is removed first so clearing the field genuinely reverts;
|
|
/// removing a symlink never touches its target. Idempotent otherwise.
|
|
/// - **`repos_dir` set, `REPOS` absent** → create a symlink to the target.
|
|
/// - **`repos_dir` set, `REPOS` is a symlink** (any target) → replace it
|
|
/// (`remove_file` + re-symlink). Removing a symlink never touches the
|
|
/// target's contents, so this is data-safe.
|
|
/// - **`repos_dir` set, `REPOS` is an empty real dir** → remove it and
|
|
/// symlink. Converting an empty dir loses nothing.
|
|
/// - **`repos_dir` set, `REPOS` is a NON-EMPTY real dir** → refuse and warn.
|
|
/// Never `remove_dir_all` — that would destroy repos the agent cloned
|
|
/// in-nest. The user must clear or relocate them first.
|
|
///
|
|
/// Validation (`validate_repos_dir`) runs before any filesystem mutation, so
|
|
/// an invalid path returns `Err` with `REPOS` left exactly as it was.
|
|
#[cfg(unix)]
|
|
pub fn ensure_repos_symlink(nest_root: &Path, repos_dir: Option<&str>) -> Result<(), String> {
|
|
let repos_path = nest_root.join("REPOS");
|
|
|
|
let Some(target) = repos_dir
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(|raw| validate_repos_dir(nest_root, raw))
|
|
.transpose()?
|
|
else {
|
|
// No repos_dir: REPOS must be a real in-nest directory. If it is
|
|
// currently a symlink (from a prior repos_dir), remove the link first
|
|
// — create_dir_all follows a symlink and would leave the stale link in
|
|
// place. remove_file never touches the link's target.
|
|
if let Ok(meta) = repos_path.symlink_metadata() {
|
|
if meta.file_type().is_symlink() {
|
|
fs::remove_file(&repos_path)
|
|
.map_err(|e| format!("remove symlink {}: {e}", repos_path.display()))?;
|
|
}
|
|
}
|
|
fs::create_dir_all(&repos_path)
|
|
.map_err(|e| format!("create {}: {e}", repos_path.display()))?;
|
|
return Ok(());
|
|
};
|
|
|
|
match repos_path.symlink_metadata() {
|
|
// Existing symlink → replace it if it points elsewhere. Re-pointing a
|
|
// symlink is data-safe; remove_file never follows the link.
|
|
Ok(meta) if meta.file_type().is_symlink() => {
|
|
if symlink_points_to(&repos_path, &target) {
|
|
return Ok(()); // already correct
|
|
}
|
|
fs::remove_file(&repos_path)
|
|
.map_err(|e| format!("remove symlink {}: {e}", repos_path.display()))?;
|
|
symlink_repos(&target, &repos_path)
|
|
}
|
|
// Existing real directory → convert only if empty; otherwise refuse.
|
|
Ok(meta) if meta.is_dir() => {
|
|
let empty = fs::read_dir(&repos_path)
|
|
.map_err(|e| format!("read {}: {e}", repos_path.display()))?
|
|
.next()
|
|
.is_none();
|
|
if !empty {
|
|
return Err(format!(
|
|
"{} holds repositories; move or delete them before pointing repos dir elsewhere",
|
|
repos_path.display()
|
|
));
|
|
}
|
|
fs::remove_dir(&repos_path)
|
|
.map_err(|e| format!("remove {}: {e}", repos_path.display()))?;
|
|
symlink_repos(&target, &repos_path)
|
|
}
|
|
// Exists but is neither symlink nor dir (e.g. a file) → refuse.
|
|
Ok(_) => Err(format!(
|
|
"{} exists and is not a directory; cannot point repos dir there",
|
|
repos_path.display()
|
|
)),
|
|
// Absent → create the symlink.
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => symlink_repos(&target, &repos_path),
|
|
Err(e) => Err(format!("stat {}: {e}", repos_path.display())),
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn symlink_repos(target: &Path, link: &Path) -> Result<(), String> {
|
|
create_symlink(target, link)
|
|
.map_err(|e| format!("symlink {} → {}: {e}", link.display(), target.display()))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub fn ensure_repos_symlink(nest_root: &Path, _repos_dir: Option<&str>) -> Result<(), String> {
|
|
let repos_path = nest_root.join("REPOS");
|
|
fs::create_dir_all(&repos_path).map_err(|e| format!("create {}: {e}", repos_path.display()))
|
|
}
|
|
|
|
/// Provision `REPOS` at nest setup, before any configured `repos_dir` is known.
|
|
///
|
|
/// Leaves an existing symlink untouched — `apply_workspace` is the sole
|
|
/// authority over a configured symlink. Clearing it here with `None` would
|
|
/// destroy a symlink restored from a prior session; async-restored agents
|
|
/// would then write into the fresh real dir, and the later FE re-point would
|
|
/// refuse the now-non-empty REPOS — silently breaking `repos_dir` on restart.
|
|
/// Otherwise (absent, or a real dir) lands the default real-dir fallback.
|
|
pub fn ensure_repos_setup_default(nest_root: &Path) -> Result<(), String> {
|
|
let repos_path = nest_root.join("REPOS");
|
|
let is_symlink = repos_path
|
|
.symlink_metadata()
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false);
|
|
if is_symlink {
|
|
return Ok(());
|
|
}
|
|
ensure_repos_symlink(nest_root, None)
|
|
}
|
|
|
|
/// Decide what `repos_dir` `apply_workspace` should persist and symlink for a
|
|
/// candidate value, running the single source-of-truth [`validate_repos_dir`].
|
|
///
|
|
/// - **`None`/empty candidate** → `Ok(None)`: no override; `REPOS` reverts to a
|
|
/// real in-nest directory.
|
|
/// - **valid candidate** → `Ok(Some(raw_trimmed))`: persist the *raw* trimmed
|
|
/// string (not the canonical path — persisting canonical would drift the
|
|
/// symlink target on `..`/symlinked-ancestor paths).
|
|
/// - **invalid candidate** → `Err(reason)`: the caller must persist `None`
|
|
/// (clearing the override so the next boot resolves clean and agent restore
|
|
/// proceeds) and surface `reason` to the user. Returning `Err` rather than
|
|
/// silently `Ok(None)` lets the caller emit the human-readable cause.
|
|
///
|
|
/// One validate call drives both the persisted value and the error: a bad path
|
|
/// is never persisted, so it can never silently skip agent restore on a later
|
|
/// boot. Pure (no FS mutation, no emit) so the persist decision is unit-tested.
|
|
pub fn effective_repos_dir(
|
|
nest_root: &Path,
|
|
candidate: Option<&str>,
|
|
) -> Result<Option<String>, String> {
|
|
let Some(trimmed) = candidate.map(str::trim).filter(|s| !s.is_empty()) else {
|
|
return Ok(None);
|
|
};
|
|
validate_repos_dir(nest_root, trimmed).map(|_| Some(trimmed.to_string()))
|
|
}
|
|
|
|
/// Filename of the dotfile persisting the active workspace's `repos_dir`.
|
|
const REPOS_DIR_FILE: &str = ".repos-dir";
|
|
|
|
/// Read the persisted `repos_dir` from `nest_root/.repos-dir`.
|
|
///
|
|
/// Returns the trimmed value, or `None` when the file is absent, unreadable,
|
|
/// or empty. This is the backend's sole knowledge of `repos_dir` at boot —
|
|
/// the frontend persists it via [`write_persisted_repos_dir`] on every
|
|
/// `apply_workspace`, so [`resolve_repos_at_boot`] can resolve the `REPOS`
|
|
/// symlink before any agent is restored.
|
|
fn read_persisted_repos_dir(nest_root: &Path) -> Option<String> {
|
|
fs::read_to_string(nest_root.join(REPOS_DIR_FILE))
|
|
.ok()
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
}
|
|
|
|
/// Persist the active workspace's `repos_dir` to `nest_root/.repos-dir`.
|
|
///
|
|
/// Writes the trimmed value (one line). A `None`/empty value clears the
|
|
/// override by removing the file, so a later boot reverts `REPOS` to a real
|
|
/// in-nest directory. Removing an absent file is not an error. Mirrors the
|
|
/// `.nest-agents-version` dotfile pattern.
|
|
pub fn write_persisted_repos_dir(nest_root: &Path, repos_dir: Option<&str>) -> Result<(), String> {
|
|
let path = nest_root.join(REPOS_DIR_FILE);
|
|
match repos_dir.map(str::trim).filter(|s| !s.is_empty()) {
|
|
Some(value) => fs::write(&path, format!("{value}\n"))
|
|
.map_err(|e| format!("write {}: {e}", path.display())),
|
|
None => match fs::remove_file(&path) {
|
|
Ok(()) => Ok(()),
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(format!("remove {}: {e}", path.display())),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Decide whether launch-time agent restore is safe given the boot symlink
|
|
/// outcome. Fails CLOSED: when a `repos_dir` was configured but its symlink
|
|
/// could not be resolved, restoring agents would let one clone into the empty
|
|
/// real `REPOS` that `ensure_nest` provisioned — the wrong location — and once
|
|
/// it is non-empty [`ensure_repos_symlink`] refuses forever, permanently
|
|
/// re-triggering the race. Skipping restore is recoverable on the next boot
|
|
/// once the external target is reachable; a misplaced clone is not.
|
|
///
|
|
/// The no-`repos_dir` case (`persisted_present == false`) is always safe: the
|
|
/// real in-nest `REPOS` default is exactly where clones belong.
|
|
fn should_restore_agents(persisted_present: bool, symlink_result: &Result<(), String>) -> bool {
|
|
!(persisted_present && symlink_result.is_err())
|
|
}
|
|
|
|
/// Resolve the `REPOS` symlink at boot from the persisted `repos_dir` and
|
|
/// report whether agent restore is safe to proceed.
|
|
///
|
|
/// Runs in the synchronous setup hook, after `ensure_nest` and before the
|
|
/// async agent restore is spawned, so `REPOS` is the user's configured symlink
|
|
/// before any agent can clone. Logs on failure (no toast path exists
|
|
/// pre-mount) and returns the fail-closed [`should_restore_agents`] decision.
|
|
pub fn resolve_repos_at_boot(nest_root: &Path) -> bool {
|
|
let persisted = read_persisted_repos_dir(nest_root);
|
|
let symlink_result = ensure_repos_symlink(nest_root, persisted.as_deref());
|
|
if let Err(error) = &symlink_result {
|
|
eprintln!("buzz-desktop: repos dir setup failed at boot: {error}");
|
|
}
|
|
let restore = should_restore_agents(persisted.is_some(), &symlink_result);
|
|
// Log the resolved outcome on success so a healthy boot is observable (the
|
|
// Err/skip branches already log; previously success was silent).
|
|
if symlink_result.is_ok() {
|
|
match persisted.as_deref() {
|
|
Some(dir) => eprintln!(
|
|
"buzz-desktop: repos dir resolved at boot — REPOS symlinked to configured `{dir}`"
|
|
),
|
|
None => eprintln!(
|
|
"buzz-desktop: repos dir resolved at boot — no configured override, REPOS is the default real dir"
|
|
),
|
|
}
|
|
}
|
|
if !restore {
|
|
eprintln!(
|
|
"buzz-desktop: skipping agent restore — configured repos_dir `{}` could not be resolved at boot; will retry on next launch",
|
|
persisted.as_deref().unwrap_or_default()
|
|
);
|
|
}
|
|
restore
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// ── ensure_repos_symlink ──────────────────────────────────────────────
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn ensure_repos_symlink_none_creates_real_dir() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
|
|
ensure_repos_symlink(&root, None).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert!(repos.is_dir(), "REPOS should be a real directory");
|
|
assert!(
|
|
!repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"REPOS should not be a symlink when repos_dir is None"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn ensure_repos_symlink_none_reverts_existing_symlink_to_real_dir() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let external = tmp.path().join("Development");
|
|
fs::create_dir_all(&external).unwrap();
|
|
let payload = external.join("KEEP.md");
|
|
fs::write(&payload, "data").unwrap();
|
|
|
|
// First point REPOS at the external dir, then clear the field.
|
|
ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap();
|
|
ensure_repos_symlink(&root, None).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert!(
|
|
repos.is_dir(),
|
|
"REPOS should be a real directory after clear"
|
|
);
|
|
assert!(
|
|
!repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"REPOS should no longer be a symlink after clearing repos_dir"
|
|
);
|
|
assert!(
|
|
payload.exists(),
|
|
"clearing repos_dir must not touch the external target's contents"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn ensure_repos_symlink_absent_creates_symlink() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let external = tmp.path().join("Development");
|
|
fs::create_dir_all(&external).unwrap();
|
|
|
|
ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert!(repos.symlink_metadata().unwrap().file_type().is_symlink());
|
|
assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn ensure_repos_symlink_repoints_existing_wrong_symlink() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let old = tmp.path().join("old");
|
|
let new = tmp.path().join("new");
|
|
fs::create_dir_all(&old).unwrap();
|
|
fs::create_dir_all(&new).unwrap();
|
|
let payload = old.join("KEEP.md");
|
|
fs::write(&payload, "data").unwrap();
|
|
|
|
ensure_repos_symlink(&root, Some(old.to_str().unwrap())).unwrap();
|
|
ensure_repos_symlink(&root, Some(new.to_str().unwrap())).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert_eq!(repos.read_link().unwrap(), new.canonicalize().unwrap());
|
|
assert!(
|
|
payload.exists(),
|
|
"re-pointing a symlink must not touch the old target's contents"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn ensure_repos_symlink_correct_symlink_is_noop() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let external = tmp.path().join("Development").canonicalize_or_make();
|
|
|
|
ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap();
|
|
ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert_eq!(repos.read_link().unwrap(), external);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn ensure_repos_symlink_empty_real_dir_converts() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(root.join("REPOS")).unwrap();
|
|
let external = tmp.path().join("Development");
|
|
fs::create_dir_all(&external).unwrap();
|
|
|
|
ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert!(
|
|
repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"an empty real REPOS should convert to a symlink"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn ensure_repos_symlink_nonempty_real_dir_refuses_and_preserves() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
let repos = root.join("REPOS");
|
|
fs::create_dir_all(&repos).unwrap();
|
|
let checkout = repos.join("buzz");
|
|
fs::create_dir_all(&checkout).unwrap();
|
|
fs::write(checkout.join("code.rs"), "fn main() {}").unwrap();
|
|
let external = tmp.path().join("Development");
|
|
fs::create_dir_all(&external).unwrap();
|
|
|
|
let result = ensure_repos_symlink(&root, Some(external.to_str().unwrap()));
|
|
|
|
assert!(result.is_err(), "non-empty real REPOS must refuse");
|
|
assert!(
|
|
!repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"refused REPOS must stay a real directory"
|
|
);
|
|
assert!(
|
|
checkout.join("code.rs").exists(),
|
|
"refusal must never delete existing repositories"
|
|
);
|
|
}
|
|
|
|
// ensure_nest_at must NOT clobber an existing REPOS symlink on startup.
|
|
// Regression guard for Finding 1: the startup `ensure_repos_symlink(_, None)`
|
|
// call used to remove a configured symlink and mint an empty real REPOS,
|
|
// which async-restored agents could write into — the FE re-point then
|
|
// refused the now-non-empty dir, silently breaking a configured repos_dir.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn ensure_nest_startup_preserves_existing_repos_symlink() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
|
|
// First launch creates the real nest with a real REPOS dir.
|
|
crate::managed_agents::ensure_nest_at(&root).unwrap();
|
|
|
|
// Simulate a configured repos_dir: REPOS points at an external dir
|
|
// holding agent checkouts.
|
|
let external = tmp.path().join("Development");
|
|
fs::create_dir(&external).unwrap();
|
|
fs::write(external.join("KEEP.md"), "data").unwrap();
|
|
fs::remove_dir(root.join("REPOS")).unwrap();
|
|
std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap();
|
|
|
|
// Next launch must leave the configured symlink intact.
|
|
crate::managed_agents::ensure_nest_at(&root).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert!(
|
|
repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"an existing REPOS symlink must survive startup"
|
|
);
|
|
assert_eq!(repos.read_link().unwrap(), external);
|
|
assert!(
|
|
external.join("KEEP.md").exists(),
|
|
"the symlink's target contents must be untouched"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn validate_repos_dir_rejects_tilde_relative_and_missing() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
|
|
assert!(validate_repos_dir(&root, "~/Development").is_err());
|
|
assert!(validate_repos_dir(&root, "relative/path").is_err());
|
|
assert!(validate_repos_dir(&root, "/no/such/dir/here").is_err());
|
|
|
|
let file = tmp.path().join("afile");
|
|
fs::write(&file, "x").unwrap();
|
|
assert!(validate_repos_dir(&root, file.to_str().unwrap()).is_err());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn validate_repos_dir_rejects_nest_ancestor() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join("home").join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let parent = root.parent().unwrap();
|
|
|
|
assert!(
|
|
validate_repos_dir(&root, parent.to_str().unwrap()).is_err(),
|
|
"a parent of the nest would nest REPOS inside its own target"
|
|
);
|
|
}
|
|
|
|
// ── persisted repos_dir dotfile ───────────────────────────────────────
|
|
|
|
#[test]
|
|
fn persisted_repos_dir_roundtrips_write_read_clear() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
|
|
assert_eq!(read_persisted_repos_dir(&root), None, "absent → None");
|
|
|
|
write_persisted_repos_dir(&root, Some(" /Users/me/Development ")).unwrap();
|
|
assert_eq!(
|
|
read_persisted_repos_dir(&root).as_deref(),
|
|
Some("/Users/me/Development"),
|
|
"value is trimmed on write/read"
|
|
);
|
|
|
|
write_persisted_repos_dir(&root, None).unwrap();
|
|
assert_eq!(read_persisted_repos_dir(&root), None, "cleared → None");
|
|
assert!(
|
|
!root.join(".repos-dir").exists(),
|
|
"clearing removes the dotfile"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persisted_repos_dir_empty_value_clears() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
write_persisted_repos_dir(&root, Some("/Users/me/Development")).unwrap();
|
|
|
|
write_persisted_repos_dir(&root, Some(" ")).unwrap();
|
|
assert_eq!(
|
|
read_persisted_repos_dir(&root),
|
|
None,
|
|
"a whitespace-only value clears the override"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persisted_repos_dir_clear_when_absent_is_ok() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
|
|
write_persisted_repos_dir(&root, None).expect("clearing an absent dotfile is not an error");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn boot_resolves_symlink_from_persisted_value_into_empty_repos() {
|
|
// Mirrors the boot sequence: ensure_nest leaves REPOS an empty real
|
|
// dir, then the setup hook reads the persisted value and symlinks.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(root.join("REPOS")).unwrap();
|
|
let external = tmp.path().join("Development");
|
|
fs::create_dir_all(&external).unwrap();
|
|
|
|
write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap();
|
|
let persisted = read_persisted_repos_dir(&root);
|
|
ensure_repos_symlink(&root, persisted.as_deref()).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert!(
|
|
repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"boot must convert the empty real REPOS into a symlink"
|
|
);
|
|
assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn boot_leaves_already_correct_symlink_untouched() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let external = tmp.path().join("Development");
|
|
fs::create_dir_all(&external).unwrap();
|
|
|
|
write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap();
|
|
// First boot converts; second boot must be a noop.
|
|
let persisted = read_persisted_repos_dir(&root);
|
|
ensure_repos_symlink(&root, persisted.as_deref()).unwrap();
|
|
ensure_repos_symlink(&root, persisted.as_deref()).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert!(repos.symlink_metadata().unwrap().file_type().is_symlink());
|
|
assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn boot_with_cleared_value_reverts_symlink_to_real_dir() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let external = tmp.path().join("Development");
|
|
fs::create_dir_all(&external).unwrap();
|
|
fs::write(external.join("KEEP.md"), "data").unwrap();
|
|
|
|
// Configure, then clear the field.
|
|
write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap();
|
|
ensure_repos_symlink(&root, read_persisted_repos_dir(&root).as_deref()).unwrap();
|
|
write_persisted_repos_dir(&root, None).unwrap();
|
|
|
|
// Next boot reads None and reverts REPOS to a real in-nest dir.
|
|
ensure_repos_symlink(&root, read_persisted_repos_dir(&root).as_deref()).unwrap();
|
|
|
|
let repos = root.join("REPOS");
|
|
assert!(
|
|
!repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"clearing the persisted value reverts REPOS to a real dir"
|
|
);
|
|
assert!(
|
|
external.join("KEEP.md").exists(),
|
|
"reverting must not touch the external target's contents"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn effective_repos_dir_drives_persisted_dotfile_for_all_three_cases() {
|
|
// Pins the CRITICAL persist decision on `.repos-dir` CONTENTS, not just
|
|
// a return value: a bad path must clear the dotfile so the next boot's
|
|
// `should_restore_agents(false, _)` restores agents (the regression
|
|
// this hardening fixes). Drives each case through the same
|
|
// effective→persist path `apply_workspace` uses.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let good = tmp.path().join("Development");
|
|
fs::create_dir_all(&good).unwrap();
|
|
let good_str = good.to_str().unwrap();
|
|
|
|
// Pre-seed a value so each case proves it overwrites/clears, not just
|
|
// that an absent dotfile stays absent.
|
|
let seed = |root: &Path| write_persisted_repos_dir(root, Some(good_str)).unwrap();
|
|
let persist = |root: &Path, candidate: Option<&str>| {
|
|
let effective = effective_repos_dir(root, candidate);
|
|
// Mirror apply_workspace: Err clears the override (persist None).
|
|
let to_persist = effective.unwrap_or(None);
|
|
write_persisted_repos_dir(root, to_persist.as_deref()).unwrap();
|
|
};
|
|
|
|
// Bad path → Err → dotfile cleared (the CRITICAL).
|
|
seed(&root);
|
|
persist(&root, Some("/no/such/dir/here"));
|
|
assert_eq!(
|
|
read_persisted_repos_dir(&root),
|
|
None,
|
|
"a bad repos_dir must clear `.repos-dir` so the next boot restores agents"
|
|
);
|
|
|
|
// Good path → Ok(Some(raw)) → dotfile holds the raw trimmed value.
|
|
persist(&root, Some(&format!(" {good_str} ")));
|
|
assert_eq!(
|
|
read_persisted_repos_dir(&root).as_deref(),
|
|
Some(good_str),
|
|
"a valid repos_dir must persist the raw trimmed path (not the canonical path)"
|
|
);
|
|
|
|
// Empty/whitespace → Ok(None) → dotfile cleared.
|
|
seed(&root);
|
|
persist(&root, Some(" "));
|
|
assert_eq!(
|
|
read_persisted_repos_dir(&root),
|
|
None,
|
|
"an empty repos_dir clears the override"
|
|
);
|
|
|
|
// None candidate → Ok(None) → dotfile cleared.
|
|
seed(&root);
|
|
persist(&root, None);
|
|
assert_eq!(
|
|
read_persisted_repos_dir(&root),
|
|
None,
|
|
"no repos_dir clears the override"
|
|
);
|
|
}
|
|
|
|
// ── resolve_repos_at_boot (boot sequence + fail-closed gate) ──────────
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn resolve_repos_at_boot_converts_empty_real_repos_and_allows_restore() {
|
|
// Drives the real boot sequence: ensure_repos_setup_default (called by
|
|
// ensure_nest) provisions REPOS as an empty real dir, then the setup
|
|
// hook calls resolve_repos_at_boot. Asserts the convert happens at that
|
|
// position and restore is allowed.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let external = tmp.path().join("Development");
|
|
fs::create_dir_all(&external).unwrap();
|
|
write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap();
|
|
|
|
ensure_repos_setup_default(&root).unwrap();
|
|
let repos = root.join("REPOS");
|
|
assert!(
|
|
repos.is_dir() && !repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"ensure_nest provisions REPOS as an empty real dir before the boot resolve"
|
|
);
|
|
|
|
let restore = resolve_repos_at_boot(&root);
|
|
|
|
assert!(restore, "restore proceeds when the symlink resolves");
|
|
assert!(
|
|
repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"boot resolve converts the empty real REPOS into the configured symlink"
|
|
);
|
|
assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn resolve_repos_at_boot_fails_closed_when_target_unresolvable() {
|
|
// Persisted repos_dir whose target does not exist at boot (transiently
|
|
// unavailable external volume) → ensure_repos_symlink Errs. Restore must
|
|
// be skipped and REPOS left as the empty real dir, never the symlink, so
|
|
// no agent clones into the wrong place.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
let missing = tmp.path().join("not-mounted-yet");
|
|
write_persisted_repos_dir(&root, Some(missing.to_str().unwrap())).unwrap();
|
|
ensure_repos_setup_default(&root).unwrap();
|
|
|
|
let restore = resolve_repos_at_boot(&root);
|
|
|
|
assert!(
|
|
!restore,
|
|
"restore must be skipped when a configured repos_dir cannot resolve at boot"
|
|
);
|
|
let repos = root.join("REPOS");
|
|
assert!(
|
|
!repos.symlink_metadata().unwrap().file_type().is_symlink(),
|
|
"REPOS must not become a symlink to an unresolved target"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn resolve_repos_at_boot_allows_restore_with_no_repos_dir() {
|
|
// No configured repos_dir → the real in-nest REPOS default is correct,
|
|
// restore proceeds normally (the fail-closed gate must not fire here).
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join(".buzz");
|
|
fs::create_dir_all(&root).unwrap();
|
|
ensure_repos_setup_default(&root).unwrap();
|
|
|
|
assert!(
|
|
resolve_repos_at_boot(&root),
|
|
"restore proceeds when no repos_dir is configured"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn should_restore_agents_only_blocks_configured_unresolved_boot() {
|
|
assert!(
|
|
should_restore_agents(false, &Ok(())),
|
|
"no repos_dir, symlink ok → restore"
|
|
);
|
|
assert!(
|
|
should_restore_agents(false, &Err("boom".into())),
|
|
"no repos_dir → real-dir default is correct even on Err → restore"
|
|
);
|
|
assert!(
|
|
should_restore_agents(true, &Ok(())),
|
|
"configured repos_dir resolved → restore"
|
|
);
|
|
assert!(
|
|
!should_restore_agents(true, &Err("boom".into())),
|
|
"configured repos_dir unresolved → fail closed, skip restore"
|
|
);
|
|
}
|
|
|
|
/// Test helper: canonicalize a path, creating it as a directory first.
|
|
#[cfg(unix)]
|
|
trait CanonicalizeOrMake {
|
|
fn canonicalize_or_make(&self) -> PathBuf;
|
|
}
|
|
#[cfg(unix)]
|
|
impl CanonicalizeOrMake for PathBuf {
|
|
fn canonicalize_or_make(&self) -> PathBuf {
|
|
fs::create_dir_all(self).unwrap();
|
|
self.canonicalize().unwrap()
|
|
}
|
|
}
|
|
}
|