//! `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/` in kit_root //! 4. Move worktree to `_archive/forks/YYYY-MM-DD//` (preserving //! the agent's artefacts for post-hoc review / rescue) //! 5. `git worktree prune && git branch -D fork/` to clean up refs //! 6. `kei-ledger done ` unless `KEI_FORK_SKIP_LEDGER=1` //! //! On SUCCESS: `_forks//` 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 { let worktree_abs = kit_root.join("_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 { 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}"))), } }