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:
parent
0ea429054f
commit
5b5e7c6d7b
16 changed files with 2309 additions and 0 deletions
15
_primitives/_rust/Cargo.lock
generated
15
_primitives/_rust/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
1178
_primitives/_rust/kei-fork/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
27
_primitives/_rust/kei-fork/Cargo.toml
Normal file
27
_primitives/_rust/kei-fork/Cargo.toml
Normal 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"
|
||||
118
_primitives/_rust/kei-fork/src/collect.rs
Normal file
118
_primitives/_rust/kei-fork/src/collect.rs
Normal 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}"))),
|
||||
}
|
||||
}
|
||||
89
_primitives/_rust/kei-fork/src/create.rs
Normal file
89
_primitives/_rust/kei-fork/src/create.rs
Normal 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}"))),
|
||||
}
|
||||
}
|
||||
37
_primitives/_rust/kei-fork/src/error.rs
Normal file
37
_primitives/_rust/kei-fork/src/error.rs
Normal 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),
|
||||
}
|
||||
87
_primitives/_rust/kei-fork/src/gc.rs
Normal file
87
_primitives/_rust/kei-fork/src/gc.rs
Normal 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}"))),
|
||||
}
|
||||
}
|
||||
96
_primitives/_rust/kei-fork/src/git.rs
Normal file
96
_primitives/_rust/kei-fork/src/git.rs
Normal 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)
|
||||
}
|
||||
42
_primitives/_rust/kei-fork/src/handle.rs
Normal file
42
_primitives/_rust/kei-fork/src/handle.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
30
_primitives/_rust/kei-fork/src/lib.rs
Normal file
30
_primitives/_rust/kei-fork/src/lib.rs
Normal 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;
|
||||
114
_primitives/_rust/kei-fork/src/list.rs
Normal file
114
_primitives/_rust/kei-fork/src/list.rs
Normal 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
|
||||
}
|
||||
137
_primitives/_rust/kei-fork/src/main.rs
Normal file
137
_primitives/_rust/kei-fork/src/main.rs
Normal 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}")),
|
||||
}
|
||||
}
|
||||
49
_primitives/_rust/kei-fork/src/meta.rs
Normal file
49
_primitives/_rust/kei-fork/src/meta.rs
Normal 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()))
|
||||
}
|
||||
54
_primitives/_rust/kei-fork/src/rescue.rs
Normal file
54
_primitives/_rust/kei-fork/src/rescue.rs
Normal 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)
|
||||
}
|
||||
234
_primitives/_rust/kei-fork/tests/fork_integration.rs
Normal file
234
_primitives/_rust/kei-fork/tests/fork_integration.rs
Normal 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"));
|
||||
}
|
||||
Loading…
Reference in a new issue