use std::path::Path;
use axum::http::StatusCode;
use git2::{Repository, Signature};
use serde_json::{json, Value};
use selfware::evolution::ast_tools::cleanup_worktree;
use selfware::evolve::apply::{self, ApplyError, ApplyStatus, RejectReason};
use selfware::evolve::server::EvolveServer;
use selfware::evolve::Graph;
use crate::post_json;
fn committed_repository() -> (tempfile::TempDir, String) {
let project = tempfile::tempdir().unwrap();
let repo = Repository::init(project.path()).unwrap();
std::fs::write(project.path().join("README.md"), "initial\n").unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("README.md")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let signature = Signature::now("Selfware Test", "selfware@example.test").unwrap();
let head = repo
.commit(
Some("HEAD"),
&signature,
&signature,
"initial commit",
&tree,
&[],
)
.unwrap()
.to_string();
(project, head)
}
#[tokio::test]
async fn staged_run_writes_isolated_from_live_checkout() {
let (project, head) = committed_repository();
let registry = apply::new_registry();
let staged = apply::stage_run(
"append a line to README".to_string(),
project.path().to_path_buf(),
registry.clone(),
)
.await
.expect("staging a run in a clean repo must succeed");
let run = apply::get(®istry, &staged.id)
.await
.expect("staged run is registered");
assert_eq!(run.status, ApplyStatus::Running);
assert_eq!(
run.shadow_path.as_deref(),
Some(staged.shadow_path.as_path())
);
assert_eq!(run.base_revision.as_deref(), Some(head.as_str()));
assert_eq!(staged.base_revision, head);
assert!(run.diff.is_none());
assert!(staged.shadow_path.exists());
let shadow_file = staged.shadow_path.join("README.md");
assert_eq!(std::fs::read_to_string(&shadow_file).unwrap(), "initial\n");
std::fs::write(&shadow_file, "initial\nagent change\n").unwrap();
std::fs::write(staged.shadow_path.join("NEW_FILE.md"), "created by agent\n").unwrap();
assert_eq!(
std::fs::read_to_string(project.path().join("README.md")).unwrap(),
"initial\n"
);
assert!(!project.path().join("NEW_FILE.md").exists());
cleanup_worktree(project.path(), &staged.shadow_path).unwrap();
assert!(!staged.shadow_path.exists());
assert_eq!(
std::fs::read_to_string(project.path().join("README.md")).unwrap(),
"initial\n"
);
drop(staged.guard);
}
#[tokio::test]
async fn staging_failure_marks_run_failed_with_typed_error() {
let dir = tempfile::tempdir().unwrap();
let registry = apply::new_registry();
let err = apply::stage_run(
"do something".to_string(),
dir.path().to_path_buf(),
registry.clone(),
)
.await
.expect_err("staging outside a git repo must fail");
assert!(matches!(err, ApplyError::BaseRevision(_)));
let runs = registry.lock().await;
assert_eq!(runs.len(), 1);
let run = runs.values().next().unwrap();
assert_eq!(run.status, ApplyStatus::Failed);
assert!(run.shadow_path.is_none());
assert!(run.base_revision.is_none());
}
#[tokio::test]
async fn in_scope_change_produces_staged_diff_with_stable_digest() {
let (project, _head) = committed_repository();
let registry = apply::new_registry();
let staged = apply::stage_run(
"add src and docs changes".to_string(),
project.path().to_path_buf(),
registry.clone(),
)
.await
.expect("staging must succeed");
std::fs::create_dir_all(staged.shadow_path.join("src")).unwrap();
std::fs::write(
staged.shadow_path.join("src/main.rs"),
"fn main() { println!(\"hi\"); }\n",
)
.unwrap();
std::fs::create_dir_all(staged.shadow_path.join("docs")).unwrap();
std::fs::write(staged.shadow_path.join("docs/notes.md"), "# notes\n").unwrap();
let first = apply::verify_staged_diff(&staged.shadow_path, &staged.base_revision)
.expect("git2 diff computation must not fail")
.expect("in-scope diff must be accepted");
assert_eq!(first.files_changed, 2);
assert_eq!(first.insertions, 2);
assert_eq!(first.deletions, 0);
assert!(first.preview.contains("src/main.rs"));
assert!(first.preview.contains("docs/notes.md"));
assert!(first.preview.len() <= 8 * 1024);
assert_eq!(first.digest.len(), 64, "sha256 hex digest");
let second = apply::verify_staged_diff(&staged.shadow_path, &staged.base_revision)
.expect("git2 diff computation must not fail")
.expect("in-scope diff must be accepted");
assert_eq!(first.digest, second.digest);
assert_eq!(first.preview, second.preview);
cleanup_worktree(project.path(), &staged.shadow_path).unwrap();
drop(staged.guard);
}
#[tokio::test]
async fn out_of_scope_change_is_rejected_with_path_named() {
let (project, _head) = committed_repository();
let registry = apply::new_registry();
let staged = apply::stage_run(
"touch build.rs".to_string(),
project.path().to_path_buf(),
registry.clone(),
)
.await
.expect("staging must succeed");
std::fs::write(staged.shadow_path.join("build.rs"), "fn main() {}\n").unwrap();
let rejection = apply::verify_staged_diff(&staged.shadow_path, &staged.base_revision)
.expect("git2 diff computation must not fail")
.expect_err("out-of-scope diff must be rejected");
assert_eq!(rejection, RejectReason::OutOfScope("build.rs".to_string()));
assert_eq!(rejection.to_string(), "diff_out_of_scope: build.rs");
cleanup_worktree(project.path(), &staged.shadow_path).unwrap();
drop(staged.guard);
}
#[tokio::test]
async fn no_change_is_rejected_as_empty_diff() {
let (project, _head) = committed_repository();
let registry = apply::new_registry();
let staged = apply::stage_run(
"do nothing".to_string(),
project.path().to_path_buf(),
registry.clone(),
)
.await
.expect("staging must succeed");
let rejection = apply::verify_staged_diff(&staged.shadow_path, &staged.base_revision)
.expect("git2 diff computation must not fail")
.expect_err("an empty diff must be rejected");
assert_eq!(rejection, RejectReason::Empty);
assert_eq!(rejection.to_string(), "empty_diff");
cleanup_worktree(project.path(), &staged.shadow_path).unwrap();
drop(staged.guard);
}
async fn staged_agent_run(
server: &EvolveServer,
root: &Path,
) -> (String, String, std::path::PathBuf) {
let registry = server.apply_registry();
let staged = apply::stage_run(
"add src/agent.rs".to_string(),
root.to_path_buf(),
registry.clone(),
)
.await
.expect("staging must succeed");
std::fs::create_dir_all(staged.shadow_path.join("src")).unwrap();
std::fs::write(
staged.shadow_path.join("src/agent.rs"),
"pub fn agent() {}\n",
)
.unwrap();
let diff = apply::verify_staged_diff(&staged.shadow_path, &staged.base_revision)
.expect("git2 diff computation must not fail")
.expect("in-scope diff must be accepted");
let run_id = staged.id.clone();
let digest = diff.digest.clone();
let shadow_path = staged.shadow_path.clone();
if let Some(run) = registry.lock().await.get_mut(&run_id) {
run.status = ApplyStatus::Staged;
run.diff = Some(diff);
}
drop(staged.guard);
(run_id, digest, shadow_path)
}
fn commit_paths(repo: &Repository, paths: &[&str], message: &str) -> String {
let mut index = repo.index().unwrap();
for path in paths {
index.add_path(Path::new(path)).unwrap();
}
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let signature = Signature::now("Selfware Test", "selfware@example.test").unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&[&parent],
)
.unwrap()
.to_string()
}
async fn staged_fixture() -> (
tempfile::TempDir,
std::path::PathBuf,
EvolveServer,
String,
String,
String,
std::path::PathBuf,
) {
let (project, base_head) = committed_repository();
let root = std::fs::canonicalize(project.path()).unwrap();
let server = EvolveServer::for_project(Graph::default(), &root).unwrap();
let (run_id, digest, shadow_path) = staged_agent_run(&server, &root).await;
(
project,
root,
server,
base_head,
run_id,
digest,
shadow_path,
)
}
#[tokio::test]
async fn commit_with_correct_digest_merges_into_live_checkout() {
let (_project, root, server, base_head, run_id, digest, shadow_path) = staged_fixture().await;
let (status, body) = post_json(
&server,
"/api/actions/apply/commit",
json!({ "run_id": run_id, "diff_digest": digest }),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let body: Value = serde_json::from_str(&body).unwrap();
assert_eq!(body["merged"], true);
assert_eq!(body["files_changed"], 1);
let new_head = body["new_head"].as_str().expect("new_head is a string");
assert_ne!(new_head, base_head, "HEAD must advance");
assert_eq!(
std::fs::read_to_string(root.join("src/agent.rs")).unwrap(),
"pub fn agent() {}\n"
);
let repo = Repository::open(&root).unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
assert_eq!(head.id().to_string(), new_head);
assert_eq!(head.parent_id(0).unwrap().to_string(), base_head);
assert!(
head.message().unwrap_or_default().contains(&run_id),
"merge commit message references the run id: {:?}",
head.message()
);
assert!(apply::get(&server.apply_registry(), &run_id)
.await
.is_none());
assert!(!shadow_path.exists(), "shadow worktree is cleaned up");
}
#[tokio::test]
async fn second_commit_call_is_consumed_unknown_run() {
let (_project, _root, server, _base, run_id, digest, _shadow) = staged_fixture().await;
let request = json!({ "run_id": run_id, "diff_digest": digest });
let (status, body) = post_json(&server, "/api/actions/apply/commit", request.clone()).await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, body) = post_json(&server, "/api/actions/apply/commit", request).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(body.contains("unknown_run"), "{body}");
}
#[tokio::test]
async fn commit_with_wrong_digest_is_unknown_run() {
let (_project, _root, server, _base, run_id, _digest, _shadow) = staged_fixture().await;
let (status, body) = post_json(
&server,
"/api/actions/apply/commit",
json!({ "run_id": run_id, "diff_digest": "0".repeat(64) }),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(body.contains("unknown_run"), "{body}");
let run = apply::get(&server.apply_registry(), &run_id)
.await
.expect("a wrong digest must not consume the run");
assert_eq!(run.status, ApplyStatus::Staged);
}
#[tokio::test]
async fn commit_after_live_head_moved_is_base_moved_conflict() {
let (_project, root, server, _base, run_id, digest, _shadow) = staged_fixture().await;
std::fs::write(root.join("README.md"), "initial\nmoved\n").unwrap();
let repo = Repository::open(&root).unwrap();
commit_paths(&repo, &["README.md"], "unrelated commit moving HEAD");
let (status, body) = post_json(
&server,
"/api/actions/apply/commit",
json!({ "run_id": run_id, "diff_digest": digest }),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert!(body.contains("base_moved"), "{body}");
assert!(!root.join("src/agent.rs").exists());
let run = apply::get(&server.apply_registry(), &run_id)
.await
.expect("a base_moved conflict must not consume the run");
assert_eq!(run.status, ApplyStatus::Staged);
}