feat(wave15): kei-fork — managed git-worktree + ledger lifecycle primitive

45 crates, 726 tests green (up from 713).

Closes the ad-hoc `cp files from worktree` workflow that lost data when
Claude Code auto-cleaned worktrees mid-session. After this crate ships,
orchestrator never touches `git worktree` or manual `cp` again.

## Public API
- `create(agent_id, base, kit_root)` → ForkHandle + ledger row
- `collect(agent_id, msg, kit_root)` → commit + merge --no-ff + archive
- `list(kit_root, status_filter)` → Active/Done/Stale/Merged enumeration
- `gc(kit_root, hours)` → prune stale forks (git + branch + ledger fail)
- `rescue(agent_id, kit_root, out)` → salvage files live or from archive

## Key design decisions
- Worktrees indexed by agent_id (`.claude/forks/<agent_id>/`), NOT uuid —
  grepable, no more "which worktree has my files" confusion.
- `.DONE` marker gates collect — agent signals completion explicitly.
- Archive path `_archive/forks/YYYY-MM-DD/<agent_id>/` preserves history.
- `KEI_FORK_SKIP_LEDGER=1` env for hermetic tests.
- Constructor Pattern: 10 modules, largest file main.rs 137 LOC.

13 hermetic integration tests via tempfile + git-init kit_roots.

Next: wire kei-fork into kei-spawn for the three-role pipeline
(Writer → Auditor → Merger with branch-as-sandbox).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-23 17:43:20 +08:00
parent 0ea429054f
commit 5b5e7c6d7b
16 changed files with 2309 additions and 0 deletions

View file

@ -2559,6 +2559,21 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "kei-fork"
version = "0.1.0"
dependencies = [
"chrono",
"clap",
"kei-agent-runtime",
"rusqlite",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"toml",
]
[[package]]
name = "kei-graph-check"
version = "0.1.0"

View file

@ -67,6 +67,8 @@ members = [
"kei-hibernate",
# v0.30 Wave 14 — ed25519 creator attestation
"kei-ledger-sign",
# v0.31 Wave 15 — managed git worktree + ledger lifecycle (fork/collect/gc/rescue)
"kei-fork",
]
[workspace.package]

1178
_primitives/_rust/kei-fork/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
[package]
name = "kei-fork"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Managed git-worktree + ledger lifecycle for agent spawns (Wave 15 foundation)"
[[bin]]
name = "kei-fork"
path = "src/main.rs"
[lib]
name = "kei_fork"
path = "src/lib.rs"
[dependencies]
kei-agent-runtime = { path = "../kei-agent-runtime" }
rusqlite = { version = "0.31", features = ["bundled"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
thiserror = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,118 @@
//! `collect(agent_id, commit_msg, kit_root)` — merge the fork back.
//!
//! Contract:
//! 1. `.DONE` must exist inside the worktree, else `Error::NotDone`
//! 2. `git add -A && git commit` inside the worktree
//! 3. Capture commit SHA, then `git merge --no-ff fork/<id>` in kit_root
//! 4. Move worktree to `_archive/forks/YYYY-MM-DD/<id>/` (preserving
//! the agent's artefacts for post-hoc review / rescue)
//! 5. `git worktree prune && git branch -D fork/<id>` to clean up refs
//! 6. `kei-ledger done <id>` unless `KEI_FORK_SKIP_LEDGER=1`
//!
//! On SUCCESS: `.claude/forks/<id>/` is gone, archive exists, merge
//! commit is on HEAD of kit_root. Return value carries the SHA and
//! count of files added by the agent.
use crate::error::Error;
use crate::git;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectReport {
pub files_added: usize,
pub commit_sha: String,
pub archive_path: PathBuf,
}
pub fn collect(agent_id: &str, commit_msg: &str, kit_root: &Path) -> Result<CollectReport, Error> {
let worktree_abs = kit_root.join(".claude/forks").join(agent_id);
if !worktree_abs.join(".DONE").exists() {
return Err(Error::NotDone(agent_id.to_string()));
}
let files_added = count_tracked_files(&worktree_abs);
let branch = format!("fork/{agent_id}");
git::add_all(&worktree_abs)?;
git::commit(&worktree_abs, commit_msg)?;
let commit_sha = git::rev_parse_head(&worktree_abs)?;
let merge_msg = format!("Merge {branch}");
git::merge_no_ff(kit_root, &branch, &merge_msg)?;
let archive_path = archive_worktree(kit_root, agent_id, &worktree_abs)?;
// worktree_remove is unnecessary after fs::rename — prune cleans the
// stale worktree metadata and branch -D removes the ref.
let _ = git::worktree_prune(kit_root);
let _ = git::branch_delete(kit_root, &branch);
ledger_done(agent_id)?;
Ok(CollectReport {
files_added,
commit_sha,
archive_path,
})
}
fn count_tracked_files(worktree_abs: &Path) -> usize {
// Cheap approximation — walk the worktree, skip `.git*` and the
// KEI_FORK meta file. Used for the report only, not for decisions.
fn walk(dir: &Path) -> usize {
let mut n = 0;
let Ok(rd) = fs::read_dir(dir) else { return 0 };
for e in rd.flatten() {
let p = e.path();
let name = e.file_name();
let s = name.to_string_lossy();
if s.starts_with(".git") {
continue;
}
if p.is_dir() {
n += walk(&p);
} else {
n += 1;
}
}
n
}
walk(worktree_abs)
}
fn archive_worktree(
kit_root: &Path,
agent_id: &str,
worktree_abs: &Path,
) -> Result<PathBuf, Error> {
let date = Utc::now().format("%Y-%m-%d").to_string();
let archive_dir = kit_root.join("_archive/forks").join(&date);
fs::create_dir_all(&archive_dir)?;
let target = archive_dir.join(agent_id);
if target.exists() {
fs::remove_dir_all(&target)?;
}
fs::rename(worktree_abs, &target)?;
Ok(target)
}
fn ledger_skipped() -> bool {
std::env::var("KEI_FORK_SKIP_LEDGER").ok().as_deref() == Some("1")
}
fn ledger_done(agent_id: &str) -> Result<(), Error> {
if ledger_skipped() {
return Ok(());
}
let status = Command::new("kei-ledger")
.args(["done", agent_id, "--summary", "fork collected"])
.status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(Error::Ledger(format!("kei-ledger done exit {s}"))),
Err(e) => Err(Error::Ledger(format!("kei-ledger not runnable: {e}"))),
}
}

View file

@ -0,0 +1,89 @@
//! `create(agent_id, base_branch, kit_root)` — spawn a managed fork.
//!
//! Steps:
//! 1. `validate_agent_id` (path-traversal defence)
//! 2. Reject if `.claude/forks/<agent_id>/` OR branch `fork/<agent_id>` already exist
//! 3. `git worktree add .claude/forks/<agent_id> -b fork/<agent_id> <base>`
//! 4. Write `.KEI_FORK_META.toml` with agent_id + started_ts + base_branch + ledger_id
//! 5. `kei-ledger fork` unless env `KEI_FORK_SKIP_LEDGER=1`
//!
//! Worktree path is indexed by `agent_id`, not UUID, so `rescue()` /
//! `collect()` can be resolved from a human-readable CLI arg.
use crate::error::Error;
use crate::git;
use crate::handle::ForkHandle;
use crate::meta::{write_meta, ForkMeta};
use kei_agent_runtime::validate::validate_agent_id;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn create(agent_id: &str, base_branch: &str, kit_root: &Path) -> Result<ForkHandle, Error> {
validate_agent_id(agent_id).map_err(|e| Error::Validate(e.reason))?;
let worktree_rel = PathBuf::from(".claude/forks").join(agent_id);
let worktree_abs = kit_root.join(&worktree_rel);
let branch = format!("fork/{agent_id}");
if worktree_abs.exists() || git::branch_exists(kit_root, &branch) {
return Err(Error::Duplicate(agent_id.to_string()));
}
if let Some(parent) = worktree_abs.parent() {
fs::create_dir_all(parent)?;
}
git::worktree_add(kit_root, &worktree_rel, &branch, base_branch)?;
let started_ts = unix_now();
let meta = build_meta(agent_id, base_branch, started_ts);
write_meta(&worktree_abs, &meta)?;
ledger_fork(agent_id, &branch, base_branch)?;
Ok(ForkHandle {
agent_id: agent_id.to_string(),
worktree: worktree_abs,
branch,
ledger_id: meta.ledger_id,
started_ts,
})
}
fn build_meta(agent_id: &str, base_branch: &str, started_ts: i64) -> ForkMeta {
ForkMeta {
agent_id: agent_id.to_string(),
started_ts,
base_branch: base_branch.to_string(),
ledger_id: agent_id.to_string(),
}
}
fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn ledger_skipped() -> bool {
std::env::var("KEI_FORK_SKIP_LEDGER").ok().as_deref() == Some("1")
}
fn ledger_fork(agent_id: &str, branch: &str, base: &str) -> Result<(), Error> {
if ledger_skipped() {
return Ok(());
}
// Best-effort spec_sha placeholder: caller stamps real sha post-commit.
let status = Command::new("kei-ledger")
.args([
"fork",
agent_id,
branch,
"--parent",
base,
"--spec-sha",
"pending",
])
.status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(Error::Ledger(format!("kei-ledger fork exit {s}"))),
Err(e) => Err(Error::Ledger(format!("kei-ledger not runnable: {e}"))),
}
}

View file

@ -0,0 +1,37 @@
//! Typed error — every kei-fork public op returns `Result<_, Error>`.
//!
//! Categories:
//! - `Validate` — agent-id failed `kei_agent_runtime::validate`
//! - `Duplicate` — worktree/branch for this agent-id already exists
//! - `NotDone` — collect() called before the agent wrote `.DONE`
//! - `Gone` — rescue() could not find the worktree (live or archived)
//! - `Io` / `Git` / `Ledger` / `Meta` — subsystem failures
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("invalid agent-id: {0}")]
Validate(String),
#[error("fork already exists for agent-id '{0}'")]
Duplicate(String),
#[error(".DONE marker missing for agent-id '{0}' (agent not finished)")]
NotDone(String),
#[error("no live or archived worktree found for agent-id '{0}'")]
Gone(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("git command failed ({cmd}): {stderr}")]
Git { cmd: String, stderr: String },
#[error("ledger command failed: {0}")]
Ledger(String),
#[error("meta file malformed: {0}")]
Meta(String),
}

View file

@ -0,0 +1,87 @@
//! `gc(kit_root, older_than_hours)` — prune stale forks.
//!
//! A fork is STALE when `.DONE` is absent AND `age > older_than_hours`.
//! For each stale fork we:
//! 1. `git worktree remove --force <worktree>`
//! 2. `git branch -D fork/<id>`
//! 3. `kei-ledger fail <id>` unless `KEI_FORK_SKIP_LEDGER=1`
//!
//! Returns the list of agent_ids pruned. Errors on individual forks are
//! swallowed into the report so a single bad fork cannot block cleanup
//! of the rest.
use crate::error::Error;
use crate::git;
use crate::handle::ForkStatus;
use crate::list::live_with_status;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GcReport {
pub pruned: Vec<String>,
pub skipped: Vec<String>,
}
pub fn gc(kit_root: &Path, older_than_hours: u32) -> Result<GcReport, Error> {
let mut report = GcReport::default();
for (worktree_abs, handle, status) in live_with_status(kit_root) {
if !is_prunable(status, handle.started_ts, older_than_hours) {
continue;
}
match prune_one(kit_root, &worktree_abs, &handle.branch, &handle.agent_id) {
Ok(()) => report.pruned.push(handle.agent_id),
Err(_) => report.skipped.push(handle.agent_id),
}
}
Ok(report)
}
fn is_prunable(status: ForkStatus, started_ts: i64, threshold_h: u32) -> bool {
if status != ForkStatus::Stale && status != ForkStatus::Active {
return false;
}
let age = age_hours(started_ts);
age >= threshold_h
}
fn age_hours(started_ts: i64) -> u32 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(started_ts);
let delta = (now - started_ts).max(0);
(delta / 3600) as u32
}
fn prune_one(
kit_root: &Path,
worktree_abs: &Path,
branch: &str,
agent_id: &str,
) -> Result<(), Error> {
git::worktree_remove_force(kit_root, worktree_abs)?;
let _ = git::branch_delete(kit_root, branch);
let _ = ledger_fail(agent_id);
Ok(())
}
fn ledger_skipped() -> bool {
std::env::var("KEI_FORK_SKIP_LEDGER").ok().as_deref() == Some("1")
}
fn ledger_fail(agent_id: &str) -> Result<(), Error> {
if ledger_skipped() {
return Ok(());
}
let status = Command::new("kei-ledger")
.args(["fail", agent_id, "--reason", "gc: stale fork"])
.status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(Error::Ledger(format!("kei-ledger fail exit {s}"))),
Err(e) => Err(Error::Ledger(format!("kei-ledger not runnable: {e}"))),
}
}

View file

@ -0,0 +1,96 @@
//! Thin `Command::new("git")` wrappers.
//!
//! Every helper runs `git` in `kit_root` (or a specified worktree),
//! captures stdout/stderr, and returns `Error::Git` on non-zero exit.
//! No parsing beyond `trim()` on stdout — callers interpret the string.
use crate::error::Error;
use std::path::Path;
use std::process::{Command, Output};
fn run(cmd_desc: &str, c: &mut Command) -> Result<Output, Error> {
let out = c.output().map_err(Error::Io)?;
if !out.status.success() {
return Err(Error::Git {
cmd: cmd_desc.to_string(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(out)
}
pub fn worktree_add(
kit_root: &Path,
worktree_rel: &Path,
new_branch: &str,
base: &str,
) -> Result<(), Error> {
let mut c = Command::new("git");
c.current_dir(kit_root)
.args(["worktree", "add", worktree_rel.to_str().unwrap_or("."), "-b", new_branch, base]);
run("git worktree add", &mut c)?;
Ok(())
}
pub fn add_all(cwd: &Path) -> Result<(), Error> {
let mut c = Command::new("git");
c.current_dir(cwd).args(["add", "-A"]);
run("git add -A", &mut c)?;
Ok(())
}
pub fn commit(cwd: &Path, msg: &str) -> Result<(), Error> {
let mut c = Command::new("git");
c.current_dir(cwd).args(["commit", "--allow-empty", "-m", msg]);
run("git commit", &mut c)?;
Ok(())
}
pub fn rev_parse_head(cwd: &Path) -> Result<String, Error> {
let mut c = Command::new("git");
c.current_dir(cwd).args(["rev-parse", "HEAD"]);
let out = run("git rev-parse HEAD", &mut c)?;
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn merge_no_ff(kit_root: &Path, branch: &str, msg: &str) -> Result<(), Error> {
let mut c = Command::new("git");
c.current_dir(kit_root).args(["merge", "--no-ff", branch, "-m", msg]);
run("git merge --no-ff", &mut c)?;
Ok(())
}
pub fn worktree_prune(kit_root: &Path) -> Result<(), Error> {
let mut c = Command::new("git");
c.current_dir(kit_root).args(["worktree", "prune"]);
run("git worktree prune", &mut c)?;
Ok(())
}
pub fn worktree_remove_force(kit_root: &Path, worktree_abs: &Path) -> Result<(), Error> {
let mut c = Command::new("git");
c.current_dir(kit_root).args([
"worktree",
"remove",
"--force",
worktree_abs.to_str().unwrap_or("."),
]);
run("git worktree remove --force", &mut c)?;
Ok(())
}
pub fn branch_delete(kit_root: &Path, branch: &str) -> Result<(), Error> {
let mut c = Command::new("git");
c.current_dir(kit_root).args(["branch", "-D", branch]);
run("git branch -D", &mut c)?;
Ok(())
}
/// Check whether `branch` exists. `git show-ref` exits 0 if the ref is
/// present, non-zero otherwise — we treat both as valid data, no error.
pub fn branch_exists(kit_root: &Path, branch: &str) -> bool {
let full = format!("refs/heads/{branch}");
let mut c = Command::new("git");
c.current_dir(kit_root).args(["show-ref", "--verify", "--quiet", &full]);
c.status().map(|s| s.success()).unwrap_or(false)
}

View file

@ -0,0 +1,42 @@
//! `ForkHandle` value type + `ForkStatus` enum.
//!
//! `ForkHandle` is the return of `create()` and each row of `list()`. Its
//! fields are derived from `.KEI_FORK_META.toml` plus the worktree path
//! on disk. The handle is `Clone`, `serde::Serialize`, and
//! `serde::Deserialize` so the CLI can emit JSON and downstream callers
//! can round-trip it without touching the TOML file.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForkHandle {
pub agent_id: String,
pub worktree: PathBuf,
pub branch: String,
pub ledger_id: String,
pub started_ts: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ForkStatus {
Active,
Done,
Stale,
Merged,
}
impl ForkStatus {
/// Parse CLI `--status` value. Returns `None` for unknown strings so
/// the CLI layer can emit a domain-appropriate error.
pub fn from_cli(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"active" => Some(ForkStatus::Active),
"done" => Some(ForkStatus::Done),
"stale" => Some(ForkStatus::Stale),
"merged" => Some(ForkStatus::Merged),
_ => None,
}
}
}

View file

@ -0,0 +1,30 @@
//! kei-fork — managed git-worktree + ledger lifecycle for agent spawns.
//!
//! Public API: `create`, `collect`, `list`, `gc`, `rescue`. Each op is
//! backed by one module under `src/`, keeping every file ≤200 LOC and
//! every function ≤30 LOC (Constructor Pattern). Shell-out helpers for
//! `git` live in `git.rs`; TOML round-trip for `.KEI_FORK_META.toml`
//! lives in `meta.rs`; the `ForkHandle` value type and the
//! `ForkStatus` enum live in `handle.rs`.
//!
//! Ledger integration is optional at runtime: if env
//! `KEI_FORK_SKIP_LEDGER=1` is set, create/collect/gc skip the
//! `kei-ledger` subprocess call. Tests rely on this for hermeticity.
pub mod collect;
pub mod create;
pub mod error;
pub mod gc;
pub mod git;
pub mod handle;
pub mod list;
pub mod meta;
pub mod rescue;
pub use collect::{collect, CollectReport};
pub use create::create;
pub use error::Error;
pub use gc::{gc, GcReport};
pub use handle::{ForkHandle, ForkStatus};
pub use list::list;
pub use rescue::rescue;

View file

@ -0,0 +1,114 @@
//! `list(kit_root, status_filter)` — enumerate known forks.
//!
//! Walks two roots:
//! - `.claude/forks/<id>/` — live worktrees (Active, Done, Stale)
//! - `_archive/forks/<date>/<id>/` — post-collect (Merged)
//!
//! For each discovered directory, reads `.KEI_FORK_META.toml` to build
//! a `ForkHandle`, classifies status, and filters. Returns `Vec` sorted
//! by `started_ts` ascending so oldest forks list first.
use crate::error::Error;
use crate::handle::{ForkHandle, ForkStatus};
use crate::meta::read_meta;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const STALE_HOURS_DEFAULT: u32 = 24;
pub fn list(kit_root: &Path, status: Option<ForkStatus>) -> Result<Vec<ForkHandle>, Error> {
let mut out = Vec::new();
collect_live(&kit_root.join(".claude/forks"), &mut out, status);
collect_archive(&kit_root.join("_archive/forks"), &mut out, status);
out.sort_by_key(|h| h.started_ts);
Ok(out)
}
fn collect_live(root: &Path, out: &mut Vec<ForkHandle>, filter: Option<ForkStatus>) {
let Ok(rd) = fs::read_dir(root) else { return };
for e in rd.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let Ok(meta) = read_meta(&p) else { continue };
let status = classify_live(&p, meta.started_ts);
if matches_filter(filter, status) {
out.push(meta.into_handle(p));
}
}
}
fn collect_archive(root: &Path, out: &mut Vec<ForkHandle>, filter: Option<ForkStatus>) {
let Ok(dates) = fs::read_dir(root) else { return };
for date_entry in dates.flatten() {
let date_dir = date_entry.path();
if !date_dir.is_dir() {
continue;
}
scan_date_dir(&date_dir, out, filter);
}
}
fn scan_date_dir(date_dir: &Path, out: &mut Vec<ForkHandle>, filter: Option<ForkStatus>) {
let Ok(rd) = fs::read_dir(date_dir) else { return };
for e in rd.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let Ok(meta) = read_meta(&p) else { continue };
let status = ForkStatus::Merged;
if matches_filter(filter, status) {
out.push(meta.into_handle(p));
}
}
}
fn classify_live(worktree: &Path, started_ts: i64) -> ForkStatus {
if worktree.join(".DONE").exists() {
return ForkStatus::Done;
}
let age_h = age_hours(started_ts);
if age_h >= STALE_HOURS_DEFAULT {
ForkStatus::Stale
} else {
ForkStatus::Active
}
}
fn age_hours(started_ts: i64) -> u32 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(started_ts);
let delta = (now - started_ts).max(0);
(delta / 3600) as u32
}
fn matches_filter(filter: Option<ForkStatus>, s: ForkStatus) -> bool {
match filter {
None => true,
Some(want) => want == s,
}
}
/// Helper reused by `gc` — enumerate live worktrees with their
/// classified status, without filter.
pub(crate) fn live_with_status(kit_root: &Path) -> Vec<(PathBuf, ForkHandle, ForkStatus)> {
let mut out = Vec::new();
let root = kit_root.join(".claude/forks");
let Ok(rd) = fs::read_dir(&root) else { return out };
for e in rd.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let Ok(meta) = read_meta(&p) else { continue };
let status = classify_live(&p, meta.started_ts);
let handle = meta.into_handle(p.clone());
out.push((p, handle, status));
}
out
}

View file

@ -0,0 +1,137 @@
//! kei-fork — CLI dispatcher.
//!
//! Single responsibility: parse args, dispatch to lib ops, print JSON.
//! Default `kit_root = std::env::current_dir()`.
use clap::{Parser, Subcommand};
use kei_fork::{collect, create, gc, list, rescue, ForkStatus};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser)]
#[command(name = "kei-fork", version, about = "Managed git-worktree + ledger lifecycle")]
struct Cli {
/// Override kit_root (default: current dir).
#[arg(long)]
kit_root: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Spawn a new managed fork.
Create {
#[arg(long)]
agent_id: String,
#[arg(long, default_value = "main")]
base: String,
},
/// Collect a done fork: commit, merge --no-ff, archive.
Collect {
#[arg(long)]
agent_id: String,
#[arg(long)]
msg: String,
},
/// List forks, optionally filtered by status.
List {
/// active | done | stale | merged | all
#[arg(long, default_value = "all")]
status: String,
},
/// Prune stale forks (no .DONE and age ≥ --older-than hours).
Gc {
#[arg(long, default_value_t = 24)]
older_than: u32,
},
/// Copy a fork's files out of band.
Rescue {
#[arg(long)]
agent_id: String,
#[arg(long)]
out: PathBuf,
},
}
fn resolve_kit_root(arg: Option<PathBuf>) -> PathBuf {
arg.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
fn err(msg: &str) -> ExitCode {
eprintln!("kei-fork: {msg}");
ExitCode::from(1)
}
fn parse_status_filter(raw: &str) -> Result<Option<ForkStatus>, String> {
if raw.eq_ignore_ascii_case("all") {
return Ok(None);
}
ForkStatus::from_cli(raw)
.map(Some)
.ok_or_else(|| format!("unknown status '{raw}'"))
}
fn main() -> ExitCode {
let cli = Cli::parse();
let kit_root = resolve_kit_root(cli.kit_root);
match cli.cmd {
Cmd::Create { agent_id, base } => run_create(&agent_id, &base, &kit_root),
Cmd::Collect { agent_id, msg } => run_collect(&agent_id, &msg, &kit_root),
Cmd::List { status } => run_list(&status, &kit_root),
Cmd::Gc { older_than } => run_gc(older_than, &kit_root),
Cmd::Rescue { agent_id, out } => run_rescue(&agent_id, &kit_root, &out),
}
}
fn run_create(agent_id: &str, base: &str, kit_root: &std::path::Path) -> ExitCode {
match create(agent_id, base, kit_root) {
Ok(h) => print_json(&h),
Err(e) => err(&e.to_string()),
}
}
fn run_collect(agent_id: &str, msg: &str, kit_root: &std::path::Path) -> ExitCode {
match collect(agent_id, msg, kit_root) {
Ok(r) => print_json(&r),
Err(e) => err(&e.to_string()),
}
}
fn run_list(status: &str, kit_root: &std::path::Path) -> ExitCode {
let filter = match parse_status_filter(status) {
Ok(f) => f,
Err(e) => return err(&e),
};
match list(kit_root, filter) {
Ok(rows) => print_json(&rows),
Err(e) => err(&e.to_string()),
}
}
fn run_gc(older_than: u32, kit_root: &std::path::Path) -> ExitCode {
match gc(kit_root, older_than) {
Ok(r) => print_json(&r),
Err(e) => err(&e.to_string()),
}
}
fn run_rescue(agent_id: &str, kit_root: &std::path::Path, out: &std::path::Path) -> ExitCode {
match rescue(agent_id, kit_root, out) {
Ok(n) => {
println!("{n}");
ExitCode::SUCCESS
}
Err(e) => err(&e.to_string()),
}
}
fn print_json<T: serde::Serialize>(v: &T) -> ExitCode {
match serde_json::to_string_pretty(v) {
Ok(s) => {
println!("{s}");
ExitCode::SUCCESS
}
Err(e) => err(&format!("json encode failed: {e}")),
}
}

View file

@ -0,0 +1,49 @@
//! `.KEI_FORK_META.toml` — on-disk metadata written once by `create()`
//! and read by `list()` / `collect()` / `rescue()` / `gc()`.
//!
//! Layout is stable: `agent_id`, `started_ts`, `base_branch`, `ledger_id`.
//! Never add fields without bumping a schema version.
use crate::error::Error;
use crate::handle::ForkHandle;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
pub const META_FILENAME: &str = ".KEI_FORK_META.toml";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForkMeta {
pub agent_id: String,
pub started_ts: i64,
pub base_branch: String,
pub ledger_id: String,
}
impl ForkMeta {
pub fn branch(&self) -> String {
format!("fork/{}", self.agent_id)
}
pub fn into_handle(self, worktree: PathBuf) -> ForkHandle {
let branch = self.branch();
ForkHandle {
agent_id: self.agent_id,
worktree,
branch,
ledger_id: self.ledger_id,
started_ts: self.started_ts,
}
}
}
pub fn write_meta(worktree: &Path, meta: &ForkMeta) -> Result<(), Error> {
let body = toml::to_string(meta).map_err(|e| Error::Meta(e.to_string()))?;
fs::write(worktree.join(META_FILENAME), body)?;
Ok(())
}
pub fn read_meta(worktree: &Path) -> Result<ForkMeta, Error> {
let raw = fs::read_to_string(worktree.join(META_FILENAME))?;
toml::from_str(&raw).map_err(|e| Error::Meta(e.to_string()))
}

View file

@ -0,0 +1,54 @@
//! `rescue(agent_id, kit_root, out_dir)` — copy a fork's files out of
//! band.
//!
//! Resolution order:
//! 1. `.claude/forks/<id>/` (live) → copy to `out_dir`
//! 2. `_archive/forks/<date>/<id>/` (archived) → copy to `out_dir`
//! 3. Neither → `Error::Gone`
//!
//! Copy is recursive; the destination may pre-exist (we merge on top).
//! Returns the number of regular files copied.
use crate::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
pub fn rescue(agent_id: &str, kit_root: &Path, out_dir: &Path) -> Result<usize, Error> {
let src = locate(agent_id, kit_root).ok_or_else(|| Error::Gone(agent_id.to_string()))?;
fs::create_dir_all(out_dir)?;
Ok(copy_tree(&src, out_dir)?)
}
fn locate(agent_id: &str, kit_root: &Path) -> Option<PathBuf> {
let live = kit_root.join(".claude/forks").join(agent_id);
if live.is_dir() {
return Some(live);
}
let archive_root = kit_root.join("_archive/forks");
let dates = fs::read_dir(&archive_root).ok()?;
for e in dates.flatten() {
let candidate = e.path().join(agent_id);
if candidate.is_dir() {
return Some(candidate);
}
}
None
}
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<usize> {
let mut n = 0;
for entry in fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
let from = entry.path();
let to = dst.join(&name);
if from.is_dir() {
fs::create_dir_all(&to)?;
n += copy_tree(&from, &to)?;
} else if from.is_file() {
fs::copy(&from, &to)?;
n += 1;
}
}
Ok(n)
}

View file

@ -0,0 +1,234 @@
//! Integration tests for kei-fork — hermetic, ledger skipped.
//!
//! Each test spins up a fresh `TempDir`, runs `git init` + initial
//! commit, then drives the public API. `KEI_FORK_SKIP_LEDGER=1` keeps
//! the test tree free of SQLite side-effects.
//!
//! NOTE: `KEI_FORK_SKIP_LEDGER` is process-wide. Tests set it once in
//! `setup_kit()` — do not unset mid-test.
use kei_fork::{collect, create, gc, list, rescue, ForkStatus};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
fn setup_kit() -> (TempDir, PathBuf) {
std::env::set_var("KEI_FORK_SKIP_LEDGER", "1");
let td = TempDir::new().expect("tempdir");
let root = td.path().to_path_buf();
run_git(&root, &["init", "-q", "-b", "main"]);
run_git(&root, &["config", "user.email", "t@example.com"]);
run_git(&root, &["config", "user.name", "Test"]);
run_git(&root, &["config", "commit.gpgsign", "false"]);
fs::write(root.join("README.md"), "hi").unwrap();
run_git(&root, &["add", "README.md"]);
run_git(&root, &["commit", "-q", "-m", "init"]);
(td, root)
}
fn run_git(cwd: &Path, args: &[&str]) {
let out = Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.expect("git runnable");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn mark_done(worktree: &Path) {
fs::write(worktree.join(".DONE"), "").unwrap();
// Add one real artefact so collect has something to commit.
fs::write(worktree.join("hello.txt"), "world").unwrap();
}
#[test]
fn create_produces_worktree_and_branch() {
let (_td, root) = setup_kit();
let h = create("ag-one", "main", &root).expect("create ok");
assert_eq!(h.agent_id, "ag-one");
assert_eq!(h.branch, "fork/ag-one");
assert!(h.worktree.exists());
assert!(h.worktree.join(".KEI_FORK_META.toml").exists());
let br = Command::new("git")
.current_dir(&root)
.args(["branch", "--list", "fork/ag-one"])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&br.stdout).contains("fork/ag-one"));
}
#[test]
fn create_rejects_invalid_agent_id() {
let (_td, root) = setup_kit();
let err = create("../evil", "main", &root).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid agent-id"), "got: {msg}");
}
#[test]
fn create_rejects_duplicate_agent_id() {
let (_td, root) = setup_kit();
create("ag-dup", "main", &root).expect("first create");
let err = create("ag-dup", "main", &root).unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[test]
fn create_writes_meta_toml() {
let (_td, root) = setup_kit();
let h = create("ag-meta", "main", &root).expect("create ok");
let raw = fs::read_to_string(h.worktree.join(".KEI_FORK_META.toml")).unwrap();
let parsed: toml::Value = toml::from_str(&raw).unwrap();
assert_eq!(parsed["agent_id"].as_str(), Some("ag-meta"));
assert_eq!(parsed["base_branch"].as_str(), Some("main"));
assert!(parsed["started_ts"].as_integer().unwrap() > 0);
assert_eq!(parsed["ledger_id"].as_str(), Some("ag-meta"));
}
#[test]
fn collect_without_done_fails() {
let (_td, root) = setup_kit();
create("ag-nodone", "main", &root).unwrap();
let err = collect("ag-nodone", "msg", &root).unwrap_err();
assert!(err.to_string().contains(".DONE"));
}
#[test]
fn collect_with_done_produces_merge_commit() {
let (_td, root) = setup_kit();
let h = create("ag-merge", "main", &root).unwrap();
mark_done(&h.worktree);
let report = collect("ag-merge", "feat: agent work", &root).expect("collect ok");
assert_eq!(report.commit_sha.len(), 40);
// HEAD of kit_root must be a merge commit with 2 parents.
let out = Command::new("git")
.current_dir(&root)
.args(["log", "-1", "--pretty=%P"])
.output()
.unwrap();
let parents: Vec<&str> = std::str::from_utf8(&out.stdout)
.unwrap()
.trim()
.split_whitespace()
.collect();
assert_eq!(parents.len(), 2, "expected merge commit");
}
#[test]
fn collect_archives_worktree() {
let (_td, root) = setup_kit();
let h = create("ag-arch", "main", &root).unwrap();
mark_done(&h.worktree);
let report = collect("ag-arch", "msg", &root).expect("collect ok");
assert!(report.archive_path.exists());
assert!(report.archive_path.starts_with(root.join("_archive/forks")));
assert!(report.archive_path.ends_with("ag-arch"));
}
#[test]
fn collect_removes_live_worktree() {
let (_td, root) = setup_kit();
let h = create("ag-gone", "main", &root).unwrap();
mark_done(&h.worktree);
collect("ag-gone", "msg", &root).expect("collect ok");
assert!(!h.worktree.exists(), "live worktree should be gone");
}
#[test]
fn list_filters_by_status() {
let (_td, root) = setup_kit();
// Active
create("ag-active", "main", &root).unwrap();
// Done (mark .DONE but do not collect)
let h_done = create("ag-done", "main", &root).unwrap();
fs::write(h_done.worktree.join(".DONE"), "").unwrap();
// Merged (collect one)
let h_merged = create("ag-merged", "main", &root).unwrap();
mark_done(&h_merged.worktree);
collect("ag-merged", "msg", &root).unwrap();
let all = list(&root, None).unwrap();
assert_eq!(all.len(), 3);
let active = list(&root, Some(ForkStatus::Active)).unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].agent_id, "ag-active");
let done = list(&root, Some(ForkStatus::Done)).unwrap();
assert_eq!(done.len(), 1);
assert_eq!(done[0].agent_id, "ag-done");
let merged = list(&root, Some(ForkStatus::Merged)).unwrap();
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].agent_id, "ag-merged");
}
#[test]
fn gc_prunes_stale() {
let (_td, root) = setup_kit();
let h = create("ag-stale", "main", &root).unwrap();
// Backdate meta.started_ts by 48h — no .DONE → STALE under 24h threshold.
let raw = fs::read_to_string(h.worktree.join(".KEI_FORK_META.toml")).unwrap();
let mut parsed: toml::Value = toml::from_str(&raw).unwrap();
let old_ts = parsed["started_ts"].as_integer().unwrap() - 48 * 3600;
parsed.as_table_mut().unwrap().insert(
"started_ts".to_string(),
toml::Value::Integer(old_ts),
);
fs::write(
h.worktree.join(".KEI_FORK_META.toml"),
toml::to_string(&parsed).unwrap(),
)
.unwrap();
let report = gc(&root, 24).unwrap();
assert_eq!(report.pruned, vec!["ag-stale".to_string()]);
assert!(!h.worktree.exists());
}
#[test]
fn rescue_copies_live_files() {
let (_td, root) = setup_kit();
let h = create("ag-rescue-live", "main", &root).unwrap();
fs::write(h.worktree.join("note.txt"), "payload").unwrap();
fs::create_dir_all(h.worktree.join("sub")).unwrap();
fs::write(h.worktree.join("sub/nested.txt"), "deep").unwrap();
let out_dir = root.join("rescue-out");
let n = rescue("ag-rescue-live", &root, &out_dir).unwrap();
assert!(n >= 3, "expected ≥3 files, got {n}");
assert_eq!(
fs::read_to_string(out_dir.join("note.txt")).unwrap(),
"payload"
);
assert_eq!(
fs::read_to_string(out_dir.join("sub/nested.txt")).unwrap(),
"deep"
);
}
#[test]
fn rescue_extracts_archived() {
let (_td, root) = setup_kit();
let h = create("ag-rescue-arch", "main", &root).unwrap();
mark_done(&h.worktree);
fs::write(h.worktree.join("artefact.md"), "# hi").unwrap();
collect("ag-rescue-arch", "msg", &root).unwrap();
let out_dir = root.join("rescue-out-arch");
let n = rescue("ag-rescue-arch", &root, &out_dir).unwrap();
assert!(n >= 1);
assert!(out_dir.join("artefact.md").exists());
}
#[test]
fn rescue_missing_agent_errors() {
let (_td, root) = setup_kit();
let err = rescue("ag-nope", &root, &root.join("x")).unwrap_err();
assert!(err.to_string().contains("no live or archived"));
}