//! Claude Code, the one harness `vivac setup` knows today.
//!
//! What is Claude Code's own: the two files it reads (`.claude/settings.json`
//! and `.mcp.json`), the skill it looks for under `.claude/skills/`, the
//! shape of a hook entry, and how a command line already there is told apart
//! from a foreign one. `t565` §9: this is the module `INTEGRATION.md` points
//! at for the level-one work of a new harness.
use super::json::{self, Value};
use crate::args::Args;
use crate::failure::Failure;
use crate::output::outln;
use std::path::{Path, PathBuf};
const SETTINGS_LABEL: &str = ".claude/settings.json";
const MCP_LABEL: &str = ".mcp.json";
const SKILL_LABEL: &str = ".claude/skills/vivac-migrate/SKILL.md";
const VIVAC_LABEL: &str = ".vivac/";
const GITIGNORE_LABEL: &str = ".vivac/.gitignore";
const LANE_LABEL: &str = ".vivac/lane";
const SESSION_START_COMMAND: &str = "vivac session start --hook";
const SESSION_END_COMMAND: &str = "vivac session end --hook";
const FRONTMATTER: &str = include_str!("skill-frontmatter.md");
const BODY: &str = include_str!("skill-body.md");
// ---------------------------------------------------------------------------
// `--name`: naming the product on purpose (`t640`), rather than always
// deriving it from whichever folder holds the tree.
// ---------------------------------------------------------------------------
/// `--name` beside `--join` or `--undo` (`t640`, point 2): checked first,
/// the same reason `refuse_unsupported_flags` in `codex.rs` checks its own
/// list before reading or writing anything -- a flag nobody reads is a
/// flag nobody obeys.
fn refuse_name_with(a: &Args) -> Option<Failure> {
a.opt("name")?;
if a.has("join") {
return Some(Failure::usage(
"--join joins a product that already has a name, so --name has \
nothing left to fix.\n\n Give one or the other.",
));
}
if a.has("undo") {
return Some(Failure::usage(
"--undo removes what setup wrote and fixes nothing, so --name has \
nothing to do here.\n\n Give one or the other.",
));
}
None
}
/// The most `--name` may be, once trimmed. Generous on purpose: this is a
/// product's own name, not a title with a budget of its own.
const NAME_MAX_LEN: usize = 100;
/// The minimal shape `--name`'s own value has to have before it ever
/// reaches the redaction guard (`t640`, point 5): not empty once
/// surrounding space is trimmed, one line -- the same rule
/// `ops::validate_arm_text` already holds an arm to -- and at most
/// [`NAME_MAX_LEN`] characters. Spaces inside are fine: the registry
/// already knows how to quote a name that has them
/// (`registry::quote_if_needed`).
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)
}
/// `--name`'s own value, validated (point 5) and passed through the same
/// redaction guard `folder_name` already reads a derived name through
/// (point 4): `Ok(None)` when `--name` was not given at all, and every
/// other outcome already carries the right exit code -- a usage error
/// (2) for a shape the guard never gets to see, or `Failure::Redaction`
/// (3) for one it refuses.
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())),
}
}
/// The product name this run's own plan shows, on the line that names a
/// lane (`t640`, point 9): `requested`'s own value where this run is
/// planting with one, or the tree's own effective name otherwise.
/// `registry::effective_name` already falls back to `tree`'s own folder
/// name once there is nothing on file for it -- exactly a fresh plant's
/// own case, since nothing can be on file yet for a tree that does not
/// exist.
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)
}
/// `name`, quoted the way every other sentence in this module names a
/// product, or a placeholder once the redaction guard has withheld it --
/// `registry::label_for`'s own shape, for a product rather than a folder,
/// since "another folder" reads wrong beside a lane's own name.
fn product_label(name: Option<&str>) -> String {
match name {
Some(n) => format!("\"{n}\""),
None => "this product".to_string(),
}
}
/// Saves `name` into the registry as `tree`'s own (`t640`, point 6), once
/// this run has given it a first event to be keyed by. Quiet when there
/// is nothing to key it by yet, or nowhere to save it to -- the same
/// promise `note_registry` already makes, and a name is no different: the
/// registry is a comfort a command can do without.
fn note_name(tree: &Path, name: Option<&str>) {
let Some(name) = name else { return };
let Some(store_dir) = crate::store::store_dir() else {
return;
};
let Some(project_id) = crate::store::first_event_id(tree) else {
return;
};
crate::registry::set_name(&store_dir, &project_id, name);
}
pub fn run(roots: &super::Roots, a: &Args) -> Result<i32, Failure> {
// `t640`, point 2: checked before either branch below, the same
// reason `refuse_home_or_global_store` moved up here -- a guard
// inside one branch is a guard the other does not have.
if let Some(refusal) = refuse_name_with(a) {
return Err(refusal);
}
if a.has("undo") {
return undo(roots, a);
}
// Checked here, before the branch below, rather than inside `apply`
// alone: a guard that lives in one branch is a guard the other branch
// does not have, and `--join` used to skip it entirely (`t594`).
// `--undo` is still excluded, on purpose: undoing whatever
// an earlier setup wrote there is always safe.
if let Some(refusal) = super::refuse_home_or_global_store(roots) {
return Err(refusal);
}
// Here for the same reason, and it took a second round to actually put
// it here: `refuse_second_map`'s own doc already said trees below run
// in both branches, but the check itself stayed inside it, and
// `refuse_second_map` is only ever called from `apply` -- so `--join`
// walked around this one exactly the way it walked around the guard
// above. §4.5.1 still decides the order within `apply`: a tree below
// describes a state of the disk that has to be fixed before the
// product question, or "plant or join", means anything at all, and
// moving it up here only makes that truer.
//
// `d626`: fixed being asked before either branch runs, this still
// answered every caller with the plant branch's own sentence, since
// nothing here had looked at `--join` yet to know which door it was
// answering. The state itself does not wait on the flag; only which
// sentence names it does, so the flag is read here too, before the
// branch it would have picked.
let below = trees_below(&roots.here);
let join_spec = a.opt("join");
if !below.is_empty() {
return Err(match join_spec {
Some(spec) => tree_below_join_refusal(&roots.here, &below, spec),
None => tree_below_refusal(&below),
});
}
if let Some(spec) = join_spec {
return join(roots, spec, a.opt("lane-name"), a);
}
apply(roots, a)
}
// ---------------------------------------------------------------------------
// Shared: the vivac-command test, and reading the two JSON files.
// ---------------------------------------------------------------------------
/// Whether `word`'s first token, quotes and path stripped, is `vivac`:
/// `t565` §7.4.
fn is_vivac_command(word: &str) -> bool {
let word = word.replace('"', "");
let base = word.rsplit(['/', '\\']).next().unwrap_or(word.as_str());
let stem = if base.len() >= 4 && base[base.len() - 4..].eq_ignore_ascii_case(".exe") {
&base[..base.len() - 4]
} else {
base
};
stem.eq_ignore_ascii_case("vivac")
}
struct JsonFile {
exists: bool,
raw: String,
indent: String,
eol: &'static str,
trailing_newline: bool,
/// `Some` once parsed as an object; `None` for a missing file (nothing to
/// parse) or a conflict (unreadable, or not an object).
value: Option<Value>,
/// Line and column of a parse failure, for the conflict message.
parse_error: Option<(usize, usize)>,
not_object: bool,
}
fn read_json(path: &Path) -> JsonFile {
let Ok(raw) = std::fs::read_to_string(path) else {
return JsonFile {
exists: false,
raw: String::new(),
indent: " ".to_string(),
eol: "\n",
trailing_newline: true,
value: Some(Value::object(vec![])),
parse_error: None,
not_object: false,
};
};
let indent = json::detect_indent(&raw);
let eol = json::detect_eol(&raw);
let trailing_newline = json::has_trailing_newline(&raw);
match json::parse(&raw) {
Ok(v) if v.is_object() => JsonFile {
exists: true,
raw,
indent,
eol,
trailing_newline,
value: Some(v),
parse_error: None,
not_object: false,
},
Ok(_) => JsonFile {
exists: true,
raw,
indent,
eol,
trailing_newline,
value: None,
parse_error: None,
not_object: true,
},
Err(e) => JsonFile {
exists: true,
raw,
indent,
eol,
trailing_newline,
value: None,
parse_error: Some((e.line(), e.column())),
not_object: false,
},
}
}
// ---------------------------------------------------------------------------
// Hooks: SessionStart and Stop.
// ---------------------------------------------------------------------------
enum HookState {
Missing,
Exact,
Different(String),
}
fn command_first_word(cmd: &str) -> Option<&str> {
cmd.split_whitespace().next()
}
/// Looks through `event`'s array, under the top-level `hooks` object
/// (`SessionStart` or `Stop` are never top-level keys of their own: Claude
/// Code nests every event under `hooks`), for a vivac command whose
/// arguments start with `session start` or `session end`.
fn hook_state(root: &Value, event: &str, session_word: &str, ours: &str) -> HookState {
let Some(arr) = root
.get("hooks")
.and_then(|h| h.get(event))
.and_then(Value::as_array)
else {
return HookState::Missing;
};
for entry in arr {
let Some(hooks) = entry.get("hooks").and_then(Value::as_array) else {
continue;
};
for h in hooks {
let Some(cmd) = h.get("command").and_then(Value::as_str) else {
continue;
};
let words: Vec<&str> = cmd.split_whitespace().collect();
let Some(prog) = command_first_word(cmd) else {
continue;
};
if !is_vivac_command(prog) {
continue;
}
if words.get(1) == Some(&"session") && words.get(2) == Some(&session_word) {
return if cmd == ours {
HookState::Exact
} else {
HookState::Different(cmd.to_string())
};
}
}
}
HookState::Missing
}
fn our_hook_entry(command: &str) -> Value {
Value::object(vec![(
"hooks",
Value::Array(vec![Value::object(vec![
("type", Value::str("command")),
("command", Value::str(command)),
])]),
)])
}
/// Gets `root[key]` as an object, creating it first if it is missing.
fn get_or_insert_object<'a>(root: &'a mut Value, key: &str) -> &'a mut Value {
if root.get(key).map(Value::is_object) != Some(true) {
root.set(key, Value::object(vec![]));
}
root.as_object_mut()
.unwrap()
.iter_mut()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
.unwrap()
}
fn append_hook(root: &mut Value, event: &str, command: &str) {
let hooks = get_or_insert_object(root, "hooks");
if hooks.get(event).map(Value::as_array).is_none() {
hooks.set(event, Value::Array(vec![]));
}
let arr = hooks
.as_object_mut()
.unwrap()
.iter_mut()
.find(|(k, _)| k == event)
.map(|(_, v)| v)
.unwrap()
.as_array_mut()
.unwrap();
arr.push(our_hook_entry(command));
}
/// Removes every array entry whose sole hook is exactly `command`, then
/// drops the event key if its array is now empty, and `hooks` itself if
/// that leaves it with nothing. `t565` §7.7.
fn remove_hook(root: &mut Value, event: &str, command: &str) -> bool {
let mut removed = false;
let Some(hooks) = root.get("hooks").cloned() else {
return false;
};
let Some(arr) = hooks.get(event).and_then(Value::as_array) else {
return false;
};
let kept: Vec<Value> = arr
.iter()
.filter(|entry| {
let is_ours = entry
.get("hooks")
.and_then(Value::as_array)
.map(|hs| {
hs.len() == 1 && hs[0].get("command").and_then(Value::as_str) == Some(command)
})
.unwrap_or(false);
if is_ours {
removed = true;
}
!is_ours
})
.cloned()
.collect();
let mut new_hooks = hooks;
if kept.is_empty() {
if let Some(obj) = new_hooks.as_object_mut() {
obj.retain(|(k, _)| k.as_str() != event);
}
} else {
new_hooks.set(event, Value::Array(kept));
}
if new_hooks.as_object().is_some_and(|o| o.is_empty()) {
if let Some(obj) = root.as_object_mut() {
obj.retain(|(k, _)| k.as_str() != "hooks");
}
} else {
root.set("hooks", new_hooks);
}
removed
}
// ---------------------------------------------------------------------------
// The MCP server entry.
// ---------------------------------------------------------------------------
enum McpState {
Missing,
Ours,
OtherName(String),
NameTaken(String),
}
fn describe_command(v: &Value) -> String {
let cmd = v.get("command").and_then(Value::as_str).unwrap_or("");
let args: Vec<String> = v
.get("args")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
if args.is_empty() {
cmd.to_string()
} else {
format!("{cmd} {}", args.join(" "))
}
}
fn is_our_mcp_entry(v: &Value) -> bool {
let is_vivac = v
.get("command")
.and_then(Value::as_str)
.is_some_and(is_vivac_command);
let args_ok = v
.get("args")
.and_then(Value::as_array)
.is_some_and(|a| a.len() == 1 && a[0].as_str() == Some("mcp"));
is_vivac && args_ok
}
fn mcp_state(root: &Value) -> McpState {
let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else {
return McpState::Missing;
};
if let Some((_, v)) = servers.iter().find(|(k, _)| k == "vivac") {
return if is_our_mcp_entry(v) {
McpState::Ours
} else {
McpState::NameTaken(describe_command(v))
};
}
for (name, v) in servers {
if is_our_mcp_entry(v) {
return McpState::OtherName(name.clone());
}
}
McpState::Missing
}
fn our_mcp_entry() -> Value {
Value::object(vec![
("type", Value::str("stdio")),
("command", Value::str("vivac")),
("args", Value::Array(vec![Value::str("mcp")])),
])
}
/// `root` with `mcpServers.vivac` removed, and `mcpServers` itself dropped
/// once that leaves it empty -- the same "an empty container does not
/// linger" rule `remove_hook` applies to `hooks`.
fn without_our_mcp_server(root: &Value) -> Value {
let mut root = root.clone();
let Some(mut servers) = root.get("mcpServers").cloned() else {
return root;
};
if let Some(obj) = servers.as_object_mut() {
obj.retain(|(k, _)| k != "vivac");
}
if servers.as_object().is_some_and(|o| o.is_empty()) {
if let Some(obj) = root.as_object_mut() {
obj.retain(|(k, _)| k.as_str() != "mcpServers");
}
} else {
root.set("mcpServers", servers);
}
root
}
// ---------------------------------------------------------------------------
// The skill file.
// ---------------------------------------------------------------------------
enum SkillState {
Missing,
Same,
Replaceable,
Conflict,
}
fn marker_line(fingerprint: u64) -> String {
format!(
"<!-- written by vivac setup; fingerprint {fingerprint:016x}; vivac setup \
claude-code --undo removes it while the text is unchanged -->\n"
)
}
fn skill_content_without_marker() -> String {
format!("{FRONTMATTER}{BODY}")
}
fn skill_fingerprint() -> u64 {
super::fnv1a64(skill_content_without_marker().as_bytes())
}
/// `pub(super)`: `codex.rs` writes this same file at its own path, byte for
/// byte, rather than keeping a second copy of the skill (`d653`).
pub(super) fn skill_text() -> String {
format!("{FRONTMATTER}{}{BODY}", marker_line(skill_fingerprint()))
}
/// Splits `text` into its frontmatter, the marker's claimed fingerprint (as
/// the hex it was written with) and the content the fingerprint should have
/// been taken over -- `text` with the marker line and its newline removed.
/// `None` when there is no frontmatter or no line right after it: `t565`
/// §7.4's "any other case" for a skill with no marker at all.
fn extract_marker(text: &str) -> Option<(String, String)> {
let lines: Vec<&str> = text.split('\n').collect();
if lines.first() != Some(&"---") {
return None;
}
let close = lines.iter().skip(1).position(|&l| l == "---")? + 1;
let marker_idx = close + 1;
let marker = *lines.get(marker_idx)?;
let fp = marker
.strip_prefix("<!-- written by vivac setup; fingerprint ")?
.split(';')
.next()?
.trim()
.to_string();
let mut without = lines;
without.remove(marker_idx);
Some((fp, without.join("\n")))
}
fn skill_state(existing: &str) -> SkillState {
if existing == skill_text() {
return SkillState::Same;
}
match extract_marker(existing) {
Some((fp_hex, content)) => {
let claimed = u64::from_str_radix(&fp_hex, 16).ok();
let actual = super::fnv1a64(content.as_bytes());
if claimed == Some(actual) {
SkillState::Replaceable
} else {
SkillState::Conflict
}
}
None => SkillState::Conflict,
}
}
/// Whether an existing skill's fingerprint is intact, regardless of whether
/// its text still matches what this version would write today. `--undo`
/// only ever removes a file it (or an earlier vivac) actually wrote.
fn skill_fingerprint_intact(existing: &str) -> bool {
matches!(
skill_state(existing),
SkillState::Same | SkillState::Replaceable
)
}
// ---------------------------------------------------------------------------
// Paths.
// ---------------------------------------------------------------------------
struct Paths {
settings: PathBuf,
mcp: PathBuf,
skill: PathBuf,
}
fn paths(root: &Path) -> Paths {
Paths {
settings: root.join(".claude").join("settings.json"),
mcp: root.join(".mcp.json"),
skill: root
.join(".claude")
.join("skills")
.join("vivac-migrate")
.join("SKILL.md"),
}
}
// ---------------------------------------------------------------------------
// Recognizing an existing product, before planting a second map of it:
// `t594` §4.5, case 3 -- reached only when there is no tree above `here`
// at all. Checked in this order because §4.5.1 describes a state of the
// disk that has to be fixed before either of the other two questions
// means anything: a tree below (`trees_below`), then a product this
// machine's registry already tracks (`sharing_repos`).
// ---------------------------------------------------------------------------
/// The deepest a nested tree can sit beneath the folder being set up, the
/// same two levels `repos::scan` fixes for a repository -- and for the
/// same reason: it also keeps a symlink cycle from running away with the
/// walk.
const TREE_SCAN_DEPTH: u32 = 2;
/// Every `.vivac/` holding a tree (`events` or `config`) strictly inside
/// `folder`: the same walk `repos::scan` does over `.git` -- two levels
/// down, never descending into a repository or into a `.vivac/` already
/// found -- but looking for a tree instead of a repository, and never
/// checking `folder` itself. That last part used to be unreachable rather
/// than absent: the only caller skipped calling this at all once `folder`
/// already had a tree of its own. `t594` made that call
/// reachable, and it surfaced the gap -- calling this on a folder that
/// already holds a tree used to report the folder itself as a tree
/// sitting "below" it.
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
}
/// `dir`'s own immediate subdirectories, `.vivac/` excluded, in a fixed
/// order: the one piece `trees_below` and `walk_for_trees`'s own
/// recursive step both need.
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());
// Never descend into a tree already found: whatever sits inside
// it belongs to that tree, not to this walk.
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);
}
}
/// `path`'s own folder name, or `None` when the redaction guard rejects
/// it: this text reaches an agent's context (`d600`), the same rule
/// `registry::folder_name` already follows for a copy's folder.
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),
}
}
/// §6.4: a tree already sitting inside this folder. Named, unless the
/// guard withholds a name; with two or more, the withheld ones are simply
/// left out rather than replaced one by one.
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 setup 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 {}", 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."
))
}
/// `d626`: the same disk state `tree_below_refusal` names for a plant,
/// met by `--join` instead. The remedy is not the same door -- nothing
/// here was about to be planted, so "move that tree up, then run setup
/// again" would have pointed at a choice nobody was making, and naming
/// the folder to run `relocate` from, rather than a destination for it,
/// is what actually matches how `relocate` works: it runs from inside
/// the tree it moves, not from above it. `spec` is printed back exactly
/// as typed and quoted, the same as every other refusal in this module
/// names something -- it is the choice being made, not a tree this call
/// went looking for and resolved.
///
/// Every tree found is named, following `tree_below_refusal`'s own
/// shape for the same disk state: whoever fixes the first and hits this
/// refusal again would only be learning the same thing twice.
///
/// A route is withheld whole when any segment of it trips the redaction
/// guard (`guarded_relative`, `d600`) -- the guard covers the folder
/// name it was built to cover, and a route this refusal prints can be
/// several of those deep. With a mix of withheld and shown routes, only
/// the shown ones are listed, and how many are missing is never said:
/// the count is also something the guard would be handing over.
fn tree_below_join_refusal(here: &Path, below: &[PathBuf], spec: &str) -> Failure {
let routes: Vec<Option<String>> = below.iter().map(|p| guarded_relative(here, p)).collect();
let shown: Vec<&str> = routes.iter().filter_map(|r| r.as_deref()).collect();
if let [only] = routes.as_slice() {
return match only {
Some(rel) => Failure::Model(format!(
" There is another product's tree below this folder:\n \
{rel}\n\n \
This folder cannot be a lane of \"{spec}\" while that tree is there: one\n \
folder answers for one product, and a lane that contains another\n \
product's tree would answer for two.\n\n \
If the tree below is part of \"{spec}\", move it up. From inside {rel}:\n \
vivac relocate ..\n \
If it is a different product, join from a folder that does not contain it."
)),
None => Failure::Model(format!(
" There is another product's tree below this folder, under a name this tool\n \
will not write down.\n\n \
This folder cannot be a lane of \"{spec}\" while that tree is there: one\n \
folder answers for one product, and a lane that contains another\n \
product's tree would answer for two.\n\n \
Join from a folder that does not contain it, or move that tree up from\n \
inside it: vivac relocate .."
)),
};
}
if shown.is_empty() {
return Failure::Model(format!(
" There are other products' trees below this folder, under names this tool\n \
will not write down.\n\n \
This folder cannot be a lane of \"{spec}\" while any of them is there: one\n \
folder answers for one product, and a lane that contains another\n \
product's tree would answer for two.\n\n \
Join from a folder that does not contain them, or move them up from\n \
inside each one: vivac relocate .."
));
}
let listed: String = shown.iter().map(|r| format!(" {r}\n")).collect();
Failure::Model(format!(
" There are other products' trees below this folder:\n\
{listed}\n \
This folder cannot be a lane of \"{spec}\" while any of them is there: one\n \
folder answers for one product, and a lane that contains another\n \
product's tree would answer for two.\n\n \
Any of them that belongs to \"{spec}\" can move up, from inside it:\n \
vivac relocate ..\n \
For the rest, join from a folder that does not contain them."
))
}
/// `path`'s own route down from `base`, forward slashes on every
/// platform, the same convention `event::Repo::relative` already prints
/// a repository under -- or `None` when any segment of that route trips
/// the redaction guard: `guarded_folder_name` only ever checked the last
/// one, and a route `tree_below_join_refusal` prints can run several
/// folders deep, any of which might be the one that should not travel
/// (`d600`).
fn guarded_relative(base: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(base).unwrap_or(path);
let mut parts = Vec::new();
for c in rel.components() {
let part = c.as_os_str().to_string_lossy().into_owned();
match crate::redact::check_field("folder name", &part) {
Some(_) => return None,
None => parts.push(part),
}
}
Some(parts.join("/"))
}
/// §6.4's mirror image, upward: a folder with no `.vivac/` of its own,
/// told to `--join` a tree somewhere else while the tree it already
/// resolves to sits above it.
///
/// `Failure::already_a_lane` used to answer here, and its own doc says
/// what is wrong with that: it is for "a folder that already carries
/// somebody else's `.vivac/lane`", and this folder carries none at all.
/// The refusal itself was never in doubt -- joining would split the
/// product either way -- so what changes is only the sentence, which now
/// says the thing that is true and where to go and read it.
///
/// Named, and the name withheld when the redaction guard rejects it
/// (`d600`), the same as every other folder this module names.
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"
))
}
/// §6.3: this folder's own repositories already belong to a project the
/// registry tracks. `here_repos` names the repositories printed --
/// **this** folder's own, per `repos::scan`, never the other project's.
///
/// Names three ways out, not two (`d680`): the correct one when the tree
/// belongs here rather than where it landed -- `relocate` it into place
/// first, then join -- used to go unnamed, and the two that were left,
/// joining from here or planting a second tree, cost a reader who followed
/// them the tree they meant to keep. The `relocate` line sits before
/// `--new-tree`'s own: the escape that keeps the tree, read before the one
/// that gives it up.
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();
// `f677`: "." is what `Repo::relative` writes when the folder itself
// is the repository, and printed bare it disappears into the
// sentence's own closing period -- named here instead, the one place
// this list turns into words a person reads.
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 setup claude-code --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 setup claude-code --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 setup claude-code --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 setup claude-code --new-tree"
)),
}
}
/// `t594` §4.5, case 3's own second refusal: this folder's repositories
/// already belong to a project the registry tracks. Skipped for
/// `bypass_registered` -- `--new-tree` (`t594` §4.5's own escape for two
/// forks that share a root commit) -- and skipped when there is a tree
/// above `here` at all, since with one this is an ordinary join and the
/// product question does not arise.
///
/// Case 3's *first* refusal, a tree below, is **not** here: it is `run`'s
/// own, checked before it picks a branch at all. It lived here once, which
/// made it a guard only the planting branch ever ran -- the same shape
/// that let `--join` walk around `refuse_home_or_global_store`. The order
/// §4.5.1 fixes is unchanged, and firmer: a tree below describes a state
/// of the disk that has to be fixed before the product question means
/// anything, and `run` now refuses one before this is ever called.
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(()),
}
}
/// `f676`/`d682`: the guard above only speaks when this folder's own
/// repositories share a root commit with a project the registry already
/// tracks -- read the other way round, when the registry knows other
/// products and this folder shares a root commit with **none** of them,
/// it says nothing at all. A folder that genuinely is a new product and
/// one whose repositories the registry simply never learned about yet
/// look identical from here, and only the first is what a silent plant
/// should mean.
///
/// A warning, never a refusal: it changes nothing about what this run
/// does, so it is checked independent of `--new-tree`, which only bypasses
/// the guard above. `None` once the registry has nothing on file yet --
/// there is nothing for this folder to fail to share with.
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(
" This plants a new product. Nothing here shares a repository with the\n \
projects vivac already tracks, so it cannot tell whether this is one of\n \
them. If it is, stop and use --join <name> instead.\n\n"
.to_string(),
)
}
/// §6.5: this folder's own tree -- freshly planted, or the closer one it
/// just joined -- itself sits inside yet another one, found by continuing
/// the very same upward walk past it. `t594` §4.5, case 2's own extra
/// check: it never blocks anything, and it is checked for a fresh plant
/// too, where it always reads `None` -- `store::locate` already walked
/// every ancestor of `here` looking for exactly this, and found nothing,
/// or there would be a tree above to join instead of planting.
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!(
"\n This tree sits inside another one, in folder {label}. Sessions opened\n \
above this folder use that one: keep one tree per product.\n"
)
}
// ---------------------------------------------------------------------------
// The lane: `t594` §4.5, joining the tree above rather than planting a
// second one.
// ---------------------------------------------------------------------------
/// What this run has to do about the lane `roots.here` is, worked out
/// before anything is written so the plan can say it.
struct LanePlan {
lane_id: String,
name: String,
repos: Vec<crate::event::Repo>,
/// This folder does not carry `.vivac/lane` yet, so this run has to
/// write it before it can declare (`t594` §4.5.2, case (c)). The id
/// this points back at is minted here, since it never depends on the
/// tree's own state; the project it points back at does, and is
/// worked out at write time instead (`write_lane`).
is_new: bool,
/// Whether the config still needs `lock_lanes_in_config`: absent for
/// a tree that does not exist yet, which always needs it once
/// planted, and read off the existing one otherwise.
needs_lock: bool,
/// The tree already says exactly this (`t594` §4.5.2, case (e)):
/// nothing to write, and running `setup` twice in a row does not
/// leave two events behind.
unchanged: bool,
/// How many repositories the redaction guard kept out, and the first
/// rule that caught one. `d600`: they are still missing from the
/// declaration, and that is said rather than left silent, without
/// repeating which repository it was.
excluded: Option<(usize, &'static str)>,
/// Other lanes in this tree that joined as a worktree of one of these
/// repositories while it still had no root commit recorded, and are
/// still declared with none (`f609`): each one's id, its name kept as
/// it was, and the repository it shares with this folder's own, now
/// carrying the root commit this run just found for it.
stale_worktrees: Vec<(String, String, crate::event::Repo)>,
}
/// `scanned`, filtered through the redaction guard (`d600`): what is left
/// to declare, and the count and first rule of whatever it kept out.
/// Shared by declaring a lane's own folder and by declaring `main` on the
/// tree's own folder, whether that happens because someone asked for it
/// or because `ensure_first_event` needs to seed it -- one piece of work,
/// one place that does it.
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())),
)
}
/// The tree at `tree_root`, folded once. A `.vivac/` that is empty or not
/// there at all (`f566`, or no tree yet) folds to `Tree::default`, which
/// answers every question below the same way absence always has --
/// `main_claimed: false`, nothing declared -- so callers never need to
/// know which kind of "nothing" they got. Shared by `plan_lane`'s own
/// decision and by `existing_lane`, so a `setup` run folds the tree once
/// rather than once per question asked of it.
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)
}
/// Whether `lane_id` has changed `tree_root`'s own tree beyond declaring
/// itself: `LaneState::seq_change` already skips the context events
/// (`lane.declared`, `lane.claimed`, `where.changed`) a join writes on a
/// lane's own behalf, so a lane that only ever joined and never pushed,
/// popped or noted anything answers `false` here. `--undo`'s own use is
/// the one thing this decides: a lane that never wrote owns no history for
/// removing `.vivac/lane` to orphan (`d680`).
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)
}
/// What the tree already says about `lane_id`, read without writing
/// anything: `Store::open` would fill a missing `config` in on its own,
/// and that write is one `--dry-run` must never trigger just by asking
/// what a tree is on (`t594`). `config_version` reads
/// `ConfigVersion::One` for a tree with no config at all -- the same
/// answer `Store::open` would settle on for a tree with no lane and no
/// pillar or rule either, so `needs_lock` comes out right either way
/// without this having to know why the file is missing.
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())),
}
}
/// `t594` §4.5.2's five cases, decided from `roots` alone: whether there is
/// a tree above `here` at all, and whether `here` already carries its own
/// `.vivac/lane` (`Located::lane_dir == here`, rather than some ancestor's)
/// -- plus a sixth, `t594`: `here` holds the tree, has no
/// lane file, and `main` has already been claimed by another folder
/// (`main_claimed`). Declaring `main` there again would be a lie about
/// where `main` actually lives, so this mints `here` a lane of its own
/// instead, the same as any other folder that never had one.
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();
// `--lane-name` (`t594` §4.5's own `--lane-name <name>`), or this
// folder's own name when nobody named it: the word `declared_name`
// guards below either way, for every lane -- `main` included since
// `d624`, which made `main_lane` (`:891-897`) fall back to this same
// folder name instead of staying literally `main` when nobody names
// it. Accepting `--lane-name` and silently doing nothing with it --
// §2.3 names both planting and joining -- would be worse than either
// using it or refusing it outright (`t594`).
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);
// Folded once, ahead of the decision below, which needs to know
// whether `main` has already been claimed elsewhere before it can
// tell "here is main" apart from "here holds the tree, but is not
// main any more" -- and `existing_lane`, further down, needs the
// very same fold.
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,
}
}
/// The already-declared worktree lanes one of `here`'s own repositories
/// explains but never told: each one joined while its matching repository
/// here still had no root commit recorded, copied that absence forward
/// (`ops::resolve_whose`), and nothing has revisited it since -- the
/// tree's own fold has no way to tell a worktree lane's folder apart from
/// any other lane's, so this reads it straight off git's own worktree
/// bookkeeping instead of guessing at it from the fold alone (`f609`).
///
/// Skips `lane_id`: a repository whose own root just changed already gets
/// declared by the caller through the ordinary path, and finding it here
/// too would only redeclare it a second time under the same identity.
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
}
/// Every worktree git still links to the repository at `repo_root`, read
/// off `.git/worktrees/*/gitdir` rather than spawning `git worktree list`:
/// one file read costs nothing beside the `git rev-list` `repos::scan`
/// already pays for this same folder, and a worktree git has pruned
/// leaves no `gitdir` file behind for this to find in the first place
/// (`f609`).
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()
}
/// `main`'s id never changes. Its name falls back to this folder's own
/// name exactly like every other lane's (`:837`), unless `lane_name`
/// asks for a different one (`d624`).
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)
}
/// The tree's own first event id, seeding one when there is none: a brand
/// new lane's own `.vivac/lane` needs a stable id to point back at
/// (`resolve_lane`, `store.rs` -- it reads a tree's first line as the
/// cheap fingerprint that ties a lane to the right tree), and there is
/// nothing stable to point at in a tree that has never written anything,
/// which a tree fresh out of `init` or a bare plant still is.
///
/// The seed is the tree's own implicit `main` declaring itself for real,
/// with its own folder's actual repositories -- the same walk declaring
/// `main` by hand would do, and not a placeholder: task 8 decides with
/// this list whether a linked worktree is one of the lane's own
/// repositories or a lane apart, and an empty list would hand it the
/// wrong answer (`t594`). Taken and released under its
/// own lock, before the new lane's own lock is taken, since a second
/// attempt to lock the same file from this same process would otherwise
/// wait on itself.
///
/// If this write succeeds and the log's first line still will not parse
/// as an id right after, that is not this call's own failure to undo --
/// it already appended a real event and already locked the config, and
/// the log only ever grows. The error says so, since the caller cannot.
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",
))
})
}
/// What this run actually does, in order: this folder's own `.vivac/lane`
/// on disk first -- only for a brand new lane, and with no lock held over
/// it at all -- and only then `declare_lane`, which takes the write lock,
/// locks the config and emits `lane.declared` together.
///
/// That is *not* `t594` §4.5.2's own order, which puts the file inside the
/// lock and after the config is closed. This one is at least as safe: if
/// the process dies between the file and the lock, the folder already
/// knows whose thread it is and the tree finds out the moment the fold
/// sees the matching event, which is exactly what dying between the file
/// and the event -- the ordering the spec itself calls safe -- already
/// leaves behind. If it dies between the file and the *config* closing
/// specifically, the tree does not have a lane event yet either, so an
/// older vivac reading it in between is not being lied to. What the file
/// must never do is land *after* the event: that is the one ordering that
/// leaves a folder signing as `main` while the tree already says
/// otherwise, and nothing here permits it.
fn write_lane(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())?;
// `Whose::Declared`, not `Whose::Resolved`: this lane is `plan`'s own
// decision, already made from `roots` and `repos::scan` above, and
// `t594` §2.3's own resolution -- built for a folder that has not
// said which lane it is yet -- would ask a question this call already
// answered, and could answer it differently for a worktree `setup`
// is declaring by hand rather than leaving to join on its own
// (`t594`). `roots.here`, not `roots.tree`: `plan.repos` was scanned
// from `roots.here` too, and a redeclaration reads this folder back
// through `where_to_write` -- a lane joined from elsewhere is not
// sitting at the tree's own root.
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)
}
/// Just `plan`'s stale-worktree redeclarations (`f609`), for a run whose
/// own lane has nothing new to declare -- `write_lane` above is not
/// reached at all in that case, and a worktree stuck with no root commit
/// from before this folder's own ever had one would otherwise stay stuck
/// on every such run, forever, once this folder's own declaration has
/// settled. Opens the tree's write lock on its own, the same way
/// `relock_lanes` does, since there is no other write in this run to
/// share it with.
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)
}
/// `plan.stale_worktrees`, applied one at a time under `ctx`'s already-held
/// lock. Shared by `write_lane`, which reaches it right after declaring
/// this folder's own lane, and by `redeclare_only_stale_worktrees`, which
/// has no declaration of its own to declare first.
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(())
}
/// Redeclares a stale worktree lane's own repository with the root commit
/// its founding lane just learned, straight through `Store::append`
/// rather than `Ctx::emit` (`f609`). `emit` would run `where_to_write`
/// against `ctx.lane_dir`, which is wherever this run is standing --
/// `roots.here`, never the worktree's own folder this call never visited
/// -- and hand that lane a location that is not its own. Writing only
/// `lane.declared` says the one thing this run actually knows: the
/// repository's root commit, and nothing about where that lane is right
/// now.
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(())
}
/// Locks the tree's config to `t594`'s own sentence without touching the
/// log: for a lane whose declaration already matches (`unchanged`), there
/// is nothing new to say, but the config can still have lost the lock
/// underneath it -- by hand, or by an older `Store::open` regenerating one
/// that went missing before it knew a lane event counts too (`t594`).
/// `unchanged` must never decide this on its own: a folder
/// that has nothing new to declare can still be the reason the config
/// needs relocking.
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(())
}
/// The clause text for a `Failure`, without doubling an `Io` variant's own
/// "Input/output error:" prefix once `failure_with_rollback` wraps it a
/// second time (`t594`): `Failure::message` already adds
/// that prefix for `Io`, and the planting failure this mirrors uses a raw
/// `std::io::Error` -- which has no such prefix to begin with -- for the
/// exact same reason.
fn detail_of(e: &Failure) -> String {
match e {
Failure::Io(io) => io.to_string(),
other => other.message(),
}
}
/// The exit-5 text for a lane declaration or a config relock that failed,
/// after `unrestored` -- what `super::rollback` could not put back among
/// the settings/mcp/skill/gitignore pieces -- is already known.
///
/// Unlike `failure_with_rollback`, this never says every file came back:
/// by the time either call above can fail, a real event may already sit
/// in the tree's own log (`ensure_first_event`'s seed) or the config may
/// already be locked, and neither of those is a file `rollback` ever
/// touches or could undo. `t565` §7.7 accepts the same gap for planting,
/// on the same reasoning -- but planting never writes anything of
/// informational value before it can fail, and a lane's own event does,
/// so this says the log stays instead of claiming a rollback it did not
/// do and cannot do.
fn lane_failure_with_rollback(clause: String, unrestored: &[PathBuf]) -> Failure {
let mut message = clause;
if unrestored.is_empty() {
message.push_str(
", so setup put the settings, the server entry and the skill back\n \
as they were. Whatever this already wrote to the tree's own log stays\n \
either way: the log only ever grows.",
);
} else {
message.push_str(", and setup could not put these back as they were:\n");
for p in unrestored {
message.push_str(&format!(" {}\n", p.display()));
}
message.push_str(
" setup 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))
}
/// Every root commit any lane of `tree` has declared, deduplicated and
/// sorted: the same union `relocate::union_repo_roots` computes, for the
/// same reason -- `note_registry`'s own `Sighting.repos` wants every
/// repository this tree's lanes declare, not just the one this run
/// happens to be about, so a later `setup` elsewhere can tell that a
/// folder it has never seen still holds this product (`t594` §4.8,
/// `registry::Sighting.repos`'s own doc).
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
}
/// Notes `tree` in this machine's registry, the same bookkeeping every
/// ordinary command already does on its way out (`main.rs`). `setup`
/// itself never used to reach that block -- it returns before it
/// (`f277`) -- and that was harmless while every folder it touched was
/// found by walking up from itself. It stopped being harmless the moment
/// `setup` could join a folder whose only path back to its tree is the
/// registry: a linked worktree that sits beside the tree's own folder
/// rather than above it, which `resolve_lane` (`store.rs`) can only ever
/// find through here (`t594`). Quiet when there is
/// nowhere to note or nothing to note it with yet, the same as the
/// ordinary path.
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()))
});
// The tree is folded once more here, past whatever `plan_lane`
// already folded: this call always runs after every write this
// run makes, so it is the one place that can report the whole
// tree's repositories as they stand once this run is done, the
// same union `relocate` already writes on a move (`t594` §4.8).
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),
},
);
// Left for `registry::warn_if_wrote` to decide, once this run is
// done and can say whether it actually wrote anything: the
// `nothing_to_write` branch above reaches this call too, and that
// one is a read (`t594`).
crate::registry::set_pending(noted);
}
}
// ---------------------------------------------------------------------------
// Formatting: the two-column plan lines `t565` §7.8 fixes the width of.
// ---------------------------------------------------------------------------
// `pub(super)`: `codex.rs` renders its own plan in the same two columns,
// rather than fixing the same widths a second time (`d653`).
pub(super) fn piece_line(label: &str, status: &str) -> String {
format!(" {label:<41}{status}\n")
}
pub(super) fn sub_line(label: &str, value: &str) -> String {
format!(" {label:<15}{value}\n")
}
pub(super) fn wrapped_piece_line(label: &str, first: &str, second: &str) -> String {
format!(" {label:<41}{first}\n{:45}{second}\n", "")
}
// ---------------------------------------------------------------------------
// `--join`: `t594` §4.5's own escape from §6.3, and the remedy `--new-tree`
// or a fresh `setup` plants past instead. It resolves a tree that lives
// somewhere else, then walks the same path planting does, minus planting
// itself (`t640`, point 11): the plan, the confirmation, and the hooks,
// the server and the skill along with this folder's own lane, all in one
// write. A folder that already ran setup somewhere else still needed the
// brief and the tools waiting for it the moment it opened a session here
// -- that is what a join used to leave undone (`f667`/`f669`).
// ---------------------------------------------------------------------------
/// `spec`, printed back exactly as typed when the tree it names cannot be
/// joined: a person's own words, the same reasoning `relocate`'s own
/// destination is printed under -- not a path this tool went looking for.
fn join(
roots: &super::Roots,
spec: &str,
lane_name: Option<&str>,
a: &Args,
) -> Result<i32, 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 setup claude-code"
)));
}
// §4.5: refuses when this folder already is a lane of *another* tree --
// joining the very one it already resolves to does nothing at all, since
// there is nothing left to do. A folder that holds a tree of its own
// gets a different text: it carries no lane to redirect, it carries
// the tree (`t594`).
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());
}
// Two different folders reach this line, and only one of them
// is a lane: the one that carries `.vivac/lane` itself.
// Everything else here has no `.vivac/` of its own at all and
// simply resolves up into the tree above it, which is a
// different sentence -- `already_a_lane` names a file that
// folder does not have.
if lane_carried_by(l, &roots.here).is_some() {
return Err(Failure::already_a_lane());
}
return Err(tree_above_refusal(&l.root));
}
// The same tree, and this folder already carries the lane file
// that says so: everything below would mint a second lane id for
// a folder that already has one, orphaning the stack, the focus
// and the counters the first one holds. Nothing is written and
// nothing is appended, so this returns ahead of `--dry-run` too:
// what that flag reports is what a run would do, and this run
// would do nothing either way.
if let Some(id) = lane_carried_by(l, &roots.here) {
say_nothing_was_done(&target, id, lane_name);
return Ok(0);
}
}
// Never `spec`, and never `target` either (`t594`):
// unlike the "no tree yet" refusal above, this is the one place `join`
// would otherwise echo a path back that a person did not necessarily
// type themselves -- `spec` might have resolved through a project
// name, not a path at all.
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(),
));
}
// `t640`, point 11: from here, this run walks the very same path
// `apply_writes` already walks for a plant, minus the plant --
// `join_roots.tree` is `target`, already confirmed planted above, so
// `apply_writes` never mints a `Store::create` for it. `located: None`
// is safe: `write_lane`, `note_registry` and `apply_writes` itself
// only ever read `roots.tree` and `roots.here` off this value, never
// `located`, which is `plan_lane`'s own question and `join` answers
// for itself with `plan_join_lane` instead.
let join_roots = super::Roots {
here: roots.here.clone(),
tree: target.clone(),
located: None,
};
let lane = plan_join_lane(&roots.here, &target, lane_name);
apply_writes(&join_roots, a, lane, None)
}
/// `--join`'s own lane plan (`t640`, point 11): always a brand new lane.
/// `join`'s own preamble already rules out the one case where this folder
/// carries a lane of `target` already -- the idempotent no-op
/// `say_nothing_was_done` answers with, before this is ever reached -- so
/// `here` reaching this function never already has an id of its own to
/// keep, the same as `plan_lane`'s own `Some(_)` branch for a folder that
/// merely resolves into a tree above it rather than carrying one itself.
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;
// A fresh id can never already be declared, so this always reads
// `false` -- computed the same way `plan_lane` computes it rather
// than hardcoded, so the two never have a reason to drift apart.
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,
}
}
/// The id of the lane `here` itself is, or `None` for a folder that merely
/// resolves up into a tree above it. The one criterion, asked in the two
/// places `join` needs it: a lane file, carried by this folder rather than
/// by some ancestor. `same_folder`, never a path compared as text -- a
/// second spelling of the same folder is the same folder (`f612`).
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())
}
/// What a person learns from a `--join` that had nothing left to do: that
/// it is done already, and that this run left it alone. It reads as an
/// answer rather than as a refusal because a re-run of the provisioning a
/// team shares is the ordinary way to arrive here -- the same reason a
/// plain `setup` run twice says the tree was already there.
///
/// The second sentence is for `--lane-name` asking for a name the lane
/// does not have: the flag was read and not acted on, and a flag accepted
/// in silence leaves nothing behind to say it was ignored (`t594`).
/// Asking for the name it already carries needs no sentence --
/// nothing was left undone. The tree is folded only for that question, so
/// a run without the flag reads no log at all.
fn say_nothing_was_done(target: &Path, lane_id: &str, lane_name: Option<&str>) {
outln!(" This folder is already a lane of that tree, and setup changed nothing in it.");
let Some(requested) = lane_name else {
return;
};
// `declared_name` is what the name would have become had it been
// written, redaction guard and all (`d600`): comparing the raw request
// instead would report a difference the write itself would have
// collapsed.
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 {
outln!(" The lane name it already has was left as it is.");
}
}
// ---------------------------------------------------------------------------
// Applying: plan, ask, write.
// ---------------------------------------------------------------------------
fn apply(roots: &super::Roots, a: &Args) -> Result<i32, Failure> {
// `run` already refused the home folder and the global store before
// reaching here (`t594`): both guards used to live in
// this function alone, which is exactly what let `--join` skip them.
// `refuse_second_map` is plant-only, on purpose: `--join` already
// knows exactly which tree it means, so asking "did you mean to join
// a tree that already tracks these repositories?" a second time
// would be asking a question this run already answered (`t640`).
refuse_second_map(roots, a.has("new-tree"))?;
let tree = &roots.tree;
// `t640`, point 1: `--name` fixes a product's own name, and a product
// that already exists already has one -- valid only while this run
// is planting a fresh tree, or bypassing that question outright with
// `--new-tree`. Checked before anything is read or written, so a
// shape the redaction guard would refuse never gets the chance to
// (point 4): both exits below leave the disk exactly as it was.
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 setup 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"));
apply_writes(roots, a, lane, requested)
}
/// The plant path's own writes, shared with `--join` (`t640`, point 11):
/// everything past deciding which lane this run declares -- reading the
/// three files, building the plan, asking, and writing all or nothing.
/// `roots.tree` decides whether this run plants (`vivac_missing`) or
/// joins a tree already there; a join's own `roots` always resolves it
/// to a tree `join`'s own preamble already confirmed exists, so this
/// never plants on that path.
fn apply_writes(
roots: &super::Roots,
a: &Args,
lane: LanePlan,
requested: Option<String>,
) -> Result<i32, Failure> {
let here = &roots.here;
let tree = &roots.tree;
// `t594` §4.5, case 2's own extra check (§6.5): never blocks anything,
// so it is worked out once, up front, and printed alongside whichever
// of the three exits below this run actually reaches.
let above_warning =
tree_root_above(tree).map(|p| tree_above_warning(guarded_folder_name(&p).as_deref()));
let paths = paths(here);
let settings = read_json(&paths.settings);
let mcp = read_json(&paths.mcp);
let skill_raw = std::fs::read_to_string(&paths.skill).ok();
let mut conflicts: Vec<String> = Vec::new();
if let Some((line, col)) = settings.parse_error {
conflicts.push(unreadable_conflict(SETTINGS_LABEL, line, col));
} else if settings.not_object {
conflicts.push(not_object_conflict(SETTINGS_LABEL));
}
if let Some((line, col)) = mcp.parse_error {
conflicts.push(unreadable_conflict(MCP_LABEL, line, col));
} else if mcp.not_object {
conflicts.push(not_object_conflict(MCP_LABEL));
}
let mcp_root = mcp.value.clone().unwrap_or_else(|| Value::object(vec![]));
let mcp_server_state = if mcp.parse_error.is_none() && !mcp.not_object {
mcp_state(&mcp_root)
} else {
McpState::Missing
};
if let McpState::NameTaken(cmd) = &mcp_server_state {
conflicts.push(mcp_name_conflict(cmd));
}
let skill_file_state = match &skill_raw {
None => SkillState::Missing,
Some(text) => skill_state(text),
};
if matches!(skill_file_state, SkillState::Conflict) {
conflicts.push(skill_conflict());
}
if !conflicts.is_empty() {
let mut msg = conflicts.join("\n\n");
msg.push_str("\n\n Nothing written.");
return Err(Failure::Model(msg));
}
let settings_root = settings.value.clone().unwrap();
let start_hook_state = hook_state(
&settings_root,
"SessionStart",
"start",
SESSION_START_COMMAND,
);
let stop_hook_state = hook_state(&settings_root, "Stop", "end", SESSION_END_COMMAND);
let vivac_missing = !crate::store::already_planted(tree);
// `f676`/`d682`: only a genuine plant can be a product the registry
// never learned about yet -- `--join` already named its tree, and a
// tree already here is already a known one.
let unknown_product_warning = if vivac_missing {
second_map_hint(here).unwrap_or_default()
} else {
String::new()
};
// A tree this run plants already carries its `.gitignore`, straight out
// of `Store::create`: only a tree from before `t594` §4.9 can lack it.
let gitignore_missing = !vivac_missing
&& !tree
.join(crate::store::DIR)
.join(crate::store::GITIGNORE)
.is_file();
let start_missing = matches!(start_hook_state, HookState::Missing);
let stop_missing = matches!(stop_hook_state, HookState::Missing);
let mcp_missing = matches!(mcp_server_state, McpState::Missing);
let skill_missing_or_replaceable = matches!(
skill_file_state,
SkillState::Missing | SkillState::Replaceable
);
let nothing_to_write = !vivac_missing
&& !gitignore_missing
&& !start_missing
&& !stop_missing
&& !mcp_missing
&& !skill_missing_or_replaceable
&& lane.unchanged
&& !lane.needs_lock
&& lane.stale_worktrees.is_empty();
// `t640`, point 9: the plan names the product on the very line that
// names the lane, in every shape that line takes.
let product_name = product_name_for_plan(tree, requested.as_deref());
// `t640`, point 10 bis: a warning, never a refusal -- a project's
// identity is its first event's id, not its name, so two projects
// answering to the same name break nothing but `--join <name>`'s own
// convenience, and that is worth saying before this is written.
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))
});
let piece_block = render_piece_block(
here,
tree,
vivac_missing,
gitignore_missing,
settings.exists,
mcp.exists,
&start_hook_state,
&stop_hook_state,
start_missing,
stop_missing,
&mcp_server_state,
&skill_file_state,
&lane,
product_name.as_deref(),
name_collision,
);
// Asked once per run, and before either early exit below, so a log
// already tracked is flagged whether this run has anything else to
// write or not: someone already set up is exactly who never reaches
// the branch that used to be the only one carrying this warning.
let log_tracked = crate::anchor::in_working_tree(tree)
&& crate::anchor::tracks(tree, ".vivac/events") == Some(true);
// Checked before `nothing_to_write`, not after: that branch notes the
// registry (`note_registry`), and `--dry-run` promises to write
// nothing anywhere, the machine's registry included (`t594`).
// An already-set-up project asking for `--dry-run` used
// to reach the other branch first and note it anyway.
if a.has("dry-run") {
outln!("{piece_block}{unknown_product_warning}{TRAILING_PARAGRAPH}\n Nothing written: --dry-run.");
if log_tracked {
print!("{}", tracked_git_warning());
}
if let Some(w) = &above_warning {
print!("{w}");
}
return Ok(0);
}
if nothing_to_write {
// A real run, never `--dry-run`, thanks to the check above: noting
// the registry is bookkeeping every ordinary command already does
// on a pure read, not a write this promise is about.
note_registry(roots);
outln!("{piece_block} Nothing to write: this project is already set up.");
if log_tracked {
print!("{}", tracked_git_warning());
}
if let Some(w) = &above_warning {
print!("{w}");
}
return Ok(0);
}
if !a.has("yes") && !super::stdin_is_terminal() {
return Err(Failure::Model(no_terminal_text(a)));
}
print!("{piece_block}{unknown_product_warning}{TRAILING_PARAGRAPH}");
let proceed = a.has("yes") || super::ask("\n Write it? [y/N] ");
if !proceed {
outln!("\n Nothing written.");
return Ok(0);
}
// Build every write, then commit them together (`t565` §7.3: "se
// pregunta una sola vez por todo y se escribe todo o nada").
let mut writes = Vec::new();
if start_missing || stop_missing {
let mut new_settings = settings_root.clone();
if start_missing {
append_hook(&mut new_settings, "SessionStart", SESSION_START_COMMAND);
}
if stop_missing {
append_hook(&mut new_settings, "Stop", SESSION_END_COMMAND);
}
let rendered = json::finalize(
&json::render(&new_settings, &settings.indent),
settings.eol,
settings.trailing_newline,
);
let before = settings_root.clone();
writes.push(super::PlannedWrite {
path: paths.settings.clone(),
action: super::Action::Write(rendered),
original: settings.exists.then(|| settings.raw.clone().into_bytes()),
preserved: Some(Box::new(move |updated| json::extends(&before, updated))),
});
}
if mcp_missing {
let mut new_mcp = mcp_root.clone();
let mut servers = new_mcp
.get("mcpServers")
.cloned()
.unwrap_or_else(|| Value::object(vec![]));
servers.set("vivac", our_mcp_entry());
new_mcp.set("mcpServers", servers);
let rendered = json::finalize(
&json::render(&new_mcp, &mcp.indent),
mcp.eol,
mcp.trailing_newline,
);
let before = mcp_root.clone();
writes.push(super::PlannedWrite {
path: paths.mcp.clone(),
action: super::Action::Write(rendered),
original: mcp.exists.then(|| mcp.raw.clone().into_bytes()),
preserved: Some(Box::new(move |updated| json::extends(&before, updated))),
});
}
if skill_missing_or_replaceable {
writes.push(super::PlannedWrite::write(
paths.skill.clone(),
skill_text(),
skill_raw.clone().map(String::into_bytes),
));
}
if gitignore_missing {
writes.push(super::PlannedWrite::write(
tree.join(crate::store::DIR).join(crate::store::GITIGNORE),
"*\n".to_string(),
None,
));
}
super::commit(&writes)?;
// Planting is the one step this run takes after the commit above, which
// may already have written `.vivac/.gitignore` (`gitignore_missing`) --
// so `.vivac/` is not untouched by the time this runs. What stays true
// is narrower: planting itself never rolls back. A failure here undoes
// the JSON commit by hand, but whatever `Store::create` managed to
// write in `.vivac/` before failing is left exactly as it is (`t565`
// §7.7).
if vivac_missing {
if let Err(e) = crate::store::Store::create(tree) {
let unrestored = super::rollback(&writes);
return Err(super::failure_with_rollback(
format!("the tree could not be planted ({e})"),
&unrestored,
));
}
}
// Declaring the lane, or just relocking the config, goes right after
// planting, next to it: never rolled back on its own, only the JSON
// commit undone by hand if it fails -- `write_lane`'s own doc explains
// why that is still safe.
if !lane.unchanged {
if let Err(e) = write_lane(roots, &lane) {
let unrestored = super::rollback(&writes);
return Err(lane_failure_with_rollback(
format!("the lane could not be declared ({})", detail_of(&e)),
&unrestored,
));
}
} else {
// Nothing new about this lane's own declaration, but a worktree
// from before this fix existed can still be stuck with no root
// commit, and `write_lane` above is only ever reached when this
// lane itself has something new to say (`f609`).
if !lane.stale_worktrees.is_empty() {
if let Err(e) = redeclare_only_stale_worktrees(roots, &lane) {
let unrestored = super::rollback(&writes);
return Err(lane_failure_with_rollback(
format!("the lane could not be declared ({})", detail_of(&e)),
&unrestored,
));
}
}
if lane.needs_lock {
// Nothing new to declare, but the config still needs the lock
// `unchanged` must never decide on its own (`t594`):
// here the only write is the lock itself, so a failure has
// nothing irreversible to own up to and the ordinary wording
// is accurate as it stands.
if let Err(e) = relock_lanes(tree) {
let unrestored = super::rollback(&writes);
return Err(super::failure_with_rollback(
format!(
"the tree's config could not be relocked ({})",
detail_of(&e)
),
&unrestored,
));
}
}
}
// What *this run* actually did to the tree, for `written_text`
// (`t594`): every one of these is independent, and `needs_lock`
// decides `config_locked` regardless of which branch above closed
// it -- both `write_lane`'s own `declare_lane` and `relock_lanes`
// close the same lock, and only ever do it for real when it was
// still open beforehand.
let written = Written {
connection: start_missing || stop_missing || mcp_missing,
// `f638`, `d641`: the tree existed before this run (this run did
// not plant it) and this run is the one adding the "vivac" server
// -- `mcp_missing` decided the write above, at `:1753`.
hand_registered_risk: !vivac_missing && mcp_missing,
skill: skill_missing_or_replaceable,
planted: vivac_missing,
gitignore_created: gitignore_missing,
lane_declared: !lane.unchanged || !lane.stale_worktrees.is_empty(),
config_locked: lane.needs_lock,
joined_new_lane: !vivac_missing && lane.is_new,
undoable: start_missing
&& stop_missing
&& mcp_missing
&& matches!(skill_file_state, SkillState::Missing),
};
note_registry(roots);
note_name(tree, requested.as_deref());
print!("\n{}", written_text(&written));
if log_tracked {
print!("{}", tracked_git_warning());
}
if let Some(w) = &above_warning {
print!("{w}");
}
Ok(0)
}
#[allow(clippy::too_many_arguments)]
fn render_piece_block(
here: &Path,
tree: &Path,
vivac_missing: bool,
gitignore_missing: bool,
settings_exists: bool,
mcp_exists: bool,
start_hook_state: &HookState,
stop_hook_state: &HookState,
start_missing: bool,
stop_missing: bool,
mcp_server_state: &McpState,
skill_file_state: &SkillState,
lane: &LanePlan,
product_name: Option<&str>,
name_collision: Option<&str>,
) -> String {
let mut s = format!(" vivac setup claude-code, in {}\n\n", here.display());
// `t640`, point 10 bis: said before anything is written, never a
// refusal -- `name_collision` is only ever `Some` once `--name`'s own
// value already matches another project's effective name.
if let Some(name) = name_collision {
s.push_str(&format!(
" \"{name}\" already names another project on this machine. With both\n \
answering to it, --join will need a path instead of the name: two\n \
projects that share a name give it nothing to tell them apart by.\n\n"
));
}
// `t579` §4's warning: only when `here` sits inside a repository but is
// not its root, so nobody has to guess which folder Claude Code was
// actually opened in.
if !here.join(".git").exists() {
if let Some(git_root) = super::git_root_above(here) {
s.push_str(&format!(
" This folder is inside the repository at {}, not at its root.\n \
Claude Code reads these files only from the folder it is opened in: if\n \
you open it at {}, run setup there instead.\n\n",
git_root.display(),
git_root.display()
));
}
}
let vivac_status = if vivac_missing {
"plant the tree".to_string()
} else if tree == here {
"already there".to_string()
} else {
format!("already there, in {}", tree.display())
};
s.push_str(&piece_line(VIVAC_LABEL, &vivac_status));
if gitignore_missing {
// Two different files, in two different folders, can both need
// this line in the same run -- the tree's own, from before `t594`
// §4.9, and a brand new lane's own (below). Only then does the
// tree's own copy say whose it is; on its own it reads exactly as
// it always has (`t594`).
let status = if lane.is_new {
"create: keeps the tree's .vivac/ out of version control"
} else {
"create: keeps .vivac/ out of version control"
};
s.push_str(&piece_line(GITIGNORE_LABEL, status));
}
let settings_status = match (settings_exists, start_missing, stop_missing) {
(_, false, false) => "already has both hooks",
(_, true, false) => "add the SessionStart hook",
(_, false, true) => "add the Stop hook",
(false, true, true) => "create, with two hooks",
(true, true, true) => "add two hooks",
};
s.push_str(&piece_line(SETTINGS_LABEL, settings_status));
match start_hook_state {
HookState::Missing => s.push_str(&sub_line("SessionStart", SESSION_START_COMMAND)),
HookState::Different(cmd) => {
s.push_str(&sub_line("SessionStart", &format!("already runs {cmd}")))
}
HookState::Exact => {}
}
match stop_hook_state {
HookState::Missing => s.push_str(&sub_line("Stop", SESSION_END_COMMAND)),
HookState::Different(cmd) => s.push_str(&sub_line("Stop", &format!("already runs {cmd}"))),
HookState::Exact => {}
}
let mcp_status = match mcp_server_state {
McpState::Missing if !mcp_exists => "create, with the server \"vivac\"".to_string(),
McpState::Missing => "add the server \"vivac\"".to_string(),
McpState::Ours => "already has the server \"vivac\"".to_string(),
McpState::OtherName(name) => format!("already runs vivac mcp as \"{name}\""),
McpState::NameTaken(_) => unreachable!("a name conflict never reaches the plan"),
};
s.push_str(&piece_line(MCP_LABEL, &mcp_status));
if matches!(mcp_server_state, McpState::Missing) {
s.push_str(" vivac mcp\n");
}
match skill_file_state {
SkillState::Missing => s.push_str(&wrapped_piece_line(
SKILL_LABEL,
"create: how an agent brings",
"another memory into vivac",
)),
SkillState::Replaceable => s.push_str(&piece_line(
SKILL_LABEL,
"replace the copy an earlier vivac wrote",
)),
SkillState::Same => s.push_str(&piece_line(SKILL_LABEL, "already there")),
SkillState::Conflict => unreachable!("a skill conflict never reaches the plan"),
}
if !lane.unchanged {
// `t640`, point 9: the plan names the product on this same line,
// in both shapes it takes -- a brand new lane and a redeclared
// one alike. `product_label` also replaces "the tree above",
// which named where this folder sits rather than what it joins,
// and stopped being true the moment `--join`'s own plan started
// reaching this same line for a tree that is not above it at all.
let product = product_label(product_name);
if lane.is_new {
s.push_str(&piece_line(
LANE_LABEL,
&format!(
"create: this folder becomes lane \"{}\" of {product}",
lane.name
),
));
s.push_str(&piece_line(
GITIGNORE_LABEL,
"create: keeps .vivac/ out of version control",
));
} else {
// One sentence for both: declaring `main` on the tree's own
// folder and redeclaring a lane that already existed are the
// same write, and neither creates a file the way a brand new
// lane does above -- it is the log that changes.
s.push_str(&piece_line(
".vivac/events",
&format!(
"record: this folder is lane \"{}\" of {product}, with its repositories",
lane.name
),
));
}
}
// Independent of `unchanged` too: a worktree can be stuck with no root
// commit from before this folder's own repositories ever had one,
// which a run that finds nothing new of its own to declare still
// repairs (`f609`).
if !lane.stale_worktrees.is_empty() {
let count = lane.stale_worktrees.len();
let noun = if count == 1 { "lane" } else { "lanes" };
s.push_str(&piece_line(
".vivac/events",
&format!("redeclare {count} worktree {noun} with the repositories this run found"),
));
}
// What the redaction guard kept out is the folder's own state, not a
// change: it is still true on a run that declares nothing new, so it
// is said every time rather than only on the run that first found it
// (`t594`).
if let Some((count, rule)) = lane.excluded {
let noun = if count == 1 {
"repository"
} else {
"repositories"
};
s.push_str(&sub_line(
"kept out",
&format!("{count} {noun}, refused: {rule}"),
));
}
// Independent of `unchanged`: the config can need the lock even when
// nothing about the declaration itself changed (`t594`).
if lane.needs_lock {
s.push_str(&piece_line(
"config",
"lock: from now on this tree needs vivac 0.12 or newer",
));
}
s.push('\n');
s
}
// `pub(super)`: true of `codex.rs`'s own hooks and server too, and neither
// names Claude Code (`d653`).
pub(super) const TRAILING_PARAGRAPH: &str = " The hooks run a command in every session, and the server is how the\n agent writes to the tree. Nothing outside this directory is touched,\n and no file is copied.\n";
/// A value repeated into `no_terminal_text`, quoted only when it has a
/// space in it: the same rule the copied command line needs to survive a
/// shell, and no more than that -- an unquoted path or name with none
/// reads back exactly as it was typed.
fn quoted_if_it_has_a_space(value: &str) -> String {
if value.contains(' ') {
format!("\"{value}\"")
} else {
value.to_string()
}
}
/// The flags this run was given, in the fixed order the two commands
/// `no_terminal_text` suggests repeat them in, and only the ones present.
/// `f675`: dropping them used to hand back two bare commands, and running
/// the first one literally -- `vivac setup claude-code --dry-run` -- plans
/// a plant even on a run that asked to `--join` a tree elsewhere. That is
/// not a shorter version of the advice, it is different advice.
fn no_terminal_flags(a: &Args) -> String {
let mut s = String::new();
if let Some(v) = a.opt("join") {
s.push_str(" --join ");
s.push_str("ed_if_it_has_a_space(v));
}
if a.has("new-tree") {
s.push_str(" --new-tree");
}
if let Some(v) = a.opt("name") {
s.push_str(" --name ");
s.push_str("ed_if_it_has_a_space(v));
}
if let Some(v) = a.opt("lane-name") {
s.push_str(" --lane-name ");
s.push_str("ed_if_it_has_a_space(v));
}
s
}
/// `f675`: built rather than constant, so the two commands it suggests
/// name the run that is actually stuck rather than a bare plant. The two
/// columns keep the alignment a fixed label already fixes; only what
/// comes after `claude-code` grows.
fn no_terminal_text(a: &Args) -> String {
let flags = no_terminal_flags(a);
format!(
" setup asks before writing, and there is no terminal here to ask.\n See what it would write: vivac setup claude-code{flags} --dry-run\n Then write it: vivac setup claude-code{flags} --yes"
)
}
/// What this run wrote, which decides how it ends (`t579` §15.5): a
/// paragraph is only printed when it is true of this run, and it says
/// what that run did, no more and no less (`t594`) -- every one of
/// these is a separate thing `apply` can write to the tree or the
/// folder, and any subset of them can be true together.
struct Written {
/// A hook or the server, which only a new session picks up.
connection: bool,
/// This run added the "vivac" server to a tree that was already here
/// before it (`f638`, `d641`): a hand-registered local-scope server
/// from before `setup` existed can shadow the one this run just added,
/// and nothing on screen says so. Always `false`
/// when `connection` is, since this is never true without the server
/// being part of what made `connection` true.
hand_registered_risk: bool,
/// The skill, where it was missing or an earlier release's copy.
skill: bool,
/// The tree, planted by this run rather than found.
planted: bool,
/// The tree's own `.vivac/.gitignore`, on a tree from before `t594`
/// §4.9 that never got one (`gitignore_missing`). Independent of
/// everything else here: a tree can be missing this and have its
/// lanes fully settled, or the other way round.
gitignore_created: bool,
/// This run declared this folder's lane, redeclared an existing one,
/// or redeclared a worktree lane stuck with no root commit (`f609`):
/// a real change to the tree's own log, either way.
lane_declared: bool,
/// This run closed the lanes lock, whether that happened on its own
/// (nothing else changed) or alongside declaring the lane above
/// (`t594` first tried to treat these as mutually
/// exclusive, which they are not: a brand new lane commonly closes
/// the lock in the very same write that declares it).
config_locked: bool,
/// This run declared a lane that did not exist here before, on a tree
/// that was already there rather than one it just planted (`f678`,
/// `d683`): joining, whether that came from an explicit `--join` or
/// from `setup` finding the tree above `here` on its own. Always
/// `false` when `planted` is, since planting mints the tree's very
/// first lane and `MIGRATE_PARAGRAPHS` already covers it.
joined_new_lane: bool,
/// All four of setup's pieces, the skill among them missing before:
/// `--undo` removes all four, so only then does it take back exactly
/// this run.
undoable: bool,
}
fn written_text(w: &Written) -> String {
let mut s = String::from(" Written.\n");
if w.connection {
s.push_str(SESSION_PARAGRAPH);
if w.hand_registered_risk {
s.push_str(HAND_REGISTERED_PARAGRAPH);
}
} else if w.skill {
s.push_str(SKILL_PARAGRAPH);
}
if w.planted {
s.push_str(MIGRATE_PARAGRAPHS);
} else {
s.push_str(&tree_paragraph(
w.gitignore_created,
w.lane_declared,
w.config_locked,
));
// `f678`/`d683`: the argument for staying quiet here was the
// **tree**'s, which a join finds already there and may already
// hold content for. It says nothing about the folder, which
// arrives with its own instruction files, its own harness memory
// and its own documents, and joining a tree never reads any of
// that.
if w.joined_new_lane {
s.push_str(JOIN_MIGRATE_PARAGRAPHS);
}
}
s.push_str(FILES_PARAGRAPH);
if w.undoable {
s.push_str(UNDO_LINE);
}
s
}
/// The paragraph about the tree itself, once planting it is ruled out
/// (`MIGRATE_PARAGRAPHS` covers that): `TREE_KEPT_PARAGRAPH` when none of
/// the three actually happened, and one sentence naming exactly the ones
/// that did otherwise -- never more than what this run wrote, and never
/// silent about any of it.
///
/// The three are independent, and saying so in the type is the fix: they
/// were mutually exclusive branches before, so a run that did two of them
/// could only name one, and a run that only wrote the tree's `.gitignore`
/// had no branch at all and claimed to have changed nothing -- two lines
/// under its own plan announcing that write (`t594`).
fn tree_paragraph(gitignore_created: bool, lane_declared: bool, config_locked: bool) -> String {
let mut clauses = Vec::new();
if gitignore_created {
clauses.push("its own .gitignore");
}
if lane_declared {
clauses.push("this folder's own thread");
}
if config_locked {
clauses.push("the sentence that stops an older vivac from reading it");
}
if clauses.is_empty() {
return TREE_KEPT_PARAGRAPH.to_string();
}
// Noun phrases rather than verb phrases: they share one subject, so
// two of them join without the reader having to carry a verb across
// the list, and none of them can be read as belonging to this run
// rather than to the tree.
format!(
"\n{}",
wrapped(&format!(
"The tree was already there, and setup wrote in it: {}.",
join_with_and(&clauses)
))
)
}
/// `text`, wrapped to the same width every other paragraph in this file
/// already wraps to by hand, each line indented by two spaces. A plain
/// greedy word wrap is all this needs: nothing it ever wraps runs past a
/// short sentence naming one to three clauses.
///
/// `pub(super)`: `codex.rs` wraps its own one-sentence refusals the same
/// way, rather than hand-wrapping each one to the same width again.
pub(super) fn wrapped(text: &str) -> String {
const WIDTH: usize = 76;
let mut out = String::new();
let mut line = String::from(" ");
for word in text.split_whitespace() {
if line.len() + word.len() + 1 > WIDTH && line.trim() != "" {
out.push_str(line.trim_end());
out.push('\n');
line = String::from(" ");
}
line.push_str(word);
line.push(' ');
}
out.push_str(line.trim_end());
out.push('\n');
out
}
/// `items`, in English list form: one on its own, two joined by "and",
/// three or more comma-separated with "and" before the last.
fn join_with_and(items: &[&str]) -> String {
match items {
[] => String::new(),
[one] => one.to_string(),
[a, b] => format!("{a} and {b}"),
_ => {
let (last, rest) = items.split_last().expect("checked non-empty above");
format!("{} and {last}", rest.join(", "))
}
}
}
const SESSION_PARAGRAPH: &str = "\n Open a new Claude Code session in this folder. The brief arrives on its\n own when it starts. If Claude Code asks whether to use the \"vivac\" server\n from .mcp.json, say yes: it is what lets the agent write to the tree.\n";
/// `f638`: before `setup` existed, the README told people to run
/// `claude mcp add vivac -- vivac mcp`, which registers the server in
/// Claude Code's local scope. Claude Code connects to a same-named server
/// once, preferring local scope over the project scope `.mcp.json` holds,
/// so an entry this run adds there can go silently unused.
///
/// setup never reads a harness's personal configuration to check for a
/// hand-made registration directly: for Claude Code that file
/// (`~/.claude.json`) also holds the sign-in session, and the security
/// pillar vetoes opening it (`d641`). So the condition below is inferred
/// from the project instead -- the tree was here before this run, and
/// this run is the one adding the "vivac" server to `.mcp.json` -- rather
/// than read from the harness itself.
///
/// Each harness `setup` covers later says this in its own words and with
/// its own command, at this same point in its closing message.
const HAND_REGISTERED_PARAGRAPH: &str = "\n The tree was here before this server was. If you once registered vivac\n by hand with claude mcp add, Claude Code keeps using that registration\n and not this one. To keep only this one, run from this folder:\n\n claude mcp remove vivac -s local\n";
const SKILL_PARAGRAPH: &str = "\n The vivac-migrate skill is now the one this version of vivac ships.\n Sessions opened from now on use it.\n";
const MIGRATE_PARAGRAPHS: &str = "\n Nothing has been brought in from anywhere yet. To bring in what this\n project already knows, from another memory system, the harness's own\n memory, instruction files or its documents, ask the agent:\n\n Use the vivac-migrate skill to bring everything this project knows\n into vivac.\n\n It shows you a plan before writing anything, checks what it wrote, and\n offers to retire the other maps one at a time, only if you say yes.\n\n Until then, another memory system you use keeps talking to the agent as\n before, and may tell it to use that system first. That is expected: the\n skill only reads from it.\n";
/// `f678`/`d683`: `MIGRATE_PARAGRAPHS`'s own argument was for the
/// **tree**, which a join finds already there and may already carry
/// content for -- true, and beside the point. This folder's own
/// instruction files, the harness's memory and its documents came with
/// the folder, not the tree, and joining a tree never reads any of that.
const JOIN_MIGRATE_PARAGRAPHS: &str = "\n This folder's own knowledge is not in the tree. Instruction files, the\n harness's memory and the documents that live here came with the folder,\n and joining a tree does not read them. To bring them in, ask the agent:\n\n Use the vivac-migrate skill to bring everything this project knows\n into vivac.\n\n The tree already has content, and the skill expects that: it looks at\n what is there before writing, and proposes a note on the node that\n already says it rather than a duplicate.\n";
const TREE_KEPT_PARAGRAPH: &str =
"\n The tree was already there, and setup changed nothing in it.\n";
const FILES_PARAGRAPH: &str = "\n The hooks, the server and the skill are plain files in this project:\n commit them if everyone who works here uses vivac, and keep them out of\n version control if only you do. .vivac/ is never committed: it is this\n machine's record, and a copy of it in every clone would diverge from the\n others. Its own .gitignore keeps it out.\n";
const UNDO_LINE: &str = "\n Undo: vivac setup claude-code --undo\n";
/// The words come from `anchor::EVENTS_TRACKED_WARNING` (`f619`), wrapped
/// to this file's own paragraph width: `check` reads that very same
/// constant, so the two can no longer drift the way they once did, and
/// `check`'s copy never named a worktree at all.
fn tracked_git_warning() -> String {
format!("\n{}", wrapped(crate::anchor::EVENTS_TRACKED_WARNING))
}
fn unreadable_conflict(label: &str, line: usize, column: usize) -> String {
format!(
" {label} is not JSON setup can read (line {line}, column {column}), so\n \
it will not touch it: a file it cannot read is a file it could only\n \
overwrite."
)
}
fn not_object_conflict(label: &str) -> String {
format!(
" {label} holds JSON whose top level is not an object, so setup will\n \
not touch it: a file it cannot read is a file it could only overwrite."
)
}
fn mcp_name_conflict(command_and_args: &str) -> String {
format!(
" .mcp.json already has a server called \"vivac\", and it does not run\n \
vivac mcp:\n {command_and_args}\n \
setup never rewrites an entry it did not write. Rename or remove that\n \
one, then run setup again."
)
}
fn skill_conflict() -> String {
" .claude/skills/vivac-migrate/SKILL.md is already there, and either setup\n \
did not write it or it was changed since. setup never overwrites it:\n \
move it away, then run setup again."
.to_string()
}
// ---------------------------------------------------------------------------
// `--undo`.
// ---------------------------------------------------------------------------
fn undo(roots: &super::Roots, a: &Args) -> Result<i32, Failure> {
let root = roots.here.as_path();
let paths = paths(root);
let settings = read_json(&paths.settings);
let mcp = read_json(&paths.mcp);
let skill_raw = std::fs::read_to_string(&paths.skill).ok();
let lane_path = root.join(crate::store::DIR).join(crate::lane::FILE);
let lane_raw = std::fs::read(&lane_path).ok();
let mut conflicts: Vec<String> = Vec::new();
if let Some((line, col)) = settings.parse_error {
conflicts.push(unreadable_conflict(SETTINGS_LABEL, line, col));
} else if settings.not_object {
conflicts.push(not_object_conflict(SETTINGS_LABEL));
}
if let Some((line, col)) = mcp.parse_error {
conflicts.push(unreadable_conflict(MCP_LABEL, line, col));
} else if mcp.not_object {
conflicts.push(not_object_conflict(MCP_LABEL));
}
if !conflicts.is_empty() {
let mut msg = conflicts.join("\n\n");
msg.push_str("\n\n Nothing written.");
return Err(Failure::Model(msg));
}
let settings_root = settings.value.clone().unwrap();
let start_hook_state = hook_state(
&settings_root,
"SessionStart",
"start",
SESSION_START_COMMAND,
);
let stop_hook_state = hook_state(&settings_root, "Stop", "end", SESSION_END_COMMAND);
let mcp_root = mcp.value.clone().unwrap();
let mcp_server_state = mcp_state(&mcp_root);
let skill_ours = skill_raw.as_deref().is_some_and(skill_fingerprint_intact);
let start_ours = matches!(start_hook_state, HookState::Exact);
let stop_ours = matches!(stop_hook_state, HookState::Exact);
let mcp_ours = matches!(mcp_server_state, McpState::Ours);
// `d680`: `.vivac/lane` is not something setup wrote *for* Claude Code,
// and it carries no field saying who wrote it -- its shape is
// `{version, id, project}` and nothing else -- so this cannot ask "did
// setup write this". What it asks instead is the one thing that can be
// checked and loses nothing either way: whether the lane it names has
// ever changed the tree. `lane_removable` is `false` whenever there is
// no file to weigh in the first place.
let own_lane = crate::lane::read(&root.join(crate::store::DIR))?;
let lane_wrote = own_lane
.as_ref()
.is_some_and(|lane| lane_has_written(&roots.tree, &lane.id));
let lane_removable = own_lane.is_some() && !lane_wrote;
let nothing_to_undo = !start_ours && !stop_ours && !mcp_ours && !skill_ours && !lane_removable;
if nothing_to_undo {
outln!(" Nothing to undo: none of what setup writes is here.");
return Ok(0);
}
// Preview the settings.json result to know whether it empties out.
let mut preview = settings_root.clone();
if start_ours {
remove_hook(&mut preview, "SessionStart", SESSION_START_COMMAND);
}
if stop_ours {
remove_hook(&mut preview, "Stop", SESSION_END_COMMAND);
}
let settings_becomes_empty = preview
.as_object()
.is_some_and(|s: &[(String, Value)]| s.is_empty());
let mcp_becomes_empty = mcp_ours
&& without_our_mcp_server(&mcp_root)
.as_object()
.is_some_and(|s: &[(String, Value)]| s.is_empty());
let settings_status: String = match (start_ours, stop_ours) {
(true, true) if settings_becomes_empty => {
"remove the two hooks setup wrote;\nNOTHING_ELSE".to_string()
}
(true, true) => "remove the two hooks setup wrote".to_string(),
(true, false) => "remove the SessionStart hook".to_string(),
(false, true) => "remove the Stop hook".to_string(),
(false, false) => "left as it is".to_string(),
};
let mut s = format!(
" vivac setup claude-code --undo, in {}\n\n",
root.display()
);
if settings_status.contains("NOTHING_ELSE") {
s.push_str(&wrapped_piece_line(
SETTINGS_LABEL,
"remove the two hooks setup wrote;",
"nothing else is left, so it goes",
));
} else {
s.push_str(&piece_line(SETTINGS_LABEL, &settings_status));
}
if let HookState::Different(_) = &start_hook_state {
s.push_str(&sub_line(
"SessionStart",
"runs vivac another way; left as it is",
));
}
if let HookState::Different(_) = &stop_hook_state {
s.push_str(&sub_line("Stop", "runs vivac another way; left as it is"));
}
if mcp_becomes_empty {
s.push_str(&wrapped_piece_line(
MCP_LABEL,
"remove the server \"vivac\";",
"nothing else is left, so it goes",
));
} else {
s.push_str(&piece_line(
MCP_LABEL,
if mcp_ours {
"remove the server \"vivac\""
} else {
"left as it is"
},
));
}
s.push_str(&piece_line(
SKILL_LABEL,
if skill_ours {
"remove"
} else if skill_raw.is_some() {
"changed since setup wrote it; left as it is"
} else {
"left as it is"
},
));
s.push_str(&piece_line(VIVAC_LABEL, "kept: the tree is not setup's"));
if own_lane.is_some() {
if lane_removable {
s.push_str(&piece_line(LANE_LABEL, "remove this folder's lane"));
} else {
s.push_str(&wrapped_piece_line(
LANE_LABEL,
"left as it is: this lane has written to the tree,",
"and removing it would orphan what it wrote",
));
}
}
s.push('\n');
if a.has("dry-run") {
outln!("{s} Nothing written: --dry-run.");
return Ok(0);
}
print!("{s}");
let proceed = a.has("yes") || super::ask(" Undo it? [y/N] ");
if !proceed {
outln!("\n Nothing written.");
return Ok(0);
}
// Every change -- a rewrite or a removal -- is one commit, the same
// all-or-nothing guarantee `apply` gives (`t565` §7.6).
let mut writes = Vec::new();
if start_ours || stop_ours {
let original = settings.raw.clone().into_bytes();
if settings_becomes_empty {
writes.push(super::PlannedWrite::delete(
paths.settings.clone(),
original,
));
} else {
let mut new_settings = settings_root.clone();
if start_ours {
remove_hook(&mut new_settings, "SessionStart", SESSION_START_COMMAND);
}
if stop_ours {
remove_hook(&mut new_settings, "Stop", SESSION_END_COMMAND);
}
let rendered = json::finalize(
&json::render(&new_settings, &settings.indent),
settings.eol,
settings.trailing_newline,
);
let before = settings_root.clone();
writes.push(super::PlannedWrite {
path: paths.settings.clone(),
action: super::Action::Write(rendered),
original: Some(original),
preserved: Some(Box::new(move |updated| {
json::contained_in(updated, &before)
})),
});
}
}
if mcp_ours {
let original = mcp.raw.clone().into_bytes();
if mcp_becomes_empty {
writes.push(super::PlannedWrite::delete(paths.mcp.clone(), original));
} else {
let new_mcp = without_our_mcp_server(&mcp_root);
let rendered = json::finalize(
&json::render(&new_mcp, &mcp.indent),
mcp.eol,
mcp.trailing_newline,
);
let before = mcp_root.clone();
writes.push(super::PlannedWrite {
path: paths.mcp.clone(),
action: super::Action::Write(rendered),
original: Some(original),
preserved: Some(Box::new(move |updated| {
json::contained_in(updated, &before)
})),
});
}
}
if skill_ours {
writes.push(super::PlannedWrite::delete(
paths.skill.clone(),
skill_raw.clone().unwrap().into_bytes(),
));
}
if lane_removable {
writes.push(super::PlannedWrite::delete(
lane_path.clone(),
lane_raw.clone().unwrap_or_default(),
));
}
super::commit(&writes)?;
// Best-effort, and only once the commit above is known to have
// succeeded: an empty directory left behind costs nothing to leave for
// a later run, but is tidier gone.
if skill_ours {
remove_if_empty(paths.skill.parent());
remove_if_empty(paths.skill.parent().and_then(Path::parent));
remove_if_empty(
paths
.skill
.parent()
.and_then(Path::parent)
.and_then(Path::parent),
);
}
outln!(" Undone. The tree in .vivac/ is untouched.");
Ok(0)
}
fn remove_if_empty(dir: Option<&Path>) {
if let Some(dir) = dir {
let _ = std::fs::remove_dir(dir);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `tree_below_refusal`'s own fallback for two or more trees below
/// whose names the redaction guard withholds entirely: unspecified by
/// `t594` §1.2, which only names the plural form's shape, not what it
/// says once nothing is nameable at all -- so it earns its keep by
/// having a test rather than by being removed (`t594`).
#[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}");
}
/// The same promise for the refusal's mirror image, upward: the tree
/// above is named, and a name the redaction guard rejects is not
/// written down at all -- the sentence still says where to go and
/// read it.
#[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 is_vivac_command_strips_quotes_path_and_extension() {
assert!(is_vivac_command("vivac"));
assert!(is_vivac_command("VIVAC"));
assert!(is_vivac_command("\"vivac\""));
assert!(is_vivac_command("C:/tools/vivac.exe"));
assert!(is_vivac_command("C:\\tools\\vivac.EXE"));
assert!(is_vivac_command("/usr/local/bin/vivac"));
assert!(!is_vivac_command("vivacx"));
assert!(!is_vivac_command("notvivac"));
}
#[test]
fn the_fingerprint_matches_the_known_hash_of_the_literal_text() {
// Computed independently (Python's own FNV-1a/64) over the exact
// frontmatter and body this file embeds.
assert_eq!(skill_fingerprint(), 0x373ea81ebebe9f73);
}
#[test]
fn extract_marker_reads_back_what_skill_text_writes() {
let text = skill_text();
let (fp_hex, content) = extract_marker(&text).unwrap();
let fp = u64::from_str_radix(&fp_hex, 16).unwrap();
assert_eq!(fp, skill_fingerprint());
assert_eq!(content, skill_content_without_marker());
}
}