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
74pub 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 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 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 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 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 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 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 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