1use anyhow::{Context as _, Result};
2use std::io::IsTerminal;
3use std::path::Path;
4use crate::ctx::CmdContext;
5use apm_core::epic::{branch_to_title, epic_id_from_branch, MergeStatus};
6
7fn freshness_label(ahead: usize, clean: bool) -> String {
8 if ahead == 0 {
9 "up to date".to_string()
10 } else if clean {
11 format!("↓{ahead} clean")
12 } else {
13 format!("↓{ahead} CONFLICTS")
14 }
15}
16
17pub fn run_list(root: &Path) -> Result<()> {
18 let ctx = CmdContext::load(root, false)?;
19
20 let epic_branches = apm_core::epic::epic_branches(root)?;
21 if epic_branches.is_empty() {
22 return Ok(());
23 }
24
25 let tickets = ctx.tickets;
26 let default_branch = &ctx.config.project.default_branch;
27
28 for branch in &epic_branches {
29 let id = epic_id_from_branch(branch);
30 let title = branch_to_title(branch);
31
32 let epic_tickets: Vec<_> = tickets
34 .iter()
35 .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
36 .collect();
37
38 let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
40 .iter()
41 .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
42 .collect();
43
44 let derived = apm_core::epic::derive_epic_state(&state_configs);
45
46 let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
48 for t in &epic_tickets {
49 *counts.entry(t.frontmatter.state.clone()).or_insert(0) += 1;
50 }
51 let counts_str: String = counts
52 .iter()
53 .filter(|(_, &v)| v > 0)
54 .map(|(k, v)| format!("{v} {k}"))
55 .collect::<Vec<_>>()
56 .join(", ");
57
58 let s = apm_core::epic::merge_tree_status(root, default_branch, branch)
59 .unwrap_or(MergeStatus { ahead: 0, clean: true });
60 println!("{id:<8} [{derived:<12}] {title:<40} {counts_str:<30} {}", freshness_label(s.ahead, s.clean));
61 }
62
63 Ok(())
64}
65
66pub fn run_new(root: &Path, title: String) -> Result<()> {
67 let config = apm_core::config::Config::load(root)?;
68 let branch = apm_core::epic::create(root, &title, &config)?;
69 println!("{branch}");
70 Ok(())
71}
72
73pub fn run_close(root: &Path, id_arg: &str, close_all: bool, merge: bool, _pr: bool, auto_mode: bool) -> Result<()> {
74 let config = CmdContext::load_config_only(root)?;
75
76 let matches = apm_core::epic::find_epic_branches(root, id_arg);
78 let epic_branch = match matches.len() {
79 0 => anyhow::bail!("no epic branch found matching '{id_arg}'"),
80 1 => matches.into_iter().next().unwrap(),
81 _ => anyhow::bail!(
82 "ambiguous id '{id_arg}': matches {}\n {}",
83 matches.len(),
84 matches.join("\n ")
85 ),
86 };
87
88 let epic_id = epic_id_from_branch(&epic_branch);
90
91 let worktrees = apm_core::worktree::list_ticket_worktrees(root)?;
93 let result = apm_core::epic::classify_epic_quiescence(
94 root, epic_id, &config, &worktrees, &epic_branch,
95 )?;
96
97 if !result.unsafe_tickets.is_empty() {
99 let rows = result.unsafe_tickets.iter()
100 .map(|t| format!(" {:<8} {:<13} {}", t.id, t.state, t.title))
101 .collect::<Vec<_>>()
102 .join("\n");
103 anyhow::bail!(
104 "cannot close epic: the following tickets require manual resolution:\n{}\nResolve them manually, then retry.",
105 rows
106 );
107 }
108
109 let mut remaining: Vec<&apm_core::epic::EpicTicketInfo> =
112 result.genuine_blockers.iter().collect();
113 if !result.auto_closeable.is_empty() {
114 let should_close = close_all || (std::io::stdout().is_terminal() && {
115 let n = result.auto_closeable.len();
116 println!("\nTickets merged but not yet closed ({n}):");
117 for t in &result.auto_closeable {
118 println!(" {} {}", t.id, t.title);
119 }
120 crate::util::prompt_yes_no(&format!("\nClose {n} merged ticket(s)? [y/N] "))?
121 });
122 if should_close {
123 let actor = format!("{}(apm-epic-close)", apm_core::config::resolve_caller_name());
124 for t in &result.auto_closeable {
125 match apm_core::ticket::close(root, &config, &t.id, None, &actor, false) {
126 Ok(msgs) => msgs.iter().for_each(|m| println!("{m}")),
127 Err(e) => eprintln!("warning: could not close {}: {e:#}", t.id),
128 }
129 }
130 } else {
131 remaining.extend(result.auto_closeable.iter());
133 }
134 }
135
136 if !remaining.is_empty() {
138 if !close_all {
139 let rows = remaining.iter()
140 .map(|t| format!(" {:<8} {:<13} {}", t.id, t.state, t.title))
141 .collect::<Vec<_>>()
142 .join("\n");
143 anyhow::bail!(
144 "epic has {} non-terminal ticket(s):\n{}\nRe-run with --close-all to cascade close, or close them manually first.",
145 remaining.len(),
146 rows
147 );
148 }
149 let actor = format!("{}(apm-epic-close)", apm_core::config::resolve_caller_name());
150 for t in &remaining {
151 print!("closing ticket #{} ... ", t.id);
152 apm_core::ticket::close(root, &config, &t.id, None, &actor, false)
153 .with_context(|| format!("failed to close ticket #{}", t.id))?;
154 println!("done");
155 }
156 }
157
158 let pr_title = branch_to_title(&epic_branch);
160
161 let default_branch = &config.project.default_branch;
163 let ahead = std::process::Command::new("git")
164 .current_dir(root)
165 .args(["log", "--oneline", &format!("{default_branch}..{epic_branch}")])
166 .output()?;
167 if String::from_utf8_lossy(&ahead.stdout).trim().is_empty() {
168 println!("epic/{epic_id} is already merged into {default_branch}; skipping PR");
170 delete_epic_branch(root, &epic_branch)?;
171 return Ok(());
172 }
173
174 let do_merge = merge || (auto_mode && {
176 let s = apm_core::epic::merge_tree_status(root, default_branch, &epic_branch)?;
177 s.clean
178 });
179
180 if do_merge {
181 let main_root = apm_core::git_util::main_worktree_root(root)
182 .unwrap_or_else(|| root.to_path_buf());
183 let head_out = std::process::Command::new("git")
184 .current_dir(&main_root)
185 .args(["symbolic-ref", "--short", "HEAD"])
186 .output()?;
187 let head = String::from_utf8_lossy(&head_out.stdout);
188 if head.trim() != default_branch {
189 anyhow::bail!(
190 "cannot merge: main worktree is on '{}', not '{default_branch}'. \
191 Check out {default_branch} first, or use --pr.",
192 head.trim()
193 );
194 }
195 let mut messages = vec![];
196 match apm_core::git_util::merge_ref(&main_root, &epic_branch, &mut messages) {
197 Some(msg) => {
198 for m in &messages { println!("{m}"); }
199 println!("{msg}");
200 }
201 None => anyhow::bail!(
202 "merge conflict — resolve manually after checking out {default_branch}, \
203 or use --pr to open a PR instead"
204 ),
205 }
206 } else {
207 apm_core::git::push_branch_tracking(root, &epic_branch)?;
208 let mut messages = vec![];
209 apm_core::github::gh_pr_create_or_update(
210 root,
211 &epic_branch,
212 default_branch,
213 epic_id,
214 &pr_title,
215 &format!("Epic: {epic_branch}"),
216 &mut messages,
217 )?;
218 for m in &messages { println!("{m}"); }
219 }
220 Ok(())
221}
222
223pub fn run_refresh_epic(root: &Path, id_arg: &str, merge: bool, pr: bool, auto_mode: bool, push: bool, no_push: bool) -> Result<()> {
224 let config = CmdContext::load_config_only(root)?;
225
226 let matches = apm_core::epic::find_epic_branches(root, id_arg);
227 let epic_branch = match matches.len() {
228 0 => anyhow::bail!("no epic branch found matching '{id_arg}'"),
229 1 => matches.into_iter().next().unwrap(),
230 _ => anyhow::bail!(
231 "ambiguous id '{id_arg}': matches {}\n {}",
232 matches.len(),
233 matches.join("\n ")
234 ),
235 };
236
237 let epic_id = epic_id_from_branch(&epic_branch);
238 let default_branch = &config.project.default_branch;
239
240 let status = apm_core::epic::merge_tree_status(root, default_branch, &epic_branch)?;
241
242 let acting = merge || pr || auto_mode;
243
244 if !acting {
245 if status.ahead == 0 {
246 println!("epic branch is up to date with {default_branch}");
247 } else {
248 let cleanliness = if status.clean { "clean" } else { "conflicted" };
249 println!("{} commit(s) ahead on {default_branch}; merge would be {cleanliness}", status.ahead);
250 }
251 return Ok(());
252 }
253
254 let worktrees = apm_core::worktree::list_ticket_worktrees(root)?;
255 let blockers = apm_core::epic::epic_is_quiescent(root, epic_id, &config, &worktrees)?;
256 if !blockers.is_empty() {
257 anyhow::bail!(
258 "cannot refresh epic: the following tickets are not quiescent:\n{}",
259 blockers.join("\n")
260 );
261 }
262
263 if status.ahead == 0 {
264 println!("epic branch is up to date with {default_branch}");
265 return Ok(());
266 }
267
268 let do_merge = merge || (auto_mode && status.clean);
269
270 if do_merge {
271 let main_root = apm_core::git_util::main_worktree_root(root)
272 .unwrap_or_else(|| root.to_path_buf());
273 let worktrees_base = main_root.join(&config.worktrees.dir);
274 let epic_wt_path = apm_core::worktree::find_worktree_for_branch(root, &epic_branch)
275 .map(Ok)
276 .unwrap_or_else(|| apm_core::worktree::ensure_worktree(root, &worktrees_base, &epic_branch))?;
277 let mut messages = vec![];
278 match apm_core::git_util::merge_ref(&epic_wt_path, default_branch, &mut messages) {
279 Some(msg) => {
280 for m in &messages {
281 println!("{m}");
282 }
283 println!("{msg}");
284 }
285 None => {
286 anyhow::bail!(
287 "merge conflict — resolve manually after checking out {epic_branch}, or use --pr to open a PR instead"
288 );
289 }
290 }
291
292 let should_push = if push {
293 true
294 } else if no_push {
295 false
296 } else if std::io::stdout().is_terminal() {
297 crate::util::prompt_yes_no_default_yes("Push refreshed epic to origin? [Y/n] ")?
298 } else {
299 false
300 };
301
302 if should_push {
303 apm_core::git::push_branch_tracking(root, &epic_branch)?;
304 println!("pushed {epic_branch} to origin");
305 } else {
306 eprintln!(
307 "warning: {epic_branch} was not pushed; \
308 downstream `apm start` will read stale origin content until pushed manually"
309 );
310 }
311 } else {
312 let log_out = std::process::Command::new("git")
313 .current_dir(root)
314 .args(["log", "--oneline", "--no-decorate", &format!("{epic_branch}..{default_branch}")])
315 .output()?;
316 let pr_body = String::from_utf8_lossy(&log_out.stdout).trim().to_string();
317 let pr_title = format!("{epic_id}: refresh from {default_branch}");
318
319 apm_core::git::push_branch_tracking(root, &epic_branch)?;
320
321 let mut messages = vec![];
322 apm_core::github::gh_pr_create_or_update_between(
323 root,
324 default_branch,
325 &epic_branch,
326 &pr_title,
327 &pr_body,
328 &mut messages,
329 )?;
330 for m in &messages {
331 println!("{m}");
332 }
333 }
334
335 Ok(())
336}
337
338pub fn run_show(root: &std::path::Path, id_arg: &str, no_aggressive: bool) -> anyhow::Result<()> {
339 let ctx = CmdContext::load(root, no_aggressive)?;
340
341 let matches = apm_core::epic::find_epic_branches(root, id_arg);
342 let branch = match matches.len() {
343 0 => anyhow::bail!("no epic matching '{id_arg}'"),
344 1 => matches.into_iter().next().unwrap(),
345 _ => anyhow::bail!(
346 "ambiguous prefix '{id_arg}', matches:\n {}",
347 matches.join("\n ")
348 ),
349 };
350
351 let epic_id = epic_id_from_branch(&branch);
352 let title = branch_to_title(&branch);
353
354 let epic_tickets: Vec<_> = ctx.tickets
355 .iter()
356 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
357 .collect();
358
359 let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
360 .iter()
361 .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
362 .collect();
363
364 let derived = apm_core::epic::derive_epic_state(&state_configs);
365
366 let s = apm_core::epic::merge_tree_status(root, &ctx.config.project.default_branch, &branch)
367 .unwrap_or(MergeStatus { ahead: 0, clean: true });
368
369 println!("Epic: {title}");
370 println!("Branch: {branch}");
371 println!("State: {derived}");
372 println!("Freshness: {}", freshness_label(s.ahead, s.clean));
373
374 if epic_tickets.is_empty() {
375 println!();
376 println!("(no tickets)");
377 return Ok(());
378 }
379
380 let id_w = 8usize;
382 let state_w = 13usize;
383 let title_w = 32usize;
384
385 println!();
386 println!(
387 "{:<id_w$} {:<state_w$} {:<title_w$} Depends on",
388 "ID", "State", "Title"
389 );
390 println!(
391 "{:-<id_w$} {:-<state_w$} {:-<title_w$} ----------",
392 "", "", ""
393 );
394
395 for t in &epic_tickets {
396 let fm = &t.frontmatter;
397 let deps = fm
398 .depends_on
399 .as_deref()
400 .map(|d| d.join(", "))
401 .unwrap_or_else(|| "-".to_string());
402 println!(
403 "{:<id_w$} {:<state_w$} {:<title_w$} {}",
404 fm.id, fm.state, fm.title, deps
405 );
406 }
407
408 Ok(())
409}
410
411pub fn run_set(root: &std::path::Path, id_arg: &str, field: &str, value: &str) -> anyhow::Result<()> {
412 if field != "owner" {
413 anyhow::bail!("unknown field {field:?}; valid fields: owner");
414 }
415
416 let matches = apm_core::epic::find_epic_branches(root, id_arg);
418 if matches.is_empty() {
419 eprintln!("error: no epic branch found matching '{id_arg}'");
420 std::process::exit(1);
421 }
422 if matches.len() > 1 {
423 anyhow::bail!(
424 "ambiguous id '{id_arg}': matches {}\n {}",
425 matches.len(),
426 matches.join("\n ")
427 );
428 }
429 let branch = &matches[0];
430 let epic_id = epic_id_from_branch(branch).to_string();
431
432 let config = apm_core::config::Config::load(root)?;
433
434 let local = apm_core::config::LocalConfig::load(root);
436 apm_core::validate::validate_owner(&config, &local, value)?;
437
438 let (changed, skipped) = apm_core::epic::set_epic_owner(root, &epic_id, value, &config)?;
439 println!("updated {changed} ticket(s), skipped {skipped} terminal ticket(s)");
440 Ok(())
441}
442
443
444fn delete_epic_branch(root: &Path, branch: &str) -> Result<()> {
445 if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
446 apm_core::worktree::remove_worktree(root, &wt_path, false)?;
447 }
448 let del = std::process::Command::new("git")
449 .current_dir(root)
450 .args(["branch", "-d", branch])
451 .output()?;
452 if del.status.success() {
453 println!("deleted local branch {branch}");
454 } else {
455 eprintln!("warning: could not delete local branch {branch}: {}", String::from_utf8_lossy(&del.stderr).trim());
456 }
457 let del_remote = std::process::Command::new("git")
458 .current_dir(root)
459 .args(["push", "origin", "--delete", branch])
460 .output()?;
461 if !del_remote.status.success() {
462 let stderr = String::from_utf8_lossy(&del_remote.stderr);
463 if !stderr.contains("remote ref does not exist") && !stderr.contains("error: unable to delete") {
464 eprintln!("warning: could not delete remote branch {branch}: {}", stderr.trim());
465 }
466 }
467 Ok(())
468}
469
470pub(crate) fn run_epic_clean(
471 root: &Path,
472 config: &apm_core::config::Config,
473 dry_run: bool,
474 yes: bool,
475) -> Result<()> {
476 let local_output = std::process::Command::new("git")
478 .current_dir(root)
479 .args(["branch", "--list", "epic/*"])
480 .output()?;
481
482 let local_branches: Vec<String> = String::from_utf8_lossy(&local_output.stdout)
483 .lines()
484 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
485 .filter(|l| !l.is_empty())
486 .collect();
487
488 let tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
490
491 let default_branch = &config.project.default_branch;
494 let mut candidates: Vec<String> = Vec::new();
495 for branch in &local_branches {
496 let id = apm_core::epic::epic_id_from_branch(branch);
497
498 let epic_tickets: Vec<_> = tickets
499 .iter()
500 .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
501 .collect();
502
503 let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
504 .iter()
505 .filter_map(|t| config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
506 .collect();
507
508 let derived = apm_core::epic::derive_epic_state(&state_configs);
509 let is_merged = || -> bool {
510 let out = std::process::Command::new("git")
511 .current_dir(root)
512 .args(["log", "--oneline", &format!("{default_branch}..{branch}")])
513 .output()
514 .ok();
515 out.map(|o| String::from_utf8_lossy(&o.stdout).trim().is_empty()).unwrap_or(false)
516 };
517
518 if derived == "done" || (derived == "empty" && is_merged()) {
519 candidates.push(branch.clone());
520 }
521 }
522
523 if candidates.is_empty() {
524 println!("Nothing to clean.");
525 return Ok(());
526 }
527
528 println!("Epics to delete ({}):", candidates.len());
530 for branch in &candidates {
531 let id = apm_core::epic::epic_id_from_branch(branch);
532 let title = apm_core::epic::branch_to_title(branch);
533 println!(" {id} {title}");
534 }
535
536 if dry_run {
537 println!("Dry run — no changes made.");
538 return Ok(());
539 }
540
541 if !yes {
543 if std::io::stdout().is_terminal() {
544 if !crate::util::prompt_yes_no(&format!("Delete {} epic(s)? [y/N] ", candidates.len()))? {
545 println!("Aborted.");
546 return Ok(());
547 }
548 } else {
549 println!("Skipping — non-interactive terminal. Use --yes to confirm.");
550 return Ok(());
551 }
552 }
553
554 for branch in &candidates {
556 if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
558 if let Err(e) = apm_core::worktree::remove_worktree(root, &wt_path, false) {
559 eprintln!(
560 "skipping {branch}: could not remove worktree at {}: {e}",
561 wt_path.display()
562 );
563 continue;
564 }
565 }
566
567 let del_local = std::process::Command::new("git")
569 .current_dir(root)
570 .args(["branch", "-d", branch])
571 .output()?;
572 if !del_local.status.success() {
573 eprintln!(
574 "error: failed to delete local branch {branch}: {}",
575 String::from_utf8_lossy(&del_local.stderr).trim()
576 );
577 continue;
578 }
579
580 let del_remote = std::process::Command::new("git")
582 .current_dir(root)
583 .args(["push", "origin", "--delete", branch])
584 .output()?;
585 if !del_remote.status.success() {
586 let stderr = String::from_utf8_lossy(&del_remote.stderr);
587 if !stderr.contains("remote ref does not exist")
588 && !stderr.contains("error: unable to delete")
589 {
590 eprintln!(
591 "warning: failed to delete remote {branch}: {}",
592 stderr.trim()
593 );
594 }
595 }
596
597 println!("deleted {branch}");
598 }
599
600 Ok(())
601}