Skip to main content

apm_core/
epic.rs

1use anyhow::Result;
2use std::path::Path;
3
4use crate::config::StateConfig;
5use crate::git_util;
6
7pub struct MergeStatus {
8    pub ahead: usize,
9    pub clean: bool,
10}
11
12pub fn merge_tree_status(root: &Path, default_branch: &str, epic_branch: &str) -> Result<MergeStatus> {
13    let log_out = git_util::run(root, &[
14        "log", "--oneline", "--no-decorate",
15        &format!("{epic_branch}..{default_branch}"),
16    ])?;
17    let ahead = log_out.lines().filter(|l| !l.is_empty()).count();
18    if ahead == 0 {
19        return Ok(MergeStatus { ahead: 0, clean: true });
20    }
21    let merge_base = git_util::run(root, &["merge-base", epic_branch, default_branch])?;
22    let merge_base = merge_base.trim();
23    let merge_tree_out = git_util::run(root, &[
24        "merge-tree", merge_base, default_branch, epic_branch,
25    ])?;
26    let clean = !merge_tree_out.contains("<<<<<<< ");
27    Ok(MergeStatus { ahead, clean })
28}
29
30pub fn epic_is_quiescent(
31    root: &Path,
32    epic_id: &str,
33    config: &crate::config::Config,
34    worktrees: &[(std::path::PathBuf, String)],
35) -> Result<Vec<String>> {
36    let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
37    let mut blockers = Vec::new();
38    let impl_states = config.implementation_state_ids();
39    let terminal_states = config.terminal_state_ids();
40
41    for t in all_tickets.iter().filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id)) {
42        let id = &t.frontmatter.id;
43        let title = &t.frontmatter.title;
44        let state_id = &t.frontmatter.state;
45
46        let has_reached_impl = impl_states.contains(state_id.as_str())
47            || crate::ticket_fmt::history_target_states(&t.body)
48                .iter()
49                .any(|s| impl_states.contains(s.as_str()));
50        if has_reached_impl && !terminal_states.contains(state_id.as_str()) {
51            blockers.push(format!("  {id} — {title} (state: {state_id})"));
52            continue;
53        }
54
55        let ticket_branch = t.frontmatter.branch.clone()
56            .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path));
57        if let Some(branch) = ticket_branch {
58            if let Some((wt_path, _)) = worktrees.iter().find(|(_, b)| b == &branch) {
59                let pid_file = wt_path.join(".apm-worker.pid");
60                if pid_file.exists() {
61                    if let Ok((pid, _)) = crate::worker::read_pid_file(&pid_file) {
62                        if crate::worker::is_alive(pid) {
63                            blockers.push(format!("  {id} — {title} (live worker)"));
64                        }
65                    }
66                }
67            }
68        }
69    }
70
71    Ok(blockers)
72}
73
74/// Derive the display state of an epic from the `StateConfig`s of its tickets.
75///
76/// Rules (evaluated in order):
77/// 1. Empty slice → "empty"
78/// 2. Any state has neither `satisfies_deps` nor `terminal` → "in_progress"
79/// 3. All states have `terminal = true` → "done"
80/// 4. All states have `satisfies_deps = true` or `terminal = true`, but not
81///    all are terminal → "implemented"
82/// 5. Otherwise → "in_progress"
83pub fn derive_epic_state(states: &[&StateConfig]) -> &'static str {
84    if states.is_empty() {
85        return "empty";
86    }
87    if states.iter().any(|s| !matches!(s.satisfies_deps, crate::config::SatisfiesDeps::Bool(true)) && !s.terminal) {
88        return "in_progress";
89    }
90    if states.iter().all(|s| s.terminal) {
91        return "done";
92    }
93    "implemented"
94}
95
96pub fn create(root: &Path, title: &str, config: &crate::config::Config) -> Result<String> {
97    let id = crate::ticket_fmt::gen_hex_id();
98    let slug = crate::ticket::slugify(title);
99    let branch = format!("epic/{id}-{slug}");
100    let default_branch = &config.project.default_branch;
101    let _ = git_util::fetch_branch(root, default_branch);
102    if git_util::run(root, &["branch", &branch, &format!("origin/{default_branch}")]).is_err()
103        && git_util::run(root, &["branch", &branch, default_branch]).is_err()
104    {
105        crate::git::commit_to_branch(root, &branch, "tickets/.gitkeep", "", "epic: init")?;
106    }
107    let _ = crate::git::push_branch_tracking(root, &branch);
108    Ok(branch)
109}
110
111pub fn find_epic_branch(root: &Path, short_id: &str) -> Option<String> {
112    let pattern = format!("epic/{short_id}-*");
113    let local = crate::git_util::run(root, &["branch", "--list", &pattern]).ok()?;
114    for b in local.lines().map(|l| l.trim().trim_start_matches(['*', '+']).trim()) {
115        if !b.is_empty() {
116            return Some(b.to_string());
117        }
118    }
119    let remote_pattern = format!("origin/epic/{short_id}-*");
120    let remote = crate::git_util::run(root, &["branch", "-r", "--list", &remote_pattern]).ok()?;
121    for b in remote.lines().map(|l| l.trim()) {
122        if !b.is_empty() {
123            return Some(b.trim_start_matches("origin/").to_string());
124        }
125    }
126    None
127}
128
129pub fn find_epic_branches(root: &Path, id_prefix: &str) -> Vec<String> {
130    let mut seen = std::collections::HashSet::new();
131    let mut result = Vec::new();
132
133    let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
134    for b in local.lines()
135        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
136        .filter(|l| !l.is_empty())
137    {
138        let id_part = b.trim_start_matches("epic/").split('-').next().unwrap_or("");
139        if id_part.starts_with(id_prefix) && seen.insert(b.to_string()) {
140            result.push(b.to_string());
141        }
142    }
143
144    let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
145    for b in remote.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
146        let short = b.trim_start_matches("origin/");
147        let id_part = short.trim_start_matches("epic/").split('-').next().unwrap_or("");
148        if id_part.starts_with(id_prefix) && seen.insert(short.to_string()) {
149            result.push(short.to_string());
150        }
151    }
152
153    result
154}
155
156pub fn epic_branches(root: &Path) -> Result<Vec<String>> {
157    let mut seen = std::collections::HashSet::new();
158    let mut branches = Vec::new();
159
160    let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
161    for b in local.lines()
162        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
163        .filter(|l| !l.is_empty())
164    {
165        if seen.insert(b.to_string()) {
166            branches.push(b.to_string());
167        }
168    }
169
170    let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
171    for b in remote.lines()
172        .map(|l| l.trim().trim_start_matches("origin/").to_string())
173        .filter(|l| !l.is_empty())
174    {
175        if seen.insert(b.clone()) {
176            branches.push(b);
177        }
178    }
179
180    branches.sort();
181    Ok(branches)
182}
183
184pub fn branch_to_title(branch: &str) -> String {
185    let rest = branch.trim_start_matches("epic/");
186    let slug = match rest.find('-') {
187        Some(pos) => &rest[pos + 1..],
188        None => rest,
189    };
190    slug.split('-')
191        .map(|word| {
192            let mut chars = word.chars();
193            match chars.next() {
194                None => String::new(),
195                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
196            }
197        })
198        .collect::<Vec<_>>()
199        .join(" ")
200}
201
202pub fn epic_id_from_branch(branch: &str) -> &str {
203    let rest = branch.trim_start_matches("epic/");
204    match rest.find('-') {
205        Some(pos) => &rest[..pos],
206        None => rest,
207    }
208}
209
210pub fn set_epic_owner(
211    root: &Path,
212    epic_id: &str,
213    new_owner: &str,
214    config: &crate::config::Config,
215) -> Result<(usize, usize)> {
216    let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
217    let terminal = config.terminal_state_ids();
218
219    let (mut to_change, skipped): (Vec<_>, Vec<_>) = all_tickets
220        .into_iter()
221        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
222        .partition(|t| !terminal.contains(&t.frontmatter.state));
223
224    for t in &to_change {
225        crate::ticket::check_owner(root, t)?;
226    }
227
228    for t in &mut to_change {
229        crate::ticket::set_field(&mut t.frontmatter, "owner", new_owner)?;
230        let content = t.serialize()?;
231        let rel_path = format!(
232            "{}/{}",
233            config.tickets.dir.to_string_lossy(),
234            t.path.file_name().unwrap().to_string_lossy()
235        );
236        let ticket_branch = t.frontmatter.branch.clone()
237            .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path))
238            .unwrap_or_else(|| format!("ticket/{}", t.frontmatter.id));
239        crate::git::commit_to_branch(
240            root,
241            &ticket_branch,
242            &rel_path,
243            &content,
244            &format!("ticket({}): bulk set owner = {}", t.frontmatter.id, new_owner),
245        )?;
246    }
247
248    Ok((to_change.len(), skipped.len()))
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::config::StateConfig;
255
256    #[test]
257    fn branch_to_title_basic() {
258        assert_eq!(branch_to_title("epic/ab12cd34-user-authentication"), "User Authentication");
259    }
260
261    #[test]
262    fn branch_to_title_single_word() {
263        assert_eq!(branch_to_title("epic/ab12cd34-dashboard"), "Dashboard");
264    }
265
266    #[test]
267    fn branch_to_title_many_words() {
268        assert_eq!(branch_to_title("epic/ab12cd34-add-oauth-login-flow"), "Add Oauth Login Flow");
269    }
270
271    #[test]
272    fn branch_to_title_no_slug() {
273        assert_eq!(branch_to_title("epic/ab12cd34"), "Ab12cd34");
274    }
275
276    #[test]
277    fn epic_id_from_branch_happy_path() {
278        assert_eq!(epic_id_from_branch("epic/57bce963-refactor-apm-core"), "57bce963");
279    }
280
281    #[test]
282    fn epic_id_from_branch_no_epic_prefix() {
283        assert_eq!(epic_id_from_branch("57bce963-refactor"), "57bce963");
284    }
285
286    #[test]
287    fn epic_id_from_branch_no_dash() {
288        assert_eq!(epic_id_from_branch("nodash"), "nodash");
289    }
290
291    fn git_cmd(dir: &std::path::Path, args: &[&str]) {
292        std::process::Command::new("git")
293            .args(args)
294            .current_dir(dir)
295            .env("GIT_AUTHOR_NAME", "test")
296            .env("GIT_AUTHOR_EMAIL", "test@test.com")
297            .env("GIT_COMMITTER_NAME", "test")
298            .env("GIT_COMMITTER_EMAIL", "test@test.com")
299            .output()
300            .unwrap();
301    }
302
303    fn setup_repo() -> tempfile::TempDir {
304        let tmp = tempfile::tempdir().unwrap();
305        let p = tmp.path();
306        git_cmd(p, &["init", "-q", "-b", "main"]);
307        git_cmd(p, &["config", "user.email", "test@test.com"]);
308        git_cmd(p, &["config", "user.name", "test"]);
309        // Initial commit so commit_to_branch can use worktrees.
310        std::fs::write(p.join("README.md"), "init\n").unwrap();
311        git_cmd(p, &["add", "README.md"]);
312        git_cmd(p, &["commit", "-m", "init"]);
313        tmp
314    }
315
316    const TOML_WITH_STATES: &str = concat!(
317        "[project]\nname = \"test\"\n\n",
318        "[tickets]\ndir = \"tickets\"\n\n",
319        "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\n\n",
320        "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
321    );
322
323    fn make_ticket_content(id: &str, state: &str, epic: &str) -> String {
324        format!(
325            "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
326        )
327    }
328
329    #[test]
330    fn set_epic_owner_updates_non_terminal_skips_terminal() {
331        let tmp = setup_repo();
332        let p = tmp.path();
333        std::fs::create_dir_all(p.join(".apm")).unwrap();
334        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
335        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
336
337        let config = crate::config::Config::load(p).unwrap();
338
339        // Non-terminal ticket in this epic.
340        let content_a = make_ticket_content("aaaa1234", "ready", "epic1234");
341        crate::git::commit_to_branch(p, "ticket/aaaa1234-t1", "tickets/aaaa1234-t1.md", &content_a, "add t1").unwrap();
342
343        // Terminal ticket in this epic — should be skipped.
344        let content_b = make_ticket_content("bbbb5678", "closed", "epic1234");
345        crate::git::commit_to_branch(p, "ticket/bbbb5678-t2", "tickets/bbbb5678-t2.md", &content_b, "add t2").unwrap();
346
347        // Ticket in a different epic — should be ignored.
348        let content_c = make_ticket_content("cccc9012", "ready", "other123");
349        crate::git::commit_to_branch(p, "ticket/cccc9012-t3", "tickets/cccc9012-t3.md", &content_c, "add t3").unwrap();
350
351        let (changed, skipped) = set_epic_owner(p, "epic1234", "alice", &config).unwrap();
352        assert_eq!(changed, 1, "one non-terminal ticket should be changed");
353        assert_eq!(skipped, 1, "one terminal ticket should be skipped");
354    }
355
356    #[test]
357    fn set_epic_owner_all_terminal_returns_zero_changed() {
358        let tmp = setup_repo();
359        let p = tmp.path();
360        std::fs::create_dir_all(p.join(".apm")).unwrap();
361        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
362
363        let config = crate::config::Config::load(p).unwrap();
364
365        let content_a = make_ticket_content("dddd1111", "closed", "epic5678");
366        crate::git::commit_to_branch(p, "ticket/dddd1111-t4", "tickets/dddd1111-t4.md", &content_a, "add t4").unwrap();
367        let content_b = make_ticket_content("eeee2222", "closed", "epic5678");
368        crate::git::commit_to_branch(p, "ticket/eeee2222-t5", "tickets/eeee2222-t5.md", &content_b, "add t5").unwrap();
369
370        let (changed, skipped) = set_epic_owner(p, "epic5678", "bob", &config).unwrap();
371        assert_eq!(changed, 0);
372        assert_eq!(skipped, 2);
373    }
374
375    const TOML_WITH_WORKER_END: &str = concat!(
376        "[project]\nname = \"test\"\n\n",
377        "[tickets]\ndir = \"tickets\"\n\n",
378        "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\nworker_end = false\n\n",
379        "[[workflow.states]]\nid = \"implemented\"\nlabel = \"Implemented\"\nterminal = false\nworker_end = true\n\n",
380        "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
381    );
382
383    const TOML_WITH_IMPL_STATES: &str = r#"
384[project]
385name = "test"
386
387[tickets]
388dir = "tickets"
389
390[[workflow.states]]
391id    = "ready"
392label = "Ready"
393
394  [[workflow.states.transitions]]
395  to      = "in_progress"
396  trigger = "command:start"
397
398[[workflow.states]]
399id             = "in_progress"
400label          = "In Progress"
401worker_profile = "claude/coder"
402
403  [[workflow.states.transitions]]
404  to         = "implemented"
405  trigger    = "manual"
406  completion = "pr_or_epic_merge"
407
408[[workflow.states]]
409id    = "implemented"
410label = "Implemented"
411
412[[workflow.states]]
413id    = "ammend"
414label = "Ammend"
415
416[[workflow.states]]
417id    = "closed"
418label = "Closed"
419terminal = true
420"#;
421
422    const TOML_WITH_IMPL_STATES_REVERSED: &str = r#"
423[project]
424name = "test"
425
426[tickets]
427dir = "tickets"
428
429[[workflow.states]]
430id    = "closed"
431label = "Closed"
432terminal = true
433
434[[workflow.states]]
435id    = "ammend"
436label = "Ammend"
437
438[[workflow.states]]
439id    = "implemented"
440label = "Implemented"
441
442[[workflow.states]]
443id             = "in_progress"
444label          = "In Progress"
445worker_profile = "claude/coder"
446
447  [[workflow.states.transitions]]
448  to         = "implemented"
449  trigger    = "manual"
450  completion = "pr_or_epic_merge"
451
452[[workflow.states]]
453id    = "ready"
454label = "Ready"
455
456  [[workflow.states.transitions]]
457  to      = "in_progress"
458  trigger = "command:start"
459"#;
460
461    fn make_ticket_content_with_history(id: &str, state: &str, epic: &str, history: &[(&str, &str)]) -> String {
462        let mut s = format!(
463            "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
464        );
465        s.push_str("\n## History\n\n| When | From | To | By |\n|------|------|----|----|\n");
466        for (from, to) in history {
467            s.push_str(&format!("| 2026-01-01T00:00Z | {from} | {to} | test |\n"));
468        }
469        s
470    }
471
472    #[test]
473    fn epic_is_quiescent_all_done() {
474        let tmp = setup_repo();
475        let p = tmp.path();
476        std::fs::create_dir_all(p.join(".apm")).unwrap();
477        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
478        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
479
480        let config = crate::config::Config::load(p).unwrap();
481
482        let closed = make_ticket_content("aaaa0001", "closed", "epic0001");
483        crate::git::commit_to_branch(p, "ticket/aaaa0001-t1", "tickets/aaaa0001-t1.md", &closed, "add t1").unwrap();
484
485        let implemented = make_ticket_content("bbbb0002", "implemented", "epic0001");
486        crate::git::commit_to_branch(p, "ticket/bbbb0002-t2", "tickets/bbbb0002-t2.md", &implemented, "add t2").unwrap();
487
488        let blockers = epic_is_quiescent(p, "epic0001", &config, &[]).unwrap();
489        assert!(blockers.is_empty(), "expected no blockers, got: {blockers:?}");
490    }
491
492    #[test]
493    fn epic_is_quiescent_state_blocker() {
494        let tmp = setup_repo();
495        let p = tmp.path();
496        std::fs::create_dir_all(p.join(".apm")).unwrap();
497        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
498        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
499
500        let config = crate::config::Config::load(p).unwrap();
501
502        let content = make_ticket_content("cccc0003", "in_progress", "epic0002");
503        crate::git::commit_to_branch(p, "ticket/cccc0003-t3", "tickets/cccc0003-t3.md", &content, "add t3").unwrap();
504
505        let blockers = epic_is_quiescent(p, "epic0002", &config, &[]).unwrap();
506        assert_eq!(blockers.len(), 1);
507        assert!(blockers[0].contains("cccc0003"));
508        assert!(blockers[0].contains("(state: in_progress)"));
509    }
510
511    #[test]
512    fn epic_is_quiescent_live_worker_blocker() {
513        let tmp = setup_repo();
514        let p = tmp.path();
515        std::fs::create_dir_all(p.join(".apm")).unwrap();
516        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
517        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
518
519        let config = crate::config::Config::load(p).unwrap();
520
521        // Ticket in worker_end state (quiescent by state check).
522        let content = make_ticket_content("dddd0004", "implemented", "epic0003");
523        crate::git::commit_to_branch(p, "ticket/dddd0004-t4", "tickets/dddd0004-t4.md", &content, "add t4").unwrap();
524
525        // Simulate a worktree with a live .apm-worker.pid (current process PID).
526        let wt_path = tmp.path().join("fake-worktree-dddd0004");
527        std::fs::create_dir_all(&wt_path).unwrap();
528        let pid = std::process::id();
529        std::fs::write(
530            wt_path.join(".apm-worker.pid"),
531            format!(r#"{{"pid":{pid},"ticket_id":"dddd0004","started_at":"2026-01-01T00:00:00Z"}}"#),
532        ).unwrap();
533
534        let worktrees = vec![(wt_path, "ticket/dddd0004-t4".to_string())];
535        let blockers = epic_is_quiescent(p, "epic0003", &config, &worktrees).unwrap();
536        assert_eq!(blockers.len(), 1);
537        assert!(blockers[0].contains("dddd0004"));
538        assert!(blockers[0].contains("(live worker)"));
539    }
540
541    #[test]
542    fn epic_is_quiescent_ready_no_history_does_not_block() {
543        let tmp = setup_repo();
544        let p = tmp.path();
545        std::fs::create_dir_all(p.join(".apm")).unwrap();
546        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
547        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
548
549        let config = crate::config::Config::load(p).unwrap();
550
551        let content = make_ticket_content("eeee0005", "ready", "epic0005");
552        crate::git::commit_to_branch(p, "ticket/eeee0005-t5", "tickets/eeee0005-t5.md", &content, "add t5").unwrap();
553
554        let blockers = epic_is_quiescent(p, "epic0005", &config, &[]).unwrap();
555        assert!(blockers.is_empty(), "expected no blockers for ready ticket with no history, got: {blockers:?}");
556    }
557
558    #[test]
559    fn epic_is_quiescent_ammend_with_impl_history_blocks() {
560        let tmp = setup_repo();
561        let p = tmp.path();
562        std::fs::create_dir_all(p.join(".apm")).unwrap();
563        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
564        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
565
566        let config = crate::config::Config::load(p).unwrap();
567
568        let content = make_ticket_content_with_history(
569            "ffff0006", "ammend", "epic0006",
570            &[("groomed", "in_progress"), ("in_progress", "ammend")],
571        );
572        crate::git::commit_to_branch(p, "ticket/ffff0006-t6", "tickets/ffff0006-t6.md", &content, "add t6").unwrap();
573
574        let blockers = epic_is_quiescent(p, "epic0006", &config, &[]).unwrap();
575        assert_eq!(blockers.len(), 1, "expected ammend ticket with impl history to block");
576        assert!(blockers[0].contains("ffff0006"));
577    }
578
579    #[test]
580    fn epic_is_quiescent_closed_with_impl_history_does_not_block() {
581        let tmp = setup_repo();
582        let p = tmp.path();
583        std::fs::create_dir_all(p.join(".apm")).unwrap();
584        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
585        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
586
587        let config = crate::config::Config::load(p).unwrap();
588
589        let content = make_ticket_content_with_history(
590            "gggg0007", "closed", "epic0007",
591            &[("in_progress", "implemented"), ("implemented", "closed")],
592        );
593        crate::git::commit_to_branch(p, "ticket/gggg0007-t7", "tickets/gggg0007-t7.md", &content, "add t7").unwrap();
594
595        let blockers = epic_is_quiescent(p, "epic0007", &config, &[]).unwrap();
596        assert!(blockers.is_empty(), "expected closed ticket with impl history not to block, got: {blockers:?}");
597    }
598
599    #[test]
600    fn epic_is_quiescent_order_invariant() {
601        let tmp = setup_repo();
602        let p = tmp.path();
603        std::fs::create_dir_all(p.join(".apm")).unwrap();
604        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
605
606        // A blocking ticket (in_progress) and a non-blocking ticket (ready, no history).
607        let blocking = make_ticket_content("hhhh0008", "in_progress", "epic0008");
608        crate::git::commit_to_branch(p, "ticket/hhhh0008-t8", "tickets/hhhh0008-t8.md", &blocking, "add t8").unwrap();
609        let non_blocking = make_ticket_content("iiii0009", "ready", "epic0008");
610        crate::git::commit_to_branch(p, "ticket/iiii0009-t9", "tickets/iiii0009-t9.md", &non_blocking, "add t9").unwrap();
611
612        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
613        let config1 = crate::config::Config::load(p).unwrap();
614        let blockers1 = epic_is_quiescent(p, "epic0008", &config1, &[]).unwrap();
615
616        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES_REVERSED).unwrap();
617        let config2 = crate::config::Config::load(p).unwrap();
618        let blockers2 = epic_is_quiescent(p, "epic0008", &config2, &[]).unwrap();
619
620        let mut b1 = blockers1.clone();
621        let mut b2 = blockers2.clone();
622        b1.sort();
623        b2.sort();
624        assert_eq!(b1, b2, "epic_is_quiescent must be invariant to [[workflow.states]] order");
625        assert_eq!(b1.len(), 1, "expected exactly one blocker (the in_progress ticket)");
626    }
627
628    fn make_state(terminal: bool, satisfies_deps: bool) -> StateConfig {
629        StateConfig {
630            id: "x".to_string(),
631            label: "x".to_string(),
632            description: String::new(),
633            terminal,
634            worker_end: false,
635            satisfies_deps: crate::config::SatisfiesDeps::Bool(satisfies_deps),
636            dep_requires: None,
637            worker_profile: None,
638            transitions: vec![],
639        }
640    }
641
642    #[test]
643    fn empty_slice_is_empty() {
644        assert_eq!(derive_epic_state(&[]), "empty");
645    }
646
647    #[test]
648    fn all_terminal_is_done() {
649        let a = make_state(true, false);
650        let b = make_state(true, false);
651        assert_eq!(derive_epic_state(&[&a, &b]), "done");
652    }
653
654    #[test]
655    fn all_satisfies_deps_not_all_terminal_is_implemented() {
656        let a = make_state(false, true);
657        let b = make_state(true, false);
658        assert_eq!(derive_epic_state(&[&a, &b]), "implemented");
659    }
660
661    #[test]
662    fn any_neither_satisfies_nor_terminal_is_in_progress() {
663        let a = make_state(false, false);
664        let b = make_state(true, false);
665        assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
666    }
667
668    #[test]
669    fn mixed_non_terminal_non_satisfies_is_in_progress() {
670        let a = make_state(false, false);
671        let b = make_state(true, false);
672        assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
673    }
674
675    #[test]
676    fn merge_tree_status_up_to_date() {
677        let tmp = setup_repo();
678        let p = tmp.path();
679        git_cmd(p, &["checkout", "-b", "epic/aa000001-test"]);
680        git_cmd(p, &["checkout", "main"]);
681
682        let status = super::merge_tree_status(p, "main", "epic/aa000001-test").unwrap();
683        assert_eq!(status.ahead, 0);
684        assert!(status.clean);
685    }
686
687    #[test]
688    fn merge_tree_status_clean_merge() {
689        let tmp = setup_repo();
690        let p = tmp.path();
691        git_cmd(p, &["checkout", "-b", "epic/bb000002-test"]);
692        git_cmd(p, &["checkout", "main"]);
693        std::fs::write(p.join("main_only.md"), "content\n").unwrap();
694        git_cmd(p, &["add", "main_only.md"]);
695        git_cmd(p, &["commit", "-m", "add main-only file"]);
696
697        let status = super::merge_tree_status(p, "main", "epic/bb000002-test").unwrap();
698        assert!(status.ahead > 0, "expected main to be ahead");
699        assert!(status.clean, "expected clean merge");
700    }
701
702    #[test]
703    fn merge_tree_status_conflict() {
704        let tmp = setup_repo();
705        let p = tmp.path();
706        std::fs::write(p.join("shared.md"), "original line\n").unwrap();
707        git_cmd(p, &["add", "shared.md"]);
708        git_cmd(p, &["commit", "-m", "add shared file"]);
709
710        git_cmd(p, &["checkout", "-b", "epic/cc000003-test"]);
711        std::fs::write(p.join("shared.md"), "epic branch content\n").unwrap();
712        git_cmd(p, &["add", "shared.md"]);
713        git_cmd(p, &["commit", "-m", "modify shared on epic"]);
714
715        git_cmd(p, &["checkout", "main"]);
716        std::fs::write(p.join("shared.md"), "main branch content\n").unwrap();
717        git_cmd(p, &["add", "shared.md"]);
718        git_cmd(p, &["commit", "-m", "modify shared on main"]);
719
720        let status = super::merge_tree_status(p, "main", "epic/cc000003-test").unwrap();
721        assert!(status.ahead > 0, "expected main to be ahead");
722        assert!(!status.clean, "expected conflicted merge");
723    }
724}
725