1use anyhow::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) -> 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 blockers = apm_core::epic::epic_is_quiescent(root, epic_id, &config, &worktrees)?;
94 if !blockers.is_empty() {
95 anyhow::bail!(
96 "cannot close epic: the following tickets are not quiescent:\n{}",
97 blockers.join("\n")
98 );
99 }
100
101 let pr_title = branch_to_title(&epic_branch);
103
104 let default_branch = &config.project.default_branch;
106 let ahead = std::process::Command::new("git")
107 .current_dir(root)
108 .args(["log", "--oneline", &format!("{default_branch}..{epic_branch}")])
109 .output()?;
110 if String::from_utf8_lossy(&ahead.stdout).trim().is_empty() {
111 println!("epic/{epic_id} is already merged into {default_branch}; skipping PR");
113 delete_epic_branch(root, &epic_branch)?;
114 return Ok(());
115 }
116
117 apm_core::git::push_branch_tracking(root, &epic_branch)?;
119 let mut messages = vec![];
120 apm_core::github::gh_pr_create_or_update(
121 root,
122 &epic_branch,
123 default_branch,
124 epic_id,
125 &pr_title,
126 &format!("Epic: {epic_branch}"),
127 &mut messages,
128 )?;
129 for m in &messages {
130 println!("{m}");
131 }
132 Ok(())
133}
134
135pub fn run_refresh_epic(root: &Path, id_arg: &str, merge: bool, pr: bool, auto_mode: bool) -> Result<()> {
136 let config = CmdContext::load_config_only(root)?;
137
138 let matches = apm_core::epic::find_epic_branches(root, id_arg);
139 let epic_branch = match matches.len() {
140 0 => anyhow::bail!("no epic branch found matching '{id_arg}'"),
141 1 => matches.into_iter().next().unwrap(),
142 _ => anyhow::bail!(
143 "ambiguous id '{id_arg}': matches {}\n {}",
144 matches.len(),
145 matches.join("\n ")
146 ),
147 };
148
149 let epic_id = epic_id_from_branch(&epic_branch);
150 let default_branch = &config.project.default_branch;
151
152 let status = apm_core::epic::merge_tree_status(root, default_branch, &epic_branch)?;
153
154 let acting = merge || pr || auto_mode;
155
156 if !acting {
157 if status.ahead == 0 {
158 println!("epic branch is up to date with {default_branch}");
159 } else {
160 let cleanliness = if status.clean { "clean" } else { "conflicted" };
161 println!("{} commit(s) ahead on {default_branch}; merge would be {cleanliness}", status.ahead);
162 }
163 return Ok(());
164 }
165
166 let worktrees = apm_core::worktree::list_ticket_worktrees(root)?;
167 let blockers = apm_core::epic::epic_is_quiescent(root, epic_id, &config, &worktrees)?;
168 if !blockers.is_empty() {
169 anyhow::bail!(
170 "cannot refresh epic: the following tickets are not quiescent:\n{}",
171 blockers.join("\n")
172 );
173 }
174
175 if status.ahead == 0 {
176 println!("epic branch is up to date with {default_branch}");
177 return Ok(());
178 }
179
180 let do_merge = merge || (auto_mode && status.clean);
181
182 if do_merge {
183 let main_root = apm_core::git_util::main_worktree_root(root)
184 .unwrap_or_else(|| root.to_path_buf());
185 let worktrees_base = main_root.join(&config.worktrees.dir);
186 let epic_wt_path = apm_core::worktree::find_worktree_for_branch(root, &epic_branch)
187 .map(Ok)
188 .unwrap_or_else(|| apm_core::worktree::ensure_worktree(root, &worktrees_base, &epic_branch))?;
189 let mut messages = vec![];
190 match apm_core::git_util::merge_ref(&epic_wt_path, default_branch, &mut messages) {
191 Some(msg) => {
192 for m in &messages {
193 println!("{m}");
194 }
195 println!("{msg}");
196 }
197 None => {
198 anyhow::bail!(
199 "merge conflict — resolve manually after checking out {epic_branch}, or use --pr to open a PR instead"
200 );
201 }
202 }
203 } else {
204 let log_out = std::process::Command::new("git")
205 .current_dir(root)
206 .args(["log", "--oneline", "--no-decorate", &format!("{epic_branch}..{default_branch}")])
207 .output()?;
208 let pr_body = String::from_utf8_lossy(&log_out.stdout).trim().to_string();
209 let pr_title = format!("{epic_id}: refresh from {default_branch}");
210
211 apm_core::git::push_branch_tracking(root, &epic_branch)?;
212
213 let mut messages = vec![];
214 apm_core::github::gh_pr_create_or_update_between(
215 root,
216 default_branch,
217 &epic_branch,
218 &pr_title,
219 &pr_body,
220 &mut messages,
221 )?;
222 for m in &messages {
223 println!("{m}");
224 }
225 }
226
227 Ok(())
228}
229
230pub fn run_show(root: &std::path::Path, id_arg: &str, no_aggressive: bool) -> anyhow::Result<()> {
231 let ctx = CmdContext::load(root, no_aggressive)?;
232
233 let matches = apm_core::epic::find_epic_branches(root, id_arg);
234 let branch = match matches.len() {
235 0 => anyhow::bail!("no epic matching '{id_arg}'"),
236 1 => matches.into_iter().next().unwrap(),
237 _ => anyhow::bail!(
238 "ambiguous prefix '{id_arg}', matches:\n {}",
239 matches.join("\n ")
240 ),
241 };
242
243 let epic_id = epic_id_from_branch(&branch);
244 let title = branch_to_title(&branch);
245
246 let epic_tickets: Vec<_> = ctx.tickets
247 .iter()
248 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
249 .collect();
250
251 let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
252 .iter()
253 .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
254 .collect();
255
256 let derived = apm_core::epic::derive_epic_state(&state_configs);
257
258 let s = apm_core::epic::merge_tree_status(root, &ctx.config.project.default_branch, &branch)
259 .unwrap_or(MergeStatus { ahead: 0, clean: true });
260
261 println!("Epic: {title}");
262 println!("Branch: {branch}");
263 println!("State: {derived}");
264 println!("Freshness: {}", freshness_label(s.ahead, s.clean));
265
266 if epic_tickets.is_empty() {
267 println!();
268 println!("(no tickets)");
269 return Ok(());
270 }
271
272 let id_w = 8usize;
274 let state_w = 13usize;
275 let title_w = 32usize;
276
277 println!();
278 println!(
279 "{:<id_w$} {:<state_w$} {:<title_w$} {}",
280 "ID", "State", "Title", "Depends on"
281 );
282 println!(
283 "{:-<id_w$} {:-<state_w$} {:-<title_w$} {}",
284 "", "", "", "----------"
285 );
286
287 for t in &epic_tickets {
288 let fm = &t.frontmatter;
289 let deps = fm
290 .depends_on
291 .as_deref()
292 .map(|d| d.join(", "))
293 .unwrap_or_else(|| "-".to_string());
294 println!(
295 "{:<id_w$} {:<state_w$} {:<title_w$} {}",
296 fm.id, fm.state, fm.title, deps
297 );
298 }
299
300 Ok(())
301}
302
303pub fn run_set(root: &std::path::Path, id_arg: &str, field: &str, value: &str) -> anyhow::Result<()> {
304 if field != "owner" {
305 anyhow::bail!("unknown field {field:?}; valid fields: owner");
306 }
307
308 let matches = apm_core::epic::find_epic_branches(root, id_arg);
310 if matches.is_empty() {
311 eprintln!("error: no epic branch found matching '{id_arg}'");
312 std::process::exit(1);
313 }
314 if matches.len() > 1 {
315 anyhow::bail!(
316 "ambiguous id '{id_arg}': matches {}\n {}",
317 matches.len(),
318 matches.join("\n ")
319 );
320 }
321 let branch = &matches[0];
322 let epic_id = epic_id_from_branch(branch).to_string();
323
324 let config = apm_core::config::Config::load(root)?;
325
326 let local = apm_core::config::LocalConfig::load(root);
328 apm_core::validate::validate_owner(&config, &local, value)?;
329
330 let (changed, skipped) = apm_core::epic::set_epic_owner(root, &epic_id, value, &config)?;
331 println!("updated {changed} ticket(s), skipped {skipped} terminal ticket(s)");
332 Ok(())
333}
334
335
336fn delete_epic_branch(root: &Path, branch: &str) -> Result<()> {
337 if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
338 apm_core::worktree::remove_worktree(root, &wt_path, false)?;
339 }
340 let del = std::process::Command::new("git")
341 .current_dir(root)
342 .args(["branch", "-d", branch])
343 .output()?;
344 if del.status.success() {
345 println!("deleted local branch {branch}");
346 } else {
347 eprintln!("warning: could not delete local branch {branch}: {}", String::from_utf8_lossy(&del.stderr).trim());
348 }
349 let del_remote = std::process::Command::new("git")
350 .current_dir(root)
351 .args(["push", "origin", "--delete", branch])
352 .output()?;
353 if !del_remote.status.success() {
354 let stderr = String::from_utf8_lossy(&del_remote.stderr);
355 if !stderr.contains("remote ref does not exist") && !stderr.contains("error: unable to delete") {
356 eprintln!("warning: could not delete remote branch {branch}: {}", stderr.trim());
357 }
358 }
359 Ok(())
360}
361
362pub(crate) fn run_epic_clean(
363 root: &Path,
364 config: &apm_core::config::Config,
365 dry_run: bool,
366 yes: bool,
367) -> Result<()> {
368 let local_output = std::process::Command::new("git")
370 .current_dir(root)
371 .args(["branch", "--list", "epic/*"])
372 .output()?;
373
374 let local_branches: Vec<String> = String::from_utf8_lossy(&local_output.stdout)
375 .lines()
376 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
377 .filter(|l| !l.is_empty())
378 .collect();
379
380 let tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
382
383 let default_branch = &config.project.default_branch;
386 let mut candidates: Vec<String> = Vec::new();
387 for branch in &local_branches {
388 let id = apm_core::epic::epic_id_from_branch(branch);
389
390 let epic_tickets: Vec<_> = tickets
391 .iter()
392 .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
393 .collect();
394
395 let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
396 .iter()
397 .filter_map(|t| config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
398 .collect();
399
400 let derived = apm_core::epic::derive_epic_state(&state_configs);
401 let is_merged = || -> bool {
402 let out = std::process::Command::new("git")
403 .current_dir(root)
404 .args(["log", "--oneline", &format!("{default_branch}..{branch}")])
405 .output()
406 .ok();
407 out.map(|o| String::from_utf8_lossy(&o.stdout).trim().is_empty()).unwrap_or(false)
408 };
409
410 if derived == "done" || (derived == "empty" && is_merged()) {
411 candidates.push(branch.clone());
412 }
413 }
414
415 if candidates.is_empty() {
416 println!("Nothing to clean.");
417 return Ok(());
418 }
419
420 println!("Epics to delete ({}):", candidates.len());
422 for branch in &candidates {
423 let id = apm_core::epic::epic_id_from_branch(branch);
424 let title = apm_core::epic::branch_to_title(branch);
425 println!(" {id} {title}");
426 }
427
428 if dry_run {
429 println!("Dry run — no changes made.");
430 return Ok(());
431 }
432
433 if !yes {
435 if std::io::stdout().is_terminal() {
436 if !crate::util::prompt_yes_no(&format!("Delete {} epic(s)? [y/N] ", candidates.len()))? {
437 println!("Aborted.");
438 return Ok(());
439 }
440 } else {
441 println!("Skipping — non-interactive terminal. Use --yes to confirm.");
442 return Ok(());
443 }
444 }
445
446 for branch in &candidates {
448 if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
450 if let Err(e) = apm_core::worktree::remove_worktree(root, &wt_path, false) {
451 eprintln!(
452 "skipping {branch}: could not remove worktree at {}: {e}",
453 wt_path.display()
454 );
455 continue;
456 }
457 }
458
459 let del_local = std::process::Command::new("git")
461 .current_dir(root)
462 .args(["branch", "-d", branch])
463 .output()?;
464 if !del_local.status.success() {
465 eprintln!(
466 "error: failed to delete local branch {branch}: {}",
467 String::from_utf8_lossy(&del_local.stderr).trim()
468 );
469 continue;
470 }
471
472 let del_remote = std::process::Command::new("git")
474 .current_dir(root)
475 .args(["push", "origin", "--delete", branch])
476 .output()?;
477 if !del_remote.status.success() {
478 let stderr = String::from_utf8_lossy(&del_remote.stderr);
479 if !stderr.contains("remote ref does not exist")
480 && !stderr.contains("error: unable to delete")
481 {
482 eprintln!(
483 "warning: failed to delete remote {branch}: {}",
484 stderr.trim()
485 );
486 }
487 }
488
489 println!("deleted {branch}");
490 }
491
492 Ok(())
493}