mod common;
use anyhow::Result;
use chrono::Utc;
use lazyspec::engine::config::{Config, CoordinationConfig, StoreBackend, TypeDef};
use lazyspec::engine::gh::{GhIssue, GhIssueReader};
use lazyspec::engine::git_ref::{GitCli, GitRefOps};
use lazyspec::engine::git_ref_store::GitRefStore;
use lazyspec::engine::lease::LeaseEngine;
use lazyspec::engine::store_dispatch::DocumentStore;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
struct NoopGh;
impl GhIssueReader for NoopGh {
fn issue_list(
&self,
_repo: &str,
_labels: &[String],
_json_fields: &[String],
_limit: Option<u64>,
) -> Result<Vec<GhIssue>> {
Ok(vec![])
}
fn issue_view(&self, _repo: &str, _number: u64) -> Result<GhIssue> {
unreachable!("not used in this test")
}
}
fn config_with_git_ref_iteration() -> Config {
let mut config = Config::default();
for t in &mut config.documents.types {
if t.name == "iteration" {
t.store = StoreBackend::GitRef;
}
}
config.coordination = Some(CoordinationConfig {
remote: "origin".to_string(),
lease_duration: "60m".to_string(),
grace_period: "2m".to_string(),
max_push_retries: 5,
max_clock_skew: "5m".to_string(),
});
config
}
fn iteration_type_def(config: &Config) -> TypeDef {
config
.type_by_name("iteration")
.expect("iteration type")
.clone()
}
fn make_clone_b(bare: &Path) -> TempDir {
let dir = TempDir::new().unwrap();
let bare_str = bare.to_str().unwrap();
let target_str = dir.path().to_str().unwrap();
let out = Command::new("git")
.args(["clone", bare_str, target_str])
.output()
.expect("git clone");
assert!(
out.status.success(),
"git clone failed: {}",
String::from_utf8_lossy(&out.stderr)
);
Command::new("git")
.args(["config", "user.email", "b@test.com"])
.current_dir(dir.path())
.output()
.expect("git config email");
Command::new("git")
.args(["config", "user.name", "B"])
.current_dir(dir.path())
.output()
.expect("git config name");
dir
}
fn run_fetch(root: &Path, config: &Config) -> Result<()> {
let gh = NoopGh;
lazyspec::cli::fetch::run(root, config, &gh, &GitCli, "origin", None, true)
}
#[test]
fn fetch_prunes_deleted_remote_doc_refs() {
let (fixture_a, bare) = common::TestFixture::with_git_remote();
let clone_b = make_clone_b(bare.path());
let config = config_with_git_ref_iteration();
let type_def = iteration_type_def(&config);
let mut store_a = GitRefStore {
git: GitCli,
root: fixture_a.root().to_path_buf(),
config: config.clone(),
reserved_number: Some(1),
};
let created = store_a
.create(&type_def, "First Iteration", "agent-a", "body content")
.expect("create iteration on A");
assert_eq!(created.id, "ITERATION-001");
run_fetch(clone_b.path(), &config).expect("first fetch on B");
let git = GitCli;
let refname = "refs/lazyspec/iteration/ITERATION-001";
let cache_file = clone_b
.path()
.join(".lazyspec/cache/iteration/ITERATION-001.md");
assert!(
git.resolve_ref(clone_b.path(), refname).unwrap().is_some(),
"B should have local ref after first fetch"
);
assert!(
cache_file.exists(),
"B should have cache file after first fetch"
);
store_a
.delete(&type_def, "ITERATION-001")
.expect("delete iteration on A");
run_fetch(clone_b.path(), &config).expect("second fetch on B");
assert_eq!(
git.resolve_ref(clone_b.path(), refname).unwrap(),
None,
"B's local doc ref should be pruned after fetch"
);
assert!(
!cache_file.exists(),
"B's cache file should be removed after fetch prunes the ref"
);
}
#[test]
fn update_rolls_back_on_push_rejection_then_recovers_after_fetch() {
let (fixture_a, bare) = common::TestFixture::with_git_remote();
let clone_b = make_clone_b(bare.path());
let config = config_with_git_ref_iteration();
let type_def = iteration_type_def(&config);
let refname = "refs/lazyspec/iteration/ITERATION-001";
let git = GitCli;
let mut store_a = GitRefStore {
git: GitCli,
root: fixture_a.root().to_path_buf(),
config: config.clone(),
reserved_number: Some(1),
};
store_a
.create(&type_def, "First Iteration", "agent-a", "body")
.expect("create iteration on A");
let draft_sha = git
.resolve_ref(fixture_a.root(), refname)
.unwrap()
.expect("A has draft ref");
run_fetch(clone_b.path(), &config).expect("B initial fetch");
assert_eq!(
git.resolve_ref(clone_b.path(), refname).unwrap(),
Some(draft_sha.clone()),
"B's local ref should be at draft SHA after first fetch"
);
let lock_b = lazyspec::engine::cache_lock::CacheLock::load(clone_b.path()).unwrap();
assert_eq!(
lock_b.get("iteration/ITERATION-001"),
Some(draft_sha.as_str()),
"B's cache.lock should be at draft SHA"
);
let cache_file_b = clone_b
.path()
.join(".lazyspec/cache/iteration/ITERATION-001.md");
let draft_cache_content = std::fs::read_to_string(&cache_file_b).unwrap();
assert!(
draft_cache_content.contains("status: draft"),
"B's cache file should reflect draft status, got: {}",
draft_cache_content
);
store_a
.update(&type_def, "ITERATION-001", &[("status", "accepted")])
.expect("A updates to accepted and pushes");
let accepted_sha = git
.resolve_ref(fixture_a.root(), refname)
.unwrap()
.expect("A has accepted ref");
assert_ne!(accepted_sha, draft_sha);
let mut store_b = GitRefStore {
git: GitCli,
root: clone_b.path().to_path_buf(),
config: config.clone(),
reserved_number: None,
};
let result = store_b.update(&type_def, "ITERATION-001", &[("status", "review")]);
assert!(
result.is_err(),
"B's first update should fail (non-fast-forward push)"
);
let lock_b_after_fail = lazyspec::engine::cache_lock::CacheLock::load(clone_b.path()).unwrap();
let lock_sha_after_fail = lock_b_after_fail
.get("iteration/ITERATION-001")
.expect("lock entry should still exist");
assert_eq!(
lock_sha_after_fail, draft_sha,
"B's cache.lock should still hold the pre-attempt (draft) SHA"
);
let local_ref_after_fail = git
.resolve_ref(clone_b.path(), refname)
.unwrap()
.expect("B's local ref should still exist after rollback");
assert_eq!(
local_ref_after_fail, draft_sha,
"B's local ref should equal cache.lock SHA (rollback restored draft)"
);
let cache_after_fail = std::fs::read_to_string(&cache_file_b).unwrap();
assert_eq!(
cache_after_fail, draft_cache_content,
"B's cache file content must be unchanged after the failed update"
);
run_fetch(clone_b.path(), &config).expect("B recovery fetch");
assert_eq!(
git.resolve_ref(clone_b.path(), refname).unwrap(),
Some(accepted_sha.clone()),
"B's local ref should now be at A's accepted SHA"
);
let lock_b_after_fetch = lazyspec::engine::cache_lock::CacheLock::load(clone_b.path()).unwrap();
assert_eq!(
lock_b_after_fetch.get("iteration/ITERATION-001"),
Some(accepted_sha.as_str()),
"B's cache.lock should track A's accepted SHA after fetch"
);
store_b
.update(&type_def, "ITERATION-001", &[("status", "review")])
.expect("B's retry update should succeed after fetch");
let b_final_sha = git
.resolve_ref(clone_b.path(), refname)
.unwrap()
.expect("B has final ref");
assert_ne!(b_final_sha, accepted_sha);
let bare_resolved = Command::new("git")
.args(["rev-parse", refname])
.current_dir(bare.path())
.output()
.expect("git rev-parse on bare");
assert!(bare_resolved.status.success());
let bare_sha = String::from_utf8_lossy(&bare_resolved.stdout)
.trim()
.to_string();
assert_eq!(
bare_sha, b_final_sha,
"remote ref should equal B's final commit"
);
let parent_out = Command::new("git")
.args(["rev-parse", &format!("{}^", b_final_sha)])
.current_dir(clone_b.path())
.output()
.expect("git rev-parse parent");
assert!(parent_out.status.success());
let b_parent = String::from_utf8_lossy(&parent_out.stdout)
.trim()
.to_string();
assert_eq!(
b_parent, accepted_sha,
"B's final commit should be parented on A's accepted commit"
);
}
#[test]
fn fetch_prunes_deleted_remote_lease_refs_so_claim_succeeds() {
let (fixture_a, bare) = common::TestFixture::with_git_remote();
let clone_b = make_clone_b(bare.path());
let config = config_with_git_ref_iteration();
let coord = config.coordination.clone().unwrap();
let type_def = iteration_type_def(&config);
{
let mut store_a = GitRefStore {
git: GitCli,
root: fixture_a.root().to_path_buf(),
config: config.clone(),
reserved_number: Some(1),
};
store_a
.create(&type_def, "First Iteration", "agent-a", "body")
.expect("create iteration on A");
}
let lease_ref = "refs/lazyspec/leases/iteration/ITERATION-001";
let git = GitCli;
let engine_a = LeaseEngine::new(GitCli, coord.clone());
engine_a
.acquire(
fixture_a.root(),
"iteration",
"ITERATION-001",
"agent-a",
Utc::now(),
)
.expect("A acquires lease");
git.fetch_refs(clone_b.path(), "origin", "refs/lazyspec/leases/*")
.expect("B fetches leases");
assert!(
git.resolve_ref(clone_b.path(), lease_ref)
.unwrap()
.is_some(),
"B should have lease ref locally after fetch"
);
engine_a
.release(fixture_a.root(), "iteration", "ITERATION-001", "agent-a")
.expect("A releases lease");
let engine_b = LeaseEngine::new(GitCli, coord);
let lease_b = engine_b
.acquire(
clone_b.path(),
"iteration",
"ITERATION-001",
"agent-b",
Utc::now(),
)
.expect("B claim should succeed after A released");
assert_eq!(lease_b.agent, "agent-b");
}