use anyhow::{anyhow, bail, Context, Result};
use kranz_engine::cost;
use kranz_engine::merge_gate::{parse_gate_suite, MERGE_GATES_PATH};
use kranz_server::{HostConfig, MultiRepoHost};
use serde_json::{json, Map, Value};
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
const RUNTIME_IGNORE_HEADER: &str = "# kranz runtime bookkeeping (generated by kranz init)";
#[cfg(not(windows))]
const RUST_TEST_GATE: &str = r#"output=$(mktemp) && trap 'rm -f "$output"' EXIT; cargo test --workspace >"$output" 2>&1; test_status=$?; cat "$output"; [ "$test_status" -eq 0 ] && grep -qE 'test result: ok\. [1-9][0-9]* passed' "$output""#;
#[cfg(windows)]
const RUST_TEST_GATE: &str = r#"powershell -NoProfile -Command "$output = cargo test --workspace 2>&1 | Out-String; $status = $LASTEXITCODE; Write-Output $output; if ($status -ne 0 -or $output -notmatch 'test result: ok\. [1-9][0-9]* passed') { exit 1 }""#;
const RUNTIME_IGNORE_PATTERNS: &[&str] = &[
".kranz/config.json",
".kranz/missions/*/events.jsonl",
".kranz/missions/*/events.jsonl.lock",
".kranz/missions/*/state.json",
".kranz/missions/*/state.json.tmp",
".kranz/missions/*/estimate.json",
".kranz/missions/*/enqueue-source*.json",
".kranz/missions/*/control/",
".kranz/missions/*/runs/",
".kranz/missions/*/workspace/",
".kranz/slack-threads.json",
".kranz/slack/",
".kranz/queue/",
".kranz/hook-status/",
".kranz/tickets/*.status",
".kranz/serve.token",
".kranz/serve.read.token",
];
#[derive(Debug, Clone, Default)]
pub struct InitOptions {
pub gates: Vec<String>,
pub registration: Option<Registration>,
pub global_config: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct Registration {
pub id: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileOutcome {
Created(PathBuf),
Updated(PathBuf),
Kept(PathBuf),
}
#[derive(Debug, Clone)]
pub struct InitReport {
pub repo_root: PathBuf,
pub files: Vec<FileOutcome>,
pub gate_count: usize,
pub completed_missions: usize,
pub registered_repo: Option<(String, bool)>,
}
pub fn initialize(repo: &Path, options: &InitOptions) -> Result<InitReport> {
let repo_root = exact_git_root(repo)?;
let kranz_dir = repo_root.join(".kranz");
let gates_path = repo_root.join(MERGE_GATES_PATH);
let (gate_bytes, gate_count, gate_exists) = prepare_gates(&repo_root, &gates_path, options)?;
let gitignore_path = repo_root.join(".gitignore");
let old_gitignore = match fs::read_to_string(&gitignore_path) {
Ok(text) => Some(text),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
return Err(error).with_context(|| format!("reading {}", gitignore_path.display()))
}
};
let new_gitignore = extend_gitignore(old_gitignore.as_deref().unwrap_or(""));
let registration = match options.registration.as_ref() {
Some(registration) => {
let path = options
.global_config
.as_deref()
.ok_or_else(|| anyhow!("cannot resolve the operator config path for --register"))?;
Some(prepare_registration(path, &repo_root, registration)?)
}
None => None,
};
fs::create_dir_all(kranz_dir.join("tickets"))
.with_context(|| format!("creating {}", kranz_dir.display()))?;
let mut files = Vec::new();
if gate_exists {
files.push(FileOutcome::Kept(gates_path.clone()));
} else {
atomic_write(&gates_path, &gate_bytes, false)?;
files.push(FileOutcome::Created(gates_path));
}
if old_gitignore.as_deref() == Some(new_gitignore.as_str()) {
files.push(FileOutcome::Kept(gitignore_path));
} else {
atomic_write(&gitignore_path, new_gitignore.as_bytes(), false)?;
files.push(if old_gitignore.is_some() {
FileOutcome::Updated(gitignore_path)
} else {
FileOutcome::Created(gitignore_path)
});
}
let keep_path = kranz_dir.join("tickets").join(".gitkeep");
if keep_path.exists() {
files.push(FileOutcome::Kept(keep_path));
} else {
atomic_write(&keep_path, b"", false)?;
files.push(FileOutcome::Created(keep_path));
}
let registered_repo = if let Some(candidate) = registration {
if candidate.changed {
let text = format!("{}\n", serde_json::to_string_pretty(&candidate.tree)?);
atomic_write(&candidate.path, text.as_bytes(), true)?;
}
Some((candidate.id, candidate.changed))
} else {
None
};
Ok(InitReport {
repo_root: repo_root.clone(),
files,
gate_count,
completed_missions: cost::calibrate(&repo_root).missions_used,
registered_repo,
})
}
pub fn render(report: &InitReport) -> String {
let mut out = format!("initialized {}\n", report.repo_root.display());
for file in &report.files {
let (verb, path) = match file {
FileOutcome::Created(path) => ("created", path),
FileOutcome::Updated(path) => ("updated", path),
FileOutcome::Kept(path) => ("kept", path),
};
let relative = path.strip_prefix(&report.repo_root).unwrap_or(path);
out.push_str(&format!(" {verb}: {}\n", relative.display()));
}
out.push_str(&format!(
"merge gates: {} configured gate(s)\n",
report.gate_count
));
out.push_str("worker isolation: worktree (safe default)\n");
if report.completed_missions == 0 {
out.push_str(
"calibration: COLD START — 0 completed missions; estimates use built-in defaults\n",
);
} else {
out.push_str(&format!(
"calibration: {} completed mission(s) available\n",
report.completed_missions
));
}
if let Some((id, changed)) = &report.registered_repo {
out.push_str(&format!(
"host catalog: {} repository '{id}'\n",
if *changed { "registered" } else { "kept" }
));
}
out.push_str(
"next: review and commit .gitignore, .kranz/merge-gates.json, and .kranz/tickets/.gitkeep\n\
next: run `kranz ready`, then create a ticket with `kranz ticket new`\n",
);
out
}
fn exact_git_root(repo: &Path) -> Result<PathBuf> {
let requested = fs::canonicalize(repo)
.with_context(|| format!("cannot resolve repository path {}", repo.display()))?;
let output = Command::new("git")
.arg("-C")
.arg(&requested)
.args(["rev-parse", "--show-toplevel"])
.output()
.context("running git rev-parse --show-toplevel")?;
if !output.status.success() {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
bail!(
"{} is not a Git worktree{}",
requested.display(),
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
);
}
let detected = PathBuf::from(String::from_utf8(output.stdout)?.trim());
let detected = fs::canonicalize(&detected).unwrap_or(detected);
if requested != detected {
bail!(
"--repo must name the Git worktree root; {} belongs to {}",
requested.display(),
detected.display()
);
}
Ok(detected)
}
fn prepare_gates(
repo: &Path,
path: &Path,
options: &InitOptions,
) -> Result<(Vec<u8>, usize, bool)> {
match fs::read(path) {
Ok(bytes) => {
let suite = parse_gate_suite(&bytes).map_err(anyhow::Error::msg)?;
return Ok((bytes, suite.gates.len(), true));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
}
let gates = if options.gates.is_empty() {
detect_gates(repo)?
} else {
options.gates.clone()
};
if gates.is_empty() {
bail!("no validation commands detected; rerun with one or more `--gate <COMMAND>` values");
}
let value = json!({
"gates": gates
.iter()
.map(|command| json!({ "command": command }))
.collect::<Vec<_>>()
});
let bytes = format!("{}\n", serde_json::to_string_pretty(&value)?).into_bytes();
let suite = parse_gate_suite(&bytes).map_err(anyhow::Error::msg)?;
Ok((bytes, suite.gates.len(), false))
}
fn detect_gates(repo: &Path) -> Result<Vec<String>> {
let mut gates = Vec::new();
if repo.join("Cargo.toml").exists() {
gates.extend([
"cargo fmt --all --check".to_string(),
"cargo clippy --workspace --all-targets -- -D warnings".to_string(),
RUST_TEST_GATE.to_string(),
"cargo build --workspace".to_string(),
]);
}
let package_json = repo.join("package.json");
if package_json.exists() {
let text = fs::read_to_string(&package_json)
.with_context(|| format!("reading {}", package_json.display()))?;
let package: Value = serde_json::from_str(&text)
.with_context(|| format!("invalid JSON in {}", package_json.display()))?;
let scripts = package.get("scripts").and_then(Value::as_object);
let (run, test) = if repo.join("pnpm-lock.yaml").exists() {
gates.push("pnpm install --frozen-lockfile".to_string());
("pnpm run", "pnpm test")
} else if repo.join("yarn.lock").exists() {
gates.push("yarn install --frozen-lockfile".to_string());
("yarn", "yarn test")
} else {
if repo.join("package-lock.json").exists() {
gates.push("npm ci".to_string());
}
("npm run", "npm test")
};
for script in ["typecheck", "test", "build", "lint"] {
if scripts.is_some_and(|scripts| scripts.get(script).and_then(Value::as_str).is_some())
{
gates.push(if script == "test" {
test.to_string()
} else {
format!("{run} {script}")
});
}
}
}
if repo.join("pytest.ini").exists()
|| repo.join("tox.ini").exists()
|| pyproject_uses_pytest(&repo.join("pyproject.toml"))?
{
gates.push("python -m pytest".to_string());
}
Ok(gates)
}
fn pyproject_uses_pytest(path: &Path) -> Result<bool> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
};
Ok(text.contains("[tool.pytest") || text.contains("pytest"))
}
fn extend_gitignore(existing: &str) -> String {
let present: HashSet<&str> = existing.lines().map(str::trim).collect();
let missing: Vec<_> = RUNTIME_IGNORE_PATTERNS
.iter()
.copied()
.filter(|pattern| !present.contains(pattern))
.collect();
if missing.is_empty() {
return existing.to_string();
}
let mut next = existing.to_string();
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
if !next.is_empty() && !next.ends_with("\n\n") {
next.push('\n');
}
if !present.contains(RUNTIME_IGNORE_HEADER) {
next.push_str(RUNTIME_IGNORE_HEADER);
next.push('\n');
}
for pattern in missing {
next.push_str(pattern);
next.push('\n');
}
next
}
struct RegistrationCandidate {
path: PathBuf,
tree: Value,
id: String,
changed: bool,
}
fn prepare_registration(
path: &Path,
repo_root: &Path,
registration: &Registration,
) -> Result<RegistrationCandidate> {
let mut tree = match fs::read_to_string(path) {
Ok(text) => serde_json::from_str::<Value>(&text)
.with_context(|| format!("invalid JSON in {}", path.display()))?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => json!({}),
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
};
let root = tree
.as_object_mut()
.ok_or_else(|| anyhow!("{} must contain a JSON object", path.display()))?;
let before = root.clone();
let host = root.entry("host").or_insert_with(|| json!({}));
let host = host
.as_object_mut()
.ok_or_else(|| anyhow!("host in {} must be a JSON object", path.display()))?;
host.entry("maxConcurrentRepos").or_insert(json!(1));
let id = registration
.id
.clone()
.unwrap_or_else(|| fallback_repo_id(repo_root));
let repos = host.entry("repos").or_insert_with(|| json!([]));
let repos = repos
.as_array_mut()
.ok_or_else(|| anyhow!("host.repos in {} must be an array", path.display()))?;
let canonical_root = canonical_or_lexical(repo_root);
let mut match_index = None;
for (index, value) in repos.iter().enumerate() {
let entry_id = value.get("id").and_then(Value::as_str);
let entry_root = value.get("root").and_then(Value::as_str).map(PathBuf::from);
let same_id = entry_id == Some(id.as_str());
let same_root =
entry_root.as_deref().map(canonical_or_lexical).as_ref() == Some(&canonical_root);
if same_id && !same_root {
bail!("host repository id '{id}' already names a different root");
}
if same_root && !same_id {
bail!(
"host repository root {} is already registered as '{}'",
canonical_root.display(),
entry_id.unwrap_or("<invalid>")
);
}
if same_id && same_root {
match_index = Some(index);
}
}
if let Some(index) = match_index {
if let Some(display_name) = registration.display_name.as_ref() {
repos[index]
.as_object_mut()
.ok_or_else(|| anyhow!("host repository '{id}' must be a JSON object"))?
.insert("displayName".into(), json!(display_name));
}
} else {
let mut entry = Map::from_iter([
("id".into(), json!(id)),
(
"root".into(),
json!(canonical_root.to_string_lossy().into_owned()),
),
]);
if let Some(display_name) = registration.display_name.as_ref() {
entry.insert("displayName".into(), json!(display_name));
}
repos.push(Value::Object(entry));
}
let sole_entry = repos.len() == 1;
if sole_entry
&& (!host.contains_key("defaultRepo") || host.get("defaultRepo") == Some(&Value::Null))
{
host.insert("defaultRepo".into(), json!(id));
}
let host_config: HostConfig = serde_json::from_value(Value::Object(host.clone()))
.with_context(|| format!("invalid host catalog in {}", path.display()))?;
MultiRepoHost::from_config(host_config)
.with_context(|| format!("invalid host catalog in {}", path.display()))?;
let changed = *root != before;
Ok(RegistrationCandidate {
path: path.to_path_buf(),
tree,
id,
changed,
})
}
fn fallback_repo_id(root: &Path) -> String {
let raw = root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("repo");
let mut id = String::new();
for character in raw.chars() {
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
id.push(character);
} else if !id.ends_with('-') {
id.push('-');
}
}
let id = id.trim_matches(|character| matches!(character, '-' | '_'));
if id.is_empty() {
"repo".to_string()
} else {
id.to_string()
}
}
fn canonical_or_lexical(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
})
}
fn atomic_write(path: &Path, bytes: &[u8], private: bool) -> Result<()> {
#[cfg(not(unix))]
let _ = private;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let file_name = path
.file_name()
.ok_or_else(|| anyhow!("path {} has no file name", path.display()))?;
let tmp = path.with_file_name(format!("{}.kranz-init.tmp", file_name.to_string_lossy()));
{
let mut file = fs::File::create(&tmp)
.with_context(|| format!("creating temporary file {}", tmp.display()))?;
file.write_all(bytes)
.with_context(|| format!("writing temporary file {}", tmp.display()))?;
file.sync_data()
.with_context(|| format!("syncing temporary file {}", tmp.display()))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(path)
.map(|metadata| metadata.permissions().mode())
.unwrap_or(if private { 0o600 } else { 0o644 });
fs::set_permissions(&tmp, fs::Permissions::from_mode(mode))
.with_context(|| format!("setting permissions on {}", tmp.display()))?;
}
match fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(_) if cfg!(windows) && path.exists() => {
fs::remove_file(path)
.with_context(|| format!("removing {} before Windows replace", path.display()))?;
fs::rename(&tmp, path)
.with_context(|| format!("replacing {} from {}", path.display(), tmp.display()))
}
Err(error) => {
let _ = fs::remove_file(&tmp);
Err(error)
.with_context(|| format!("replacing {} from {}", path.display(), tmp.display()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn git(repo: &Path, args: &[&str]) {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn repo(name: &str) -> (TempDir, PathBuf) {
let parent = TempDir::new().unwrap();
let root = parent.path().join(name);
fs::create_dir(&root).unwrap();
git(&root, &["init", "-q"]);
(parent, root)
}
#[test]
fn kranz_init_detects_node_gates_and_reports_cold_start() {
let (_parent, root) = repo("node-app");
fs::write(
root.join("package.json"),
r#"{"scripts":{"test":"vitest run","build":"vite build","lint":"oxlint"}}"#,
)
.unwrap();
fs::write(
root.join("package-lock.json"),
r#"{"lockfileVersion":3,"packages":{}}"#,
)
.unwrap();
let report = initialize(&root, &InitOptions::default()).unwrap();
assert_eq!(report.gate_count, 4);
assert_eq!(report.completed_missions, 0);
let gates = fs::read(root.join(MERGE_GATES_PATH)).unwrap();
let suite = parse_gate_suite(&gates).unwrap();
assert_eq!(suite.gates.len(), 4);
assert_eq!(suite.gates[0].command, "npm ci");
let rendered = render(&report);
assert!(rendered.contains("worktree (safe default)"));
assert!(rendered.contains("0 completed missions"));
}
#[test]
fn kranz_init_is_byte_idempotent_and_preserves_existing_content() {
let (_parent, root) = repo("rust-app");
fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
fs::write(root.join(".gitignore"), "target/\n").unwrap();
let options = InitOptions::default();
initialize(&root, &options).unwrap();
let first_ignore = fs::read(root.join(".gitignore")).unwrap();
let first_gates = fs::read(root.join(MERGE_GATES_PATH)).unwrap();
let suite = parse_gate_suite(&first_gates).unwrap();
assert_eq!(suite.gates[2].command, RUST_TEST_GATE);
#[cfg(not(windows))]
assert!(suite.gates[2].command.contains("grep -qE"));
#[cfg(windows)]
assert!(suite.gates[2].command.contains("-notmatch"));
assert!(suite.gates[2].command.contains("[1-9]"));
let second = initialize(&root, &options).unwrap();
assert_eq!(fs::read(root.join(".gitignore")).unwrap(), first_ignore);
assert_eq!(fs::read(root.join(MERGE_GATES_PATH)).unwrap(), first_gates);
assert!(String::from_utf8(first_ignore)
.unwrap()
.starts_with("target/\n"));
assert!(second
.files
.iter()
.all(|outcome| matches!(outcome, FileOutcome::Kept(_))));
}
#[test]
fn kranz_init_requires_an_explicit_gate_for_unknown_toolchains() {
let (_parent, root) = repo("unknown-app");
let error = initialize(&root, &InitOptions::default()).unwrap_err();
assert!(error.to_string().contains("--gate <COMMAND>"));
assert!(!root.join(".kranz").exists());
let report = initialize(
&root,
&InitOptions {
gates: vec!["make verify".into()],
..InitOptions::default()
},
)
.unwrap();
assert_eq!(report.gate_count, 1);
}
#[test]
fn kranz_init_registers_canonical_root_without_losing_global_keys() {
let (parent, root) = repo("catalog-app");
fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
let global = parent.path().join("home/.kranz/config.json");
fs::create_dir_all(global.parent().unwrap()).unwrap();
fs::write(&global, r#"{"slack":{"botToken":"secret"}}"#).unwrap();
let options = InitOptions {
registration: Some(Registration {
id: Some("catalog-app".into()),
display_name: Some("Catalog App".into()),
}),
global_config: Some(global.clone()),
..InitOptions::default()
};
let first = initialize(&root, &options).unwrap();
let second = initialize(&root, &options).unwrap();
assert_eq!(first.registered_repo, Some(("catalog-app".into(), true)));
assert_eq!(second.registered_repo, Some(("catalog-app".into(), false)));
let value: Value = serde_json::from_slice(&fs::read(global).unwrap()).unwrap();
assert_eq!(value["slack"]["botToken"], "secret");
assert_eq!(value["host"]["repos"].as_array().unwrap().len(), 1);
assert_eq!(
value["host"]["repos"][0]["root"],
canonical_or_lexical(&root).to_string_lossy().as_ref()
);
assert_eq!(value["host"]["defaultRepo"], "catalog-app");
}
#[test]
fn kranz_init_registration_never_elects_a_default_for_established_catalogs() {
let (parent, root) = repo("second-app");
fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
let existing = parent.path().join("existing");
fs::create_dir_all(&existing).unwrap();
let global = parent.path().join("home/.kranz/config.json");
fs::create_dir_all(global.parent().unwrap()).unwrap();
fs::write(
&global,
serde_json::to_vec_pretty(&serde_json::json!({
"host": { "repos": [{ "id": "existing", "root": existing }] }
}))
.unwrap(),
)
.unwrap();
let options = InitOptions {
registration: Some(Registration {
id: Some("second-app".into()),
display_name: None,
}),
global_config: Some(global.clone()),
..InitOptions::default()
};
initialize(&root, &options).unwrap();
let value: Value = serde_json::from_slice(&fs::read(&global).unwrap()).unwrap();
assert_eq!(value["host"]["repos"].as_array().unwrap().len(), 2);
assert!(value["host"].get("defaultRepo").is_none());
}
#[test]
fn kranz_init_registration_rejects_duplicate_ids_before_local_writes() {
let (parent, root) = repo("new-app");
fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
let other = parent.path().join("other");
fs::create_dir(&other).unwrap();
git(&other, &["init", "-q"]);
let global = parent.path().join("config.json");
fs::write(
&global,
format!(
r#"{{"host":{{"repos":[{{"id":"shared","root":{}}}]}}}}"#,
serde_json::to_string(&other).unwrap()
),
)
.unwrap();
let error = initialize(
&root,
&InitOptions {
registration: Some(Registration {
id: Some("shared".into()),
display_name: None,
}),
global_config: Some(global),
..InitOptions::default()
},
)
.unwrap_err();
assert!(error.to_string().contains("already names a different root"));
assert!(!root.join(".kranz").exists());
}
}