use tempfile::TempDir;
fn git(dir: &std::path::Path, args: &[&str]) {
std::process::Command::new("git")
.arg("-c").arg("init.defaultBranch=main")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.status()
.unwrap();
}
fn make_mock_worker(dir: &std::path::Path) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let bin_dir = dir.join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let bin = bin_dir.join("mock-worker");
std::fs::write(
&bin,
"#!/bin/sh\nif [ \"$1\" = \"--help\" ]; then\n echo '--output-format stream-json'\n exit 0\nfi\nexit 0\n",
)
.unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
}
fn setup() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[workflow.states]]
id = "specd"
label = "Specd"
[[workflow.states]]
id = "ammend"
label = "Ammend"
actionable = ["agent"]
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
[[ticket.sections]]
name = "Problem"
type = "free"
required = true
[[ticket.sections]]
name = "Acceptance criteria"
type = "tasks"
required = true
[[ticket.sections]]
name = "Out of scope"
type = "free"
required = true
[[ticket.sections]]
name = "Approach"
type = "free"
required = true
[[ticket.sections]]
name = "Open questions"
type = "qa"
required = false
[[ticket.sections]]
name = "Amendment requests"
type = "tasks"
required = false
"#,
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &[
"-c", "commit.gpgsign=false",
"commit", "-m", "init", "--allow-empty",
]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
fn setup_merge() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[workflow.states]]
id = "specd"
label = "Specd"
[[workflow.states]]
id = "ammend"
label = "Ammend"
actionable = ["agent"]
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states.transitions]]
to = "implemented"
completion = "merge"
[[workflow.states]]
id = "implemented"
label = "Implemented"
terminal = true
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
[[ticket.sections]]
name = "Problem"
type = "free"
required = true
[[ticket.sections]]
name = "Acceptance criteria"
type = "tasks"
required = true
[[ticket.sections]]
name = "Out of scope"
type = "free"
required = true
[[ticket.sections]]
name = "Approach"
type = "free"
required = true
[[ticket.sections]]
name = "Open questions"
type = "qa"
required = false
[[ticket.sections]]
name = "Amendment requests"
type = "tasks"
required = false
"#,
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &[
"-c", "commit.gpgsign=false",
"commit", "-m", "init", "--allow-empty",
]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
fn branch_content(dir: &std::path::Path, branch: &str, path: &str) -> String {
let out = std::process::Command::new("git")
.args(["show", &format!("{branch}:{path}")])
.current_dir(dir)
.output()
.unwrap();
assert!(out.status.success(), "git show {branch}:{path} failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
fn sync_from_branch(dir: &std::path::Path, branch: &str, path: &str) {
git(dir, &["checkout", branch, "--", path]);
}
fn find_ticket_branch(dir: &std::path::Path, slug: &str) -> String {
let pattern = format!("ticket/*-{slug}");
let out = std::process::Command::new("git")
.args(["branch", "--list", &pattern])
.current_dir(dir)
.output()
.unwrap();
let stdout = String::from_utf8(out.stdout).unwrap();
stdout
.lines()
.find(|l| !l.trim().is_empty())
.map(|l| l.trim().trim_start_matches(['*', '+']).trim_start_matches(' ').to_string())
.unwrap_or_else(|| panic!("no branch found for slug: {slug}"))
}
fn find_ticket_id(dir: &std::path::Path, slug: &str) -> String {
let branch = find_ticket_branch(dir, slug);
branch
.strip_prefix("ticket/")
.and_then(|s| s.split('-').next())
.unwrap_or_else(|| panic!("bad branch: {branch}"))
.to_string()
}
fn ticket_rel_path(branch: &str) -> String {
let suffix = branch.strip_prefix("ticket/").expect("not a ticket branch");
format!("tickets/{suffix}.md")
}
fn write_valid_spec_to_branch(dir: &std::path::Path, branch: &str, path: &str) {
let existing = branch_content(dir, branch, path);
let fm_end = existing.find("\n+++\n").expect("frontmatter close not found") + 5;
let frontmatter = &existing[..fm_end];
let body = "\n## Spec\n\n### Problem\n\nTest problem.\n\n### Acceptance criteria\n\n- [ ] One criterion\n\n### Out of scope\n\nNothing.\n\n### Approach\n\nDirect approach.\n\n## History\n\n| When | From | To | By |\n|------|------|----|-----|\n| 2026-01-01T00:00Z | — | new | test-agent |\n";
let content = format!("{frontmatter}{body}");
git(dir, &["checkout", branch]);
std::fs::write(dir.join(path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", "write spec"]);
git(dir, &["checkout", "-"]);
}
#[test]
fn init_creates_expected_files() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
apm_core::init::setup(p, None, None, None).unwrap();
assert!(p.join("tickets").is_dir());
assert!(p.join(".apm/config.toml").exists());
assert!(p.join(".apm/workflow.toml").exists());
assert!(p.join(".apm/ticket.toml").exists());
assert!(p.join(".gitignore").exists());
assert!(!p.join(".git/hooks/pre-push").exists());
assert!(!p.join(".git/hooks/post-merge").exists());
}
#[test]
fn init_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
apm_core::init::setup(p, None, None, None).unwrap();
let toml_before = std::fs::read_to_string(p.join(".apm/config.toml")).unwrap();
apm_core::init::setup(p, None, None, None).unwrap();
let toml_after = std::fs::read_to_string(p.join(".apm/config.toml")).unwrap();
assert_eq!(toml_before, toml_after);
}
#[test]
fn init_generated_config_has_all_workflow_states() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
apm_core::init::setup(p, None, None, None).unwrap();
let toml = std::fs::read_to_string(p.join(".apm/workflow.toml")).unwrap();
for state in &["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "implemented", "merge_failed", "closed"] {
assert!(toml.contains(&format!("\"{state}\"")), "missing state: {state}");
}
assert!(toml.contains("terminal"), "closed must be terminal");
apm_core::config::Config::load(p).unwrap();
}
#[test]
fn list_excludes_terminal_tickets_by_default() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Open ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
apm::cmd::new::run(dir.path(), "Closed ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let closed_id = find_ticket_id(dir.path(), "closed-ticket");
apm::cmd::state::run(dir.path(), &closed_id, "closed".into(), false, false).unwrap();
let config = apm_core::config::Config::load(dir.path()).unwrap();
let tickets = apm_core::ticket::load_all_from_git(dir.path(), &config.tickets.dir).unwrap();
let terminal: std::collections::HashSet<&str> = config.workflow.states.iter()
.filter(|s| s.terminal)
.map(|s| s.id.as_str())
.collect();
let visible: Vec<_> = tickets.iter()
.filter(|t| !terminal.contains(t.frontmatter.state.as_str()))
.collect();
assert_eq!(visible.len(), 1, "only the open ticket should be visible");
assert_eq!(visible[0].frontmatter.title, "Open ticket");
let all: Vec<_> = tickets.iter().collect();
assert_eq!(all.len(), 2, "--all should include the closed ticket");
}
#[test]
fn build_epic_bundle_includes_title_siblings_and_guidance() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let epic_id = "ab12cd34";
let epic_branch = format!("epic/{epic_id}-test-epic");
let epic_md = "# Test Epic\n\n## Goal\nBuild something great.\n\n## Non-goals\nDo not gold-plate.\n";
apm_core::git::commit_to_branch(p, &epic_branch, "EPIC.md", epic_md, "epic: init").unwrap();
let sibling_active = concat!(
"+++\nid = \"aaaa1111\"\ntitle = \"Auth Tickets\"\n",
"state = \"specd\"\nepic = \"ab12cd34\"\n+++\n\n",
"## Spec\n\n### Problem\n\nHandle user authentication.\n\n",
"### Acceptance criteria\n\n- [ ] Criterion\n\n",
"### Out of scope\n\nPassword reset and OAuth are out.\n\n",
"## History\n\n| When | From | To | By |\n|------|------|----|----|",
);
apm_core::git::commit_to_branch(
p,
"ticket/aaaa1111-auth-tickets",
"tickets/aaaa1111-auth-tickets.md",
sibling_active,
"add active sibling",
)
.unwrap();
let sibling_closed = concat!(
"+++\nid = \"bbbb2222\"\ntitle = \"Old Feature\"\n",
"state = \"closed\"\nepic = \"ab12cd34\"\n+++\n\n",
"## Spec\n\n### Problem\n\nDeploy initial infra.\n",
);
apm_core::git::commit_to_branch(
p,
"ticket/bbbb2222-old-feature",
"tickets/bbbb2222-old-feature.md",
sibling_closed,
"add closed sibling",
)
.unwrap();
let sibling_closed2 = concat!(
"+++\nid = \"cccc3333\"\ntitle = \"Legacy Work\"\n",
"state = \"closed\"\nepic = \"ab12cd34\"\n+++\n\n",
"## Spec\n\n### Problem\n\nMigrate legacy data.\n",
);
apm_core::git::commit_to_branch(
p,
"ticket/cccc3333-legacy-work",
"tickets/cccc3333-legacy-work.md",
sibling_closed2,
"add second closed sibling",
)
.unwrap();
let current_id = "dddd4444";
let bundle = apm_core::context::build_epic_bundle(p, epic_id, current_id, &config);
assert!(bundle.contains("Test Epic"), "bundle must have epic title");
assert!(bundle.contains("Build something great"), "bundle must have epic goal");
assert!(bundle.contains("Do not gold-plate"), "bundle must have non-goals");
assert!(bundle.contains("Auth Tickets"), "bundle must have active sibling title");
assert!(bundle.contains("Handle user authentication"), "bundle must have problem one-liner");
assert!(bundle.contains("Password reset and OAuth are out"), "bundle must have Out of scope");
assert!(bundle.contains("Old Feature"), "bundle must have closed sibling");
assert!(bundle.contains("Legacy Work"), "bundle must have second closed sibling");
assert!(bundle.contains("do not duplicate or overreach"), "bundle must have scope guidance");
assert!(!bundle.contains("dddd4444"), "current ticket must not appear in bundle");
}
#[test]
fn build_epic_bundle_empty_when_no_epic() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let bundle = apm_core::context::build_epic_bundle(p, "deadbeef", "aaaa1111", &config);
assert!(bundle.contains("deadbeef"));
assert!(bundle.contains("Scope guidance"));
}
#[test]
fn build_epic_bundle_respects_sibling_cap() {
let dir = setup();
let p = dir.path();
let config_toml = concat!(
"[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n",
"[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n\n",
"[context]\nepic_sibling_cap = 1\nepic_byte_cap = 0\n",
);
std::fs::write(p.join("apm.toml"), config_toml).unwrap();
let config = apm_core::config::Config::load(p).unwrap();
let epic_id = "ff001122";
let epic_branch = format!("epic/{epic_id}-capped-epic");
apm_core::git::commit_to_branch(p, &epic_branch, "EPIC.md", "# Capped Epic\n", "init").unwrap();
for (id, slug) in [("aaaa0001", "sib-a"), ("bbbb0002", "sib-b")] {
let content = format!(
"+++\nid = \"{id}\"\ntitle = \"Sib {id}\"\nstate = \"closed\"\nepic = \"{epic_id}\"\n+++\n\n## Spec\n\n### Problem\n\nProblem for {id}.\n",
);
apm_core::git::commit_to_branch(
p,
&format!("ticket/{id}-{slug}"),
&format!("tickets/{id}-{slug}.md"),
&content,
"add",
)
.unwrap();
}
let bundle = apm_core::context::build_epic_bundle(p, epic_id, "zzzzzzzz", &config);
assert!(bundle.contains("older closed sibling"), "elided count should be mentioned");
}
#[test]
fn new_creates_ticket_file() {
let dir = setup();
apm::cmd::new::run(dir.path(), "My first ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "my-first-ticket");
let rel_path = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel_path);
assert!(!content.is_empty());
}
#[test]
fn new_ticket_has_correct_frontmatter() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Hello World".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "hello-world");
let rel_path = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel_path);
assert!(content.contains("title = \"Hello World\""));
assert!(content.contains("state = \"new\""));
assert!(content.contains(&format!("branch = \"{branch}\"")));
}
#[test]
fn new_uses_local_toml_username_as_author() {
let dir = setup();
let apm_dir = dir.path().join(".apm");
std::fs::create_dir_all(&apm_dir).unwrap();
std::fs::write(apm_dir.join("local.toml"), "username = \"carol\"\n").unwrap();
apm::cmd::new::run(dir.path(), "My Ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "my-ticket");
let rel_path = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel_path);
assert!(content.contains("author = \"carol\""), "author should come from local.toml: {content}");
}
#[test]
fn new_uses_apm_when_no_local_toml() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Unnamed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "unnamed");
let rel_path = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel_path);
assert!(content.contains("author = \"unassigned\""), "author should be unassigned without local.toml: {content}");
}
#[test]
fn new_ticket_does_not_write_agent_field() {
let dir = setup();
apm::cmd::new::run(dir.path(), "No Agent".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "no-agent");
let rel_path = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel_path);
assert!(!content.contains("agent ="), "agent field must not appear in new tickets: {content}");
}
#[test]
fn new_increments_ids() {
let dir = setup();
apm::cmd::new::run(dir.path(), "First".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
apm::cmd::new::run(dir.path(), "Second".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id1 = find_ticket_id(dir.path(), "first");
let id2 = find_ticket_id(dir.path(), "second");
assert_ne!(id1, id2, "ticket IDs must be unique");
assert_eq!(id1.len(), 8, "ID must be 8 hex chars");
assert_eq!(id2.len(), 8, "ID must be 8 hex chars");
assert!(id1.chars().all(|c| c.is_ascii_hexdigit()), "ID must be hex: {id1}");
assert!(id2.chars().all(|c| c.is_ascii_hexdigit()), "ID must be hex: {id2}");
}
#[test]
fn list_shows_all_tickets() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Alpha".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let b1 = find_ticket_branch(dir.path(), "alpha");
sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));
apm::cmd::new::run(dir.path(), "Beta".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let b2 = find_ticket_branch(dir.path(), "beta");
sync_from_branch(dir.path(), &b2, &ticket_rel_path(&b2));
apm::cmd::list::run(dir.path(), None, false, false, None, false, true, None, None).unwrap();
}
#[test]
fn list_state_filter() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Alpha".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let b1 = find_ticket_branch(dir.path(), "alpha");
let alpha_id = find_ticket_id(dir.path(), "alpha");
sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));
apm::cmd::new::run(dir.path(), "Beta".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let b2 = find_ticket_branch(dir.path(), "beta");
sync_from_branch(dir.path(), &b2, &ticket_rel_path(&b2));
write_valid_spec_to_branch(dir.path(), &b1, &ticket_rel_path(&b1));
apm::cmd::state::run(dir.path(), &alpha_id, "specd".into(), false, false).unwrap();
sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));
apm::cmd::list::run(dir.path(), Some("specd".into()), false, false, None, false, true, None, None).unwrap();
}
#[test]
fn list_mine_filter() {
let dir = setup();
let apm_dir = dir.path().join(".apm");
std::fs::create_dir_all(&apm_dir).unwrap();
std::fs::write(apm_dir.join("local.toml"), "username = \"testuser\"\n").unwrap();
apm::cmd::new::run(dir.path(), "Mine".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let b1 = find_ticket_branch(dir.path(), "mine");
sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));
std::fs::remove_file(apm_dir.join("local.toml")).unwrap();
apm::cmd::new::run(dir.path(), "Theirs".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let b2 = find_ticket_branch(dir.path(), "theirs");
sync_from_branch(dir.path(), &b2, &ticket_rel_path(&b2));
std::fs::write(apm_dir.join("local.toml"), "username = \"testuser\"\n").unwrap();
apm::cmd::list::run(dir.path(), None, false, false, None, true, true, None, None).unwrap();
}
#[test]
fn show_existing_ticket() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Show me".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(dir.path(), "show-me");
apm::cmd::show::run(dir.path(), &id, false, false).unwrap();
}
#[test]
fn show_missing_ticket_errors() {
let dir = setup();
assert!(apm::cmd::show::run(dir.path(), "99", false, false).is_err());
}
#[test]
fn show_displays_epic_target_branch_depends_on_when_set() {
let dir = setup();
let p = dir.path();
let ticket_content = "+++\nid = \"aabb1122\"\ntitle = \"Rich ticket\"\nstate = \"new\"\nbranch = \"ticket/aabb1122-rich-ticket\"\nepic = \"epic001\"\ntarget_branch = \"epic/epic001-user-auth\"\ndepends_on = [\"ccdd3344\", \"eeff5566\"]\n+++\n\n## Spec\n\n### Problem\n\nTest.\n\n### Acceptance criteria\n\n- [ ] One\n\n### Out of scope\n\nN/A.\n\n### Approach\n\nDirect.\n\n## History\n\n| When | From | To | By |\n|------|------|----|----| \n| 2026-01-01T00:00Z | — | new | test |\n";
let ticket_dir = p.join("tickets");
std::fs::create_dir_all(&ticket_dir).unwrap();
let ticket_path = ticket_dir.join("aabb1122-rich-ticket.md");
std::fs::write(&ticket_path, ticket_content).unwrap();
git(p, &["checkout", "-b", "ticket/aabb1122-rich-ticket"]);
git(p, &["-c", "commit.gpgsign=false", "add", "tickets/aabb1122-rich-ticket.md"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add rich ticket"]);
git(p, &["checkout", "-"]);
let _ = std::fs::remove_file(&ticket_path);
let bin = env!("CARGO_BIN_EXE_apm");
let out = std::process::Command::new(bin)
.args(["show", "aabb1122"])
.current_dir(p)
.output()
.unwrap();
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(out.status.success(), "apm show failed: {}", String::from_utf8_lossy(&out.stderr));
assert!(stdout.contains("epic:"), "expected epic: line in:\n{stdout}");
assert!(stdout.contains("epic001"), "expected epic id in:\n{stdout}");
assert!(stdout.contains("target_branch:"), "expected target_branch: line in:\n{stdout}");
assert!(stdout.contains("epic/epic001-user-auth"), "expected target_branch value in:\n{stdout}");
assert!(stdout.contains("depends_on:"), "expected depends_on: line in:\n{stdout}");
assert!(stdout.contains("ccdd3344"), "expected depends_on value in:\n{stdout}");
}
#[test]
fn show_omits_epic_target_branch_depends_on_when_absent() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Plain ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(dir.path(), "plain-ticket");
let bin = env!("CARGO_BIN_EXE_apm");
let out = std::process::Command::new(bin)
.args(["show", &id])
.current_dir(dir.path())
.output()
.unwrap();
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(out.status.success(), "apm show failed: {}", String::from_utf8_lossy(&out.stderr));
assert!(!stdout.contains("epic:"), "unexpected epic: line in:\n{stdout}");
assert!(!stdout.contains("target_branch:"), "unexpected target_branch: line in:\n{stdout}");
assert!(!stdout.contains("depends_on:"), "unexpected depends_on: line in:\n{stdout}");
}
#[test]
fn state_transition_updates_file() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Transition test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "transition-test");
let id = find_ticket_id(dir.path(), "transition-test");
let rel = ticket_rel_path(&branch);
write_valid_spec_to_branch(dir.path(), &branch, &rel);
apm::cmd::state::run(dir.path(), &id, "specd".into(), false, false).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("state = \"specd\""));
}
#[test]
fn state_transition_appends_history_row() {
let dir = setup();
apm::cmd::new::run(dir.path(), "History test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "history-test");
let id = find_ticket_id(dir.path(), "history-test");
let rel = ticket_rel_path(&branch);
write_valid_spec_to_branch(dir.path(), &branch, &rel);
apm::cmd::state::run(dir.path(), &id, "specd".into(), false, false).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("| new | specd |"));
}
#[test]
fn state_ammend_inserts_amendment_section() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Ammend test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "ammend-test");
let id = find_ticket_id(dir.path(), "ammend-test");
let rel = ticket_rel_path(&branch);
apm::cmd::state::run(dir.path(), &id, "ammend".into(), false, false).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("### Amendment requests"));
}
#[test]
fn set_priority_updates_frontmatter() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Set test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "set-test");
let id = find_ticket_id(dir.path(), "set-test");
let rel = ticket_rel_path(&branch);
apm::cmd::set::run(dir.path(), &id, "priority".into(), "7".into(), true).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("priority = 7"));
}
#[test]
fn set_depends_on_single_id() {
let dir = setup_merge();
apm::cmd::new::run(dir.path(), "Dep ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id = find_ticket_id(dir.path(), "dep-ticket");
apm::cmd::new::run(dir.path(), "Dep test single".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "dep-test-single");
let id = find_ticket_id(dir.path(), "dep-test-single");
let rel = ticket_rel_path(&branch);
apm::cmd::set::run(dir.path(), &id, "depends_on".into(), dep_id.clone(), true).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains(&dep_id), "dep id missing in content");
}
#[test]
fn set_depends_on_comma_separated() {
let dir = setup_merge();
apm::cmd::new::run(dir.path(), "Dep alpha".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id1 = find_ticket_id(dir.path(), "dep-alpha");
apm::cmd::new::run(dir.path(), "Dep beta".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id2 = find_ticket_id(dir.path(), "dep-beta");
apm::cmd::new::run(dir.path(), "Dep test multi".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "dep-test-multi");
let id = find_ticket_id(dir.path(), "dep-test-multi");
let rel = ticket_rel_path(&branch);
let val = format!("{dep_id1},{dep_id2}");
apm::cmd::set::run(dir.path(), &id, "depends_on".into(), val, true).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains(&dep_id1), "first dep id missing");
assert!(content.contains(&dep_id2), "second dep id missing");
}
#[test]
fn set_depends_on_clear() {
let dir = setup_merge();
apm::cmd::new::run(dir.path(), "Dep ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id = find_ticket_id(dir.path(), "dep-ticket");
apm::cmd::new::run(dir.path(), "Dep test clear".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "dep-test-clear");
let id = find_ticket_id(dir.path(), "dep-test-clear");
let rel = ticket_rel_path(&branch);
apm::cmd::set::run(dir.path(), &id, "depends_on".into(), dep_id, true).unwrap();
apm::cmd::set::run(dir.path(), &id, "depends_on".into(), "-".into(), true).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(!content.contains("depends_on"));
}
#[test]
fn set_depends_on_trims_whitespace() {
let dir = setup_merge();
apm::cmd::new::run(dir.path(), "Dep one".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id1 = find_ticket_id(dir.path(), "dep-one");
apm::cmd::new::run(dir.path(), "Dep two".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id2 = find_ticket_id(dir.path(), "dep-two");
apm::cmd::new::run(dir.path(), "Dep test trim".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "dep-test-trim");
let id = find_ticket_id(dir.path(), "dep-test-trim");
let rel = ticket_rel_path(&branch);
let val = format!(" {dep_id1} , {dep_id2} ");
apm::cmd::set::run(dir.path(), &id, "depends_on".into(), val, true).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains(&format!("\"{dep_id1}\"")), "first dep id missing");
assert!(content.contains(&format!("\"{dep_id2}\"")), "second dep id missing");
}
#[test]
fn next_returns_highest_priority() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Low priority".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let b1 = find_ticket_branch(dir.path(), "low-priority");
sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));
apm::cmd::new::run(dir.path(), "High priority".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let b2 = find_ticket_branch(dir.path(), "high-priority");
let high_id = find_ticket_id(dir.path(), "high-priority");
apm::cmd::set::run(dir.path(), &high_id, "priority".into(), "10".into(), true).unwrap();
sync_from_branch(dir.path(), &b2, &ticket_rel_path(&b2));
apm::cmd::next::run(dir.path(), false, true).unwrap();
}
#[test]
fn next_json_is_valid() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Json test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let b = find_ticket_branch(dir.path(), "json-test");
sync_from_branch(dir.path(), &b, &ticket_rel_path(&b));
apm::cmd::next::run(dir.path(), true, true).unwrap();
}
#[test]
fn next_null_when_no_actionable() {
let dir = setup();
apm::cmd::next::run(dir.path(), true, true).unwrap();
}
#[test]
fn new_ticket_creates_branch() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Branch test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "branch-test");
assert!(branch.starts_with("ticket/"), "expected ticket/ branch, got: {branch}");
assert!(branch.ends_with("-branch-test"), "expected slug in branch: {branch}");
}
#[test]
fn new_ticket_sets_branch_in_frontmatter() {
let dir = setup();
apm::cmd::new::run(dir.path(), "Frontmatter branch".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "frontmatter-branch");
let rel = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains(&format!("branch = \"{branch}\"")));
}
#[test]
fn init_config_has_default_branch_and_parses() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "trunk"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
apm_core::init::setup(p, None, None, None).unwrap();
let toml = std::fs::read_to_string(p.join(".apm/config.toml")).unwrap();
assert!(toml.contains("default_branch = \"trunk\""), "default_branch not written: {toml}");
let config = apm_core::config::Config::load(p).unwrap();
assert_eq!(config.project.default_branch, "trunk");
}
#[test]
fn config_default_branch_defaults_to_main_when_absent() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(p.join("apm.toml"), "[project]\nname = \"test\"\n").unwrap();
let config = apm_core::config::Config::load(p).unwrap();
assert_eq!(config.project.default_branch, "main");
}
fn setup_with_close_workflow() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(p.join("apm.toml"), r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states]]
id = "implemented"
label = "Implemented"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#).unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
fn write_ticket_to_branch(dir: &std::path::Path, branch: &str, filename: &str, state: &str, id: u32, title: &str) {
let path = format!("tickets/{filename}");
let content = format!(
"+++\nid = {id}\ntitle = \"{title}\"\nstate = \"{state}\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|",
);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(dir, &["checkout", "-b", branch]);
} else {
git(dir, &["checkout", branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("ticket: {title}")]);
git(dir, &["checkout", "main"]);
}
#[test]
fn sync_closes_implemented_ticket_on_merged_branch() {
let dir = setup_with_close_workflow();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-my-ticket", "0001-my-ticket.md", "implemented", 1, "my ticket");
git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", "ticket/0001-my-ticket", "--no-edit"]);
apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();
let content = branch_content(p, "main", "tickets/0001-my-ticket.md");
assert!(content.contains("state = \"closed\""), "ticket should be closed on main: {content}");
}
#[test]
fn sync_closes_implemented_ticket_with_no_branch() {
let dir = setup_with_close_workflow();
let p = dir.path();
let path = "tickets/0001-stale.md";
let content = "+++\nid = 1\ntitle = \"stale\"\nstate = \"implemented\"\nbranch = \"ticket/0001-stale\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
std::fs::write(p.join(path), content).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", path]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add stale ticket"]);
apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();
let updated = std::fs::read_to_string(p.join(path)).unwrap();
assert!(updated.contains("state = \"closed\""), "stale ticket should be closed: {updated}");
}
#[test]
fn sync_no_close_when_nothing_to_close() {
let dir = setup_with_close_workflow();
let p = dir.path();
let log_before = branch_content(p, "main", "apm.toml"); apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();
let head = std::process::Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(p)
.output()
.unwrap();
let head_msg = String::from_utf8(head.stdout).unwrap();
assert!(!head_msg.contains("apm sync: close"), "no close commit expected: {head_msg}");
drop(log_before);
}
#[test]
fn sync_closes_multiple_tickets_on_merged_branches() {
let dir = setup_with_close_workflow();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-alpha", "0001-alpha.md", "implemented", 1, "alpha");
write_ticket_to_branch(p, "ticket/0002-beta", "0002-beta.md", "implemented", 2, "beta");
git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", "ticket/0001-alpha", "--no-edit"]);
git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", "ticket/0002-beta", "--no-edit"]);
apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();
let alpha = branch_content(p, "main", "tickets/0001-alpha.md");
let beta = branch_content(p, "main", "tickets/0002-beta.md");
assert!(alpha.contains("state = \"closed\""), "alpha should be closed on main: {alpha}");
assert!(beta.contains("state = \"closed\""), "beta should be closed on main: {beta}");
}
#[test]
fn sync_handler_closes_merged_ticket() {
let dir = setup_with_close_workflow();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-server-sync", "0001-server-sync.md", "implemented", 1, "server sync");
git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", "ticket/0001-server-sync", "--no-edit"]);
let config = apm_core::config::Config::load(p).unwrap();
let candidates = apm_core::sync::detect(p, &config).unwrap();
assert_eq!(candidates.close.len(), 1, "should detect one close candidate");
let aggressive = config.sync.aggressive;
apm_core::sync::apply(p, &config, &candidates, "apm-ui", aggressive).unwrap();
let content = branch_content(p, "main", "tickets/0001-server-sync.md");
assert!(content.contains("state = \"closed\""), "ticket should be closed: {content}");
}
#[test]
fn sync_handler_no_close_returns_zero() {
let dir = setup_with_close_workflow();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let candidates = apm_core::sync::detect(p, &config).unwrap();
assert_eq!(candidates.close.len(), 0, "no candidates when no merged tickets");
}
#[allow(dead_code)]
fn write_ticket_with_agent(dir: &std::path::Path, branch: &str, filename: &str, state: &str, id: u32, title: &str, agent: &str) {
let path = format!("tickets/{filename}");
let content = format!(
"+++\nid = {id}\ntitle = \"{title}\"\nstate = \"{state}\"\nbranch = \"{branch}\"\nagent = \"{agent}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|",
);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(dir, &["checkout", "-b", branch]);
} else {
git(dir, &["checkout", branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("ticket: {title}")]);
git(dir, &["checkout", "main"]);
}
fn write_spec_ticket(dir: &std::path::Path, id: u32, problem: &str, approach: &str) {
let branch = format!("ticket/{id:04}-spec-test");
let filename = format!("{id:04}-spec-test.md");
let path = format!("tickets/{filename}");
let content = format!(
"+++\nid = {id}\ntitle = \"spec test\"\nstate = \"in_progress\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n### Problem\n\n{problem}\n\n### Acceptance criteria\n\n- [ ] criterion one\n\n### Out of scope\n\nnothing\n\n### Approach\n\n{approach}\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|",
);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", &branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(dir, &["checkout", "-b", &branch]);
} else {
git(dir, &["checkout", &branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: spec test"]);
git(dir, &["checkout", "main"]);
}
#[test]
fn spec_prints_all_sections() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
apm::cmd::spec::run(p, "1", None, None, None, false, None, None, None, None, true).unwrap();
}
#[test]
fn spec_prints_single_section() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "the problem text", "the approach");
apm::cmd::spec::run(p, "1", Some("Problem".into()), None, None, false, None, None, None, None, true).unwrap();
}
#[test]
fn spec_set_section_commits() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "old problem", "old approach");
apm::cmd::spec::run(p, "1", Some("Problem".into()), Some("new problem text".into()), None, false, None, None, None, None, true).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("new problem text"), "updated problem not found: {content}");
}
#[test]
fn spec_check_passes_full_ticket() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
apm::cmd::spec::run(p, "1", None, None, None, true, None, None, None, None, true).unwrap();
}
#[test]
fn spec_unknown_section_errors() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
let result = apm::cmd::spec::run(p, "1", Some("NonExistent".into()), None, None, false, None, None, None, None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("unknown section"));
}
#[test]
fn spec_nonexistent_ticket_errors() {
let dir = setup();
let p = dir.path();
let result = apm::cmd::spec::run(p, "999", None, None, None, false, None, None, None, None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("no ticket matches"));
}
#[test]
fn spec_set_without_section_errors() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
let result = apm::cmd::spec::run(p, "1", None, Some("some value".into()), None, false, None, None, None, None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("--set requires --section"));
}
#[test]
fn spec_set_hyphen_value() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "old problem", "old approach");
apm::cmd::spec::run(p, "1", Some("Problem".into()), Some("- [ ] Fix the thing".into()), None, false, None, None, None, None, true).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("- [ ] Fix the thing"), "hyphen value not stored: {content}");
}
#[test]
fn spec_set_file_reads_content() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "old problem", "old approach");
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "content from file").unwrap();
apm::cmd::spec::run(p, "1", Some("Problem".into()), None, Some(tmp.path().to_string_lossy().into_owned()), false, None, None, None, None, true).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("content from file"), "file content not stored: {content}");
}
#[test]
fn spec_set_file_nonexistent_errors() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
let result = apm::cmd::spec::run(p, "1", Some("Problem".into()), None, Some("/nonexistent/path/to/file.txt".into()), false, None, None, None, None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("--set-file"));
}
#[test]
fn spec_set_file_without_section_errors() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
let result = apm::cmd::spec::run(p, "1", None, None, Some("/some/file.txt".into()), false, None, None, None, None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("--set-file requires --section"));
}
fn write_ticket_with_amendment_requests(dir: &std::path::Path, id: u32) {
let branch = format!("ticket/{id:04}-spec-test");
let filename = format!("{id:04}-spec-test.md");
let path = format!("tickets/{filename}");
let content = format!(
"+++\nid = {id}\ntitle = \"spec test\"\nstate = \"ammend\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n### Problem\n\nTest.\n\n### Acceptance criteria\n\n- [ ] criterion one\n\n### Out of scope\n\nnothing\n\n### Approach\n\nDirect.\n\n### Amendment requests\n\n- [ ] Add error handling\n- [ ] Fix the bug\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|",
);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", &branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(dir, &["checkout", "-b", &branch]);
} else {
git(dir, &["checkout", &branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: spec test with amendments"]);
git(dir, &["checkout", "main"]);
}
#[test]
fn spec_mark_checks_off_item_in_amendment_requests() {
let dir = setup();
let p = dir.path();
write_ticket_with_amendment_requests(p, 1);
apm::cmd::spec::run(
p,
"1",
Some("Amendment requests".into()),
None,
None,
false,
Some("Add error handling".into()),
None,
None,
None,
true,
).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("- [x] Add error handling"), "item not checked: {content}");
assert!(content.contains("- [ ] Fix the bug"), "other item should remain unchecked: {content}");
}
#[test]
fn spec_mark_no_match_errors() {
let dir = setup();
let p = dir.path();
write_ticket_with_amendment_requests(p, 1);
let result = apm::cmd::spec::run(
p,
"1",
Some("Amendment requests".into()),
None,
None,
false,
Some("nonexistent item".into()),
None,
None,
None,
true,
);
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("no unchecked item"), "unexpected error: {msg}");
}
#[test]
fn spec_mark_ambiguous_errors() {
let dir = setup();
let p = dir.path();
let branch = "ticket/0001-spec-test";
let filename = "0001-spec-test.md";
let path = format!("tickets/{filename}");
let content = "+++\nid = 1\ntitle = \"spec test\"\nstate = \"ammend\"\nbranch = \"ticket/0001-spec-test\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n### Problem\n\nTest.\n\n### Acceptance criteria\n\n- [ ] one\n\n### Out of scope\n\nnothing\n\n### Approach\n\nDirect.\n\n### Amendment requests\n\n- [ ] Add error handling\n- [ ] Fix the error\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
git(p, &["checkout", "-b", branch]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
std::fs::write(p.join(&path), content).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &path]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: ambiguous"]);
git(p, &["checkout", "main"]);
let result = apm::cmd::spec::run(
p,
"1",
Some("Amendment requests".into()),
None,
None,
false,
Some("error".into()),
None,
None,
None,
true,
);
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("ambiguous"), "unexpected error: {msg}");
}
#[test]
fn spec_mark_without_section_errors() {
let dir = setup();
let p = dir.path();
write_ticket_with_amendment_requests(p, 1);
let result = apm::cmd::spec::run(p, "1", None, None, None, false, Some("Add error handling".into()), None, None, None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("--mark requires --section"));
}
#[test]
fn spec_mark_case_insensitive() {
let dir = setup();
let p = dir.path();
write_ticket_with_amendment_requests(p, 1);
apm::cmd::spec::run(
p,
"1",
Some("Amendment requests".into()),
None,
None,
false,
Some("ADD ERROR".into()),
None,
None,
None,
true,
).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("- [x] Add error handling"), "item not checked: {content}");
}
#[test]
fn spec_append_without_section_errors() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
let result = apm::cmd::spec::run(p, "1", None, None, None, false, None, Some("extra".into()), None, None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("--append requires --section"));
}
#[test]
fn spec_append_file_without_section_errors() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
let result = apm::cmd::spec::run(p, "1", None, None, None, false, None, None, Some("/some/file.txt".into()), None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("--append-file requires --section"));
}
#[test]
fn spec_add_task_without_section_errors() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
let result = apm::cmd::spec::run(p, "1", None, None, None, false, None, None, None, Some("new task".into()), true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("--add-task requires --section"));
}
#[test]
fn spec_append_adds_to_existing_section() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "original problem", "an approach");
apm::cmd::spec::run(
p, "1",
Some("Problem".into()),
None, None, false, None,
Some("extra line".into()),
None, None, true,
).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("original problem"), "original text missing: {content}");
assert!(content.contains("extra line"), "appended text missing: {content}");
}
#[test]
fn spec_append_creates_absent_section() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
apm::cmd::spec::run(
p, "1",
Some("Open questions".into()),
None, None, false, None,
Some("Why does this happen?".into()),
None, None, true,
).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("Why does this happen?"), "new section content missing: {content}");
}
#[test]
fn spec_append_file_reads_and_appends() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "original", "an approach");
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "appended from file").unwrap();
apm::cmd::spec::run(
p, "1",
Some("Problem".into()),
None, None, false, None, None,
Some(tmp.path().to_string_lossy().into_owned()),
None, true,
).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("original"), "original text missing: {content}");
assert!(content.contains("appended from file"), "file content missing: {content}");
}
#[test]
fn spec_add_task_appends_checkbox() {
let dir = setup();
let p = dir.path();
write_ticket_with_amendment_requests(p, 1);
apm::cmd::spec::run(
p, "1",
Some("Amendment requests".into()),
None, None, false, None, None, None,
Some("Handle edge case".into()),
true,
).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("- [ ] Handle edge case"), "new task missing: {content}");
assert!(content.contains("- [ ] Add error handling"), "existing task missing: {content}");
}
#[test]
fn spec_add_task_creates_absent_section() {
let dir = setup();
let p = dir.path();
write_spec_ticket(p, 1, "a problem", "an approach");
apm::cmd::spec::run(
p, "1",
Some("Amendment requests".into()),
None, None, false, None, None, None,
Some("What is the scope?".into()),
true,
).unwrap();
let content = branch_content(p, "ticket/0001-spec-test", "tickets/0001-spec-test.md");
assert!(content.contains("- [ ] What is the scope?"), "new task missing: {content}");
}
#[test]
fn close_transitions_from_any_state() {
let dir = setup();
let p = dir.path();
apm::cmd::new::run(p, "Close me".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "close-me");
let id = find_ticket_id(p, "close-me");
let rel = ticket_rel_path(&branch);
apm::cmd::close::run(p, &id, None, true).unwrap();
let content = branch_content(p, &branch, &rel);
assert!(content.contains("state = \"closed\""), "state not updated: {content}");
assert!(content.contains("| new | closed |"), "history row missing: {content}");
}
#[test]
fn close_with_reason_appends_to_history() {
let dir = setup();
let p = dir.path();
apm::cmd::new::run(p, "Close reason".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "close-reason");
let id = find_ticket_id(p, "close-reason");
let rel = ticket_rel_path(&branch);
apm::cmd::close::run(p, &id, Some("superseded by #42".into()), true).unwrap();
let content = branch_content(p, &branch, &rel);
assert!(content.contains("state = \"closed\""), "state not updated: {content}");
assert!(content.contains("superseded by #42"), "reason missing: {content}");
}
#[test]
fn close_already_closed_is_error() {
let dir = setup();
let p = dir.path();
apm::cmd::new::run(p, "Already closed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "already-closed");
apm::cmd::close::run(p, &id, None, true).unwrap();
let result = apm::cmd::close::run(p, &id, None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("already closed"));
}
#[test]
fn close_nonexistent_ticket_is_error() {
let dir = setup();
let p = dir.path();
let result = apm::cmd::close::run(p, "999", None, true);
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("no ticket matches"));
}
#[test]
fn validate_does_not_flag_closed_state() {
let dir = setup();
let p = dir.path();
apm::cmd::new::run(p, "Validate closed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "validate-closed");
apm::cmd::close::run(p, &id, None, true).unwrap();
let result = apm::cmd::validate::run(p, false, false, false, true);
if let Err(e) = &result {
let msg = e.to_string();
assert!(
msg.contains("0 ticket errors"),
"validate flagged a ticket-level error (possibly unknown state): {msg}"
);
}
}
#[test]
fn state_to_closed_bypasses_transition_rules() {
let dir = setup();
let p = dir.path();
apm::cmd::new::run(p, "State closed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "state-closed");
let id = find_ticket_id(p, "state-closed");
let rel = ticket_rel_path(&branch);
apm::cmd::state::run(p, &id, "closed".into(), false, false).unwrap();
let content = branch_content(p, &branch, &rel);
assert!(content.contains("state = \"closed\""), "state not updated: {content}");
}
fn setup_aggressive() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 3
[sync]
aggressive = true
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[workflow.states]]
id = "specd"
label = "Specd"
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
#[test]
fn aggressive_no_remote_does_not_abort_next() {
let dir = setup_aggressive();
let p = dir.path();
apm::cmd::new::run(p, "Aggressive next".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
apm::cmd::next::run(p, false, false).unwrap();
}
#[test]
fn aggressive_no_remote_does_not_abort_list() {
let dir = setup_aggressive();
let p = dir.path();
apm::cmd::new::run(p, "Aggressive list".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
apm::cmd::list::run(p, None, false, false, None, false, false, None, None).unwrap();
}
#[test]
fn aggressive_no_remote_does_not_abort_close() {
let dir = setup_aggressive();
let p = dir.path();
apm::cmd::new::run(p, "Aggressive close".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "aggressive-close");
apm::cmd::close::run(p, &id, None, false).unwrap();
let branch = find_ticket_branch(p, "aggressive-close");
let rel = ticket_rel_path(&branch);
let content = branch_content(p, &branch, &rel);
assert!(content.contains("state = \"closed\""), "ticket not closed: {content}");
}
#[test]
fn no_aggressive_flag_suppresses_fetch_on_next() {
let dir = setup_aggressive();
let p = dir.path();
apm::cmd::new::run(p, "No agg next".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
apm::cmd::next::run(p, false, true).unwrap();
}
#[test]
fn no_aggressive_flag_suppresses_fetch_on_spec() {
let dir = setup_aggressive();
let p = dir.path();
apm::cmd::new::run(p, "No agg spec".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "no-agg-spec");
apm::cmd::spec::run(
p,
&id,
Some("Problem".into()),
Some("test content".into()),
None,
false,
None,
None,
None,
None,
true,
).unwrap();
}
#[test]
fn no_aggressive_flag_suppresses_fetch_on_set() {
let dir = setup_aggressive();
let p = dir.path();
apm::cmd::new::run(p, "No agg set".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "no-agg-set");
apm::cmd::set::run(p, &id, "priority".into(), "5".into(), true).unwrap();
let branch = find_ticket_branch(p, "no-agg-set");
let rel = ticket_rel_path(&branch);
let content = branch_content(p, &branch, &rel);
assert!(content.contains("priority = 5"), "priority not set: {content}");
}
fn setup_with_local_worktrees() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
let mock_worker = make_mock_worker(p);
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
format!(
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[worktrees]
dir = "worktrees"
[workers]
command = "{}"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states.transitions]]
to = "in_progress"
trigger = "command:start"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
mock_worker.display()
),
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
#[test]
fn start_next_no_tickets_prints_message() {
let dir = setup_with_local_worktrees();
let p = dir.path();
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run_next(p, true, false, false).unwrap();
}
#[test]
fn start_next_claims_highest_priority_ticket() {
let dir = setup_with_local_worktrees();
let p = dir.path();
std::fs::create_dir_all(p.join(".apm")).unwrap();
std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();
write_ticket_with_owner(p, "ticket/0001-alpha", "0001-alpha.md", "ready", 1, "alpha", "test-agent");
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run_next(p, true, false, false).unwrap();
let content = branch_content(p, "ticket/0001-alpha", "tickets/0001-alpha.md");
assert!(content.contains("state = \"in_progress\""), "ticket should be in_progress: {content}");
assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}
#[test]
fn start_next_with_instructions_includes_text_in_output() {
let dir = setup_with_local_worktrees();
let p = dir.path();
std::fs::write(p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[worktrees]
dir = "worktrees"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
instructions = "worker-instructions.txt"
[[workflow.states.transitions]]
to = "in_progress"
trigger = "command:start"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#).unwrap();
std::fs::write(p.join("worker-instructions.txt"), "WORKER INSTRUCTIONS CONTENT").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "apm.toml", "worker-instructions.txt"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add instructions"]);
write_ticket_to_branch(p, "ticket/0001-work", "0001-work.md", "ready", 1, "work");
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run_next(p, true, false, false).unwrap();
}
#[test]
fn start_next_clears_focus_section_from_ticket() {
let dir = setup_with_local_worktrees();
let p = dir.path();
std::fs::create_dir_all(p.join(".apm")).unwrap();
std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();
let branch = "ticket/0001-focused";
let filename = "0001-focused.md";
let path = format!("tickets/{filename}");
let content = "+++\nid = 1\ntitle = \"focused\"\nstate = \"ready\"\nbranch = \"ticket/0001-focused\"\nowner = \"test-agent\"\nfocus_section = \"Approach\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(p)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(p, &["checkout", "-b", branch]);
}
std::fs::create_dir_all(p.join("tickets")).unwrap();
std::fs::write(p.join(&path), content).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &path]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: focused"]);
git(p, &["checkout", "main"]);
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run_next(p, true, false, false).unwrap();
let after = branch_content(p, branch, &path);
assert!(!after.contains("focus_section"), "focus_section should be cleared: {after}");
}
#[test]
fn start_next_claims_new_ticket_when_no_ready_tickets() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[worktrees]
dir = "worktrees"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[workflow.states.transitions]]
to = "in_design"
trigger = "command:start"
[[workflow.states]]
id = "in_design"
label = "In Design"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
std::fs::create_dir_all(p.join(".apm")).unwrap();
std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();
write_ticket_with_owner(p, "ticket/0001-spec-me", "0001-spec-me.md", "new", 1, "spec me", "test-agent");
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run_next(p, true, false, false).unwrap();
let content = branch_content(p, "ticket/0001-spec-me", "tickets/0001-spec-me.md");
assert!(content.contains("state = \"in_design\""), "ticket should be in_design: {content}");
assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}
#[test]
fn start_spawn_sets_agent_to_worker_pid() {
let dir = setup_with_local_worktrees();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-alpha", "0001-alpha.md", "ready", 1, "alpha");
std::env::set_var("APM_AGENT_NAME", "delegator-agent");
apm::cmd::start::run(p, "1", true, true, false, "delegator-agent").unwrap();
let content = branch_content(p, "ticket/0001-alpha", "tickets/0001-alpha.md");
assert!(content.contains("state = \"in_progress\""), "ticket should be in_progress after spawn: {content}");
assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}
#[test]
fn start_non_spawn_keeps_agent_name() {
let dir = setup_with_local_worktrees();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-alpha", "0001-alpha.md", "ready", 1, "alpha");
std::env::set_var("APM_AGENT_NAME", "delegator-agent");
apm::cmd::start::run(p, "1", true, false, false, "delegator-agent").unwrap();
let content = branch_content(p, "ticket/0001-alpha", "tickets/0001-alpha.md");
assert!(content.contains("state = \"in_progress\""), "ticket should be in_progress: {content}");
assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}
#[test]
fn start_next_spawn_sets_agent_to_worker_pid() {
let dir = setup_with_local_worktrees();
let p = dir.path();
std::fs::create_dir_all(p.join(".apm")).unwrap();
std::fs::write(p.join(".apm/local.toml"), "username = \"delegator-agent\"\n").unwrap();
write_ticket_with_owner(p, "ticket/0001-alpha", "0001-alpha.md", "ready", 1, "alpha", "delegator-agent");
std::env::set_var("APM_AGENT_NAME", "delegator-agent");
apm::cmd::start::run_next(p, true, true, false).unwrap();
let content = branch_content(p, "ticket/0001-alpha", "tickets/0001-alpha.md");
assert!(content.contains("state = \"in_progress\""), "ticket should be in_progress after spawn: {content}");
assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}
fn write_ticket_with_owner(dir: &std::path::Path, branch: &str, filename: &str, state: &str, id: u32, title: &str, owner: &str) {
let path = format!("tickets/{filename}");
let content = format!(
"+++\nid = {id}\ntitle = \"{title}\"\nstate = \"{state}\"\nbranch = \"{branch}\"\nowner = \"{owner}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|",
);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(dir, &["checkout", "-b", branch]);
} else {
git(dir, &["checkout", branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("ticket: {title}")]);
git(dir, &["checkout", "main"]);
}
#[test]
fn start_does_not_set_owner_when_unowned() {
let dir = setup_with_local_worktrees();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-alpha", "0001-alpha.md", "ready", 1, "alpha");
std::env::set_var("APM_AGENT_NAME", "alice");
apm::cmd::start::run(p, "1", true, false, false, "alice").unwrap();
let content = branch_content(p, "ticket/0001-alpha", "tickets/0001-alpha.md");
assert!(!content.contains("owner ="), "owner should not be set by apm start when unowned: {content}");
}
#[test]
fn start_does_not_modify_owner_when_already_owned() {
let dir = setup_with_local_worktrees();
let p = dir.path();
write_ticket_with_owner(p, "ticket/0001-alpha", "0001-alpha.md", "ready", 1, "alpha", "alice");
std::env::set_var("APM_AGENT_NAME", "bob");
apm::cmd::start::run(p, "1", true, false, false, "bob").unwrap();
let content = branch_content(p, "ticket/0001-alpha", "tickets/0001-alpha.md");
assert!(content.contains("owner = \"alice\""), "owner should stay alice, not be overwritten by bob: {content}");
assert!(!content.contains("owner = \"bob\""), "bob must not become owner: {content}");
}
#[test]
fn in_design_does_not_set_owner_when_unowned() {
let dir = setup_for_prompt_dispatch();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-spec-me", "0001-spec-me.md", "new", 1, "spec me");
std::env::set_var("APM_AGENT_NAME", "alice");
apm::cmd::state::run(p, "1", "in_design".into(), true, false).unwrap();
let content = branch_content(p, "ticket/0001-spec-me", "tickets/0001-spec-me.md");
assert!(!content.contains("owner ="), "owner should not be set when transitioning to in_design unowned: {content}");
}
#[test]
fn in_design_does_not_overwrite_different_owner() {
let dir = setup_for_prompt_dispatch();
let p = dir.path();
write_ticket_with_owner(p, "ticket/0001-spec-me", "0001-spec-me.md", "new", 1, "spec me", "alice");
std::env::set_var("APM_AGENT_NAME", "bob");
apm::cmd::state::run(p, "1", "in_design".into(), true, false).unwrap();
let content = branch_content(p, "ticket/0001-spec-me", "tickets/0001-spec-me.md");
assert!(content.contains("owner = \"alice\""), "owner should stay alice when bob transitions to in_design: {content}");
assert!(!content.contains("owner = \"bob\""), "bob must not become owner: {content}");
}
fn setup_for_prompt_dispatch() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
let mock_worker = make_mock_worker(p);
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
format!(
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[worktrees]
dir = "worktrees"
[workers]
command = "{}"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[workflow.states.transitions]]
to = "in_design"
trigger = "command:start"
[[workflow.states]]
id = "in_design"
label = "In Design"
[[workflow.states]]
id = "ammend"
label = "Ammend"
actionable = ["agent"]
[[workflow.states.transitions]]
to = "in_design"
trigger = "command:start"
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states.transitions]]
to = "in_progress"
trigger = "command:start"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
mock_worker.display()
),
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
std::fs::create_dir_all(p.join(".apm")).unwrap();
dir
}
#[test]
fn spawn_new_ticket_transitions_to_in_design() {
let dir = setup_for_prompt_dispatch();
let p = dir.path();
std::fs::write(p.join(".apm/apm.spec-writer.md"), "SPEC WRITER PROMPT").unwrap();
write_ticket_to_branch(p, "ticket/0001-spec-me", "0001-spec-me.md", "new", 1, "spec me");
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run(p, "1", true, true, false, "test-agent").unwrap();
let content = branch_content(p, "ticket/0001-spec-me", "tickets/0001-spec-me.md");
assert!(content.contains("state = \"in_design\""), "new ticket should transition to in_design: {content}");
}
#[test]
fn spawn_ammend_ticket_transitions_to_in_design() {
let dir = setup_for_prompt_dispatch();
let p = dir.path();
std::fs::write(p.join(".apm/apm.spec-writer.md"), "SPEC WRITER PROMPT").unwrap();
write_ticket_to_branch(p, "ticket/0001-fix-spec", "0001-fix-spec.md", "ammend", 1, "fix spec");
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run(p, "1", true, true, false, "test-agent").unwrap();
let content = branch_content(p, "ticket/0001-fix-spec", "tickets/0001-fix-spec.md");
assert!(content.contains("state = \"in_design\""), "ammend ticket should transition to in_design: {content}");
}
#[test]
fn spawn_ready_ticket_transitions_to_in_progress() {
let dir = setup_for_prompt_dispatch();
let p = dir.path();
std::fs::write(p.join(".apm/apm.worker.md"), "WORKER PROMPT").unwrap();
write_ticket_to_branch(p, "ticket/0001-implement-me", "0001-implement-me.md", "ready", 1, "implement me");
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run(p, "1", true, true, false, "test-agent").unwrap();
let content = branch_content(p, "ticket/0001-implement-me", "tickets/0001-implement-me.md");
assert!(content.contains("state = \"in_progress\""), "ready ticket should transition to in_progress: {content}");
}
#[test]
fn start_next_spawn_new_ticket_transitions_correctly() {
let dir = setup_for_prompt_dispatch();
let p = dir.path();
std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();
std::fs::write(p.join(".apm/apm.spec-writer.md"), "SPEC WRITER PROMPT").unwrap();
write_ticket_with_owner(p, "ticket/0001-spec-me", "0001-spec-me.md", "new", 1, "spec me", "test-agent");
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run_next(p, true, true, false).unwrap();
let content = branch_content(p, "ticket/0001-spec-me", "tickets/0001-spec-me.md");
assert!(content.contains("state = \"in_design\""), "run_next on new ticket should go to in_design: {content}");
}
#[test]
fn start_next_spawn_ready_ticket_transitions_correctly() {
let dir = setup_for_prompt_dispatch();
let p = dir.path();
std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();
std::fs::write(p.join(".apm/apm.worker.md"), "WORKER PROMPT").unwrap();
write_ticket_with_owner(p, "ticket/0001-implement-me", "0001-implement-me.md", "ready", 1, "implement me", "test-agent");
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::start::run_next(p, true, true, false).unwrap();
let content = branch_content(p, "ticket/0001-implement-me", "tickets/0001-implement-me.md");
assert!(content.contains("state = \"in_progress\""), "run_next on ready ticket should go to in_progress: {content}");
}
#[test]
fn work_dry_run_lists_actionable_tickets() {
let dir = setup_with_local_worktrees();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-alpha", "0001-alpha.md", "ready", 1, "alpha");
write_ticket_to_branch(p, "ticket/0002-beta", "0002-beta.md", "ready", 2, "beta");
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::work::run(p, false, true, false, 30, None).unwrap();
}
#[test]
fn work_dry_run_no_tickets() {
let dir = setup_with_local_worktrees();
let p = dir.path();
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::work::run(p, false, true, false, 30, None).unwrap();
}
#[test]
fn sync_closes_implemented_ticket_with_merged_branch_in_one_run() {
let dir = setup_with_close_workflow();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-impl", "0001-impl.md", "implemented", 1, "impl ticket");
git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", "ticket/0001-impl", "--no-edit"]);
apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();
let content = branch_content(p, "ticket/0001-impl", "tickets/0001-impl.md");
assert!(content.contains("state = \"closed\""), "ticket should be closed in one sync run: {content}");
}
#[test]
fn context_section_approach_places_text_under_approach() {
let dir = setup();
apm::cmd::new::run(
dir.path(),
"Section test".into(),
true,
false,
Some("my approach text".into()),
Some("Approach".into()),
true,
vec![],
vec![],
None,
vec![],
).unwrap();
let branch = find_ticket_branch(dir.path(), "section-test");
let rel = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("### Approach\n\nmy approach text\n\n"), "expected context under ### Approach");
let after_problem = content.split("### Problem\n\n").nth(1).expect("Problem section not found in content");
let problem_body = after_problem.split("\n### ").next().unwrap_or("");
assert!(problem_body.trim().is_empty(), "Problem should be empty, got: {problem_body:?}");
}
#[test]
fn context_section_defaults_to_problem_without_config() {
let dir = setup();
apm::cmd::new::run(
dir.path(),
"Default section test".into(),
true,
false,
Some("default context".into()),
None,
true,
vec![],
vec![],
None,
vec![],
).unwrap();
let branch = find_ticket_branch(dir.path(), "default-section-test");
let rel = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("### Problem\n\ndefault context\n\n"), "expected context under ### Problem");
}
#[test]
fn context_section_without_context_is_error() {
let dir = setup();
let result = apm::cmd::new::run(
dir.path(),
"Error test".into(),
true,
false,
None,
Some("Approach".into()),
true,
vec![],
vec![],
None,
vec![],
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("--context-section requires --context"));
}
#[test]
fn context_section_unknown_section_is_error() {
let dir = setup();
let result = apm::cmd::new::run(
dir.path(),
"Bad section test".into(),
true,
false,
Some("some text".into()),
Some("Nonexistent".into()),
true,
vec![],
vec![],
None,
vec![],
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found in ticket body template"));
}
#[test]
fn context_section_from_transition_config() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(p.join("apm.toml"), r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[workflow.states.transitions]]
to = "in_design"
context_section = "Approach"
"#).unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
apm::cmd::new::run(
p,
"Transition context test".into(),
true,
false,
Some("transition driven context".into()),
None,
true,
vec![],
vec![],
None,
vec![],
).unwrap();
let branch = find_ticket_branch(p, "transition-context-test");
let rel = ticket_rel_path(&branch);
let content = branch_content(p, &branch, &rel);
assert!(content.contains("### Approach\n\ntransition driven context\n\n"), "expected context under ### Approach from transition config");
}
#[test]
fn new_section_set_prepopulates_multiple_sections() {
let dir = setup();
apm::cmd::new::run(
dir.path(),
"Pre-populated ticket".into(),
true,
false,
None,
None,
true,
vec!["Problem".into(), "Approach".into()],
vec!["Something is broken".into(), "Fix it with a hammer".into()],
None,
vec![],
).unwrap();
let branch = find_ticket_branch(dir.path(), "pre-populated-ticket");
let rel = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("### Problem\n\nSomething is broken\n"), "Problem section should be pre-populated");
assert!(content.contains("### Approach\n\nFix it with a hammer\n"), "Approach section should be pre-populated");
let log = std::process::Command::new("git")
.args(["log", "--oneline", &branch, "^main"])
.current_dir(dir.path())
.output()
.unwrap();
let log_str = String::from_utf8(log.stdout).unwrap();
assert_eq!(log_str.lines().count(), 1, "ticket branch should have exactly one commit above main");
}
#[test]
fn new_section_set_mismatched_counts_is_error() {
let dir = setup();
let result = apm::cmd::new::run(
dir.path(),
"Mismatch test".into(),
true,
false,
None,
None,
true,
vec!["Problem".into(), "Approach".into()],
vec!["Only one set".into()],
None,
vec![],
);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("--section") && msg.contains("--set"), "error should mention both flags");
}
#[test]
fn new_set_without_section_is_error() {
let dir = setup();
let result = apm::cmd::new::run(
dir.path(),
"Set only test".into(),
true,
false,
None,
None,
true,
vec![],
vec!["Orphaned value".into()],
None,
vec![],
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("--set requires --section"));
}
#[test]
fn new_section_unknown_name_is_error() {
let dir = setup();
let result = apm::cmd::new::run(
dir.path(),
"Unknown section test".into(),
true,
false,
None,
None,
true,
vec!["Nonexistent".into()],
vec!["some text".into()],
None,
vec![],
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unknown section"));
}
fn setup_with_epic() -> (tempfile::TempDir, String) {
let dir = setup();
let p = dir.path();
let epic_id = "ab12cd34";
let epic_branch = format!("epic/{epic_id}-my-epic");
git(p, &["-c", "commit.gpgsign=false", "checkout", "-b", &epic_branch]);
std::fs::write(p.join("epic.txt"), "epic content").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "epic.txt"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "epic commit"]);
git(p, &["checkout", "main"]);
(dir, epic_id.to_string())
}
#[test]
fn new_epic_sets_frontmatter_fields() {
let (dir, epic_id) = setup_with_epic();
apm::cmd::new::run(
dir.path(),
"Epic child ticket".into(),
true,
false,
None,
None,
true,
vec![],
vec![],
Some(epic_id.clone()),
vec![],
).unwrap();
let branch = find_ticket_branch(dir.path(), "epic-child-ticket");
let rel = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains(&format!("epic = \"{epic_id}\"")), "epic field missing in frontmatter");
let expected_target = format!("epic/{epic_id}-my-epic");
assert!(content.contains(&format!("target_branch = \"{expected_target}\"")), "target_branch missing");
}
#[test]
fn new_epic_branch_created_from_epic_tip() {
let (dir, epic_id) = setup_with_epic();
let epic_branch = format!("epic/{epic_id}-my-epic");
let epic_tip = std::process::Command::new("git")
.args(["rev-parse", &epic_branch])
.current_dir(dir.path())
.output()
.unwrap();
let epic_tip_sha = String::from_utf8(epic_tip.stdout).unwrap().trim().to_string();
apm::cmd::new::run(
dir.path(),
"Branched ticket".into(),
true,
false,
None,
None,
true,
vec![],
vec![],
Some(epic_id.clone()),
vec![],
).unwrap();
let ticket_branch = find_ticket_branch(dir.path(), "branched-ticket");
let parent = std::process::Command::new("git")
.args(["rev-parse", &format!("{ticket_branch}^1")])
.current_dir(dir.path())
.output()
.unwrap();
let parent_sha = String::from_utf8(parent.stdout).unwrap().trim().to_string();
assert_eq!(parent_sha, epic_tip_sha, "ticket branch should be created from the epic branch tip");
}
#[test]
fn new_epic_bad_id_is_error() {
let dir = setup();
let result = apm::cmd::new::run(
dir.path(),
"Orphan ticket".into(),
true,
false,
None,
None,
true,
vec![],
vec![],
Some("deadbeef".into()),
vec![],
);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("No epic branch found for id 'deadbeef'"),
"error message should mention the bad id"
);
}
#[test]
fn new_depends_on_sets_frontmatter() {
let dir = setup_merge();
apm::cmd::new::run(dir.path(), "Dep one".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id1 = find_ticket_id(dir.path(), "dep-one");
apm::cmd::new::run(dir.path(), "Dep two".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id2 = find_ticket_id(dir.path(), "dep-two");
apm::cmd::new::run(
dir.path(),
"Dependent ticket".into(),
true,
false,
None,
None,
true,
vec![],
vec![],
None,
vec![dep_id1.clone(), dep_id2.clone()],
).unwrap();
let branch = find_ticket_branch(dir.path(), "dependent-ticket");
let rel = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("depends_on"), "depends_on field missing");
assert!(content.contains(&dep_id1), "first dep missing");
assert!(content.contains(&dep_id2), "second dep missing");
assert!(!content.contains("epic ="), "epic should be absent");
assert!(!content.contains("target_branch ="), "target_branch should be absent");
}
#[test]
fn new_depends_on_comma_separated() {
let dir = setup_merge();
apm::cmd::new::run(dir.path(), "Dep one".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id1 = find_ticket_id(dir.path(), "dep-one");
apm::cmd::new::run(dir.path(), "Dep two".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let dep_id2 = find_ticket_id(dir.path(), "dep-two");
let combined = format!("{dep_id1},{dep_id2}");
apm::cmd::new::run(
dir.path(),
"Comma deps ticket".into(),
true,
false,
None,
None,
true,
vec![],
vec![],
None,
vec![combined],
).unwrap();
let branch = find_ticket_branch(dir.path(), "comma-deps-ticket");
let rel = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains(&dep_id1), "first dep missing");
assert!(content.contains(&dep_id2), "second dep missing");
}
#[test]
fn new_without_epic_flags_is_unchanged() {
let dir = setup();
apm::cmd::new::run(
dir.path(),
"Plain ticket".into(),
true,
false,
None,
None,
true,
vec![],
vec![],
None,
vec![],
).unwrap();
let branch = find_ticket_branch(dir.path(), "plain-ticket");
let rel = ticket_rel_path(&branch);
let content = branch_content(dir.path(), &branch, &rel);
assert!(!content.contains("epic ="), "epic should not be present");
assert!(!content.contains("target_branch ="), "target_branch should not be present");
assert!(!content.contains("depends_on"), "depends_on should not be present");
let main_tip = std::process::Command::new("git")
.args(["rev-parse", "main"])
.current_dir(dir.path())
.output()
.unwrap();
let main_sha = String::from_utf8(main_tip.stdout).unwrap().trim().to_string();
let parent = std::process::Command::new("git")
.args(["rev-parse", &format!("{branch}^1")])
.current_dir(dir.path())
.output()
.unwrap();
let parent_sha = String::from_utf8(parent.stdout).unwrap().trim().to_string();
assert_eq!(parent_sha, main_sha, "ticket branch should be rooted from main");
}
#[test]
fn new_body_scaffold_from_ticket_sections_config() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(p.join("apm.toml"), r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[ticket.sections]]
name = "Summary"
type = "free"
placeholder = "What does this do?"
[[ticket.sections]]
name = "Tasks"
type = "tasks"
required = true
[[ticket.sections]]
name = "Notes"
type = "free"
"#).unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
apm::cmd::new::run(p, "Scaffold test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let scaffold_branch = find_ticket_branch(p, "scaffold-test");
let scaffold_rel = ticket_rel_path(&scaffold_branch);
let content = branch_content(p, &scaffold_branch, &scaffold_rel);
assert!(content.contains("### Summary\n\nWhat does this do?\n\n"), "placeholder should appear");
assert!(content.contains("### Tasks\n\n\n\n"), "empty section should appear");
assert!(content.contains("### Notes\n\n\n\n"), "Notes section should appear");
assert!(!content.contains("### Problem\n"), "hardcoded Problem should not appear");
assert!(!content.contains("### Acceptance criteria\n"), "hardcoded AC should not appear");
}
#[test]
fn validate_config_missing_instructions_and_bad_context_section() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[[ticket.sections]]
name = "Problem"
type = "free"
[[workflow.states]]
id = "new"
label = "New"
instructions = "missing-file.md"
[[workflow.states.transitions]]
to = "closed"
context_section = "NonExistentSection"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
)
.unwrap();
let config = apm_core::config::Config::load(p).unwrap();
let errors = apm::cmd::validate::validate_config(&config, p);
assert_eq!(errors.len(), 2, "expected exactly 2 errors, got: {errors:?}");
let has_missing_file = errors.iter().any(|e| {
e.contains("state.new.instructions") && e.contains("file not found")
});
assert!(has_missing_file, "expected missing instructions error in {errors:?}");
let has_bad_section = errors.iter().any(|e| {
e.contains("context_section") && e.contains("NonExistentSection")
});
assert!(has_bad_section, "expected context_section mismatch error in {errors:?}");
}
fn setup_on_failure_fix_project(
on_failure: Option<&str>,
declare_merge_failed: bool,
) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::create_dir_all(p.join(".apm")).unwrap();
std::fs::create_dir_all(p.join("tickets")).unwrap();
std::fs::write(
p.join(".apm").join("config.toml"),
"[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n",
)
.unwrap();
let on_failure_line = on_failure
.map(|v| format!("on_failure = \"{v}\"\n"))
.unwrap_or_default();
let merge_failed_block = if declare_merge_failed {
"\n[[workflow.states]]\nid = \"merge_failed\"\nlabel = \"Merge failed\"\nactionable = [\"supervisor\"]\n\n [[workflow.states.transitions]]\n to = \"implemented\"\n trigger = \"manual\"\n\n [[workflow.states.transitions]]\n to = \"in_progress\"\n trigger = \"manual\"\n"
} else {
""
};
let workflow_toml = format!(
"[workflow]\n\n[[workflow.states]]\nid = \"in_progress\"\nlabel = \"In Progress\"\n\n [[workflow.states.transitions]]\n to = \"implemented\"\n completion = \"pr_or_epic_merge\"\n {on_failure_line}\n[[workflow.states]]\nid = \"implemented\"\nlabel = \"Implemented\"\nterminal = true\n{merge_failed_block}"
);
std::fs::write(p.join(".apm").join("workflow.toml"), &workflow_toml).unwrap();
git(p, &["add", ".apm/config.toml", ".apm/workflow.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
dir
}
#[test]
fn test_fix_adds_field_only() {
let dir = setup_on_failure_fix_project(None, true);
let p = dir.path();
assert!(
apm::cmd::validate::run(p, false, false, true, true).is_err(),
"expected validate to fail before fix"
);
let _ = apm::cmd::validate::run(p, true, false, true, true);
let wf_content = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();
assert!(
wf_content.contains("on_failure = \"merge_failed\""),
"expected on_failure field added to workflow.toml:\n{wf_content}"
);
apm::cmd::validate::run(p, false, false, true, true).unwrap();
}
#[test]
fn test_fix_adds_state_only() {
let dir = setup_on_failure_fix_project(Some("merge_failed"), false);
let p = dir.path();
assert!(
apm::cmd::validate::run(p, false, false, true, true).is_err(),
"expected validate to fail before fix"
);
let _ = apm::cmd::validate::run(p, true, false, true, true);
let wf_content = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();
assert!(
wf_content.contains("merge_failed"),
"expected merge_failed state appended to workflow.toml:\n{wf_content}"
);
assert!(
wf_content.contains("on_failure = \"merge_failed\""),
"on_failure field should still be present:\n{wf_content}"
);
apm::cmd::validate::run(p, false, false, true, true).unwrap();
}
#[test]
fn test_fix_adds_both_atomically() {
let dir = setup_on_failure_fix_project(None, false);
let p = dir.path();
assert!(
apm::cmd::validate::run(p, false, false, true, true).is_err(),
"expected validate to fail before fix"
);
let _ = apm::cmd::validate::run(p, true, false, true, true);
let wf_content = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();
assert!(
wf_content.contains("on_failure = \"merge_failed\""),
"expected on_failure field added:\n{wf_content}"
);
assert!(
wf_content.contains("merge_failed"),
"expected merge_failed state appended:\n{wf_content}"
);
apm::cmd::validate::run(p, false, false, true, true).unwrap();
}
#[test]
fn test_fix_is_idempotent() {
let dir = setup_on_failure_fix_project(None, false);
let p = dir.path();
let _ = apm::cmd::validate::run(p, true, false, true, true);
let after_first = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();
apm::cmd::validate::run(p, true, false, true, true).unwrap();
let after_second = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();
assert_eq!(
after_first, after_second,
"workflow.toml should be identical after two --fix runs"
);
}
#[test]
fn review_ammend_normalises_plain_bullets_to_checkboxes() {
let dir = setup();
let p = dir.path();
apm::cmd::new::run(p, "Review checkbox test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "review-checkbox-test");
let ticket_path = ticket_rel_path(&branch);
let id = find_ticket_id(p, "review-checkbox-test");
let existing = branch_content(p, &branch, &ticket_path);
let fm_end = existing.find("\n+++\n").expect("frontmatter close not found") + 5;
let frontmatter = &existing[..fm_end];
let body = "\n## Spec\n\n### Problem\n\nTest.\n\n### Acceptance criteria\n\n- [ ] AC one\n\n### Out of scope\n\nNothing.\n\n### Approach\n\nDirect.\n\n### Amendment requests\n\n- plain item\n- [ ] already a checkbox\n- [x] already checked\n\n## History\n\n| When | From | To | By |\n|------|------|----|-----|\n| 2026-01-01T00:00Z | — | new | test-agent |\n";
let content = format!("{frontmatter}{body}");
git(p, &["checkout", &branch]);
std::fs::write(p.join(&ticket_path), &content).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &ticket_path]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add amendment requests"]);
git(p, &["checkout", "-"]);
std::env::set_var("EDITOR", "true");
apm::cmd::review::run(p, &id, Some("ammend".to_string()), true).unwrap();
std::env::remove_var("EDITOR");
let committed = branch_content(p, &branch, &ticket_path);
assert!(committed.contains("- [ ] plain item"), "plain bullet should be converted to checkbox");
assert!(!committed.contains("\n- plain item\n"), "plain bullet should no longer appear as-is");
assert!(committed.contains("- [ ] already a checkbox"), "existing checkbox should be unchanged");
assert!(committed.contains("- [x] already checked"), "checked item should be unchanged");
}
fn write_closed_ticket(dir: &std::path::Path, id: u32, slug: &str) -> (String, String) {
let branch = format!("ticket/{id:04}-{slug}");
let filename = format!("{id:04}-{slug}.md");
let rel_path = format!("tickets/{filename}");
let content = format!(
"+++\nid = {id}\ntitle = \"{slug}\"\nstate = \"closed\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", &branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(dir, &["checkout", "-b", &branch]);
} else {
git(dir, &["checkout", &branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&rel_path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &rel_path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("ticket({id}): close")]);
git(dir, &["checkout", "main"]);
(branch, rel_path)
}
fn merge_into_main(dir: &std::path::Path, branch: &str) {
git(dir, &["-c", "commit.gpgsign=false", "merge", "--no-ff", branch, "-m", &format!("Merge {branch}")]);
}
fn branch_exists(dir: &std::path::Path, branch: &str) -> bool {
std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
fn clean_default_does_not_remove_local_branch() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "done");
merge_into_main(p, &branch);
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "branch should NOT have been removed without --branches");
}
#[test]
fn clean_branches_flag_removes_local_branch() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "branches-flag");
merge_into_main(p, &branch);
apm::cmd::clean::run(p, false, false, false, true, None, false, false).unwrap();
assert!(!branch_exists(p, &branch), "branch should have been removed with --branches");
}
#[test]
fn clean_dry_run_includes_state_in_output() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "dry");
merge_into_main(p, &branch);
apm::cmd::clean::run(p, true, false, false, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "branch should NOT have been removed in dry-run");
}
#[test]
fn clean_skips_ticket_not_on_main() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "ghost");
git(p, &["-c", "commit.gpgsign=false", "merge", "-s", "ours", &branch, "-m", "ours merge"]);
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "branch should NOT have been removed — ticket not on main");
}
#[test]
fn clean_proceeds_despite_state_mismatch_between_branch_and_main() {
let dir = setup();
let p = dir.path();
let (branch, rel_path) = write_closed_ticket(p, 1, "mismatch");
merge_into_main(p, &branch);
let main_content = "+++\nid = 1\ntitle = \"mismatch\"\nstate = \"new\"\nbranch = \"ticket/0001-mismatch\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
std::fs::write(p.join(&rel_path), main_content).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &rel_path]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "update ticket state on main"]);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["clean", "--branches"])
.current_dir(p)
.output()
.unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(!branch_exists(p, &branch), "branch should have been removed — branch state is authoritative");
}
#[test]
fn clean_treats_closed_as_terminal_without_config_entry() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 1
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
"#,
).unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
let (branch, _) = write_closed_ticket(p, 1, "no-terminal-config");
merge_into_main(p, &branch);
apm::cmd::clean::run(p, false, false, false, true, None, false, false).unwrap();
assert!(!branch_exists(p, &branch), "closed should be treated as terminal even without config entry");
}
#[test]
fn clean_skips_local_tip_ahead_of_remote() {
let bare = tempfile::tempdir().unwrap();
let bp = bare.path();
git(bp, &["init", "--bare", "-q"]);
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["clone", &bp.to_string_lossy(), "."]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 1
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
).unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
git(p, &["push", "origin", "main"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
let (branch, _) = write_closed_ticket(p, 1, "diverged");
git(p, &["push", "origin", &branch]);
merge_into_main(p, &branch);
git(p, &["push", "origin", "main"]);
git(p, &["checkout", &branch]);
std::fs::write(p.join("tickets/0001-diverged.md"), "extra change").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "tickets/0001-diverged.md"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "extra local commit"]);
git(p, &["checkout", "main"]);
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "branch should NOT have been removed — local tip ahead of remote");
}
#[test]
fn clean_auto_removes_known_temp_files() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "tempfiles");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-tempfiles");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
std::fs::write(wt_path.join("pr-body.md"), "pr body content").unwrap();
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "branch should NOT have been removed without --branches");
assert!(!wt_path.exists(), "worktree should have been removed");
}
#[test]
fn clean_skips_modified_tracked_files() {
let dir = setup();
let p = dir.path();
let (branch, rel_path) = write_closed_ticket(p, 1, "modtracked");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-modtracked");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
std::fs::write(wt_path.join(&rel_path), "modified content").unwrap();
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "branch should NOT have been removed — modified tracked file");
assert!(wt_path.exists(), "worktree should NOT have been removed");
}
#[test]
fn clean_dry_run_diagnoses_dirty_worktree() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "drydiagnose");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-drydiagnose");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
let temp_file = wt_path.join("pr-body.md");
std::fs::write(&temp_file, "pr body").unwrap();
apm::cmd::clean::run(p, true, false, false, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "branch should NOT have been removed in dry-run");
assert!(wt_path.exists(), "worktree should NOT have been removed in dry-run");
assert!(temp_file.exists(), "temp file should NOT have been removed in dry-run");
}
#[test]
fn clean_untracked_flag_removes_other_untracked_files() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "otheruntracked");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-otheruntracked");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
std::fs::write(wt_path.join("notes.txt"), "my notes").unwrap();
apm::cmd::clean::run(p, false, false, false, false, None, true, false).unwrap();
assert!(branch_exists(p, &branch), "branch should NOT have been removed without --branches");
assert!(!wt_path.exists(), "worktree should have been removed with --untracked");
}
#[test]
fn clean_warns_about_untracked_without_flag() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "warn-untracked");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-warn-untracked");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
std::fs::write(wt_path.join("notes.txt"), "my notes").unwrap();
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
assert!(wt_path.exists(), "worktree should NOT be removed without --untracked");
assert!(branch_exists(p, &branch), "branch should NOT be removed without --branches");
}
#[test]
fn clean_force_removes_unmerged_branch() {
let dir = setup();
let p = dir.path();
let (branch, rel_path) = write_closed_ticket(p, 1, "force-unmerged");
let closed_content = format!(
"+++\nid = 1\ntitle = \"force-unmerged\"\nstate = \"closed\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
);
std::fs::create_dir_all(p.join("tickets")).unwrap();
std::fs::write(p.join(&rel_path), &closed_content).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &rel_path]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add closed ticket to main"]);
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "normal clean should skip unmerged branch");
use std::io::Write as _;
let mut input = tempfile::NamedTempFile::new().unwrap();
writeln!(input, "y").unwrap();
input.flush().unwrap();
let input_file = std::fs::File::open(input.path()).unwrap();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["clean", "--force", "--branches"])
.current_dir(p)
.stdin(std::process::Stdio::from(input_file))
.output()
.unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(!branch_exists(p, &branch), "branch should have been removed by --force --branches clean");
}
#[test]
fn clean_force_removes_diverged_worktree() {
let bare = tempfile::tempdir().unwrap();
let bp = bare.path();
git(bp, &["init", "--bare", "-q"]);
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["clone", &bp.to_string_lossy(), "."]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 1
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
).unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
git(p, &["push", "origin", "main"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
let (branch, _) = write_closed_ticket(p, 1, "force-diverged");
git(p, &["push", "origin", &branch]);
merge_into_main(p, &branch);
git(p, &["push", "origin", "main"]);
git(p, &["checkout", &branch]);
std::fs::write(p.join("scratch.txt"), "extra change").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "scratch.txt"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "extra local commit"]);
git(p, &["checkout", "main"]);
let wt_path = p.join("worktrees").join("ticket-0001-force-diverged");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
std::fs::write(wt_path.join("notes.txt"), "scratch notes").unwrap();
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "normal clean should skip diverged+dirty ticket");
assert!(wt_path.exists(), "worktree should NOT be removed by normal clean");
use std::io::Write as _;
let mut input = tempfile::NamedTempFile::new().unwrap();
writeln!(input, "y").unwrap();
input.flush().unwrap();
let input_file = std::fs::File::open(input.path()).unwrap();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["clean", "--force", "--branches"])
.current_dir(p)
.stdin(std::process::Stdio::from(input_file))
.output()
.unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(!branch_exists(p, &branch), "branch should have been removed by --force --branches clean");
assert!(!wt_path.exists(), "worktree should have been removed by --force clean");
}
#[test]
fn clean_force_still_skips_non_terminal() {
let dir = setup();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-in-prog", "0001-in-prog.md", "in_progress", 1, "in progress");
apm::cmd::clean::run(p, false, false, true, false, None, false, false).unwrap();
assert!(
branch_exists(p, "ticket/0001-in-prog"),
"non-terminal ticket should NOT be removed by --force clean"
);
}
#[test]
fn clean_force_dry_run_shows_unmerged() {
let dir = setup();
let p = dir.path();
let (branch, rel_path) = write_closed_ticket(p, 1, "force-dryrun");
let closed_content = format!(
"+++\nid = 1\ntitle = \"force-dryrun\"\nstate = \"closed\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
);
std::fs::create_dir_all(p.join("tickets")).unwrap();
std::fs::write(p.join(&rel_path), &closed_content).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &rel_path]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add closed ticket to main"]);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["clean", "--force", "--branches", "--dry-run"])
.current_dir(p)
.output()
.unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("would remove"),
"expected 'would remove' in output: {stdout}"
);
assert!(branch_exists(p, &branch), "dry-run should not remove the branch");
}
#[test]
fn clean_force_skips_modified_tracked() {
let dir = setup();
let p = dir.path();
let (branch, rel_path) = write_closed_ticket(p, 1, "force-modtracked");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-force-modtracked");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
std::fs::write(wt_path.join(&rel_path), "modified content").unwrap();
apm::cmd::clean::run(p, false, false, true, false, None, false, false).unwrap();
assert!(branch_exists(p, &branch), "branch should NOT be removed — modified tracked file");
assert!(wt_path.exists(), "worktree should NOT be removed — modified tracked file");
}
#[test]
fn start_without_apm_agent_name_uses_fallback() {
let dir = setup_with_local_worktrees();
let p = dir.path();
write_ticket_to_branch(p, "ticket/0001-fallback", "0001-fallback.md", "ready", 1, "fallback");
apm::cmd::start::run(p, "0001", true, false, false, "ci-agent").unwrap();
let content = branch_content(p, "ticket/0001-fallback", "tickets/0001-fallback.md");
assert!(content.contains("state = \"in_progress\""), "ticket should be in_progress: {content}");
assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}
fn setup_with_worktrees() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[worktrees]
dir = "worktrees"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[workflow.states]]
id = "specd"
label = "Specd"
[[workflow.states]]
id = "ammend"
label = "Ammend"
actionable = ["agent"]
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &[
"-c", "commit.gpgsign=false",
"commit", "-m", "init", "--allow-empty",
]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
#[test]
fn workers_no_worktrees_returns_ok() {
let dir = setup();
let p = dir.path();
apm::cmd::workers::run(p, None, None).unwrap();
}
#[test]
fn workers_kill_no_pid_file_errors() {
let dir = setup_with_worktrees();
let p = dir.path();
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::new::run(p, "kill test ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "kill-test-ticket");
apm::cmd::state::run(p, &id, "ready".into(), true, false).unwrap();
apm_core::worktree::ensure_worktree(p, &p.join("worktrees"), &find_ticket_branch(p, "kill-test-ticket")).unwrap();
let result = apm::cmd::workers::run(p, None, Some(&id));
assert!(result.is_err(), "expected error when no pid file present");
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("not running") || msg.contains(".apm-worker.pid"),
"unexpected error message: {msg}"
);
}
#[test]
fn workers_stale_pid_file_detected() {
let dir = setup_with_worktrees();
let p = dir.path();
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::new::run(p, "stale pid ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "stale-pid-ticket");
apm::cmd::state::run(p, &id, "ready".into(), true, false).unwrap();
let branch = find_ticket_branch(p, "stale-pid-ticket");
apm_core::worktree::ensure_worktree(p, &p.join("worktrees"), &branch).unwrap();
let wt_name = branch.replace('/', "-");
let wt_path = p.join("worktrees").join(&wt_name);
std::fs::create_dir_all(&wt_path).unwrap();
let pid_file = wt_path.join(".apm-worker.pid");
std::fs::write(
&pid_file,
r#"{"pid":99999999,"ticket_id":"XXXX","started_at":"2026-01-01T00:00:00+00:00"}"#,
)
.unwrap();
apm::cmd::workers::run(p, None, None).unwrap();
}
#[test]
fn workers_kill_stale_pid_errors() {
let dir = setup_with_worktrees();
let p = dir.path();
std::env::set_var("APM_AGENT_NAME", "test-agent");
apm::cmd::new::run(p, "kill stale ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "kill-stale-ticket");
apm::cmd::state::run(p, &id, "ready".into(), true, false).unwrap();
let branch = find_ticket_branch(p, "kill-stale-ticket");
apm_core::worktree::ensure_worktree(p, &p.join("worktrees"), &branch).unwrap();
let wt_name = branch.replace('/', "-");
let wt_dir = p.join("worktrees").join(&wt_name);
std::fs::create_dir_all(&wt_dir).unwrap();
let pid_file = wt_dir.join(".apm-worker.pid");
std::fs::write(
&pid_file,
r#"{"pid":99999999,"ticket_id":"XXXX","started_at":"2026-01-01T00:00:00+00:00"}"#,
)
.unwrap();
let result = apm::cmd::workers::run(p, None, Some(&id));
let err_msg = match &result {
Err(e) => format!("{e:#}"),
Ok(_) => String::new(),
};
assert!(result.is_err(), "expected error when pid is stale");
assert!(
err_msg.contains("not running"),
"unexpected error (expected 'not running'): {err_msg}"
);
let real_wt = apm_core::worktree::find_worktree_for_branch(p, &branch)
.expect("worktree must still be registered");
assert!(
!real_wt.join(".apm-worker.pid").exists(),
"stale pid file should be removed on failed kill"
);
}
fn setup_with_strict_transitions() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(p.join("apm.toml"), r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 3
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
[[workflow.states.transitions]]
to = "in_progress"
trigger = "manual"
[[workflow.states]]
id = "specd"
label = "Specd"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states.transitions]]
to = "implemented"
trigger = "manual"
[[workflow.states]]
id = "implemented"
label = "Implemented"
[[workflow.states.transitions]]
to = "closed"
trigger = "manual"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#).unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
#[test]
fn state_force_bypasses_transition_rules() {
let dir = setup_with_strict_transitions();
let p = dir.path();
apm::cmd::new::run(p, "force test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "force-test");
apm::cmd::state::run(p, &id, "in_progress".into(), true, false).unwrap();
let result = apm::cmd::state::run(p, &id, "new".into(), true, false);
assert!(result.is_err(), "expected transition rejection without --force");
apm::cmd::state::run(p, &id, "new".into(), true, true).unwrap();
let tickets = apm_core::ticket::load_all_from_git(p, std::path::Path::new("tickets")).unwrap();
let t = tickets.iter().find(|t| t.frontmatter.id == id).unwrap();
assert_eq!(t.frontmatter.state, "new");
}
#[test]
fn state_force_implemented_from_in_progress() {
let dir = setup_with_strict_transitions();
let p = dir.path();
apm::cmd::new::run(p, "force progress".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "force-progress");
apm::cmd::state::run(p, &id, "in_progress".into(), true, false).unwrap();
let result = apm::cmd::state::run(p, &id, "new".into(), true, false);
assert!(result.is_err(), "expected transition rejection without --force");
apm::cmd::state::run(p, &id, "new".into(), true, true).unwrap();
let tickets = apm_core::ticket::load_all_from_git(p, std::path::Path::new("tickets")).unwrap();
let t = tickets.iter().find(|t| t.frontmatter.id == id).unwrap();
assert_eq!(t.frontmatter.state, "new");
}
#[test]
fn state_force_still_rejects_unknown_state() {
let dir = setup();
let p = dir.path();
apm::cmd::new::run(p, "force unknown".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "force-unknown");
let result = apm::cmd::state::run(p, &id, "nonexistent_state".into(), true, true);
assert!(result.is_err(), "expected error for unknown state even with --force");
}
#[test]
fn state_force_does_not_skip_doc_validation() {
let dir = setup();
let p = dir.path();
apm::cmd::new::run(p, "force doc valid".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let id = find_ticket_id(p, "force-doc-valid");
let result = apm::cmd::state::run(p, &id, "specd".into(), true, true);
assert!(result.is_err(), "expected spec validation to still fail with --force");
}
fn squash_merge_config() -> &'static str {
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 1
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
[[workflow.states]]
id = "implemented"
label = "Implemented"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#
}
fn setup_squash_remote() -> (TempDir, TempDir) {
let bare = tempfile::tempdir().unwrap();
let bp = bare.path();
git(bp, &["init", "--bare", "-q"]);
let local = tempfile::tempdir().unwrap();
let p = local.path();
git(p, &["clone", &bp.to_string_lossy(), "."]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(p.join("apm.toml"), squash_merge_config()).unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
git(p, &["push", "origin", "main"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
(bare, local)
}
fn write_implemented_ticket(dir: &std::path::Path, branch: &str, filename: &str) {
let path = format!("tickets/{filename}");
let content = format!(
"+++\nid = 1\ntitle = \"Squash test\"\nstate = \"implemented\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|",
);
let branch_exists_locally = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists_locally {
git(dir, &["checkout", "-b", branch]);
} else {
git(dir, &["checkout", branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", "implement ticket"]);
git(dir, &["checkout", "main"]);
}
fn squash_merge_into_main(dir: &std::path::Path, branch: &str) {
git(dir, &["-c", "commit.gpgsign=false", "merge", "--squash", branch]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("squash merge {branch}")]);
}
#[test]
fn sync_detect_squash_merged_branch_remote_ref_present() {
let (_bare, local) = setup_squash_remote();
let p = local.path();
let branch = "ticket/0001-squash-test";
write_implemented_ticket(p, branch, "0001-squash-test.md");
git(p, &["push", "origin", branch]);
squash_merge_into_main(p, branch);
git(p, &["push", "origin", "main"]);
git(p, &["fetch", "--all", "--quiet"]);
let config = apm_core::config::Config::load(p).unwrap();
let candidates = apm_core::sync::detect(p, &config).unwrap();
let close_branches: Vec<&str> = candidates.close.iter()
.map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
.collect();
assert!(
close_branches.contains(&branch),
"squash-merged ticket should appear in close candidates; got: {close_branches:?}"
);
}
#[test]
fn sync_detect_squash_merged_branch_remote_ref_deleted() {
let (_bare, local) = setup_squash_remote();
let p = local.path();
let branch = "ticket/0001-squash-gone";
write_implemented_ticket(p, branch, "0001-squash-gone.md");
git(p, &["push", "origin", branch]);
squash_merge_into_main(p, branch);
git(p, &["push", "origin", "main"]);
git(p, &["push", "origin", "--delete", branch]);
git(p, &["fetch", "--all", "--prune", "--quiet"]);
let config = apm_core::config::Config::load(p).unwrap();
let candidates = apm_core::sync::detect(p, &config).unwrap();
let close_branches: Vec<&str> = candidates.close.iter()
.map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
.collect();
assert!(
close_branches.contains(&branch),
"squash-merged ticket with deleted remote ref should appear in close candidates; got: {close_branches:?}"
);
}
#[test]
fn sync_detect_does_not_falsely_detect_unmerged_branch() {
let (_bare, local) = setup_squash_remote();
let p = local.path();
let branch = "ticket/0001-not-merged";
write_implemented_ticket(p, branch, "0001-not-merged.md");
git(p, &["push", "origin", branch]);
git(p, &["fetch", "--all", "--quiet"]);
let config = apm_core::config::Config::load(p).unwrap();
let candidates = apm_core::sync::detect(p, &config).unwrap();
let close_branches: Vec<&str> = candidates.close.iter()
.map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
.collect();
assert!(
!close_branches.contains(&branch),
"unmerged ticket should NOT appear in close candidates; got: {close_branches:?}"
);
}
#[test]
fn sync_detect_regular_merge_still_detected() {
let (_bare, local) = setup_squash_remote();
let p = local.path();
let branch = "ticket/0001-regular-merge";
write_implemented_ticket(p, branch, "0001-regular-merge.md");
git(p, &["push", "origin", branch]);
git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", branch, "--no-edit"]);
git(p, &["push", "origin", "main"]);
git(p, &["fetch", "--all", "--quiet"]);
let config = apm_core::config::Config::load(p).unwrap();
let candidates = apm_core::sync::detect(p, &config).unwrap();
let close_branches: Vec<&str> = candidates.close.iter()
.map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
.collect();
assert!(
close_branches.contains(&branch),
"regular-merged ticket should appear in close candidates; got: {close_branches:?}"
);
}
#[test]
fn start_uses_target_branch_as_merge_source() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[worktrees]
dir = "worktrees"
[sync]
aggressive = false
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states]]
id = "in_progress"
label = "In Progress"
"#,
)
.unwrap();
std::fs::create_dir_all(p.join("tickets")).unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
git(p, &["checkout", "-b", "epic/e1-foo"]);
std::fs::write(p.join("epic-marker.txt"), "epic content").unwrap();
git(p, &["add", "epic-marker.txt"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "epic unique commit"]);
git(p, &["checkout", "main"]);
let ticket_branch = "ticket/abc1-epic-task";
git(p, &["checkout", "-b", ticket_branch]);
let ticket_content = concat!(
"+++\n",
"id = \"abc1\"\n",
"title = \"Epic task\"\n",
"state = \"ready\"\n",
"branch = \"ticket/abc1-epic-task\"\n",
"target_branch = \"epic/e1-foo\"\n",
"+++\n\n",
);
std::fs::write(p.join("tickets/abc1-epic-task.md"), ticket_content).unwrap();
git(p, &["add", "tickets/abc1-epic-task.md"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add ticket"]);
git(p, &["checkout", "main"]);
apm::cmd::start::run(p, "abc1", true, false, false, "test-agent").unwrap();
let wt_path = p.join("worktrees").join("ticket-abc1-epic-task");
assert!(wt_path.exists(), "worktree should be created at {}", wt_path.display());
let log = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(&wt_path)
.output()
.unwrap();
let log_str = String::from_utf8(log.stdout).unwrap();
assert!(
log_str.contains("epic unique commit"),
"epic branch commit should be in worktree history; got:\n{log_str}"
);
}
fn setup_with_satisfies_deps() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states]]
id = "implemented"
label = "Implemented"
satisfies_deps = true
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
fn commit_ticket_to_branch(dir: &std::path::Path, branch: &str, path: &str, content: &str) {
let main_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", "main"])
.current_dir(dir)
.status()
.map(|s| s.success())
.unwrap_or(false);
let base = if main_exists { "main" } else { "HEAD" };
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.status()
.map(|s| s.success())
.unwrap_or(false);
if branch_exists {
git(dir, &["checkout", branch]);
} else {
git(dir, &["checkout", "-b", branch, base]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(path), content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", "add ticket"]);
git(dir, &["checkout", "-"]);
}
#[test]
fn next_skips_dep_blocked_returns_unblocked() {
use apm_core::{config::Config, ticket};
let dir = setup_with_satisfies_deps();
let p = dir.path();
let content_a = "+++\nid = \"aaaa0001\"\ntitle = \"Ticket A\"\nstate = \"ready\"\nbranch = \"ticket/aaaa0001-ticket-a\"\n+++\n\nbody\n";
commit_ticket_to_branch(p, "ticket/aaaa0001-ticket-a", "tickets/aaaa0001-ticket-a.md", content_a);
let content_b = "+++\nid = \"bbbb0001\"\ntitle = \"Ticket B\"\nstate = \"ready\"\nbranch = \"ticket/bbbb0001-ticket-b\"\ndepends_on = [\"aaaa0001\"]\n+++\n\nbody\n";
commit_ticket_to_branch(p, "ticket/bbbb0001-ticket-b", "tickets/bbbb0001-ticket-b.md", content_b);
let config = Config::load(p).unwrap();
let tickets = ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
let actionable_owned = config.actionable_states_for("agent");
let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
let p_cfg = &config.workflow.prioritization;
let next = ticket::pick_next(&tickets, &actionable, &[], p_cfg.priority_weight, p_cfg.effort_weight, p_cfg.risk_weight, &config, None, None);
assert!(next.is_some(), "should find an actionable ticket");
assert_eq!(next.unwrap().frontmatter.id, "aaaa0001", "dep-blocked ticket B should be skipped, A returned");
}
#[test]
fn next_returns_dep_blocked_after_dep_satisfies() {
use apm_core::{config::Config, ticket};
let dir = setup_with_satisfies_deps();
let p = dir.path();
let content_a = "+++\nid = \"aaaa0002\"\ntitle = \"Ticket A\"\nstate = \"implemented\"\nbranch = \"ticket/aaaa0002-ticket-a\"\n+++\n\nbody\n";
commit_ticket_to_branch(p, "ticket/aaaa0002-ticket-a", "tickets/aaaa0002-ticket-a.md", content_a);
let content_b = "+++\nid = \"bbbb0002\"\ntitle = \"Ticket B\"\nstate = \"ready\"\nbranch = \"ticket/bbbb0002-ticket-b\"\ndepends_on = [\"aaaa0002\"]\n+++\n\nbody\n";
commit_ticket_to_branch(p, "ticket/bbbb0002-ticket-b", "tickets/bbbb0002-ticket-b.md", content_b);
let config = Config::load(p).unwrap();
let tickets = ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
let actionable_owned = config.actionable_states_for("agent");
let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
let p_cfg = &config.workflow.prioritization;
let next = ticket::pick_next(&tickets, &actionable, &[], p_cfg.priority_weight, p_cfg.effort_weight, p_cfg.risk_weight, &config, None, None);
assert!(next.is_some(), "should find an actionable ticket");
assert_eq!(next.unwrap().frontmatter.id, "bbbb0002", "ticket B should be returned once dep A satisfies_deps");
}
#[test]
fn next_picks_low_priority_blocker_before_higher_raw_independent() {
use apm_core::{config::Config, ticket};
let dir = setup_with_satisfies_deps();
let p = dir.path();
let content_a = "+++\nid = \"aaaa0003\"\ntitle = \"Ticket A\"\nstate = \"ready\"\npriority = 2\nbranch = \"ticket/aaaa0003-ticket-a\"\n+++\n\nbody\n";
commit_ticket_to_branch(p, "ticket/aaaa0003-ticket-a", "tickets/aaaa0003-ticket-a.md", content_a);
let content_b = "+++\nid = \"bbbb0003\"\ntitle = \"Ticket B\"\nstate = \"ready\"\npriority = 7\nbranch = \"ticket/bbbb0003-ticket-b\"\n+++\n\nbody\n";
commit_ticket_to_branch(p, "ticket/bbbb0003-ticket-b", "tickets/bbbb0003-ticket-b.md", content_b);
let content_c = "+++\nid = \"cccc0003\"\ntitle = \"Ticket C\"\nstate = \"ready\"\npriority = 9\nbranch = \"ticket/cccc0003-ticket-c\"\ndepends_on = [\"aaaa0003\"]\n+++\n\nbody\n";
commit_ticket_to_branch(p, "ticket/cccc0003-ticket-c", "tickets/cccc0003-ticket-c.md", content_c);
let config = Config::load(p).unwrap();
let tickets = ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
let actionable_owned = config.actionable_states_for("agent");
let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
let p_cfg = &config.workflow.prioritization;
let next = ticket::pick_next(&tickets, &actionable, &[], p_cfg.priority_weight, p_cfg.effort_weight, p_cfg.risk_weight, &config, None, None);
assert!(next.is_some(), "should find an actionable ticket");
assert_eq!(next.unwrap().frontmatter.id, "aaaa0003", "A (ep=9) should beat B (ep=7)");
}
fn setup_epic_list() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[sync]
aggressive = false
[tickets]
dir = "tickets"
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states]]
id = "implemented"
label = "Implemented"
satisfies_deps = true
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
fn create_epic_branch(dir: &std::path::Path, branch: &str) {
git(dir, &["checkout", "-b", branch]);
std::fs::write(dir.join("EPIC.md"), format!("# {branch}\n")).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", "EPIC.md"]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", "create epic"]);
git(dir, &["checkout", "-"]);
let _ = std::fs::remove_file(dir.join("EPIC.md"));
}
#[test]
fn epic_list_no_epics_exits_zero_no_output() {
let dir = setup_epic_list();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "list"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(out.status.success(), "exit status: {}", out.status);
assert!(out.stdout.is_empty(), "expected no output, got: {}", String::from_utf8_lossy(&out.stdout));
}
#[test]
fn epic_list_shows_epics_with_derived_state_and_counts() {
let dir = setup_epic_list();
let p = dir.path();
let epic1_id = "ab12cd34";
let epic1_branch = format!("epic/{epic1_id}-user-authentication");
create_epic_branch(p, &epic1_branch);
let epic2_id = "ef567890";
let epic2_branch = format!("epic/{epic2_id}-billing-overhaul");
create_epic_branch(p, &epic2_branch);
let t1 = format!(
"+++\nid = \"t1000001\"\ntitle = \"Auth ticket\"\nstate = \"ready\"\nbranch = \"ticket/t1000001-auth-ticket\"\nepic = \"{epic1_id}\"\n+++\n\nbody\n"
);
commit_ticket_to_branch(p, "ticket/t1000001-auth-ticket", "tickets/t1000001-auth-ticket.md", &t1);
let t2 = format!(
"+++\nid = \"t1000002\"\ntitle = \"Auth impl\"\nstate = \"implemented\"\nbranch = \"ticket/t1000002-auth-impl\"\nepic = \"{epic1_id}\"\n+++\n\nbody\n"
);
commit_ticket_to_branch(p, "ticket/t1000002-auth-impl", "tickets/t1000002-auth-impl.md", &t2);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "list"])
.current_dir(p)
.output()
.unwrap();
assert!(out.status.success(), "exit: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 2, "expected 2 lines, got:\n{stdout}");
assert!(lines[0].contains(epic1_id), "line 0 should contain epic1 id: {}", lines[0]);
assert!(lines[0].contains("in_progress"), "line 0 should be in_progress: {}", lines[0]);
assert!(lines[0].contains("User Authentication"), "line 0 should have title: {}", lines[0]);
assert!(lines[0].contains("1 ready"), "line 0 should show 1 ready: {}", lines[0]);
assert!(lines[0].contains("1 implemented"), "line 0 should show 1 implemented: {}", lines[0]);
assert!(lines[1].contains(epic2_id), "line 1 should contain epic2 id: {}", lines[1]);
assert!(lines[1].contains("empty"), "line 1 should be empty: {}", lines[1]);
assert!(lines[1].contains("Billing Overhaul"), "line 1 should have title: {}", lines[1]);
}
fn setup_epic_show() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[sync]
aggressive = false
[tickets]
dir = "tickets"
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states]]
id = "implemented"
label = "Implemented"
satisfies_deps = true
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
#[test]
fn epic_show_displays_header_and_ticket_table() {
let dir = setup_epic_show();
let p = dir.path();
let epic_id = "ab12cd34";
let epic_branch = format!("epic/{epic_id}-user-auth");
create_epic_branch(p, &epic_branch);
let t1 = format!(
"+++\nid = \"t2000001\"\ntitle = \"Implement login\"\nstate = \"ready\"\nbranch = \"ticket/t2000001-impl-login\"\nepic = \"{epic_id}\"\nagent = \"alice\"\n+++\n\nbody\n"
);
commit_ticket_to_branch(p, "ticket/t2000001-impl-login", "tickets/t2000001-impl-login.md", &t1);
let t2 = format!(
"+++\nid = \"t2000002\"\ntitle = \"Add OAuth\"\nstate = \"implemented\"\nbranch = \"ticket/t2000002-add-oauth\"\nepic = \"{epic_id}\"\ndepends_on = [\"t2000001\"]\n+++\n\nbody\n"
);
commit_ticket_to_branch(p, "ticket/t2000002-add-oauth", "tickets/t2000002-add-oauth.md", &t2);
let t3 = "+++\nid = \"t2000003\"\ntitle = \"Unrelated\"\nstate = \"ready\"\nbranch = \"ticket/t2000003-unrelated\"\n+++\n\nbody\n";
commit_ticket_to_branch(p, "ticket/t2000003-unrelated", "tickets/t2000003-unrelated.md", t3);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "show", epic_id])
.current_dir(p)
.output()
.unwrap();
assert!(
out.status.success(),
"exit: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("User Auth"), "should contain title: {stdout}");
assert!(stdout.contains(&epic_branch), "should contain branch: {stdout}");
assert!(stdout.contains("in_progress"), "should contain derived state: {stdout}");
assert!(stdout.contains("t2000001"), "should contain ticket1 id: {stdout}");
assert!(stdout.contains("t2000002"), "should contain ticket2 id: {stdout}");
assert!(stdout.contains("t2000001"), "ticket2 depends_on should show t2000001: {stdout}");
assert!(!stdout.contains("t2000003"), "unrelated ticket must not appear: {stdout}");
assert!(!stdout.contains("Unrelated"), "unrelated ticket title must not appear: {stdout}");
}
#[test]
fn epic_show_prefix_resolves_correctly() {
let dir = setup_epic_show();
let p = dir.path();
let epic_id = "ab12cd34";
let epic_branch = format!("epic/{epic_id}-user-auth");
create_epic_branch(p, &epic_branch);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "show", "ab12"])
.current_dir(p)
.output()
.unwrap();
assert!(
out.status.success(),
"exit: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains(&epic_branch), "should resolve via prefix: {stdout}");
}
#[test]
fn epic_show_no_match_exits_nonzero() {
let dir = setup_epic_show();
let p = dir.path();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "show", "zzzzzzz"])
.current_dir(p)
.output()
.unwrap();
assert!(!out.status.success(), "expected non-zero exit");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("zzzzzzz"), "error should mention the prefix: {stderr}");
}
#[test]
fn epic_show_ambiguous_prefix_exits_nonzero() {
let dir = setup_epic_show();
let p = dir.path();
create_epic_branch(p, "epic/aa000001-first");
create_epic_branch(p, "epic/aa000002-second");
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "show", "aa"])
.current_dir(p)
.output()
.unwrap();
assert!(!out.status.success(), "expected non-zero exit for ambiguous prefix");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("ambiguous"), "error should say ambiguous: {stderr}");
}
#[test]
fn epic_show_no_tickets_prints_no_tickets() {
let dir = setup_epic_show();
let p = dir.path();
let epic_id = "cc112233";
create_epic_branch(p, &format!("epic/{epic_id}-empty-epic"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "show", epic_id])
.current_dir(p)
.output()
.unwrap();
assert!(out.status.success(), "exit: {}", out.status);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("(no tickets)"), "should print no tickets message: {stdout}");
assert!(stdout.contains("empty"), "derived state should be empty: {stdout}");
}
fn write_ticket_with_epic(dir: &std::path::Path, branch: &str, filename: &str, state: &str, id: u32, title: &str, epic: Option<&str>) {
let path = format!("tickets/{filename}");
let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
let content = format!(
"+++\nid = {id}\ntitle = \"{title}\"\nstate = \"{state}\"\nbranch = \"{branch}\"\n{epic_line}created_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|",
);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(dir, &["checkout", "-b", branch]);
} else {
git(dir, &["checkout", branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("ticket: {title}")]);
git(dir, &["checkout", "main"]);
}
#[test]
fn work_dry_run_epic_filter_shows_only_epic_ticket() {
let dir = setup_with_local_worktrees();
let p = dir.path();
std::env::set_var("APM_AGENT_NAME", "test-agent");
write_ticket_with_epic(p, "ticket/0001-epic-ticket", "0001-epic-ticket.md", "ready", 1, "epic ticket", Some("ab12cd34"));
write_ticket_with_epic(p, "ticket/0002-free-ticket", "0002-free-ticket.md", "ready", 2, "free ticket", None);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["work", "--dry-run", "--epic", "ab12cd34"])
.current_dir(p)
.env("APM_AGENT_NAME", "test-agent")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "exit: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
assert!(stdout.contains("epic ticket"), "should show epic ticket: {stdout}");
assert!(!stdout.contains("free ticket"), "should not show free ticket: {stdout}");
}
#[test]
fn work_dry_run_epic_filter_no_candidates() {
let dir = setup_with_local_worktrees();
let p = dir.path();
std::env::set_var("APM_AGENT_NAME", "test-agent");
write_ticket_with_epic(p, "ticket/0001-free-ticket", "0001-free-ticket.md", "ready", 1, "free ticket", None);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["work", "--dry-run", "--epic", "ab12cd34"])
.current_dir(p)
.env("APM_AGENT_NAME", "test-agent")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "exit: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
assert!(stdout.contains("no actionable tickets"), "should show no candidates: {stdout}");
}
#[test]
fn work_dry_run_no_flag_shows_epic_ticket() {
let dir = setup_with_local_worktrees();
let p = dir.path();
std::env::set_var("APM_AGENT_NAME", "test-agent");
write_ticket_with_epic(p, "ticket/0001-epic-ticket", "0001-epic-ticket.md", "ready", 1, "epic ticket", Some("ab12cd34"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["work", "--dry-run"])
.current_dir(p)
.env("APM_AGENT_NAME", "test-agent")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "exit: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
assert!(stdout.contains("epic ticket"), "should show epic ticket without filter: {stdout}");
}
fn pr_or_epic_merge_config_toml() -> &'static str {
r#"[project]
name = "test"
default_branch = "main"
[tickets]
dir = "tickets"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states.transitions]]
to = "implemented"
trigger = "manual"
completion = "pr_or_epic_merge"
[[workflow.states]]
id = "implemented"
label = "Implemented"
"#
}
fn setup_pr_or_epic_merge_remote() -> (TempDir, TempDir) {
let bare = tempfile::tempdir().unwrap();
let bp = bare.path();
git(bp, &["init", "--bare", "-q"]);
let local = tempfile::tempdir().unwrap();
let p = local.path();
git(p, &["clone", &bp.to_string_lossy(), "."]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(p.join("apm.toml"), pr_or_epic_merge_config_toml()).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
git(p, &["push", "origin", "main"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
(bare, local)
}
fn write_in_progress_ticket(dir: &std::path::Path, id: &str, branch: &str, filename: &str, target_branch: Option<&str>) {
let path = format!("tickets/{filename}");
let target_line = match target_branch {
Some(tb) => format!("target_branch = \"{tb}\"\n"),
None => String::new(),
};
let content = format!(
"+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"in_progress\"\nbranch = \"{branch}\"\n{target_line}created_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(dir, &["checkout", "-b", branch]);
} else {
git(dir, &["checkout", branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("ticket: {id}")]);
git(dir, &["checkout", "main"]);
}
#[test]
fn pr_or_epic_merge_with_target_branch_merges_into_target() {
let (_bare, local) = setup_pr_or_epic_merge_remote();
let p = local.path();
git(p, &["checkout", "-b", "epic/test"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "epic init", "--allow-empty"]);
git(p, &["push", "origin", "epic/test"]);
git(p, &["checkout", "main"]);
let branch = "ticket/aa000001-merge-test";
write_in_progress_ticket(p, "aa000001", branch, "aa000001-merge-test.md", Some("epic/test"));
git(p, &["push", "origin", branch]);
git(p, &["checkout", "epic/test"]);
let result = apm_core::state::transition(p, "aa000001", "implemented".into(), true, false);
assert!(result.is_ok(), "merge path should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());
let log = std::process::Command::new("git")
.args(["log", "--oneline", "epic/test"])
.current_dir(p)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.lines().count() > 1, "epic/test should have additional commits after merge: {log_str}");
}
#[test]
fn pr_or_epic_merge_without_target_branch_attempts_pr() {
let (_bare, local) = setup_pr_or_epic_merge_remote();
let p = local.path();
let branch = "ticket/bb000002-pr-test";
write_in_progress_ticket(p, "bb000002", branch, "bb000002-pr-test.md", None);
git(p, &["push", "origin", branch]);
let result = apm_core::state::transition(p, "bb000002", "implemented".into(), true, false);
assert!(result.is_err(), "PR path should fail (gh not available against local bare repo)");
let remote_refs = std::process::Command::new("git")
.args(["ls-remote", "origin", branch])
.current_dir(p)
.output()
.unwrap();
let refs = String::from_utf8_lossy(&remote_refs.stdout);
assert!(!refs.trim().is_empty(), "ticket branch should have been pushed before gh was called: {refs}");
}
#[test]
fn agents_list_shows_claude_builtin() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[[workflow.states]]
id = "new"
label = "New"
actionable = ["agent"]
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
)
.unwrap();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["agents", "list"])
.current_dir(p)
.output()
.unwrap();
assert!(out.status.success(), "expected exit 0, got: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("claude"), "expected claude in output: {stdout}");
assert!(stdout.contains("built-in"), "expected built-in in output: {stdout}");
}
fn setup_with_server_url(url: &str) -> TempDir {
let dir = setup();
let p = dir.path();
let server_block = format!("\n[server]\nurl = \"{url}\"\n");
let apm_toml = std::fs::read_to_string(p.join("apm.toml")).unwrap();
std::fs::write(p.join("apm.toml"), format!("{apm_toml}{server_block}")).unwrap();
dir
}
#[test]
fn register_prints_otp_from_server() {
let mut server = mockito::Server::new();
let mock = server
.mock("POST", "/api/auth/otp")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"otp":"ABCD1234"}"#)
.create();
let dir = setup_with_server_url(&server.url());
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["register", "alice"])
.current_dir(dir.path())
.output()
.unwrap();
mock.assert();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ABCD1234");
}
#[test]
fn sessions_empty_response_prints_no_active() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/api/auth/sessions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body("[]")
.create();
let dir = setup_with_server_url(&server.url());
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["sessions"])
.current_dir(dir.path())
.output()
.unwrap();
mock.assert();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(String::from_utf8_lossy(&out.stdout).contains("No active sessions."));
}
#[test]
fn sessions_with_data_prints_table() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/api/auth/sessions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"[{"username":"alice","device_hint":"MacBook","last_seen":"2026-04-01T14:32:00Z","expires_at":"2026-04-08T14:32:00Z"}]"#)
.create();
let dir = setup_with_server_url(&server.url());
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["sessions"])
.current_dir(dir.path())
.output()
.unwrap();
mock.assert();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("alice"), "missing username: {stdout}");
assert!(stdout.contains("MacBook"), "missing device: {stdout}");
assert!(stdout.contains("USERNAME"), "missing header: {stdout}");
}
#[test]
fn revoke_user_prints_count() {
let mut server = mockito::Server::new();
let mock = server
.mock("DELETE", "/api/auth/sessions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"revoked":2}"#)
.create();
let dir = setup_with_server_url(&server.url());
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["revoke", "alice"])
.current_dir(dir.path())
.output()
.unwrap();
mock.assert();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(String::from_utf8_lossy(&out.stdout).contains("Revoked 2 session(s)."));
}
#[test]
fn revoke_user_zero_prints_no_sessions() {
let mut server = mockito::Server::new();
let mock = server
.mock("DELETE", "/api/auth/sessions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"revoked":0}"#)
.create();
let dir = setup_with_server_url(&server.url());
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["revoke", "alice"])
.current_dir(dir.path())
.output()
.unwrap();
mock.assert();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(String::from_utf8_lossy(&out.stdout).contains("No sessions found for alice."));
}
#[test]
fn revoke_all_sends_all_flag() {
let mut server = mockito::Server::new();
let body_json = serde_json::json!({"username": null, "device": null, "all": true});
let mock = server
.mock("DELETE", "/api/auth/sessions")
.match_body(mockito::Matcher::Json(body_json))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"revoked":5}"#)
.create();
let dir = setup_with_server_url(&server.url());
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["revoke", "--all"])
.current_dir(dir.path())
.output()
.unwrap();
mock.assert();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(String::from_utf8_lossy(&out.stdout).contains("Revoked 5 session(s)."));
}
#[test]
fn revoke_with_device_hint() {
let mut server = mockito::Server::new();
let mock = server
.mock("DELETE", "/api/auth/sessions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"revoked":1}"#)
.create();
let dir = setup_with_server_url(&server.url());
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["revoke", "alice", "--device", "MacBook"])
.current_dir(dir.path())
.output()
.unwrap();
mock.assert();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(String::from_utf8_lossy(&out.stdout).contains("Revoked 1 session(s)."));
}
#[test]
fn assign_sets_owner_field() {
let dir = setup();
let apm_dir = dir.path().join(".apm");
std::fs::create_dir_all(&apm_dir).unwrap();
std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
apm::cmd::new::run(dir.path(), "Assign test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "assign-test");
let id = find_ticket_id(dir.path(), "assign-test");
let rel = ticket_rel_path(&branch);
apm::cmd::assign::run(dir.path(), &id, "alice", true, false).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("owner = \"alice\""));
}
#[test]
fn assign_clears_owner_field() {
let dir = setup();
let apm_dir = dir.path().join(".apm");
std::fs::create_dir_all(&apm_dir).unwrap();
std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
apm::cmd::new::run(dir.path(), "Assign clear test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "assign-clear-test");
let id = find_ticket_id(dir.path(), "assign-clear-test");
let rel = ticket_rel_path(&branch);
apm::cmd::assign::run(dir.path(), &id, "alice", true, false).unwrap();
apm::cmd::assign::run(dir.path(), &id, "-", true, false).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(!content.contains("owner ="));
}
#[test]
fn assign_unknown_id_errors() {
let dir = setup();
let result = apm::cmd::assign::run(dir.path(), "9999", "alice", true, false);
assert!(result.is_err());
}
#[test]
fn assign_force_succeeds_when_not_owner() {
let dir = setup();
let apm_dir = dir.path().join(".apm");
std::fs::create_dir_all(&apm_dir).unwrap();
std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
apm::cmd::new::run(dir.path(), "Force assign test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "force-assign-test");
let id = find_ticket_id(dir.path(), "force-assign-test");
let rel = ticket_rel_path(&branch);
apm::cmd::assign::run(dir.path(), &id, "alice", true, false).unwrap();
apm::cmd::assign::run_inner(dir.path(), &id, "bob", true, true, Some(true)).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("owner = \"bob\""));
}
#[test]
fn assign_force_aborts_on_deny() {
let dir = setup();
let apm_dir = dir.path().join(".apm");
std::fs::create_dir_all(&apm_dir).unwrap();
std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
apm::cmd::new::run(dir.path(), "Force deny test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "force-deny-test");
let id = find_ticket_id(dir.path(), "force-deny-test");
let rel = ticket_rel_path(&branch);
apm::cmd::assign::run(dir.path(), &id, "alice", true, false).unwrap();
let result = apm::cmd::assign::run_inner(dir.path(), &id, "bob", true, true, Some(false));
assert!(result.is_ok());
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("owner = \"alice\""));
}
#[test]
fn assign_force_skips_prompt_when_no_owner() {
let dir = setup();
let apm_dir = dir.path().join(".apm");
std::fs::create_dir_all(&apm_dir).unwrap();
std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
apm::cmd::new::run(dir.path(), "Force no owner test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(dir.path(), "force-no-owner-test");
let id = find_ticket_id(dir.path(), "force-no-owner-test");
let rel = ticket_rel_path(&branch);
apm::cmd::assign::run_inner(dir.path(), &id, "alice", true, true, Some(true)).unwrap();
let content = branch_content(dir.path(), &branch, &rel);
assert!(content.contains("owner = \"alice\""));
}
fn setup_with_archive_dir() -> TempDir {
let dir = setup();
let p = dir.path();
let toml = std::fs::read_to_string(p.join("apm.toml")).unwrap();
let updated = toml.replace(
"[tickets]\ndir = \"tickets\"",
"[tickets]\ndir = \"tickets\"\narchive_dir = \"archive/tickets\"",
);
std::fs::write(p.join("apm.toml"), updated).unwrap();
dir
}
#[test]
fn archive_no_archive_dir_errors() {
let dir = setup();
let result = apm::cmd::archive::run(dir.path(), false, None);
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("archive_dir is not set"), "unexpected error: {msg}");
}
#[test]
fn archive_moves_closed_ticket_to_archive_dir() {
let dir = setup_with_archive_dir();
let p = dir.path();
apm::cmd::new::run(p, "Archive me".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "archive-me");
let id = find_ticket_id(p, "archive-me");
apm::cmd::close::run(p, &id, None, true).unwrap();
let rel = ticket_rel_path(&branch);
let files_before = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap();
assert!(files_before.iter().any(|f| f == &rel), "ticket not on main before archive");
apm::cmd::archive::run(p, false, None).unwrap();
let files_after = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap_or_default();
assert!(!files_after.iter().any(|f| f == &rel), "ticket still in tickets/ after archive");
let filename = std::path::Path::new(&rel).file_name().unwrap().to_str().unwrap();
let archive_path = format!("archive/tickets/{filename}");
let archive_files = apm_core::git::list_files_on_branch(p, "main", "archive/tickets").unwrap();
assert!(archive_files.iter().any(|f| f == &archive_path), "ticket not in archive/tickets/ after archive");
}
#[test]
fn archive_show_finds_ticket_in_archive_after_branch_deleted() {
let dir = setup_with_archive_dir();
let p = dir.path();
apm::cmd::new::run(p, "Show archived".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "show-archived");
let id = find_ticket_id(p, "show-archived");
apm::cmd::close::run(p, &id, None, true).unwrap();
apm::cmd::archive::run(p, false, None).unwrap();
git(p, &["branch", "-D", &branch]);
apm::cmd::show::run(p, &id, true, false).unwrap();
}
#[test]
fn archive_dry_run_does_not_move_files() {
let dir = setup_with_archive_dir();
let p = dir.path();
apm::cmd::new::run(p, "Dry run me".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "dry-run-me");
let id = find_ticket_id(p, "dry-run-me");
apm::cmd::close::run(p, &id, None, true).unwrap();
apm::cmd::archive::run(p, true, None).unwrap();
let rel = ticket_rel_path(&branch);
let files = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap();
assert!(files.iter().any(|f| f == &rel), "dry-run should not move ticket file");
let archive_files = apm_core::git::list_files_on_branch(p, "main", "archive/tickets").unwrap_or_default();
assert!(archive_files.is_empty(), "dry-run should not create archive dir");
}
#[test]
fn archive_skips_non_terminal_tickets_with_warning() {
let dir = setup_with_archive_dir();
let p = dir.path();
apm::cmd::new::run(p, "Non terminal".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "non-terminal");
let rel = ticket_rel_path(&branch);
let content = branch_content(p, &branch, &rel);
std::fs::create_dir_all(p.join("tickets")).unwrap();
std::fs::write(p.join(&rel), &content).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &rel]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "put non-terminal ticket on main"]);
apm::cmd::archive::run(p, false, None).unwrap();
let files = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap();
assert!(files.iter().any(|f| f == &rel), "non-terminal ticket should remain in tickets/");
}
#[test]
fn archive_older_than_skips_recent_ticket() {
let dir = setup_with_archive_dir();
let p = dir.path();
apm::cmd::new::run(p, "Recent ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "recent-ticket");
let id = find_ticket_id(p, "recent-ticket");
apm::cmd::close::run(p, &id, None, true).unwrap();
apm::cmd::archive::run(p, false, Some("30d".into())).unwrap();
let rel = ticket_rel_path(&branch);
let files = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap();
assert!(files.iter().any(|f| f == &rel), "recent ticket should not be archived with --older-than 0d");
}
#[test]
fn archive_falls_back_to_ticket_branch_for_stale_main() {
let dir = setup_with_archive_dir();
let p = dir.path();
apm::cmd::new::run(p, "Epic ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "epic-ticket");
let rel = ticket_rel_path(&branch);
let orig = branch_content(p, &branch, &rel);
let stale = orig.replace("state = \"new\"", "state = \"in_progress\"");
std::fs::create_dir_all(p.join("tickets")).unwrap();
std::fs::write(p.join(&rel), &stale).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &rel]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "stale non-terminal on main"]);
let closed = orig.replace("state = \"new\"", "state = \"closed\"");
apm_core::git::commit_to_branch(p, &branch, &rel, &closed, "close ticket").unwrap();
let config = apm_core::config::Config::load(p).unwrap();
let out = apm_core::archive::archive(p, &config, true, None).unwrap();
let filename = std::path::Path::new(&rel).file_name().unwrap().to_str().unwrap().to_string();
let archive_path = format!("archive/tickets/{filename}");
assert!(
out.dry_run_moves.iter().any(|(old, new)| old == &rel && new == &archive_path),
"expected ticket in dry_run_moves; got: {:?}", out.dry_run_moves
);
assert!(out.warnings.is_empty(), "expected no warnings; got: {:?}", out.warnings);
}
fn merge_strategy_config_toml() -> &'static str {
r#"[project]
name = "test"
default_branch = "main"
[tickets]
dir = "tickets"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states.transitions]]
to = "implemented"
trigger = "manual"
completion = "merge"
[[workflow.states]]
id = "implemented"
label = "Implemented"
"#
}
fn setup_merge_strategy_remote() -> (TempDir, TempDir) {
let bare = tempfile::tempdir().unwrap();
let bp = bare.path();
git(bp, &["init", "--bare", "-q"]);
let local = tempfile::tempdir().unwrap();
let p = local.path();
git(p, &["clone", &bp.to_string_lossy(), "."]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(p.join("apm.toml"), merge_strategy_config_toml()).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
git(p, &["push", "origin", "main"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
(bare, local)
}
fn remote_ref_sha(dir: &std::path::Path, refname: &str) -> String {
let out = std::process::Command::new("git")
.args(["ls-remote", "origin", refname])
.current_dir(dir)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
fn local_ref_sha(dir: &std::path::Path, refname: &str) -> String {
let out = std::process::Command::new("git")
.args(["rev-parse", refname])
.current_dir(dir)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
fn merge_strategy_merges_locally_without_push() {
let (_bare, local) = setup_merge_strategy_remote();
let p = local.path();
let branch = "ticket/cc000003-merge-push-test";
write_in_progress_ticket(p, "cc000003", branch, "cc000003-merge-push-test.md", None);
let main_before = local_ref_sha(p, "main");
let result = apm_core::state::transition(p, "cc000003", "implemented".into(), true, false);
assert!(result.is_ok(), "merge strategy should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());
let main_after = local_ref_sha(p, "main");
assert_ne!(main_before, main_after, "local main should advance after merge");
let remote_sha = remote_ref_sha(p, "main");
assert_eq!(main_before, remote_sha, "origin/main should NOT advance — merge to default branch is local only");
}
#[test]
fn pr_or_epic_merge_with_target_branch_pushes_target_to_origin() {
let (_bare, local) = setup_pr_or_epic_merge_remote();
let p = local.path();
git(p, &["checkout", "-b", "epic/push-test"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "epic init", "--allow-empty"]);
git(p, &["push", "origin", "epic/push-test"]);
git(p, &["checkout", "main"]);
let branch = "ticket/dd000004-epic-push-test";
write_in_progress_ticket(p, "dd000004", branch, "dd000004-epic-push-test.md", Some("epic/push-test"));
git(p, &["push", "origin", branch]);
git(p, &["checkout", "epic/push-test"]);
let result = apm_core::state::transition(p, "dd000004", "implemented".into(), true, false);
assert!(result.is_ok(), "pr_or_epic_merge with target should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());
let local_sha = local_ref_sha(p, "epic/push-test");
let remote_sha = remote_ref_sha(p, "epic/push-test");
assert!(!local_sha.is_empty(), "local epic/push-test should exist");
assert_eq!(local_sha, remote_sha, "origin/epic/push-test should match local after push");
}
#[test]
fn epic_set_max_workers_is_now_unknown_field() {
let (dir, epic_id) = setup_with_epic();
let result = apm::cmd::epic::run_set(dir.path(), &epic_id, "max_workers", "2");
assert!(result.is_err(), "expected error: max_workers is no longer a valid field");
}
#[test]
fn epic_set_unknown_field_is_error() {
let (dir, epic_id) = setup_with_epic();
let result = apm::cmd::epic::run_set(dir.path(), &epic_id, "unknown_field", "5");
assert!(result.is_err(), "expected error for unknown field");
}
#[test]
fn epic_set_nonexistent_epic_exits_nonzero() {
let dir = setup();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "set", "deadbeef", "owner", "alice"])
.current_dir(dir.path())
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
assert!(!out.status.success(), "expected non-zero exit for nonexistent epic");
}
#[test]
fn agents_max_workers_per_epic_defaults_to_one() {
let dir = setup();
let config = apm_core::config::Config::load(dir.path()).unwrap();
assert_eq!(config.agents.max_workers_per_epic, 1);
}
fn write_ticket_in_epic(
dir: &std::path::Path,
branch: &str,
filename: &str,
state: &str,
id: &str,
title: &str,
owner: &str,
epic_id: &str,
) {
let path = format!("tickets/{filename}");
let content = format!(
"+++\nid = \"{id}\"\ntitle = \"{title}\"\nstate = \"{state}\"\nbranch = \"{branch}\"\nowner = \"{owner}\"\nepic = \"{epic_id}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|",
);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !branch_exists {
git(dir, &["checkout", "-b", branch]);
} else {
git(dir, &["checkout", branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&path), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &path]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("ticket: {title}")]);
git(dir, &["checkout", "main"]);
}
fn setup_with_epic_for_owner_tests() -> (tempfile::TempDir, String) {
let (dir, epic_id) = setup_with_epic();
let p = dir.path();
std::fs::create_dir_all(p.join(".apm")).unwrap();
std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
(dir, epic_id)
}
#[test]
fn epic_bulk_owner_change_succeeds() {
let (dir, epic_id) = setup_with_epic_for_owner_tests();
let p = dir.path();
write_ticket_in_epic(p, "ticket/aa000001-alpha", "aa000001-alpha.md", "ready", "aa000001", "Alpha", "alice", &epic_id);
write_ticket_in_epic(p, "ticket/aa000002-beta", "aa000002-beta.md", "ready", "aa000002", "Beta", "alice", &epic_id);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "set", &epic_id, "owner", "bob"])
.current_dir(p)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(out.status.success(), "expected success; stderr: {stderr}");
assert!(stdout.contains("updated 2 ticket(s), skipped 0"), "expected summary in output:\n{stdout}");
let content1 = branch_content(p, "ticket/aa000001-alpha", "tickets/aa000001-alpha.md");
assert!(content1.contains("owner = \"bob\""), "expected owner = bob in aa000001:\n{content1}");
let content2 = branch_content(p, "ticket/aa000002-beta", "tickets/aa000002-beta.md");
assert!(content2.contains("owner = \"bob\""), "expected owner = bob in aa000002:\n{content2}");
}
#[test]
fn epic_bulk_owner_change_skips_closed() {
let (dir, epic_id) = setup_with_epic_for_owner_tests();
let p = dir.path();
write_ticket_in_epic(p, "ticket/bb000001-open", "bb000001-open.md", "ready", "bb000001", "Open ticket", "alice", &epic_id);
write_ticket_in_epic(p, "ticket/bb000002-closed", "bb000002-closed.md", "closed", "bb000002", "Closed ticket", "alice", &epic_id);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "set", &epic_id, "owner", "bob"])
.current_dir(p)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(out.status.success(), "expected success; stderr: {stderr}");
assert!(stdout.contains("updated 1 ticket(s), skipped 1"), "expected summary in output:\n{stdout}");
let content_open = branch_content(p, "ticket/bb000001-open", "tickets/bb000001-open.md");
assert!(content_open.contains("owner = \"bob\""), "expected owner = bob in open ticket:\n{content_open}");
let content_closed = branch_content(p, "ticket/bb000002-closed", "tickets/bb000002-closed.md");
assert!(content_closed.contains("owner = \"alice\""), "expected owner = alice in closed ticket:\n{content_closed}");
}
#[test]
fn epic_bulk_owner_change_blocked_non_owner() {
let (dir, epic_id) = setup_with_epic_for_owner_tests();
let p = dir.path();
write_ticket_in_epic(p, "ticket/cc000001-mine", "cc000001-mine.md", "ready", "cc000001", "Mine", "alice", &epic_id);
write_ticket_in_epic(p, "ticket/cc000002-theirs", "cc000002-theirs.md", "ready", "cc000002", "Theirs", "carol", &epic_id);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["epic", "set", &epic_id, "owner", "bob"])
.current_dir(p)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
assert!(!out.status.success(), "expected failure when a ticket is not owned by current user");
let content1 = branch_content(p, "ticket/cc000001-mine", "tickets/cc000001-mine.md");
assert!(content1.contains("owner = \"alice\""), "alice's ticket should be unchanged:\n{content1}");
let content2 = branch_content(p, "ticket/cc000002-theirs", "tickets/cc000002-theirs.md");
assert!(content2.contains("owner = \"carol\""), "carol's ticket should be unchanged:\n{content2}");
}
#[test]
fn build_dependency_bundle_with_direct_and_transitive_deps() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let trans_ticket = concat!(
"+++\nid = \"trans001\"\ntitle = \"Base Library\"\n",
"state = \"closed\"\n",
"branch = \"ticket/trans001-base-library\"\n",
"+++\n\n",
"## Spec\n\n### Problem\n\nProvide low-level helpers.\n\n",
"### Acceptance criteria\n\n- [x] Done\n\n",
"### Out of scope\n\nNothing.\n\n",
"### Approach\n\nWrite the helpers.\n\n",
"## History\n\n| When | From | To | By |\n|------|------|----|----|\n",
);
apm_core::git::commit_to_branch(
p,
"ticket/trans001-base-library",
"tickets/trans001-base-library.md",
trans_ticket,
"add transitive dep",
)
.unwrap();
let direct_closed = concat!(
"+++\nid = \"dir0001a\"\ntitle = \"Auth Module\"\n",
"state = \"closed\"\n",
"branch = \"ticket/dir0001a-auth-module\"\n",
"depends_on = [\"trans001\"]\n",
"+++\n\n",
"## Spec\n\n### Problem\n\nImplement authentication.\n\n",
"### Acceptance criteria\n\n- [x] Done\n\n",
"### Out of scope\n\nOAuth only later.\n\n",
"### Approach\n\nUse JWT tokens with a helper from Base Library.\n\n",
"## History\n\n| When | From | To | By |\n|------|------|----|----|\n",
);
apm_core::git::commit_to_branch(
p,
"ticket/dir0001a-auth-module",
"tickets/dir0001a-auth-module.md",
direct_closed,
"add auth module initial",
)
.unwrap();
let direct_closed_v2 = concat!(
"+++\nid = \"dir0001a\"\ntitle = \"Auth Module\"\n",
"state = \"closed\"\n",
"branch = \"ticket/dir0001a-auth-module\"\n",
"depends_on = [\"trans001\"]\n",
"+++\n\n",
"## Spec\n\n### Problem\n\nImplement authentication.\n\n",
"### Acceptance criteria\n\n- [x] Done\n\n",
"### Out of scope\n\nOAuth only later.\n\n",
"### Approach\n\nUse JWT tokens with a helper from Base Library.\n\n",
"## History\n\n| When | From | To | By |\n|------|------|----|----|\n",
"| 2026-01-01T00:00Z | ready | closed | test |\n",
);
apm_core::git::commit_to_branch(
p,
"ticket/dir0001a-auth-module",
"tickets/dir0001a-auth-module.md",
direct_closed_v2,
"auth: implement JWT signing",
)
.unwrap();
let direct_open = concat!(
"+++\nid = \"dir0002b\"\ntitle = \"Session Store\"\n",
"state = \"in_progress\"\n",
"branch = \"ticket/dir0002b-session-store\"\n",
"+++\n\n",
"## Spec\n\n### Problem\n\nPersist user sessions.\n\n",
"### Acceptance criteria\n\n- [ ] Store sessions in Redis.\n\n",
"### Out of scope\n\nNothing.\n\n",
"### Approach\n\nRedis-backed session store with TTL.\n\n",
"## History\n\n| When | From | To | By |\n|------|------|----|----|\n",
);
apm_core::git::commit_to_branch(
p,
"ticket/dir0002b-session-store",
"tickets/dir0002b-session-store.md",
direct_open,
"add session store ticket",
)
.unwrap();
let dep_ids = vec!["dir0001a".to_string(), "dir0002b".to_string()];
let bundle = apm_core::context::build_dependency_bundle(p, &dep_ids, &config);
assert!(bundle.contains("Dependency Context Bundle"), "header missing:\n{bundle}");
assert!(bundle.contains("Auth Module"), "closed dep title missing:\n{bundle}");
assert!(bundle.contains("JWT tokens"), "closed dep approach missing:\n{bundle}");
assert!(!bundle.contains("Auth Module\n**State:** closed ⚠️"), "closed dep must not have warning:\n{bundle}");
assert!(bundle.contains("Session Store"), "open dep title missing:\n{bundle}");
assert!(bundle.contains("Redis-backed"), "open dep approach missing:\n{bundle}");
assert!(bundle.contains("may still change"), "open dep must have warning:\n{bundle}");
assert!(bundle.contains("Base Library"), "transitive dep title missing:\n{bundle}");
assert!(bundle.contains("low-level helpers"), "transitive dep problem one-liner missing:\n{bundle}");
assert!(bundle.ends_with("***\n"), "bundle must end with separator:\n{bundle}");
}
#[test]
fn build_dependency_bundle_empty_when_no_deps() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let bundle = apm_core::context::build_dependency_bundle(p, &[], &config);
assert!(bundle.is_empty(), "expected empty bundle for no deps, got:\n{bundle}");
}
#[test]
fn sync_bails_on_mid_merge_state() {
let dir = setup();
let p = dir.path();
std::fs::write(p.join(".git/MERGE_HEAD"), "0000000000000000000000000000000000000000").unwrap();
let result = apm::cmd::sync::run(p, true, true, true, true, false, false);
assert!(result.is_ok(), "sync should return Ok on mid-merge: {result:?}");
assert!(p.join(".git/MERGE_HEAD").exists(), "MERGE_HEAD should still exist after bailing");
}
fn setup_sync_repo() -> (TempDir, TempDir) {
let origin = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init", "--bare", "-q", "-b", "main"])
.current_dir(origin.path())
.status()
.unwrap();
let local = setup();
let l = local.path();
std::fs::write(l.join("shared.txt"), "version-1").unwrap();
git(l, &["add", "shared.txt"]);
git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "add shared.txt"]);
git(l, &["remote", "add", "origin", &origin.path().to_string_lossy()]);
git(l, &["push", "-u", "origin", "main"]);
(origin, local)
}
fn push_to_origin(origin_path: &std::path::Path, file: &str, content: &str) {
let tmp = tempfile::tempdir().unwrap();
let t = tmp.path();
std::process::Command::new("git")
.args(["clone", "-q", &origin_path.to_string_lossy(), "."])
.current_dir(t)
.status()
.unwrap();
git(t, &["config", "user.email", "test@test.com"]);
git(t, &["config", "user.name", "test"]);
std::fs::write(t.join(file), content).unwrap();
git(t, &["add", file]);
git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "origin-commit"]);
git(t, &["push", "origin", "main"]);
}
fn rev_parse(dir: &std::path::Path, refname: &str) -> String {
let out = std::process::Command::new("git")
.args(["rev-parse", refname])
.current_dir(dir)
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[test]
fn sync_main_equal_noop() {
let (_origin, local) = setup_sync_repo();
let l = local.path();
let sha_before = rev_parse(l, "main");
apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();
let sha_after = rev_parse(l, "main");
assert_eq!(sha_before, sha_after, "equal: main SHA must not change");
}
#[test]
fn sync_main_behind_ff_clean() {
let (origin, local) = setup_sync_repo();
let l = local.path();
push_to_origin(origin.path(), "shared.txt", "version-2");
apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();
let local_sha = rev_parse(l, "main");
let origin_sha = rev_parse(origin.path(), "main");
assert_eq!(
local_sha, origin_sha,
"behind FF clean: local main must match origin/main after fast-forward"
);
}
#[test]
fn sync_main_behind_ff_blocked_by_dirty_overlap() {
let (origin, local) = setup_sync_repo();
let l = local.path();
push_to_origin(origin.path(), "shared.txt", "version-2");
let sha_before = rev_parse(l, "main");
std::fs::write(l.join("shared.txt"), "local-dirty").unwrap();
apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();
let sha_after = rev_parse(l, "main");
assert_eq!(sha_before, sha_after, "behind FF blocked: local main SHA must be unchanged");
let content = std::fs::read_to_string(l.join("shared.txt")).unwrap();
assert_eq!(content, "local-dirty", "behind FF blocked: working tree must be untouched");
}
#[test]
fn sync_main_ahead_prints_info_no_push() {
let (origin, local) = setup_sync_repo();
let l = local.path();
let origin_sha_before = rev_parse(origin.path(), "main");
std::fs::write(l.join("local-only.txt"), "local").unwrap();
git(l, &["add", "local-only.txt"]);
git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local-ahead commit"]);
apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();
let origin_sha_after = rev_parse(origin.path(), "main");
assert_eq!(
origin_sha_before, origin_sha_after,
"ahead: sync must not push to origin"
);
}
#[test]
fn sync_main_diverged_prints_guidance() {
let (origin, local) = setup_sync_repo();
let l = local.path();
push_to_origin(origin.path(), "shared.txt", "version-origin");
std::fs::write(l.join("local-only.txt"), "local").unwrap();
git(l, &["add", "local-only.txt"]);
git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local-diverge commit"]);
let local_sha_before = rev_parse(l, "main");
apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();
let local_sha_after = rev_parse(l, "main");
assert_eq!(
local_sha_before, local_sha_after,
"diverged: local main SHA must be unchanged"
);
}
#[test]
fn sync_main_no_remote_skips() {
let local = setup();
let result = apm::cmd::sync::run(local.path(), false, true, true, true, false, false);
assert!(result.is_ok(), "no remote: sync must return Ok, got: {result:?}");
}
fn setup_branch_in_origin(branch: &str) -> (TempDir, TempDir, String) {
let origin = tempfile::tempdir().unwrap();
let local = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init", "--bare", "-q", "-b", "main"])
.current_dir(origin.path())
.status()
.unwrap();
git(local.path(), &["init", "-q", "-b", "main"]);
git(local.path(), &["config", "user.email", "test@test.com"]);
git(local.path(), &["config", "user.name", "test"]);
git(local.path(), &["remote", "add", "origin", &origin.path().to_string_lossy()]);
std::fs::write(local.path().join("README"), "init").unwrap();
git(local.path(), &["add", "README"]);
git(local.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
git(local.path(), &["push", "origin", "HEAD:main"]);
let tmp = tempfile::tempdir().unwrap();
let t = tmp.path();
std::process::Command::new("git")
.args(["clone", "-q", &origin.path().to_string_lossy(), "."])
.current_dir(t)
.status()
.unwrap();
git(t, &["config", "user.email", "test@test.com"]);
git(t, &["config", "user.name", "test"]);
git(t, &["checkout", "-b", branch]);
std::fs::write(t.join("branch.txt"), "v1").unwrap();
git(t, &["add", "branch.txt"]);
git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "branch commit"]);
git(t, &["push", "origin", branch]);
let sha = rev_parse(origin.path(), branch);
apm_core::git::fetch_all(local.path()).unwrap();
(origin, local, sha)
}
#[test]
fn sync_ticket_ref_equal_noop() {
let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-equal");
let l = local.path();
std::process::Command::new("git")
.args(["update-ref", "refs/heads/ticket/abc-equal", &origin_sha])
.current_dir(l)
.status()
.unwrap();
let sha_before = rev_parse(l, "refs/heads/ticket/abc-equal");
let mut w = Vec::new();
apm_core::git::sync_non_checked_out_refs(l, &mut w);
let sha_after = rev_parse(l, "refs/heads/ticket/abc-equal");
assert_eq!(sha_before, sha_after, "equal: local ref must be unchanged");
assert!(w.is_empty(), "equal: no warnings expected");
drop(origin);
}
#[test]
fn sync_ticket_ref_behind_ff() {
let (origin, local, _sha_v1) = setup_branch_in_origin("ticket/abc-behind");
let l = local.path();
let o = origin.path();
let main_sha = rev_parse(l, "main");
std::process::Command::new("git")
.args(["update-ref", "refs/heads/ticket/abc-behind", &main_sha])
.current_dir(l)
.status()
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let t = tmp.path();
std::process::Command::new("git")
.args(["clone", "-q", &o.to_string_lossy(), "."])
.current_dir(t)
.status()
.unwrap();
git(t, &["config", "user.email", "test@test.com"]);
git(t, &["config", "user.name", "test"]);
git(t, &["checkout", "ticket/abc-behind"]);
std::fs::write(t.join("extra.txt"), "v2").unwrap();
git(t, &["add", "extra.txt"]);
git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "second commit"]);
git(t, &["push", "origin", "ticket/abc-behind"]);
apm_core::git::fetch_all(l).unwrap();
let origin_sha = rev_parse(o, "ticket/abc-behind");
let mut w = Vec::new();
apm_core::git::sync_non_checked_out_refs(l, &mut w);
let local_sha = rev_parse(l, "refs/heads/ticket/abc-behind");
assert_eq!(local_sha, origin_sha, "behind: local ref must be fast-forwarded to origin SHA");
}
#[test]
fn sync_ticket_ref_ahead_preserves_local_commits() {
let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-ahead");
let l = local.path();
std::process::Command::new("git")
.args(["update-ref", "refs/heads/ticket/abc-ahead", &origin_sha])
.current_dir(l)
.status()
.unwrap();
std::process::Command::new("git")
.args(["checkout", "-b", "__tmp_ahead", "ticket/abc-ahead"])
.current_dir(l)
.status()
.unwrap();
std::fs::write(l.join("local-only.txt"), "local").unwrap();
git(l, &["add", "local-only.txt"]);
git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local-only commit"]);
let local_sha = rev_parse(l, "__tmp_ahead");
std::process::Command::new("git")
.args(["update-ref", "refs/heads/ticket/abc-ahead", &local_sha])
.current_dir(l)
.status()
.unwrap();
std::process::Command::new("git")
.args(["checkout", "main"])
.current_dir(l)
.status()
.unwrap();
std::process::Command::new("git")
.args(["branch", "-D", "__tmp_ahead"])
.current_dir(l)
.status()
.unwrap();
assert_ne!(local_sha, origin_sha, "pre-condition: local must have an extra commit");
let mut w = Vec::new();
apm_core::git::sync_non_checked_out_refs(l, &mut w);
let sha_after = rev_parse(l, "refs/heads/ticket/abc-ahead");
assert_eq!(
sha_after, local_sha,
"ahead: local ref must NOT be overwritten — data-loss regression"
);
assert!(!w.is_empty(), "ahead: an info line should be emitted");
drop(origin);
}
#[test]
fn sync_ticket_ref_diverged_preserves_local_commits() {
let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-diverged");
let l = local.path();
let o = origin.path();
std::process::Command::new("git")
.args(["update-ref", "refs/heads/ticket/abc-diverged", &origin_sha])
.current_dir(l)
.status()
.unwrap();
std::process::Command::new("git")
.args(["checkout", "-b", "__tmp_div", "ticket/abc-diverged"])
.current_dir(l)
.status()
.unwrap();
std::fs::write(l.join("local-div.txt"), "local").unwrap();
git(l, &["add", "local-div.txt"]);
git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local diverge commit"]);
let local_sha = rev_parse(l, "__tmp_div");
std::process::Command::new("git")
.args(["update-ref", "refs/heads/ticket/abc-diverged", &local_sha])
.current_dir(l)
.status()
.unwrap();
std::process::Command::new("git")
.args(["checkout", "main"])
.current_dir(l)
.status()
.unwrap();
std::process::Command::new("git")
.args(["branch", "-D", "__tmp_div"])
.current_dir(l)
.status()
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let t = tmp.path();
std::process::Command::new("git")
.args(["clone", "-q", &o.to_string_lossy(), "."])
.current_dir(t)
.status()
.unwrap();
git(t, &["config", "user.email", "test@test.com"]);
git(t, &["config", "user.name", "test"]);
git(t, &["checkout", "ticket/abc-diverged"]);
std::fs::write(t.join("origin-div.txt"), "origin").unwrap();
git(t, &["add", "origin-div.txt"]);
git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "origin diverge commit"]);
git(t, &["push", "origin", "ticket/abc-diverged"]);
apm_core::git::fetch_all(l).unwrap();
let mut w = Vec::new();
apm_core::git::sync_non_checked_out_refs(l, &mut w);
let sha_after = rev_parse(l, "refs/heads/ticket/abc-diverged");
assert_eq!(
sha_after, local_sha,
"diverged: local ref must be preserved — data-loss regression"
);
assert!(!w.is_empty(), "diverged: a warning must be emitted");
}
#[test]
fn sync_ticket_ref_remote_only_creates_local() {
let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-remote-only");
let l = local.path();
let exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", "refs/heads/ticket/abc-remote-only"])
.current_dir(l)
.status()
.unwrap()
.success();
assert!(!exists, "pre-condition: local ref must not exist");
let mut w = Vec::new();
apm_core::git::sync_non_checked_out_refs(l, &mut w);
let local_sha = rev_parse(l, "refs/heads/ticket/abc-remote-only");
assert_eq!(local_sha, origin_sha, "remote-only: local ref must be created at origin SHA");
drop(origin);
}
#[test]
fn sync_ticket_ref_local_only_untouched() {
let (_origin, local, _) = setup_branch_in_origin("ticket/abc-seed");
let l = local.path();
std::process::Command::new("git")
.args(["checkout", "-b", "ticket/local-only", "main"])
.current_dir(l)
.status()
.unwrap();
std::fs::write(l.join("local.txt"), "data").unwrap();
git(l, &["add", "local.txt"]);
git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local-only commit"]);
let local_sha = rev_parse(l, "ticket/local-only");
std::process::Command::new("git")
.args(["checkout", "main"])
.current_dir(l)
.status()
.unwrap();
let mut w = Vec::new();
apm_core::git::sync_non_checked_out_refs(l, &mut w);
let sha_after = rev_parse(l, "refs/heads/ticket/local-only");
assert_eq!(sha_after, local_sha, "local-only: local ref must not be changed");
assert!(
w.iter().all(|m| !m.contains("ticket/local-only")),
"local-only: no warning message expected for local-only branch"
);
}
#[test]
fn sync_epic_ref_behind_ff() {
let (origin, local, _sha_v1) = setup_branch_in_origin("epic/abcd1234-my-epic");
let l = local.path();
let o = origin.path();
let main_sha = rev_parse(l, "main");
std::process::Command::new("git")
.args(["update-ref", "refs/heads/epic/abcd1234-my-epic", &main_sha])
.current_dir(l)
.status()
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let t = tmp.path();
std::process::Command::new("git")
.args(["clone", "-q", &o.to_string_lossy(), "."])
.current_dir(t)
.status()
.unwrap();
git(t, &["config", "user.email", "test@test.com"]);
git(t, &["config", "user.name", "test"]);
git(t, &["checkout", "epic/abcd1234-my-epic"]);
std::fs::write(t.join("extra.txt"), "v2").unwrap();
git(t, &["add", "extra.txt"]);
git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "epic second commit"]);
git(t, &["push", "origin", "epic/abcd1234-my-epic"]);
apm_core::git::fetch_all(l).unwrap();
let origin_sha = rev_parse(o, "epic/abcd1234-my-epic");
let mut w = Vec::new();
apm_core::git::sync_non_checked_out_refs(l, &mut w);
let local_sha = rev_parse(l, "refs/heads/epic/abcd1234-my-epic");
assert_eq!(
local_sha, origin_sha,
"epic behind: local ref must be fast-forwarded to origin SHA"
);
}
#[test]
fn sync_epic_ref_ahead_preserves_local_commits() {
let (origin, local, origin_sha) = setup_branch_in_origin("epic/efgh5678-my-epic");
let l = local.path();
std::process::Command::new("git")
.args(["update-ref", "refs/heads/epic/efgh5678-my-epic", &origin_sha])
.current_dir(l)
.status()
.unwrap();
std::process::Command::new("git")
.args(["checkout", "-b", "__tmp_epic_ahead", "epic/efgh5678-my-epic"])
.current_dir(l)
.status()
.unwrap();
std::fs::write(l.join("local-epic.txt"), "local").unwrap();
git(l, &["add", "local-epic.txt"]);
git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local epic commit"]);
let local_sha = rev_parse(l, "__tmp_epic_ahead");
std::process::Command::new("git")
.args(["update-ref", "refs/heads/epic/efgh5678-my-epic", &local_sha])
.current_dir(l)
.status()
.unwrap();
std::process::Command::new("git")
.args(["checkout", "main"])
.current_dir(l)
.status()
.unwrap();
std::process::Command::new("git")
.args(["branch", "-D", "__tmp_epic_ahead"])
.current_dir(l)
.status()
.unwrap();
let mut w = Vec::new();
apm_core::git::sync_non_checked_out_refs(l, &mut w);
let sha_after = rev_parse(l, "refs/heads/epic/efgh5678-my-epic");
assert_eq!(
sha_after, local_sha,
"epic ahead: local ref must NOT be overwritten — data-loss regression"
);
assert!(!w.is_empty(), "epic ahead: an info line must be emitted");
drop(origin);
}
#[test]
fn sync_checked_out_ticket_skipped() {
let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-checked-out");
let l = local.path();
let o = origin.path();
std::process::Command::new("git")
.args(["update-ref", "refs/heads/ticket/abc-checked-out", &origin_sha])
.current_dir(l)
.status()
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let t = tmp.path();
std::process::Command::new("git")
.args(["clone", "-q", &o.to_string_lossy(), "."])
.current_dir(t)
.status()
.unwrap();
git(t, &["config", "user.email", "test@test.com"]);
git(t, &["config", "user.name", "test"]);
git(t, &["checkout", "ticket/abc-checked-out"]);
std::fs::write(t.join("extra.txt"), "origin-extra").unwrap();
git(t, &["add", "extra.txt"]);
git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "origin extra"]);
git(t, &["push", "origin", "ticket/abc-checked-out"]);
apm_core::git::fetch_all(l).unwrap();
let wt = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["worktree", "add", wt.path().to_str().unwrap(), "ticket/abc-checked-out"])
.current_dir(l)
.status()
.unwrap();
let sha_before = rev_parse(l, "refs/heads/ticket/abc-checked-out");
let mut w = Vec::new();
apm_core::git::sync_non_checked_out_refs(l, &mut w);
let sha_after = rev_parse(l, "refs/heads/ticket/abc-checked-out");
assert_eq!(
sha_before, sha_after,
"checked-out: branch ref must not be updated while checked out in worktree"
);
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force", wt.path().to_str().unwrap()])
.current_dir(l)
.status();
drop(origin);
}
fn add_commit_to_branch_via_worktree(
dir: &std::path::Path,
branch: &str,
filename: &str,
content: &str,
) {
let wt = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["worktree", "add", wt.path().to_str().unwrap(), branch])
.current_dir(dir)
.status()
.unwrap();
std::fs::write(wt.path().join(filename), content).unwrap();
git(wt.path(), &["add", filename]);
git(wt.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "extra commit"]);
std::process::Command::new("git")
.args(["worktree", "remove", wt.path().to_str().unwrap()])
.current_dir(dir)
.status()
.unwrap();
}
fn git_merge_base(dir: &std::path::Path, ref1: &str, ref2: &str) -> String {
let out = std::process::Command::new("git")
.args(["merge-base", ref1, ref2])
.current_dir(dir)
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[test]
fn move_standalone_ticket_into_epic() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let (epic_id, epic_branch) = apm_core::epic::create_epic_branch(p, "test epic", &config).unwrap();
let mut warnings = vec![];
let ticket = apm_core::ticket::create(
p,
&config,
"standalone ticket".into(),
"test".into(),
"test".into(),
None,
None,
false,
vec![],
None,
None,
None,
None,
&mut warnings,
)
.unwrap();
let ticket_id = ticket.frontmatter.id.clone();
let ticket_branch = ticket.frontmatter.branch.clone().unwrap();
add_commit_to_branch_via_worktree(p, &ticket_branch, "extra.txt", "extra");
let epic_tip_before = rev_parse(p, &epic_branch);
let msg = apm_core::ticket::move_to_epic(p, &config, &ticket_id, Some(&epic_id)).unwrap();
assert!(msg.contains(&ticket_id), "message should mention ticket id: {msg}");
assert!(msg.contains(&epic_id), "message should mention epic id: {msg}");
let rel_path = format!("tickets/{}.md", ticket_branch.strip_prefix("ticket/").unwrap());
let content = branch_content(p, &ticket_branch, &rel_path);
let updated = apm_core::ticket::Ticket::parse(std::path::Path::new(&rel_path), &content).unwrap();
assert_eq!(
updated.frontmatter.epic.as_deref(),
Some(epic_id.as_str()),
"epic field should be set"
);
assert_eq!(
updated.frontmatter.target_branch.as_deref(),
Some(epic_branch.as_str()),
"target_branch should be set to epic branch"
);
let merge_base = git_merge_base(p, &ticket_branch, &epic_branch);
assert_eq!(
merge_base, epic_tip_before,
"merge-base of ticket and epic should equal the epic tip at move time"
);
let all_tickets = apm_core::ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
let epic_tickets: Vec<_> = all_tickets
.iter()
.filter(|t| t.frontmatter.epic.as_deref() == Some(&epic_id))
.collect();
assert_eq!(epic_tickets.len(), 1, "epic should list the ticket");
assert_eq!(epic_tickets[0].frontmatter.id, ticket_id);
assert!(
updated.body.contains("move:"),
"history should contain move record"
);
}
#[test]
fn move_commits_replayed_on_new_base() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let (epic_id, epic_branch) = apm_core::epic::create_epic_branch(p, "replay epic", &config).unwrap();
let mut warnings = vec![];
let ticket = apm_core::ticket::create(
p,
&config,
"replay ticket".into(),
"test".into(),
"test".into(),
None,
None,
false,
vec![],
None,
None,
None,
None,
&mut warnings,
)
.unwrap();
let ticket_branch = ticket.frontmatter.branch.clone().unwrap();
add_commit_to_branch_via_worktree(p, &ticket_branch, "first.txt", "first");
add_commit_to_branch_via_worktree(p, &ticket_branch, "second.txt", "second");
let epic_tip = rev_parse(p, &epic_branch);
apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some(&epic_id)).unwrap();
let first_content = branch_content(p, &ticket_branch, "first.txt");
assert_eq!(first_content.trim(), "first");
let second_content = branch_content(p, &ticket_branch, "second.txt");
assert_eq!(second_content.trim(), "second");
let merge_base = git_merge_base(p, &ticket_branch, &epic_branch);
assert_eq!(merge_base, epic_tip, "replayed commits must sit on top of epic");
}
#[test]
fn move_ticket_out_of_epic() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let (epic_id, epic_branch) = apm_core::epic::create_epic_branch(p, "out epic", &config).unwrap();
let mut warnings = vec![];
let ticket = apm_core::ticket::create(
p,
&config,
"epic ticket".into(),
"test".into(),
"test".into(),
None,
None,
false,
vec![],
Some(epic_id.clone()),
Some(epic_branch.clone()),
None,
Some(epic_branch.clone()),
&mut warnings,
)
.unwrap();
let ticket_id = ticket.frontmatter.id.clone();
let ticket_branch = ticket.frontmatter.branch.clone().unwrap();
let main_tip = rev_parse(p, "main");
let msg =
apm_core::ticket::move_to_epic(p, &config, &ticket_id, None).unwrap();
assert!(msg.contains("out of epic") || msg.contains("main"), "message: {msg}");
let rel_path = format!("tickets/{}.md", ticket_branch.strip_prefix("ticket/").unwrap());
let content = branch_content(p, &ticket_branch, &rel_path);
let updated =
apm_core::ticket::Ticket::parse(std::path::Path::new(&rel_path), &content).unwrap();
assert!(updated.frontmatter.epic.is_none(), "epic should be cleared");
assert!(
updated.frontmatter.target_branch.is_none(),
"target_branch should be cleared"
);
let all_tickets = apm_core::ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
let epic_tickets: Vec<_> = all_tickets
.iter()
.filter(|t| t.frontmatter.epic.as_deref() == Some(&epic_id))
.collect();
assert!(epic_tickets.is_empty(), "epic should no longer list the ticket");
let merge_base = git_merge_base(p, &ticket_branch, "main");
assert_eq!(merge_base, main_tip, "ticket should be rebased onto main");
}
#[test]
fn move_between_epics() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let (epic1_id, epic1_branch) =
apm_core::epic::create_epic_branch(p, "first epic", &config).unwrap();
let (epic2_id, epic2_branch) =
apm_core::epic::create_epic_branch(p, "second epic", &config).unwrap();
let mut warnings = vec![];
let ticket = apm_core::ticket::create(
p,
&config,
"migrating ticket".into(),
"test".into(),
"test".into(),
None,
None,
false,
vec![],
Some(epic1_id.clone()),
Some(epic1_branch.clone()),
None,
Some(epic1_branch.clone()),
&mut warnings,
)
.unwrap();
let ticket_id = ticket.frontmatter.id.clone();
let ticket_branch = ticket.frontmatter.branch.clone().unwrap();
let epic2_tip = rev_parse(p, &epic2_branch);
apm_core::ticket::move_to_epic(p, &config, &ticket_id, Some(&epic2_id)).unwrap();
let rel_path = format!("tickets/{}.md", ticket_branch.strip_prefix("ticket/").unwrap());
let content = branch_content(p, &ticket_branch, &rel_path);
let updated =
apm_core::ticket::Ticket::parse(std::path::Path::new(&rel_path), &content).unwrap();
assert_eq!(
updated.frontmatter.epic.as_deref(),
Some(epic2_id.as_str()),
"epic should now be epic2"
);
assert_eq!(
updated.frontmatter.target_branch.as_deref(),
Some(epic2_branch.as_str())
);
let all_tickets = apm_core::ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
let in_epic1: Vec<_> = all_tickets
.iter()
.filter(|t| t.frontmatter.epic.as_deref() == Some(&epic1_id))
.collect();
assert!(in_epic1.is_empty(), "should not be in epic1 anymore");
let in_epic2: Vec<_> = all_tickets
.iter()
.filter(|t| t.frontmatter.epic.as_deref() == Some(&epic2_id))
.collect();
assert_eq!(in_epic2.len(), 1, "should be in epic2");
let merge_base = git_merge_base(p, &ticket_branch, &epic2_branch);
assert_eq!(merge_base, epic2_tip);
}
#[test]
fn move_already_in_same_epic_is_noop() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let (epic_id, epic_branch) = apm_core::epic::create_epic_branch(p, "same epic", &config).unwrap();
let mut warnings = vec![];
let ticket = apm_core::ticket::create(
p,
&config,
"already here".into(),
"test".into(),
"test".into(),
None,
None,
false,
vec![],
Some(epic_id.clone()),
Some(epic_branch.clone()),
None,
Some(epic_branch.clone()),
&mut warnings,
)
.unwrap();
let tip_before = rev_parse(p, &ticket.frontmatter.branch.clone().unwrap());
let msg =
apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some(&epic_id))
.unwrap();
assert!(
msg.contains("already") || msg.contains("nothing to do"),
"expected no-op message: {msg}"
);
let tip_after = rev_parse(p, &ticket.frontmatter.branch.clone().unwrap());
assert_eq!(tip_before, tip_after, "branch ref must not change on no-op");
}
#[test]
fn move_ticket_not_in_epic_to_main_is_noop() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let mut warnings = vec![];
let ticket = apm_core::ticket::create(
p,
&config,
"no epic ticket".into(),
"test".into(),
"test".into(),
None,
None,
false,
vec![],
None,
None,
None,
None,
&mut warnings,
)
.unwrap();
let tip_before = rev_parse(p, &ticket.frontmatter.branch.clone().unwrap());
let msg =
apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, None).unwrap();
assert!(
msg.contains("not in any epic") || msg.contains("nothing to do"),
"expected no-op message: {msg}"
);
let tip_after = rev_parse(p, &ticket.frontmatter.branch.clone().unwrap());
assert_eq!(tip_before, tip_after, "branch ref must not change on no-op");
}
#[test]
fn move_terminal_ticket_fails() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let (epic_id, _epic_branch) = apm_core::epic::create_epic_branch(p, "t epic", &config).unwrap();
let mut warnings = vec![];
let ticket = apm_core::ticket::create(
p,
&config,
"to close".into(),
"test".into(),
"test".into(),
None,
None,
false,
vec![],
None,
None,
None,
None,
&mut warnings,
)
.unwrap();
apm_core::ticket::close(p, &config, &ticket.frontmatter.id, None, "test", false).unwrap();
let err =
apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some(&epic_id))
.unwrap_err();
assert!(
err.to_string().contains("terminal"),
"error should mention terminal state: {err}"
);
}
#[test]
fn move_nonexistent_epic_fails() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let mut warnings = vec![];
let ticket = apm_core::ticket::create(
p,
&config,
"nomatch ticket".into(),
"test".into(),
"test".into(),
None,
None,
false,
vec![],
None,
None,
None,
None,
&mut warnings,
)
.unwrap();
let err =
apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some("deadbeef"))
.unwrap_err();
assert!(
err.to_string().contains("no epic"),
"error should mention epic not found: {err}"
);
}
#[test]
fn move_rebase_conflict_fails_cleanly() {
let dir = setup();
let p = dir.path();
let config = apm_core::config::Config::load(p).unwrap();
let (epic_id, epic_branch) =
apm_core::epic::create_epic_branch(p, "conflict epic", &config).unwrap();
add_commit_to_branch_via_worktree(p, &epic_branch, "shared.txt", "from epic\n");
let mut warnings = vec![];
let ticket = apm_core::ticket::create(
p,
&config,
"conflict ticket".into(),
"test".into(),
"test".into(),
None,
None,
false,
vec![],
None,
None,
None,
None,
&mut warnings,
)
.unwrap();
let ticket_branch = ticket.frontmatter.branch.clone().unwrap();
add_commit_to_branch_via_worktree(p, &ticket_branch, "shared.txt", "from ticket\n");
let tip_before = rev_parse(p, &ticket_branch);
let err =
apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some(&epic_id))
.unwrap_err();
assert!(
err.to_string().contains("rebase") || err.to_string().contains("conflict"),
"error should describe the rebase failure: {err}"
);
let tip_after = rev_parse(p, &ticket_branch);
assert_eq!(
tip_before, tip_after,
"branch ref must be restored after failed rebase"
);
assert!(
apm_core::git::detect_mid_merge_state(p).is_none(),
"repo should be clean after abort"
);
}
fn setup_with_merge_workflow() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[[workflow.states]]
id = "new"
label = "New"
[[workflow.states.transitions]]
to = "implemented"
trigger = "manual"
completion = "merge"
on_failure = "merge_failed"
[[workflow.states]]
id = "implemented"
label = "Implemented"
actionable = ["supervisor"]
[[workflow.states.transitions]]
to = "in_progress"
trigger = "manual"
[[workflow.states]]
id = "merge_failed"
label = "Merge failed"
actionable = ["supervisor"]
[[workflow.states.transitions]]
to = "implemented"
trigger = "manual"
[[workflow.states.transitions]]
to = "in_progress"
trigger = "manual"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states]]
id = "closed"
label = "Closed"
terminal = true
"#,
)
.unwrap();
git(p, &["add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
#[test]
fn merge_failure_transitions_ticket_to_merge_failed() {
let dir = setup_with_merge_workflow();
let p = dir.path();
apm::cmd::new::run(p, "Merge conflict".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "merge-conflict");
let id = find_ticket_id(p, "merge-conflict");
let rel = ticket_rel_path(&branch);
git(p, &["checkout", &branch]);
std::fs::write(p.join("conflict.txt"), "branch content\n").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "conflict.txt"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add file on branch"]);
git(p, &["checkout", "main"]);
std::fs::write(p.join("conflict.txt"), "main content\n").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "conflict.txt"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add conflicting file on main"]);
apm::cmd::state::run(p, &id, "implemented".into(), false, false).unwrap();
let content = branch_content(p, &branch, &rel);
assert!(
content.contains("state = \"merge_failed\""),
"expected merge_failed state:\n{content}"
);
assert!(
content.contains("### Merge notes"),
"expected Merge notes section:\n{content}"
);
assert!(
content.contains("| implemented | merge_failed |"),
"expected history row:\n{content}"
);
}
#[test]
fn merge_failed_to_implemented_does_not_trigger_another_merge() {
let dir = setup_with_merge_workflow();
let p = dir.path();
apm::cmd::new::run(p, "Already failed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "already-failed");
let id = find_ticket_id(p, "already-failed");
let rel = ticket_rel_path(&branch);
let existing = branch_content(p, &branch, &rel);
let updated = existing.replace("state = \"new\"", "state = \"merge_failed\"");
git(p, &["checkout", &branch]);
std::fs::write(p.join(&rel), &updated).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &rel]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "set merge_failed"]);
git(p, &["checkout", "main"]);
apm::cmd::state::run(p, &id, "implemented".into(), false, false).unwrap();
let content = branch_content(p, &branch, &rel);
assert!(
content.contains("state = \"implemented\""),
"expected implemented state:\n{content}"
);
}
#[test]
fn merge_failed_to_in_progress_succeeds() {
let dir = setup_with_merge_workflow();
let p = dir.path();
apm::cmd::new::run(p, "Retry merge".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
let branch = find_ticket_branch(p, "retry-merge");
let id = find_ticket_id(p, "retry-merge");
let rel = ticket_rel_path(&branch);
let existing = branch_content(p, &branch, &rel);
let updated = existing.replace("state = \"new\"", "state = \"merge_failed\"");
git(p, &["checkout", &branch]);
std::fs::write(p.join(&rel), &updated).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &rel]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "set merge_failed"]);
git(p, &["checkout", "main"]);
apm::cmd::state::run(p, &id, "in_progress".into(), false, false).unwrap();
let content = branch_content(p, &branch, &rel);
assert!(
content.contains("state = \"in_progress\""),
"expected in_progress state:\n{content}"
);
}
#[test]
fn clean_refuses_when_cwd_inside_worktree() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "cwd-guard");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-cwd-guard");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.arg("clean")
.current_dir(&wt_path)
.output()
.unwrap();
assert!(
!out.status.success(),
"apm clean from inside a worktree must exit non-zero"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("refusing to remove worktree containing the current working directory"),
"stderr must contain refusal message, got: {stderr}"
);
}
#[test]
fn clean_prunable_worktree_cleans_registry() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "prunable");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-prunable");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
std::fs::remove_dir_all(&wt_path).unwrap();
assert!(!wt_path.exists(), "worktree dir must be gone before running clean");
let list_before = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(p)
.output()
.unwrap();
let list_str = String::from_utf8_lossy(&list_before.stdout);
assert!(
list_str.contains("ticket-0001-prunable"),
"registry entry must exist before clean: {list_str}"
);
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
let list_after = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(p)
.output()
.unwrap();
let list_after_str = String::from_utf8_lossy(&list_after.stdout);
assert!(
!list_after_str.contains("ticket-0001-prunable"),
"dangling registry entry must be pruned after clean: {list_after_str}"
);
}
#[test]
fn clean_untracked_does_not_affect_worktrees_dir_debris() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "debris");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-debris");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
let debris = p.join("worktrees").join("stray-debris.txt");
std::fs::write(&debris, "should not be touched").unwrap();
apm::cmd::clean::run(p, false, false, false, false, None, true, false).unwrap();
assert!(
debris.exists(),
"debris file outside any registered worktree must not be touched by --untracked"
);
}
#[test]
fn clean_removes_closed_ticket_worktree_in_repo_layout() {
let dir = setup();
let p = dir.path();
let (branch, _) = write_closed_ticket(p, 1, "happy");
merge_into_main(p, &branch);
let wt_path = p.join("worktrees").join("ticket-0001-happy");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
assert!(wt_path.is_dir(), "worktree dir must exist before clean");
let list_before = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(p)
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&list_before.stdout).contains("ticket-0001-happy"),
"worktree must be registered before clean"
);
apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
assert!(
!wt_path.exists(),
"worktree dir must be removed by clean: {}",
wt_path.display()
);
let list_after = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(p)
.output()
.unwrap();
assert!(
!String::from_utf8_lossy(&list_after.stdout).contains("ticket-0001-happy"),
"registry entry must be gone after clean"
);
}
#[test]
fn external_layout_worktrees_provisioned_at_sibling_path() {
let outer = tempfile::tempdir().unwrap();
let p = outer.path().join("repo");
std::fs::create_dir_all(&p).unwrap();
git(&p, &["init", "-q", "-b", "main"]);
git(&p, &["config", "user.email", "test@test.com"]);
git(&p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
[tickets]
dir = "tickets"
[agents]
max_concurrent = 1
[worktrees]
dir = "../external-worktrees"
[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0
[[workflow.states]]
id = "new"
label = "New"
[[ticket.sections]]
name = "Problem"
type = "free"
required = true
[[ticket.sections]]
name = "Acceptance criteria"
type = "tasks"
required = true
[[ticket.sections]]
name = "Out of scope"
type = "free"
required = true
[[ticket.sections]]
name = "Approach"
type = "free"
required = true
"#,
)
.unwrap();
git(&p, &["add", "apm.toml"]);
git(&p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
git(&p, &["branch", "ticket/ext-test"]);
let cfg = apm_core::config::Config::load(&p).unwrap();
let mut warnings: Vec<String> = Vec::new();
let wt = apm_core::worktree::provision_worktree(&p, &cfg, "ticket/ext-test", &mut warnings)
.unwrap();
let expected = outer.path().join("external-worktrees").join("ticket-ext-test");
assert_eq!(
wt.canonicalize().unwrap(),
expected.canonicalize().unwrap(),
"external worktree must be at sibling path: wt={} expected={}",
wt.display(),
expected.display()
);
assert!(wt.is_dir(), "external worktree dir must exist on disk");
assert!(
!wt.starts_with(&p),
"external worktree must not be inside the repo: wt={}",
wt.display()
);
}
#[test]
fn clean_older_than_filters_by_updated_at() {
let dir = setup();
let p = dir.path();
let (old_branch, _) = write_closed_ticket(p, 1, "old");
merge_into_main(p, &old_branch);
let recent_branch = "ticket/0002-recent";
let recent_filename = "0002-recent.md";
let recent_content = format!(
"+++\nid = 2\ntitle = \"recent\"\nstate = \"closed\"\nbranch = \"{recent_branch}\"\ncreated_at = \"2026-04-01T00:00:00Z\"\nupdated_at = \"2026-04-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
);
git(p, &["checkout", "-b", recent_branch]);
std::fs::write(p.join("tickets").join(recent_filename), &recent_content).unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", &format!("tickets/{recent_filename}")]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket(2): close"]);
git(p, &["checkout", "main"]);
merge_into_main(p, recent_branch);
let old_wt = p.join("worktrees").join("ticket-0001-old");
let recent_wt = p.join("worktrees").join("ticket-0002-recent");
std::fs::create_dir_all(p.join("worktrees")).unwrap();
git(p, &["worktree", "add", &old_wt.to_string_lossy(), &old_branch]);
git(p, &["worktree", "add", &recent_wt.to_string_lossy(), recent_branch]);
apm::cmd::clean::run(p, false, false, false, false, Some("2026-03-01".to_string()), false, false).unwrap();
assert!(!old_wt.exists(), "old worktree should be cleaned (updated_at < threshold)");
assert!(recent_wt.exists(), "recent worktree should be kept (updated_at >= threshold)");
}
fn apm_help_commands(dir: &std::path::Path) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
.args(["help", "commands"])
.current_dir(dir)
.output()
.unwrap()
}
#[test]
fn help_commands_exits_zero() {
let dir = setup();
let out = apm_help_commands(dir.path());
assert!(
out.status.success(),
"apm help commands failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn help_commands_includes_visible_top_level_commands() {
let dir = setup();
let out = apm_help_commands(dir.path());
let stdout = String::from_utf8(out.stdout).unwrap();
for cmd in &["agents", "archive", "assign", "clean", "close", "epic",
"help", "init", "list", "move", "new", "next",
"refresh-epic", "register", "review", "revoke",
"sessions", "set", "show", "spec", "start", "state",
"sync", "validate", "version", "work",
"workers", "worktrees"] {
assert!(stdout.contains(cmd), "missing command '{}' in:\n{}", cmd, stdout);
}
}
#[test]
fn help_commands_excludes_hidden_hook() {
let dir = setup();
let out = apm_help_commands(dir.path());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
!stdout.contains("_hook"),
"hidden command '_hook' appeared in:\n{stdout}"
);
}
#[test]
fn help_commands_alphabetical_order() {
let dir = setup();
let out = apm_help_commands(dir.path());
let stdout = String::from_utf8(out.stdout).unwrap();
let agents_pos = stdout.find("\nagents\n").expect("agents not found");
let archive_pos = stdout.find("\narchive\n").expect("archive not found");
let list_pos = stdout.find("\nlist\n").expect("list not found");
let worktrees_pos = stdout.find("\nworktrees\n").expect("worktrees not found");
assert!(agents_pos < archive_pos, "agents must precede archive");
assert!(archive_pos < list_pos, "archive must precede list");
assert!(list_pos < worktrees_pos, "list must precede worktrees");
}
#[test]
fn help_commands_shows_epic_subcommands_in_order() {
let dir = setup();
let out = apm_help_commands(dir.path());
let stdout = String::from_utf8(out.stdout).unwrap();
for sub in &["epic new", "epic close", "epic list", "epic show", "epic set"] {
assert!(stdout.contains(sub), "missing subcommand '{}' in:\n{}", sub, stdout);
}
let new_pos = stdout.find("epic new").unwrap();
let close_pos = stdout.find("epic close").unwrap();
let list_pos = stdout.find("epic list").unwrap();
let show_pos = stdout.find("epic show").unwrap();
let set_pos = stdout.find("epic set").unwrap();
assert!(new_pos < close_pos, "epic new must precede epic close");
assert!(close_pos < list_pos, "epic close must precede epic list");
assert!(list_pos < show_pos, "epic list must precede epic show");
assert!(show_pos < set_pos, "epic show must precede epic set");
}
#[test]
fn help_commands_no_help_or_version_flags() {
let dir = setup();
let out = apm_help_commands(dir.path());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(!stdout.contains("--help"), "--help flag appeared in output");
assert!(!stdout.contains("--version"), "--version flag appeared in output");
}
#[test]
fn help_commands_no_ansi_codes() {
let dir = setup();
let out = apm_help_commands(dir.path());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
!stdout.contains('\x1b'),
"ANSI escape code found in output"
);
}
#[test]
fn help_commands_lines_within_100_chars() {
let dir = setup();
let out = apm_help_commands(dir.path());
let stdout = String::from_utf8(out.stdout).unwrap();
for line in stdout.lines() {
assert!(
line.len() <= 100,
"line exceeds 100 chars ({} chars): {:?}",
line.len(),
line
);
}
}
#[test]
fn help_commands_shows_defaults() {
let dir = setup();
let out = apm_help_commands(dir.path());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("(default: 30)"),
"default annotation not found in:\n{stdout}"
);
let count = stdout.matches("(default: 30)").count();
assert_eq!(count, 1, "default annotation appeared {} times (expected 1)", count);
}
fn setup_merge_local() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.email", "test@test.com"]);
git(p, &["config", "user.name", "test"]);
std::fs::write(
p.join("apm.toml"),
r#"[project]
name = "test"
default_branch = "main"
[tickets]
dir = "tickets"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
[[workflow.states.transitions]]
to = "implemented"
trigger = "manual"
completion = "merge"
[[workflow.states]]
id = "implemented"
label = "Implemented"
"#,
)
.unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "apm.toml"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
dir
}
fn write_leak_test_ticket(dir: &std::path::Path, id: &str, branch: &str) {
let rel = format!("tickets/{}.md", branch.strip_prefix("ticket/").unwrap_or(branch));
let content = format!(
"+++\nid = \"{id}\"\ntitle = \"Leak test\"\nstate = \"in_progress\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
);
let exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", branch])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !exists {
git(dir, &["checkout", "-b", branch]);
} else {
git(dir, &["checkout", branch]);
}
std::fs::create_dir_all(dir.join("tickets")).unwrap();
std::fs::write(dir.join(&rel), &content).unwrap();
git(dir, &["-c", "commit.gpgsign=false", "add", &rel]);
git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("ticket: {id}")]);
git(dir, &["checkout", "main"]);
}
#[test]
fn state_implemented_refuses_when_main_dirty_overlap() {
let dir = setup_merge_local();
let p = dir.path();
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/foo.rs"), "original").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add foo"]);
let id = "ab000001";
let branch = "ticket/ab000001-leak-overlap";
git(p, &["checkout", "-b", branch]);
std::fs::write(p.join("src/foo.rs"), "ticket change").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: change foo"]);
git(p, &["checkout", "main"]);
write_leak_test_ticket(p, id, branch);
std::fs::write(p.join("src/foo.rs"), "leaked edit").unwrap();
let result = apm_core::state::transition(p, id, "implemented".into(), true, false);
assert!(result.is_err(), "should refuse with leak diagnostic; got Ok");
let err_msg = result.err().unwrap().to_string();
assert!(err_msg.contains("src/foo.rs"), "error should name leaked file: {err_msg}");
assert!(err_msg.contains("ab000001"), "error should include ticket id: {err_msg}");
let ticket_rel = format!("tickets/{}.md", branch.strip_prefix("ticket/").unwrap());
let content = branch_content(p, branch, &ticket_rel);
assert!(content.contains("state = \"in_progress\""), "ticket state must remain in_progress: {content}");
}
#[test]
fn state_implemented_proceeds_when_main_clean() {
let dir = setup_merge_local();
let p = dir.path();
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/foo.rs"), "original").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add foo"]);
let id = "ab000002";
let branch = "ticket/ab000002-clean-main";
git(p, &["checkout", "-b", branch]);
std::fs::write(p.join("src/foo.rs"), "ticket change").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: change foo"]);
git(p, &["checkout", "main"]);
write_leak_test_ticket(p, id, branch);
let result = apm_core::state::transition(p, id, "implemented".into(), true, false);
assert!(result.is_ok(), "clean main should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());
}
#[test]
fn state_implemented_proceeds_when_dirty_no_overlap() {
let dir = setup_merge_local();
let p = dir.path();
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/foo.rs"), "original").unwrap();
std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs", "src/bar.rs"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add foo and bar"]);
let id = "ab000003";
let branch = "ticket/ab000003-no-overlap";
git(p, &["checkout", "-b", branch]);
std::fs::write(p.join("src/foo.rs"), "ticket change").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: change foo"]);
git(p, &["checkout", "main"]);
write_leak_test_ticket(p, id, branch);
std::fs::write(p.join("src/bar.rs"), "dirty bar").unwrap();
let result = apm_core::state::transition(p, id, "implemented".into(), true, false);
assert!(result.is_ok(), "no overlap should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());
}
#[test]
fn state_implemented_refuses_when_main_has_untracked_overlap() {
let dir = setup_merge_local();
let p = dir.path();
std::fs::write(p.join("existing.rs"), "base").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "existing.rs"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "base"]);
let id = "ab000004";
let branch = "ticket/ab000004-untracked-overlap";
git(p, &["checkout", "-b", branch]);
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/new.rs"), "new file").unwrap();
git(p, &["-c", "commit.gpgsign=false", "add", "src/new.rs"]);
git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: add new file"]);
git(p, &["checkout", "main"]);
write_leak_test_ticket(p, id, branch);
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
let result = apm_core::state::transition(p, id, "implemented".into(), true, false);
assert!(result.is_err(), "untracked overlap should be detected: got Ok");
let err_msg = result.err().unwrap().to_string();
assert!(err_msg.contains("src/new.rs"), "error should name the leaked file: {err_msg}");
}