use crate::args::Args;
use crate::failure::Failure;
use std::path::{Path, PathBuf};
pub(super) const VIVAC_LABEL: &str = ".vivac/";
const GITIGNORE_LABEL: &str = ".vivac/.gitignore";
pub(super) const LANE_LABEL: &str = ".vivac/lane";
const NAME_MAX_LEN: usize = 100;
fn validate_name(raw: &str) -> Result<&str, Failure> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(Failure::usage("--name cannot be empty."));
}
if trimmed.chars().any(|c| c.is_control()) {
return Err(Failure::usage(
"--name is one line: it cannot carry a newline or another control \
character.",
));
}
if trimmed.chars().count() > NAME_MAX_LEN {
return Err(Failure::usage(format!(
"--name is {} characters long; the limit is {NAME_MAX_LEN}.",
trimmed.chars().count()
)));
}
Ok(trimmed)
}
fn requested_name(a: &Args) -> Result<Option<String>, Failure> {
let Some(raw) = a.opt("name") else {
return Ok(None);
};
let trimmed = validate_name(raw)?;
match crate::redact::check_field("project name", trimmed) {
Some(finding) => Err(Failure::Redaction(Box::new(finding))),
None => Ok(Some(trimmed.to_string())),
}
}
fn product_name_for_plan(tree: &Path, requested: Option<&str>) -> Option<String> {
if let Some(name) = requested {
return Some(name.to_string());
}
let store_dir = crate::store::store_dir()?;
crate::registry::effective_name(&store_dir, tree)
}
fn product_label(name: Option<&str>) -> String {
match name {
Some(n) => format!("\"{n}\""),
None => "this product".to_string(),
}
}
pub(super) fn note_name(plan: &TreePlan) {
let Some(name) = plan.requested_name.as_deref() else {
return;
};
let Some(store_dir) = crate::store::store_dir() else {
return;
};
let Some(project_id) = crate::store::first_event_id(&plan.tree) else {
return;
};
crate::registry::set_name(&store_dir, &project_id, name);
}
const TREE_SCAN_DEPTH: u32 = 2;
pub(super) fn trees_below(folder: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
for sub in child_folders(folder) {
walk_for_trees(&sub, 1, &mut found);
}
found.sort();
found
}
fn child_folders(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut subdirs: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.filter(|p| p.file_name().is_some_and(|n| n != crate::store::DIR))
.collect();
subdirs.sort();
subdirs
}
fn walk_for_trees(dir: &Path, depth: u32, found: &mut Vec<PathBuf>) {
if crate::store::already_planted(dir) {
found.push(dir.to_path_buf());
return;
}
if dir.join(".git").exists() {
return;
}
if depth == TREE_SCAN_DEPTH {
return;
}
for sub in child_folders(dir) {
walk_for_trees(&sub, depth + 1, found);
}
}
pub(super) fn guarded_folder_name(path: &Path) -> Option<String> {
let name = path.file_name()?.to_string_lossy().into_owned();
match crate::redact::check_field("folder name", &name) {
Some(_) => None,
None => Some(name),
}
}
pub(super) fn tree_below_refusal(paths: &[PathBuf]) -> Failure {
let names: Vec<Option<String>> = paths.iter().map(|p| guarded_folder_name(p)).collect();
if let [only] = names.as_slice() {
let label = crate::registry::label_for(only.as_deref());
return Failure::Model(format!(
" There is already a tree inside this folder, in {label}.\n \
Planting another one here would split this project: sessions opened in\n \
{label} would use that one, and the rest this one.\n\n \
Move that tree up here, then run init again. From inside {label}:\n \
vivac relocate .."
));
}
let quoted: Vec<String> = names
.iter()
.filter_map(|n| n.as_deref())
.map(|n| format!("\"{n}\""))
.collect();
let quoted_refs: Vec<&str> = quoted.iter().map(String::as_str).collect();
let where_clause = if quoted_refs.is_empty() {
"under names this tool will not write down".to_string()
} else {
format!("in {}", super::claude_code::join_with_and("ed_refs))
};
Failure::Model(format!(
" There are trees inside this folder, {where_clause}.\n \
vivac cannot merge trees: keep one per product, move it up here with\n \
vivac relocate, and leave the others as they are."
))
}
fn refuse_second_map(roots: &super::Roots, bypass_registered: bool) -> Result<(), Failure> {
if roots.located.is_some() {
return Ok(());
}
if bypass_registered {
return Ok(());
}
let (here_repos, _excluded) = filtered_repos(crate::repos::scan(&roots.here));
let root_commits: Vec<String> = here_repos.iter().filter_map(|r| r.root.clone()).collect();
if root_commits.is_empty() {
return Ok(());
}
let Some(store_dir) = crate::store::store_dir() else {
return Ok(());
};
let best = crate::registry::sharing_repos(&store_dir, &root_commits)
.into_iter()
.find(|s| !crate::anchor::same_folder(&s.root, &roots.here));
match best {
Some(sharing) => Err(product_registered_refusal(&sharing, &here_repos)),
None => Ok(()),
}
}
fn product_registered_refusal(
sharing: &crate::registry::Sharing,
here_repos: &[crate::event::Repo],
) -> Failure {
let mut repo_names: Vec<&str> = here_repos
.iter()
.filter(|r| {
r.root
.as_deref()
.is_some_and(|root| sharing.shared.iter().any(|s| s == root))
})
.map(|r| r.path.as_str())
.collect();
repo_names.sort_unstable();
let repo_list = repo_names
.iter()
.map(|p| if *p == "." { "this folder itself" } else { p })
.collect::<Vec<_>>()
.join(", ");
match &sharing.name {
Some(name) => Failure::Model(format!(
" Some repositories here are already tracked by project \"{name}\":\n \
{repo_list}\n \
Planting another tree would give this product two maps.\n\n \
To work on {name} from this folder:\n \
vivac init --join {}\n \
If the tree should live here instead, run this in the folder that holds it:\n \
vivac relocate <path to this folder>\n \
To plant a separate tree anyway:\n \
vivac init --new-tree",
crate::registry::quote_if_needed(name)
)),
None => Failure::Model(format!(
" Some repositories here are already tracked by another project on this\n \
machine:\n \
{repo_list}\n \
Planting another tree would give this product two maps.\n\n \
To work on it from this folder, give the path to its folder:\n \
vivac init --join <path to that folder>\n \
If the tree should live here instead, run this in the folder that holds it:\n \
vivac relocate <path to this folder>\n \
To plant a separate tree anyway:\n \
vivac init --new-tree"
)),
}
}
fn second_map_hint(here: &Path) -> Option<String> {
let store_dir = crate::store::store_dir()?;
if crate::registry::roots(&store_dir).is_empty() {
return None;
}
let (here_repos, _excluded) = filtered_repos(crate::repos::scan(here));
let root_commits: Vec<String> = here_repos.iter().filter_map(|r| r.root.clone()).collect();
if !crate::registry::sharing_repos(&store_dir, &root_commits).is_empty() {
return None;
}
Some(format!(
"{} Nothing here shares a repository with the projects vivac already tracks, so \
it cannot tell whether this is one of them. If it is, stop and use {} instead.",
crate::style::warn(crate::style::Stream::Out, "This plants a new product."),
crate::style::bold(crate::style::Stream::Out, "--join <name>")
))
}
fn tree_root_above(tree_root: &Path) -> Option<PathBuf> {
let mut d = tree_root.to_path_buf();
while d.pop() {
if crate::store::already_planted(&d) {
return Some(d);
}
}
None
}
fn tree_above_warning(name: Option<&str>) -> String {
let label = crate::registry::label_for(name);
format!(
"This tree sits inside another one, in folder {label}. Sessions opened above \
this folder use that one: keep one tree per product."
)
}
pub(super) struct LanePlan {
lane_id: String,
pub(super) name: String,
repos: Vec<crate::event::Repo>,
pub(super) is_new: bool,
pub(super) needs_lock: bool,
pub(super) unchanged: bool,
pub(super) excluded: Option<(usize, &'static str)>,
pub(super) stale_worktrees: Vec<(String, String, crate::event::Repo)>,
}
fn filtered_repos(
scanned: Vec<crate::event::Repo>,
) -> (Vec<crate::event::Repo>, Option<(usize, &'static str)>) {
let mut excluded_count = 0usize;
let mut excluded_rule: Option<&'static str> = None;
let repos = scanned
.into_iter()
.filter(
|r| match crate::redact::check_field("repository path", &r.path) {
Some(f) => {
excluded_count += 1;
excluded_rule.get_or_insert(f.rule);
false
}
None => true,
},
)
.collect();
(
repos,
(excluded_count > 0).then(|| (excluded_count, excluded_rule.unwrap())),
)
}
fn fold_tree(tree_root: &Path) -> crate::model::Tree {
let (events, broken) =
crate::store::read_all_from(&tree_root.join(crate::store::DIR).join(crate::store::LOG))
.unwrap_or_default();
crate::model::fold(&events, broken)
}
pub(super) fn lane_has_written(tree_root: &Path, lane_id: &str) -> bool {
fold_tree(tree_root)
.lanes
.get(lane_id)
.is_some_and(|s| s.seq_change != 0)
}
struct ExistingLane {
config_version: crate::store::ConfigVersion,
declared: Option<(String, Vec<crate::event::Repo>)>,
}
fn existing_lane(tree: &Path, lane_id: &str, folded: &crate::model::Tree) -> ExistingLane {
ExistingLane {
config_version: crate::store::peek_config_version(tree)
.unwrap_or(crate::store::ConfigVersion::One),
declared: folded
.lanes
.get(lane_id)
.map(|s| (s.name.clone(), s.repos.clone())),
}
}
fn plan_lane(roots: &super::Roots, lane_name: Option<&str>) -> LanePlan {
let (repos, excluded) = filtered_repos(crate::repos::scan(&roots.here));
let folder_name = roots
.here
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let requested_name = lane_name.unwrap_or(&folder_name);
let here_has_its_own_vivac = roots
.located
.as_ref()
.is_some_and(|l| l.lane_dir == roots.here);
let folded = fold_tree(&roots.tree);
let (lane_id, name, is_new) = match &roots.located {
None => main_lane(lane_name, &folder_name),
Some(l) if here_has_its_own_vivac && l.lane.is_none() && !folded.main_claimed => {
main_lane(lane_name, &folder_name)
}
Some(l) if here_has_its_own_vivac && l.lane.is_none() => {
let id = crate::lane::new_id();
let name = crate::lane::declared_name(&id, requested_name);
(id, name, true)
}
Some(l) if here_has_its_own_vivac => {
let id = l.lane.as_ref().unwrap().id.clone();
let name = crate::lane::declared_name(&id, requested_name);
(id, name, false)
}
Some(_) => {
let id = crate::lane::new_id();
let name = crate::lane::declared_name(&id, requested_name);
(id, name, true)
}
};
let existing = existing_lane(&roots.tree, &lane_id, &folded);
let needs_lock = existing.config_version != crate::store::ConfigVersion::Lanes;
let unchanged = existing
.declared
.is_some_and(|(n, r)| n == name && r == repos);
let stale_worktrees = stale_worktree_roots(&roots.here, &repos, &lane_id, &folded);
LanePlan {
lane_id,
name,
repos,
is_new,
needs_lock,
unchanged,
excluded,
stale_worktrees,
}
}
fn stale_worktree_roots(
here: &Path,
repos: &[crate::event::Repo],
lane_id: &str,
folded: &crate::model::Tree,
) -> Vec<(String, String, crate::event::Repo)> {
let mut out = Vec::new();
for repo in repos {
let Some(root) = &repo.root else { continue };
for worktree in linked_worktrees_of(&here.join(&repo.path)) {
let Ok(Some(lane)) = crate::lane::read(&worktree.join(crate::store::DIR)) else {
continue;
};
if lane.id == lane_id {
continue;
}
let Some(state) = folded.lanes.get(&lane.id) else {
continue;
};
let pending_shape = [crate::event::Repo {
path: ".".to_string(),
root: None,
}];
if state.repos == pending_shape {
out.push((
lane.id,
state.name.clone(),
crate::event::Repo {
path: ".".to_string(),
root: Some(root.clone()),
},
));
}
}
}
out
}
fn linked_worktrees_of(repo_root: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(repo_root.join(".git").join("worktrees")) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|e| std::fs::read_to_string(e.path().join("gitdir")).ok())
.filter_map(|raw| PathBuf::from(raw.trim()).parent().map(Path::to_path_buf))
.collect()
}
fn main_lane(lane_name: Option<&str>, folder_name: &str) -> (String, String, bool) {
let requested_name = lane_name.unwrap_or(folder_name);
let name = crate::lane::declared_name(crate::lane::MAIN, requested_name);
(crate::lane::MAIN.to_string(), name, false)
}
fn ensure_first_event(tree: &Path) -> Result<String, Failure> {
if let Some(id) = crate::store::first_event_id(tree) {
return Ok(id);
}
let (repos, _excluded) = filtered_repos(crate::repos::scan(tree));
let store = crate::store::Store::open(tree.to_path_buf())?;
let mut ctx = crate::ops::Ctx::load_for_write(
store,
crate::ops::Whose::Declared(crate::lane::MAIN.to_string(), tree.to_path_buf()),
)?;
ctx.lock_for_write()?;
crate::ops::declare_lane(&mut ctx, crate::lane::MAIN.to_string(), repos)?;
crate::store::first_event_id(tree).ok_or_else(|| {
Failure::Io(std::io::Error::other(
"this folder's main lane was just declared to give the tree a first \
event, and locked its config to match, and the tree's own first \
line is still unreadable after that -- the log only ever grows, \
so what was just written stays either way",
))
})
}
fn write_lane(roots: &super::Roots, plan: &LanePlan) -> Result<(), Failure> {
match write_lane_inner(roots, plan) {
Ok(()) => Ok(()),
Err(e) => {
if plan.is_new {
undo_fresh_lane_file(roots);
}
Err(e)
}
}
}
fn write_lane_inner(roots: &super::Roots, plan: &LanePlan) -> Result<(), Failure> {
if plan.is_new {
let project = ensure_first_event(&roots.tree)?;
let lane = crate::lane::Lane {
version: 1,
id: plan.lane_id.clone(),
project,
};
crate::lane::write(&roots.here.join(crate::store::DIR), &lane)?;
}
let store = crate::store::Store::open(roots.tree.clone())?;
let mut ctx = crate::ops::Ctx::load_for_write(
store,
crate::ops::Whose::Declared(plan.lane_id.clone(), roots.here.clone()),
)?;
ctx.lock_for_write()?;
crate::ops::declare_lane(&mut ctx, plan.name.clone(), plan.repos.clone())?;
redeclare_stale_worktrees(&mut ctx, plan)
}
fn undo_fresh_lane_file(roots: &super::Roots) {
let vivac_dir = roots.here.join(crate::store::DIR);
let dir_holds_only_the_lane = vivac_dir_holds_only_the_lane(&vivac_dir);
let _ = std::fs::remove_file(vivac_dir.join(crate::lane::FILE));
if dir_holds_only_the_lane {
let _ = std::fs::remove_file(vivac_dir.join(crate::store::GITIGNORE));
let _ = std::fs::remove_dir(&vivac_dir);
}
}
fn redeclare_only_stale_worktrees(roots: &super::Roots, plan: &LanePlan) -> Result<(), Failure> {
let store = crate::store::Store::open(roots.tree.clone())?;
let mut ctx = crate::ops::Ctx::load_for_write(
store,
crate::ops::Whose::Declared(plan.lane_id.clone(), roots.here.clone()),
)?;
ctx.lock_for_write()?;
redeclare_stale_worktrees(&mut ctx, plan)
}
fn redeclare_stale_worktrees(ctx: &mut crate::ops::Ctx, plan: &LanePlan) -> Result<(), Failure> {
for (lane, name, repo) in plan.stale_worktrees.clone() {
redeclare_worktree_root(ctx, lane, name, repo)?;
}
Ok(())
}
fn redeclare_worktree_root(
ctx: &mut crate::ops::Ctx,
lane: String,
name: String,
repo: crate::event::Repo,
) -> Result<(), Failure> {
let lock = ctx
.lock
.as_ref()
.ok_or_else(|| Failure::Io(std::io::Error::other("write without the tree's lock")))?;
let appended = ctx.store.append(
lock,
&lane,
vec![crate::event::Body::LaneDeclared {
lane: lane.clone(),
name,
repos: vec![repo],
}],
ctx.tree.seq,
ctx.tree.has_governance,
)?;
for e in &appended.events {
ctx.tree.apply(e.seq, &e.ts, &e.lane, &e.payload);
}
Ok(())
}
fn relock_lanes(tree: &Path) -> Result<(), Failure> {
let mut store = crate::store::Store::open(tree.to_path_buf())?;
let lock = store.lock_for_write()?;
store.lock_lanes_in_config(&lock)?;
Ok(())
}
pub(super) fn detail_of(e: &Failure) -> String {
match e {
Failure::Io(io) => io.to_string(),
other => other.message(),
}
}
fn union_repo_roots(tree: &crate::model::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
}
pub(super) fn note_registry(roots: &super::Roots) {
let Some(store_dir) = crate::store::store_dir() else {
return;
};
if let Some(project_id) = crate::store::first_event_id(&roots.tree) {
let lane = roots.located.as_ref().and_then(|l| {
l.lane
.as_ref()
.map(|lane| (lane.id.as_str(), l.lane_dir.as_path()))
});
let repos = union_repo_roots(&fold_tree(&roots.tree));
let noted = crate::registry::note(
&store_dir,
&project_id,
crate::registry::Sighting {
root: &roots.tree,
lane,
repos: Some(&repos),
},
);
crate::registry::set_pending(noted);
}
}
pub(super) fn plan_join_lane(here: &Path, target: &Path, lane_name: Option<&str>) -> LanePlan {
let (repos, excluded) = filtered_repos(crate::repos::scan(here));
let folder_name = here
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let requested_name = lane_name.unwrap_or(&folder_name);
let id = crate::lane::new_id();
let name = crate::lane::declared_name(&id, requested_name);
let folded = fold_tree(target);
let existing = existing_lane(target, &id, &folded);
let needs_lock = existing.config_version != crate::store::ConfigVersion::Lanes;
let unchanged = existing
.declared
.is_some_and(|(n, r)| n == name && r == repos);
let stale_worktrees = stale_worktree_roots(here, &repos, &id, &folded);
LanePlan {
lane_id: id,
name,
repos,
is_new: true,
needs_lock,
unchanged,
excluded,
stale_worktrees,
}
}
pub(super) fn lane_carried_by<'a>(l: &'a crate::store::Located, here: &Path) -> Option<&'a str> {
let lane = l.lane.as_ref()?;
crate::anchor::same_folder(&l.lane_dir, here).then_some(lane.id.as_str())
}
pub(super) fn say_nothing_was_done(target: &Path, lane_id: &str, lane_name: Option<&str>) {
crate::output::outln!(
" This folder is already a lane of that tree, and init changed nothing in it."
);
let Some(requested) = lane_name else {
return;
};
let requested = crate::lane::declared_name(lane_id, requested);
let current = fold_tree(target)
.lanes
.get(lane_id)
.map(|s| s.name.clone())
.unwrap_or_default();
if current != requested {
crate::output::outln!(" The lane name it already has was left as it is.");
}
}
pub(super) struct TreePlan {
pub(super) here: PathBuf,
pub(super) tree: PathBuf,
pub(super) vivac_missing: bool,
pub(super) gitignore_missing: bool,
pub(super) lane: LanePlan,
pub(super) product_name: Option<String>,
pub(super) requested_name: Option<String>,
pub(super) name_collision: Option<String>,
pub(super) above_warning: Option<String>,
pub(super) unknown_product_warning: String,
pub(super) log_tracked: bool,
}
fn build_plan(roots: &super::Roots, lane: LanePlan, requested: Option<String>) -> TreePlan {
let here = roots.here.clone();
let tree = roots.tree.clone();
let vivac_missing = !crate::store::already_planted(&tree);
let gitignore_missing = !vivac_missing
&& !tree
.join(crate::store::DIR)
.join(crate::store::GITIGNORE)
.is_file();
let product_name = product_name_for_plan(&tree, requested.as_deref());
let name_collision = requested
.as_deref()
.filter(|name| {
crate::store::store_dir().is_some_and(|store_dir| {
crate::registry::another_project_answers_to(&store_dir, name)
})
})
.map(str::to_string);
let above_warning =
tree_root_above(&tree).map(|p| tree_above_warning(guarded_folder_name(&p).as_deref()));
let unknown_product_warning = if vivac_missing {
second_map_hint(&here).unwrap_or_default()
} else {
String::new()
};
let log_tracked = crate::anchor::in_working_tree(&tree)
&& crate::anchor::tracks(&tree, ".vivac/events") == Some(true);
TreePlan {
here,
tree,
vivac_missing,
gitignore_missing,
lane,
product_name,
requested_name: requested,
name_collision,
above_warning,
unknown_product_warning,
log_tracked,
}
}
pub(super) fn plan(roots: &super::Roots, a: &Args) -> Result<TreePlan, Failure> {
refuse_second_map(roots, a.has("new-tree"))?;
let tree = &roots.tree;
let requested = requested_name(a)?;
if requested.is_some() && crate::store::already_planted(tree) && !a.has("new-tree") {
return Err(Failure::usage(
"--name only names a product while init plants one: this \
folder's tree already exists, and already has a name of its \
own.\n\n Nothing written.",
));
}
let lane = plan_lane(roots, a.opt("lane-name"));
Ok(build_plan(roots, lane, requested))
}
pub(super) fn plan_for_join(join_roots: &super::Roots, lane: LanePlan) -> TreePlan {
build_plan(join_roots, lane, None)
}
fn tree_above_refusal(tree_root: &Path) -> Failure {
let label = crate::registry::label_for(guarded_folder_name(tree_root).as_deref());
Failure::Model(format!(
" A tree sits above this folder, in {label}, so this folder already belongs to\n \
that product. Joining it to a different tree would split the two. To see\n \
where it belongs: vivac brief"
))
}
pub(super) fn plan_join(
roots: &super::Roots,
spec: &str,
lane_name: Option<&str>,
) -> Result<Option<(super::Roots, TreePlan)>, Failure> {
let target = crate::registry::resolve(spec)?;
if !crate::store::already_planted(&target) {
return Err(Failure::Model(format!(
" \"{spec}\" has no tree yet, so there is nothing to join.\n \
Plant one there first: vivac init"
)));
}
if let Some(l) = &roots.located {
if !crate::anchor::same_folder(&l.root, &target) {
if crate::anchor::same_folder(&roots.here, &l.root) {
return Err(Failure::already_has_a_tree());
}
if lane_carried_by(l, &roots.here).is_some() {
return Err(Failure::already_a_lane());
}
return Err(tree_above_refusal(&l.root));
}
if let Some(id) = lane_carried_by(l, &roots.here) {
say_nothing_was_done(&target, id, lane_name);
return Ok(None);
}
}
if crate::store::first_event_id(&target).is_none() {
return Err(Failure::Model(
" That tree has no events yet, so there is nothing to join: it has\n \
no identity yet for a lane to point back at."
.to_string(),
));
}
let join_roots = super::Roots {
here: roots.here.clone(),
tree: target.clone(),
located: None,
};
let lane = plan_join_lane(&roots.here, &target, lane_name);
let plan = plan_for_join(&join_roots, lane);
Ok(Some((join_roots, plan)))
}
pub(super) fn opening_items(plan: &TreePlan) -> Vec<super::claude_code::PlanItem> {
use super::claude_code::PlanItem;
let mut items = Vec::new();
let mut vivac_item = if plan.vivac_missing {
PlanItem::new("plant", VIVAC_LABEL, "the tree")
} else {
PlanItem::new("keep", VIVAC_LABEL, "already there")
};
if !plan.vivac_missing && plan.tree != plan.here {
vivac_item = vivac_item.with_sub("in", plan.tree.display().to_string());
}
items.push(vivac_item);
if plan.gitignore_missing {
let what = if plan.lane.is_new {
"keeps the tree's .vivac/ out of version control"
} else {
"keeps .vivac/ out of version control"
};
items.push(PlanItem::new("create", GITIGNORE_LABEL, what));
}
items
}
pub(super) fn closing_items(plan: &TreePlan) -> Vec<super::claude_code::PlanItem> {
use super::claude_code::PlanItem;
let mut items = Vec::new();
let lane = &plan.lane;
if !lane.unchanged {
let product = product_label(plan.product_name.as_deref());
if lane.is_new {
items.push(PlanItem::new(
"create",
LANE_LABEL,
format!("this folder becomes lane \"{}\" of {product}", lane.name),
));
items.push(PlanItem::new(
"create",
GITIGNORE_LABEL,
"keeps .vivac/ out of version control",
));
} else {
items.push(PlanItem::new(
"write",
".vivac/events",
format!(
"its log, with this folder as part \"{}\" of {product}",
lane.name
),
));
}
}
if !lane.stale_worktrees.is_empty() {
let count = lane.stale_worktrees.len();
let noun = if count == 1 { "lane" } else { "lanes" };
items.push(PlanItem::new(
"redeclare",
".vivac/events",
format!("{count} worktree {noun} with the repositories this run found"),
));
}
if let Some((count, rule)) = lane.excluded {
let noun = if count == 1 {
"repository"
} else {
"repositories"
};
let value = format!("{count} {noun}, refused: {rule}");
let last = items
.pop()
.unwrap_or_else(|| PlanItem::new("keep", ".vivac/events", ""));
items.push(last.with_sub("kept out", value));
}
if lane.needs_lock {
items.push(PlanItem::new(
"lock",
".vivac/config",
"the minimum version to open it: vivac 0.12",
));
}
items
}
fn lane_failure_with_rollback(clause: String, unrestored: &[PathBuf]) -> Failure {
let mut message = clause;
if unrestored.is_empty() {
message.push_str(
", so init put back everything it had already written here and in\n \
the tree. Whatever this already wrote to the tree's own log stays\n \
either way: the log only ever grows.",
);
} else {
message.push_str(", and init could not put these back as they were:\n");
for p in unrestored {
message.push_str(&format!(" {}\n", p.display()));
}
message.push_str(
" init keeps no copy on disk, so the only other copy is whatever\n \
version control holds. Whatever this already wrote to the tree's own\n \
log stays either way: the log only ever grows.",
);
}
Failure::Io(std::io::Error::other(message))
}
pub(super) fn file_writes(roots: &super::Roots, plan: &TreePlan) -> Vec<super::PlannedWrite> {
let mut writes = Vec::new();
if plan.gitignore_missing {
writes.push(super::PlannedWrite::write(
roots
.tree
.join(crate::store::DIR)
.join(crate::store::GITIGNORE),
"*\n".to_string(),
None,
));
}
writes
}
pub(super) fn commit(
roots: &super::Roots,
plan: &TreePlan,
committed: &[super::PlannedWrite],
) -> Result<(), Failure> {
if plan.vivac_missing {
if let Err(e) = crate::store::Store::create(&roots.tree) {
let unrestored = super::rollback(committed);
return Err(super::failure_with_rollback(
format!("the tree could not be planted ({e})"),
&unrestored,
));
}
}
if !plan.lane.unchanged {
if let Err(e) = write_lane(roots, &plan.lane) {
let unrestored = super::rollback(committed);
return Err(lane_failure_with_rollback(
format!("the lane could not be declared ({})", detail_of(&e)),
&unrestored,
));
}
} else {
if !plan.lane.stale_worktrees.is_empty() {
if let Err(e) = redeclare_only_stale_worktrees(roots, &plan.lane) {
let unrestored = super::rollback(committed);
return Err(lane_failure_with_rollback(
format!("the lane could not be declared ({})", detail_of(&e)),
&unrestored,
));
}
}
if plan.lane.needs_lock {
if let Err(e) = relock_lanes(&roots.tree) {
let unrestored = super::rollback(committed);
return Err(super::failure_with_rollback(
format!(
"the tree's config could not be relocked ({})",
detail_of(&e)
),
&unrestored,
));
}
}
}
Ok(())
}
pub(super) struct UndoLane {
pub(super) path: PathBuf,
pub(super) raw: Option<Vec<u8>>,
exists: bool,
pub(super) removable: bool,
pub(super) vivac_dir: PathBuf,
joined: bool,
pub(super) vivac_dir_removable: bool,
}
pub(super) fn undo_lane(roots: &super::Roots) -> Result<UndoLane, Failure> {
let here = roots.here.as_path();
let vivac_dir = here.join(crate::store::DIR);
let path = vivac_dir.join(crate::lane::FILE);
let raw = std::fs::read(&path).ok();
let own_lane = crate::lane::read(&vivac_dir)?;
let wrote = own_lane
.as_ref()
.is_some_and(|lane| lane_has_written(&roots.tree, &lane.id));
let exists = own_lane.is_some();
let removable = exists && !wrote;
let joined = exists && !crate::store::already_planted(here);
let vivac_dir_removable = joined && removable && vivac_dir_holds_only_the_lane(&vivac_dir);
Ok(UndoLane {
path,
raw,
exists,
removable,
vivac_dir,
joined,
vivac_dir_removable,
})
}
fn vivac_dir_holds_only_the_lane(vivac_dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(vivac_dir) else {
return false;
};
for entry in entries.flatten() {
let name = entry.file_name();
if name == std::ffi::OsStr::new(crate::lane::FILE) {
continue;
}
if name == std::ffi::OsStr::new(crate::store::GITIGNORE) {
if matches!(std::fs::read_to_string(entry.path()), Ok(c) if c == "*\n") {
continue;
}
return false;
}
return false;
}
true
}
pub(super) fn undo_lane_items(lane: &UndoLane) -> Vec<super::claude_code::PlanItem> {
use super::claude_code::PlanItem;
if !lane.exists {
return Vec::new();
}
if lane.removable {
vec![PlanItem::new("remove", LANE_LABEL, "this folder's lane")]
} else {
vec![PlanItem::new(
"keep",
LANE_LABEL,
"this lane has written to the tree, and removing it would orphan what it wrote",
)]
}
}
pub(super) fn vivac_dir_items(lane: &UndoLane) -> Vec<super::claude_code::PlanItem> {
use super::claude_code::PlanItem;
if !lane.joined {
return vec![PlanItem::new(
"keep",
VIVAC_LABEL,
"the tree is not setup's",
)];
}
if lane.vivac_dir_removable {
vec![PlanItem::new(
"remove",
VIVAC_LABEL,
"it holds nothing but this lane",
)]
} else {
vec![PlanItem::new(
"keep",
VIVAC_LABEL,
"it holds more than this lane",
)]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tree_above_refusal_with_the_name_withheld_says_so_without_naming_anyone() {
let secret = "someone@example.com";
assert!(
crate::redact::check_field("folder name", secret).is_some(),
"the guard must actually reject this name, or the test proves nothing"
);
let msg = tree_above_refusal(&PathBuf::from("/tmp").join(secret)).message();
assert!(
msg.contains("A tree sits above this folder, in another folder,"),
"{msg}"
);
assert!(msg.contains("vivac brief"), "{msg}");
assert!(!msg.contains(secret), "{msg}");
}
#[test]
fn tree_below_refusal_with_every_name_withheld_says_so_without_naming_anyone() {
let secret_a = "someone@example.com";
let secret_b = "other@example.com";
assert!(
crate::redact::check_field("folder name", secret_a).is_some(),
"the guard must actually reject this name, or the test proves nothing"
);
let paths = vec![
PathBuf::from("/tmp").join(secret_a),
PathBuf::from("/tmp").join(secret_b),
];
let msg = tree_below_refusal(&paths).message();
assert!(
msg.contains("under names this tool will not write down"),
"{msg}"
);
assert!(!msg.contains(secret_a), "{msg}");
assert!(!msg.contains(secret_b), "{msg}");
}
#[test]
fn lane_failure_with_rollback_with_nothing_unrestored_still_says_the_log_stays() {
let msg =
lane_failure_with_rollback("the lane could not be declared (boom)".to_string(), &[])
.message();
assert!(
msg.contains("so init put back everything it had already written"),
"{msg}"
);
assert!(msg.contains("the log only ever grows"), "{msg}");
}
#[test]
fn lane_failure_with_rollback_with_something_unrestored_names_it_and_still_says_the_log_stays()
{
let unrestored = vec![PathBuf::from("/tmp/.vivac/.gitignore")];
let msg = lane_failure_with_rollback(
"the lane could not be declared (boom)".to_string(),
&unrestored,
)
.message();
assert!(msg.contains("/tmp/.vivac/.gitignore"), "{msg}");
assert!(msg.contains("init keeps no copy on disk"), "{msg}");
assert!(msg.contains("the log only ever grows"), "{msg}");
}
}