//! 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");
pub fn run(roots: &super::Roots, a: &Args) -> Result<i32, Failure> {
if a.has("undo") {
return undo(&roots.here, 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.has("dry-run"));
}
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())
}
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.
fn product_registered_refusal(
sharing: &crate::registry::Sharing,
here_repos: &[crate::event::Repo],
) -> Failure {
let mut repo_names: Vec<&str> = here_repos
.iter()
.filter(|r| {
r.root
.as_deref()
.is_some_and(|root| sharing.shared.iter().any(|s| s == root))
})
.map(|r| r.path.as_str())
.collect();
repo_names.sort_unstable();
let repo_list = repo_names.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 \
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: {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 \
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(()),
}
}
/// §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)
}
/// 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.
// ---------------------------------------------------------------------------
fn piece_line(label: &str, status: &str) -> String {
format!(" {label:<41}{status}\n")
}
fn sub_line(label: &str, value: &str) -> String {
format!(" {label:<15}{value}\n")
}
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. Narrower than `apply`, on
// purpose: it resolves a tree that lives somewhere else, writes this
// folder's own `.vivac/lane` and declares the lane there -- and nothing
// about the hooks, the server or the skill, since a folder that already
// ran setup somewhere else has no reason to run it a second time here.
// ---------------------------------------------------------------------------
/// `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>,
dry_run: bool,
) -> 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.
let Some(project) = crate::store::first_event_id(&target) else {
return Err(Failure::Model(
" That tree has no events yet, so there is nothing to join: it has\n \
no identity yet for a lane to point back at."
.to_string(),
));
};
let id = crate::lane::new_id();
let folder_name = roots
.here
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let name = crate::lane::declared_name(&id, lane_name.unwrap_or(&folder_name));
let (repos, _excluded) = filtered_repos(crate::repos::scan(&roots.here));
// `--dry-run` promises nothing is written by any path: `apply` got
// that back once and `--join` reopened it (`t594`), so nothing below
// this point runs.
if dry_run {
match crate::registry::folder_name(&target) {
Some(name) => outln!(" This folder would become a lane of the tree in \"{name}\"."),
None => {
outln!(" This folder would become a lane of a tree elsewhere on this machine.")
}
}
outln!(" Nothing written: --dry-run.");
return Ok(0);
}
// The file first, unlocked, then the event under the target's own
// lock: the same order `write_lane` already follows and the same
// reason -- the one ordering that must never happen is the event
// landing first, which would leave this folder signing as `main`
// while the tree it just joined already says otherwise.
crate::lane::write(
&roots.here.join(crate::store::DIR),
&crate::lane::Lane {
version: 1,
id: id.clone(),
project,
},
)?;
let store = crate::store::Store::open(target.clone())?;
let mut ctx = crate::ops::Ctx::load_for_write(
store,
crate::ops::Whose::Declared(id.clone(), target.clone()),
)?;
ctx.lock_for_write()?;
crate::ops::declare_lane(&mut ctx, name, repos)?;
// The same registry bookkeeping `note_registry` does for `apply`, but
// keyed by `target` -- this folder's own tree, not `roots.tree`, which
// still names no tree of its own at all. Quiet on any failure, the
// same promise `note_registry` already makes -- but not thrown away:
// `lane::write` and `declare_lane` above have already written for
// real by the time this runs, so whatever `note` says here is left
// for `registry::warn_if_wrote` to act on (`t594`).
if let Some(store_dir) = crate::store::store_dir() {
if let Some(project_id) = crate::store::first_event_id(&target) {
let target_repos = union_repo_roots(&fold_tree(&target));
let noted = crate::registry::note(
&store_dir,
&project_id,
crate::registry::Sighting {
root: &target,
lane: Some((&id, &roots.here)),
repos: Some(&target_repos),
},
);
crate::registry::set_pending(noted);
}
}
// What a person does not already know after joining, which is the whole
// of `d595` in two sentences: the knowledge stayed the product's and the
// thread became this folder's. The tree is named by its folder, never by
// its path, and the name is withheld when the redaction guard rejects it
// (`d600`) -- the sentence survives without it.
match crate::registry::folder_name(&target) {
Some(name) => outln!(" This folder is now a lane of the tree in \"{name}\"."),
None => outln!(" This folder is now a lane of a tree elsewhere on this machine."),
}
outln!(" The nodes and their numbering are the product's; the stack, the focus");
outln!(" and the last stop are this folder's.");
Ok(0)
}
/// 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(roots, a.has("new-tree"))?;
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);
// 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 lane = plan_lane(roots, a.opt("lane-name"));
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();
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,
);
// 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}{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.to_string()));
}
print!("{piece_block}{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,
undoable: start_missing
&& stop_missing
&& mcp_missing
&& matches!(skill_file_state, SkillState::Missing),
};
note_registry(roots);
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,
) -> String {
let mut s = format!(" vivac setup claude-code, in {}\n\n", here.display());
// `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 {
if lane.is_new {
s.push_str(&piece_line(
LANE_LABEL,
&format!(
"create: this folder becomes lane \"{}\" of the tree above",
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 \"{}\", 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
}
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";
const NO_TERMINAL_TEXT: &str = " setup asks before writing, and there is no terminal here to ask.\n See what it would write: vivac setup claude-code --dry-run\n Then write it: vivac setup claude-code --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,
/// 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,
));
}
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.
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";
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(root: &Path, a: &Args) -> Result<i32, Failure> {
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 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);
let nothing_to_undo = !start_ours && !stop_ours && !mcp_ours && !skill_ours;
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"));
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(),
));
}
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(), 0x53833e2dadbcf537);
}
#[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());
}
}