use crate::event::{Body, Repo};
use crate::failure::Failure;
use crate::model::Tree;
use crate::output::outln;
use crate::registry::Sighting;
use crate::store::{Located, Store};
use std::path::{Path, PathBuf};
const RELOCATED_LOG: &str = "events.relocated";
const RELOCATED_CONFIG: &str = "config.relocated";
pub fn run(
located: &Located,
destination: &Path,
lane_name: Option<&str>,
cwd: &Path,
) -> Result<i32, Failure> {
if located.root != located.lane_dir || !crate::anchor::same_folder(cwd, &located.root) {
return Err(Failure::Model(
" Run relocate in the folder that holds the tree, not in one of its lanes."
.to_string(),
));
}
let destination_abs = to_absolute(destination, cwd);
if crate::anchor::same_folder(&destination_abs, &located.root) {
return Err(Failure::Model(
" The destination is this folder, so there is nothing to move.".to_string(),
));
}
if is_inside(&destination_abs, &located.root) {
return Err(Failure::Model(
" The destination is inside this folder. Moving the tree into itself\n \
would leave it with two."
.to_string(),
));
}
let origin = Store::open(located.root.clone())?;
let lock = origin.lock_for_write()?;
let (events, broken) = origin.read_all()?;
let tree = crate::model::fold(&events, broken);
let Some(first_event_id) = events.first().map(|e| e.id.clone()) else {
return Err(Failure::Model(
" This tree has no events yet, so nothing points at it and nothing would\n \
break: move the folder yourself."
.to_string(),
));
};
let store_dir = crate::store::store_dir();
if let Some(elsewhere) = store_dir
.as_deref()
.and_then(|d| crate::registry::path_elsewhere(d, &first_event_id, &located.root))
{
return Err(copy_refusal(elsewhere.name.as_deref()));
}
let origin_vivac = located.root.join(crate::store::DIR);
if destination_holds_a_tree_or_lane(&destination_abs) {
return Err(Failure::Model(format!(
" {} already holds a tree or a lane. Choose a folder with neither.",
destination.display()
)));
}
std::fs::create_dir_all(&destination_abs)?;
let destination_vivac = destination_abs.join(crate::store::DIR);
let mut written = Written {
vivac_dir_created: !destination_vivac.is_dir(),
files: Vec::new(),
};
let (log_tmp, config_tmp, log_verified, config_verified) =
match verify_copy(&origin_vivac, &destination_vivac, &mut written) {
Ok(v) => v,
Err(e) => {
written.undo(&destination_vivac);
return Err(e);
}
};
if let Err(e) = commit_copy(
log_verified,
config_verified,
&log_tmp,
&config_tmp,
&origin_vivac,
&destination_vivac,
&mut written,
) {
written.undo(&destination_vivac);
return Err(e);
}
let was_implicit_main = located.lane.is_none();
let stays_lane = located
.lane
.as_ref()
.map(|l| l.id.clone())
.unwrap_or_else(|| crate::lane::MAIN.to_string());
let repos = union_repo_roots(&tree);
let Some(store_dir) = store_dir else {
written.undo(&destination_vivac);
return Err(Failure::Io(std::io::Error::other(
"no VIVAC_HOME to record the move in; the registry would lose the tree",
)));
};
if let Err(e) = crate::registry::record_move(
&store_dir,
&first_event_id,
Sighting {
root: &destination_abs,
lane: Some((&stays_lane, &located.root)),
repos: Some(&repos),
},
) {
written.undo(&destination_vivac);
return Err(Failure::Io(e));
}
if let Err(e) = write_origin_bookkeeping(&origin_vivac, &stays_lane, first_event_id) {
return Err(Failure::Io(std::io::Error::other(format!(
"{e}\n\n Something failed while updating this folder's own bookkeeping. \
The destination already has the tree, and the registry already points \
there, but this folder may still hold a working copy of its own, or may \
not. Run `vivac check` here, and at the destination, to see how they \
compare."
))));
}
let _ = &lock;
let marked = write_claim_and_declaration(
&destination_abs,
&tree,
was_implicit_main,
&stays_lane,
lane_name,
)
.is_ok();
outln!(
" Moved the tree to {} and checked it byte for byte.",
destination.display()
);
outln!(" This folder stays one of its lanes, with its own thread.");
outln!(" The old log is kept here as .vivac/{RELOCATED_LOG}.");
outln!(" The new folder holds the tree but is not a lane yet. To work there:");
outln!(" vivac setup claude-code");
outln!(" Restart any session open on this tree.");
if !marked {
outln!(" The moved tree could not be marked from here. Run this in it:");
outln!(" vivac setup claude-code");
}
Ok(0)
}
fn copy_refusal(name: Option<&str>) -> Failure {
let label = crate::registry::label_for(name);
Failure::Model(format!(
" This folder is a copy of the tree in {label}, so the project does not live\n \
here. Moving this copy would point every lane at it and leave that tree\n \
behind: run relocate in {label} instead."
))
}
fn to_absolute(destination: &Path, cwd: &Path) -> PathBuf {
let joined = if destination.is_absolute() {
destination.to_path_buf()
} else {
cwd.join(destination)
};
crate::anchor::normalize(&joined)
}
fn is_inside(destination: &Path, origin: &Path) -> bool {
crate::anchor::normalize(destination)
.ancestors()
.skip(1)
.any(|ancestor| crate::anchor::same_folder(ancestor, origin))
}
fn destination_holds_a_tree_or_lane(destination: &Path) -> bool {
let vivac = destination.join(crate::store::DIR);
vivac.join(crate::store::LOG).is_file()
|| vivac.join(crate::store::CONFIG).is_file()
|| vivac.join(crate::store::LANE).is_file()
}
struct Written {
vivac_dir_created: bool,
files: Vec<PathBuf>,
}
impl Written {
fn undo(&self, destination_vivac: &Path) {
for f in &self.files {
std::fs::remove_file(f).ok();
}
if self.vivac_dir_created {
std::fs::remove_dir(destination_vivac).ok();
}
}
}
mod verified {
use std::path::Path;
pub struct Verified(());
pub fn compare(a: &Path, b: &Path) -> std::io::Result<Option<Verified>> {
if std::fs::read(a)? == std::fs::read(b)? {
Ok(Some(Verified(())))
} else {
Ok(None)
}
}
}
use verified::Verified;
fn verify_copy(
origin_vivac: &Path,
destination_vivac: &Path,
written: &mut Written,
) -> Result<(PathBuf, PathBuf, Verified, Verified), Failure> {
std::fs::create_dir_all(destination_vivac)?;
let log_tmp = destination_vivac.join(format!("events.{}.tmp", crate::id::ulid()));
std::fs::copy(origin_vivac.join(crate::store::LOG), &log_tmp)?;
written.files.push(log_tmp.clone());
let config_tmp = destination_vivac.join(format!("config.{}.tmp", crate::id::ulid()));
std::fs::copy(origin_vivac.join(crate::store::CONFIG), &config_tmp)?;
written.files.push(config_tmp.clone());
let log_verified = verified::compare(&origin_vivac.join(crate::store::LOG), &log_tmp)?;
let config_verified = verified::compare(&origin_vivac.join(crate::store::CONFIG), &config_tmp)?;
let (Some(log_verified), Some(config_verified)) = (log_verified, config_verified) else {
return Err(Failure::Io(std::io::Error::other(
"the copy at the destination did not match the source byte for byte",
)));
};
Ok((log_tmp, config_tmp, log_verified, config_verified))
}
fn commit_copy(
_log_verified: Verified,
_config_verified: Verified,
log_tmp: &Path,
config_tmp: &Path,
origin_vivac: &Path,
destination_vivac: &Path,
written: &mut Written,
) -> Result<(), Failure> {
let destination_gitignore = destination_vivac.join(crate::store::GITIGNORE);
if !destination_gitignore.is_file() {
let origin_gitignore = origin_vivac.join(crate::store::GITIGNORE);
if origin_gitignore.is_file() {
std::fs::copy(&origin_gitignore, &destination_gitignore)?;
} else {
crate::store::write_gitignore(destination_vivac)?;
}
written.files.push(destination_gitignore);
}
let lock_path = destination_vivac.join(crate::store::LOCK);
if !lock_path.is_file() {
std::fs::File::create(&lock_path)?;
written.files.push(lock_path);
}
let log_final = destination_vivac.join(crate::store::LOG);
std::fs::rename(log_tmp, &log_final)?;
written.files.push(log_final);
let config_final = destination_vivac.join(crate::store::CONFIG);
std::fs::rename(config_tmp, &config_final)?;
written.files.push(config_final);
Ok(())
}
fn write_origin_bookkeeping(
origin_vivac: &Path,
stays_lane: &str,
first_event_id: String,
) -> std::io::Result<()> {
crate::lane::write(
origin_vivac,
&crate::lane::Lane {
version: 1,
id: stays_lane.to_string(),
project: first_event_id,
},
)?;
std::fs::rename(
origin_vivac.join(crate::store::CONFIG),
origin_vivac.join(RELOCATED_CONFIG),
)?;
std::fs::rename(
origin_vivac.join(crate::store::LOG),
origin_vivac.join(RELOCATED_LOG),
)?;
std::fs::remove_file(origin_vivac.join(crate::store::INDEX)).ok();
Ok(())
}
fn union_repo_roots(tree: &Tree) -> Vec<String> {
let mut roots: Vec<String> = tree
.lanes
.values()
.flat_map(|state| state.repos.iter())
.filter_map(|repo| repo.root.clone())
.collect();
roots.sort();
roots.dedup();
roots
}
fn write_claim_and_declaration(
destination: &Path,
tree: &Tree,
was_implicit_main: bool,
stays_lane: &str,
lane_name: Option<&str>,
) -> Result<(), Failure> {
let existing = tree.lanes.get(stays_lane);
let existing_repos: Vec<Repo> = existing.map(|s| s.repos.clone()).unwrap_or_default();
let existing_name = existing.map(|s| s.name.clone()).unwrap_or_default();
let redeclare_name: Option<String> = match lane_name {
Some(name) => Some(crate::lane::declared_name(stays_lane, name)),
None if was_implicit_main && !existing_repos.is_empty() => Some(existing_name),
None => None,
};
let mut bodies = Vec::new();
if was_implicit_main {
bodies.push(Body::LaneClaimed {
lane: crate::lane::MAIN.to_string(),
});
}
if let Some(name) = redeclare_name {
bodies.push(Body::LaneDeclared {
lane: stays_lane.to_string(),
name,
repos: existing_repos,
});
}
if bodies.is_empty() {
return Ok(());
}
let mut moved = Store::open(destination.to_path_buf())?;
let moved_lock = moved.lock_for_write()?;
moved.append(
&moved_lock,
stays_lane,
bodies,
tree.seq,
tree.has_governance,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id;
fn temp_dir(prefix: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("vivac-relocate-{prefix}-{}", id::ulid()))
}
fn seeded_located(root: &Path) -> Located {
let mut s = Store::create(root).unwrap();
let lock = s.lock_for_write().unwrap();
s.append(
&lock,
crate::lane::MAIN,
vec![crate::event::Body::NodeNoted {
node: "t1".into(),
note: "seed".into(),
}],
0,
false,
)
.unwrap();
Located {
root: root.to_path_buf(),
lane_dir: root.to_path_buf(),
lane: None,
worktree: None,
}
}
struct IsolatedVivacHome {
_guard: std::sync::MutexGuard<'static, ()>,
home: std::path::PathBuf,
previous: Option<std::ffi::OsString>,
}
impl IsolatedVivacHome {
fn new(prefix: &str) -> IsolatedVivacHome {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
let guard = LOCK
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner());
let home = temp_dir(prefix);
let previous = std::env::var_os("VIVAC_HOME");
std::env::set_var("VIVAC_HOME", &home);
IsolatedVivacHome {
_guard: guard,
home,
previous,
}
}
}
impl Drop for IsolatedVivacHome {
fn drop(&mut self) {
match self.previous.take() {
Some(v) => std::env::set_var("VIVAC_HOME", v),
None => std::env::remove_var("VIVAC_HOME"),
}
std::fs::remove_dir_all(&self.home).ok();
}
}
#[test]
fn an_old_process_writing_after_the_move_creates_no_log() {
let _home = IsolatedVivacHome::new("old-process-vivac-home");
let origin = temp_dir("old-process-origin");
let located = seeded_located(&origin);
let mut old_process = Store::open(origin.clone()).unwrap();
let destination = temp_dir("old-process-dest");
let code = run(&located, &destination, None, &origin).unwrap();
assert_eq!(code, 0);
assert!(
!origin
.join(crate::store::DIR)
.join(crate::store::LOG)
.is_file(),
"relocate must have already renamed the origin's own log away"
);
let lock = old_process.lock_for_write().unwrap();
let body = vec![crate::event::Body::NodeNoted {
node: "01OLDPROCESSAAAAAAAAAAAAAA".into(),
note: "written by a handle opened before the move".into(),
}];
let result = old_process.append(&lock, crate::lane::MAIN, body, 0, false);
assert!(
result.is_err(),
"a handle opened before the move recreated the log relocate just renamed away"
);
assert!(
!origin
.join(crate::store::DIR)
.join(crate::store::LOG)
.is_file(),
"a new events file appeared at the origin after an old handle wrote to it"
);
std::fs::remove_dir_all(&origin).ok();
std::fs::remove_dir_all(&destination).ok();
}
#[test]
fn compare_holds_a_verified_for_two_identical_files_and_none_for_two_that_differ() {
let dir = temp_dir("compare-verified");
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a");
let b = dir.join("b");
let c = dir.join("c");
std::fs::write(&a, b"hello").unwrap();
std::fs::write(&b, b"hello").unwrap();
std::fs::write(&c, b"world").unwrap();
assert!(
verified::compare(&a, &b).unwrap().is_some(),
"two identical files must agree"
);
assert!(
verified::compare(&a, &c).unwrap().is_none(),
"two files with different content must not agree"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn write_claim_and_declaration_fails_when_the_destination_cannot_be_opened() {
let destination = temp_dir("claim-blocked");
std::fs::write(&destination, b"not a directory").unwrap();
let tree = Tree::default();
let result =
write_claim_and_declaration(&destination, &tree, true, crate::lane::MAIN, None);
assert!(
result.is_err(),
"a destination that cannot be opened must fail write_claim_and_declaration"
);
std::fs::remove_file(&destination).ok();
}
#[test]
fn a_lane_name_the_guard_rejects_falls_back_without_failing() {
let _home = IsolatedVivacHome::new("lane-name-guard-vivac-home");
let secret = "ghp_16C7e42F292c6912E7710c838347Ae178B4a";
assert!(
crate::redact::check_field("lane name", secret).is_some(),
"the guard must actually reject this name, or the test proves nothing"
);
let origin = temp_dir("lane-name-guard-origin");
let located = seeded_located(&origin);
let destination = temp_dir("lane-name-guard-dest");
let code = run(&located, &destination, Some(secret), &origin).unwrap();
assert_eq!(code, 0);
let log =
std::fs::read_to_string(destination.join(crate::store::DIR).join(crate::store::LOG))
.unwrap();
assert!(
!log.contains(secret),
"a name the guard rejects must never reach the log: {log}"
);
std::fs::remove_dir_all(&origin).ok();
std::fs::remove_dir_all(&destination).ok();
}
}