use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, LazyLock};
use anyhow::Result;
use serde::Serialize;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::{Mutex, MutexGuard};
use crate::evolution::ast_tools::{cleanup_worktree, create_shadow_worktree_named};
pub static APPLY_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ApplyStatus {
Running,
Staged,
Rejected(String),
Succeeded,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct StagedDiff {
pub digest: String,
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
pub preview: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ApplyRun {
pub id: String,
pub prompt: String,
pub status: ApplyStatus,
pub output: String,
pub exit_code: Option<i32>,
pub shadow_path: Option<PathBuf>,
pub base_revision: Option<String>,
pub diff: Option<StagedDiff>,
}
pub type ApplyRegistry = Arc<Mutex<HashMap<String, ApplyRun>>>;
pub fn new_registry() -> ApplyRegistry {
Arc::new(Mutex::new(HashMap::new()))
}
const MAX_OUTPUT: usize = 200_000;
const MAX_PREVIEW: usize = 8 * 1024;
const MAX_KEPT_SHADOWS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RejectReason {
OutOfScope(String),
Empty,
}
impl std::fmt::Display for RejectReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RejectReason::OutOfScope(path) => write!(f, "diff_out_of_scope: {path}"),
RejectReason::Empty => write!(f, "empty_diff"),
}
}
}
pub fn verify_staged_diff(
shadow_path: &Path,
base_revision: &str,
) -> std::result::Result<std::result::Result<StagedDiff, RejectReason>, git2::Error> {
let repo = git2::Repository::open(shadow_path)?;
let base = repo
.find_commit(git2::Oid::from_str(base_revision)?)?
.tree()?;
let mut index = repo.index()?;
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
index.write()?;
let diff = repo.diff_tree_to_index(Some(&base), None, None)?;
if diff.deltas().len() == 0 {
return Ok(Err(RejectReason::Empty));
}
for delta in diff.deltas() {
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
if !(path.starts_with("src/") || path.starts_with("docs/")) {
return Ok(Err(RejectReason::OutOfScope(path)));
}
}
let stats = diff.stats()?;
let mut patch = Vec::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
patch.push(line.origin() as u8);
patch.extend_from_slice(line.content());
true
})?;
let digest = format!("{:x}", Sha256::digest(&patch));
let preview = String::from_utf8_lossy(&patch)
.chars()
.take(MAX_PREVIEW)
.collect();
Ok(Ok(StagedDiff {
digest,
files_changed: stats.files_changed(),
insertions: stats.insertions(),
deletions: stats.deletions(),
preview,
}))
}
fn prune_old_shadows(project_root: &Path, protected: &[PathBuf]) {
let worktrees_dir = project_root.join(".worktrees");
let Ok(entries) = std::fs::read_dir(&worktrees_dir) else {
return;
};
let mut shadows: Vec<(std::time::SystemTime, PathBuf)> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.is_dir()
&& !protected.contains(p)
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("evolve-apply-"))
})
.filter_map(|p| {
p.metadata()
.and_then(|m| m.modified())
.ok()
.map(|mtime| (mtime, p))
})
.collect();
if shadows.len() <= MAX_KEPT_SHADOWS {
return;
}
shadows.sort_by_key(|(mtime, _)| *mtime);
let excess = shadows.len() - MAX_KEPT_SHADOWS;
for (_, path) in shadows.into_iter().take(excess) {
let _ = cleanup_worktree(project_root, &path);
}
}
#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
#[error("failed to create shadow worktree: {0}")]
ShadowWorktree(#[from] crate::evolution::ast_tools::WorktreeError),
#[error("failed to read live checkout HEAD: {0}")]
BaseRevision(String),
#[error("failed to spawn agent process: {0}")]
Spawn(std::io::Error),
}
#[derive(Debug)]
pub struct StagedRun {
pub id: String,
pub shadow_path: PathBuf,
pub base_revision: String,
pub guard: MutexGuard<'static, ()>,
}
pub async fn stage_run(
prompt: String,
project_root: PathBuf,
registry: ApplyRegistry,
) -> std::result::Result<StagedRun, ApplyError> {
let guard = APPLY_LOCK.lock().await;
let id = format!("apply-{}", uuid::Uuid::new_v4().simple());
{
let registry_guard = registry.lock().await;
let protected: Vec<PathBuf> = registry_guard
.values()
.filter_map(|run| run.shadow_path.clone())
.collect();
drop(registry_guard);
prune_old_shadows(&project_root, &protected);
}
let base_revision = match read_head_oid(&project_root) {
Ok(oid) => oid,
Err(e) => {
register_failed(®istry, &id, &prompt).await;
return Err(ApplyError::BaseRevision(e.to_string()));
}
};
let shadow_path = match create_shadow_worktree_named(
&project_root,
&format!("evolve-apply-{}", id.trim_start_matches("apply-")),
) {
Ok(path) => path,
Err(e) => {
register_failed(®istry, &id, &prompt).await;
return Err(e.into());
}
};
registry.lock().await.insert(
id.clone(),
ApplyRun {
id: id.clone(),
prompt,
status: ApplyStatus::Running,
output: String::new(),
exit_code: None,
shadow_path: Some(shadow_path.clone()),
base_revision: Some(base_revision.clone()),
diff: None,
},
);
Ok(StagedRun {
id,
shadow_path,
base_revision,
guard,
})
}
fn read_head_oid(project_root: &std::path::Path) -> std::result::Result<String, git2::Error> {
let repo = git2::Repository::open(project_root)?;
let oid = repo.head()?.peel_to_commit()?.id().to_string();
Ok(oid)
}
async fn register_failed(registry: &ApplyRegistry, id: &str, prompt: &str) {
registry.lock().await.insert(
id.to_string(),
ApplyRun {
id: id.to_string(),
prompt: prompt.to_string(),
status: ApplyStatus::Failed,
output: String::new(),
exit_code: None,
shadow_path: None,
base_revision: None,
diff: None,
},
);
}
pub async fn spawn(
prompt: String,
project_root: PathBuf,
registry: ApplyRegistry,
) -> Result<String> {
let exe = std::env::current_exe()?;
let staged = stage_run(prompt.clone(), project_root.clone(), registry.clone()).await?;
let id = staged.id;
let mut child = match Command::new(exe)
.arg("run")
.arg(&prompt)
.arg("--yolo")
.current_dir(&staged.shadow_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
let _ = cleanup_worktree(&project_root, &staged.shadow_path);
if let Some(run) = registry.lock().await.get_mut(&id) {
run.status = ApplyStatus::Failed;
}
return Err(ApplyError::Spawn(e).into());
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let reg = registry.clone();
let rid = id.clone();
let shadow_for_verify = staged.shadow_path.clone();
let base_for_verify = staged.base_revision.clone();
tokio::spawn(async move {
let _guard = staged.guard;
tokio::join!(
pump_pipe(stdout, reg.clone(), rid.clone()),
pump_pipe(stderr, reg.clone(), rid.clone()),
);
let code = child.wait().await.ok().and_then(|s| s.code());
let verified = if code == Some(0) {
Some(verify_staged_diff(&shadow_for_verify, &base_for_verify))
} else {
None
};
if let Some(run) = reg.lock().await.get_mut(&rid) {
run.exit_code = code;
match verified {
Some(Ok(Ok(diff))) => {
run.status = ApplyStatus::Staged;
run.diff = Some(diff);
}
Some(Ok(Err(reason))) => {
run.status = ApplyStatus::Rejected(reason.to_string());
}
Some(Err(e)) => {
run.status = ApplyStatus::Failed;
run.output
.push_str(&format!("\ndiff verification failed: {e}\n"));
if run.output.len() > MAX_OUTPUT {
let cut = run.output.len() - MAX_OUTPUT;
run.output.drain(..cut);
}
}
None => {
run.status = ApplyStatus::Failed;
}
}
}
});
Ok(id)
}
async fn pump_pipe<R>(pipe: Option<R>, reg: ApplyRegistry, rid: String)
where
R: tokio::io::AsyncRead + Unpin,
{
let Some(pipe) = pipe else { return };
let mut lines = BufReader::new(pipe).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(run) = reg.lock().await.get_mut(&rid) {
run.output.push_str(&line);
run.output.push('\n');
if run.output.len() > MAX_OUTPUT {
let cut = run.output.len() - MAX_OUTPUT;
run.output.drain(..cut);
}
}
}
}
pub async fn get(registry: &ApplyRegistry, id: &str) -> Option<ApplyRun> {
registry.lock().await.get(id).cloned()
}
pub async fn remove(registry: &ApplyRegistry, id: &str, project_root: &Path) -> Option<ApplyRun> {
let run = registry.lock().await.remove(id);
if let Some(shadow) = run.as_ref().and_then(|r| r.shadow_path.as_ref()) {
let _ = cleanup_worktree(project_root, shadow);
}
run
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergeOutcome {
pub new_head: String,
pub files_changed: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum CommitError {
#[error("unknown_run: {0}")]
UnknownRun(String),
#[error("not_staged: run is {0:?}; only Staged runs can be committed")]
NotStaged(ApplyStatus),
#[error("base_moved: staged at {base} but live HEAD is {head}; rebase required")]
BaseMoved { base: String, head: String },
#[error("merge failed: {0}")]
Git(String),
}
pub async fn commit_staged(
registry: &ApplyRegistry,
run_id: &str,
diff_digest: &str,
project_root: &Path,
) -> std::result::Result<MergeOutcome, CommitError> {
let _guard = APPLY_LOCK.lock().await;
let run = get(registry, run_id)
.await
.ok_or_else(|| CommitError::UnknownRun(run_id.to_string()))?;
if run.status != ApplyStatus::Staged {
return Err(CommitError::NotStaged(run.status));
}
let (diff, base, shadow) = match (
run.diff.as_ref(),
run.base_revision.as_ref(),
run.shadow_path.as_ref(),
) {
(Some(diff), Some(base), Some(shadow)) => (diff, base, shadow),
_ => {
return Err(CommitError::Git(format!(
"staged run {run_id} is missing its diff, base revision, or shadow path"
)))
}
};
if diff.digest != diff_digest {
return Err(CommitError::UnknownRun(run_id.to_string()));
}
let head = read_head_oid(project_root).map_err(|e| CommitError::Git(e.to_string()))?;
if head != *base {
return Err(CommitError::BaseMoved {
base: base.clone(),
head,
});
}
let files_changed = diff.files_changed;
let new_head = merge_shadow(shadow, project_root, base, run_id, &run.prompt)?;
remove(registry, run_id, project_root).await;
Ok(MergeOutcome {
new_head,
files_changed,
})
}
fn merge_shadow(
shadow_path: &Path,
project_root: &Path,
base_revision: &str,
run_id: &str,
prompt: &str,
) -> std::result::Result<String, CommitError> {
fn git_err(e: git2::Error) -> CommitError {
CommitError::Git(e.to_string())
}
let shadow_repo = git2::Repository::open(shadow_path).map_err(git_err)?;
{
let shadow_head = shadow_repo
.head()
.and_then(|h| h.peel_to_commit())
.map_err(git_err)?;
if shadow_head.id().to_string() != base_revision {
return Err(CommitError::BaseMoved {
base: base_revision.to_string(),
head: format!("shadow at {}", shadow_head.id()),
});
}
}
let mut index = shadow_repo.index().map_err(git_err)?;
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.map_err(git_err)?;
index.write().map_err(git_err)?;
let tree_id = index.write_tree().map_err(git_err)?;
let tree = shadow_repo.find_tree(tree_id).map_err(git_err)?;
let base_commit = shadow_repo
.head()
.and_then(|head| head.peel_to_commit())
.map_err(git_err)?;
let signature = shadow_repo
.signature()
.or_else(|_| git2::Signature::now("selfware-evolve", "evolve@selfware.local"))
.map_err(git_err)?;
let summary: String = prompt
.lines()
.next()
.unwrap_or("")
.chars()
.take(72)
.collect();
let message = format!("evolve apply {run_id}: {summary}");
let new_oid = shadow_repo
.commit(
Some("HEAD"),
&signature,
&signature,
&message,
&tree,
&[&base_commit],
)
.map_err(git_err)?;
let live = git2::Repository::open(project_root).map_err(git_err)?;
let new_commit = live.find_commit(new_oid).map_err(git_err)?;
live.checkout_tree(
new_commit.as_object(),
Some(git2::build::CheckoutBuilder::new().safe()),
)
.map_err(git_err)?;
live.head()
.map_err(git_err)?
.set_target(new_oid, "evolve apply: fast-forward staged run")
.map_err(git_err)?;
Ok(new_oid.to_string())
}