1use anyhow::Result;
2use apm_core::{config::Config, git, sync};
3use std::io::IsTerminal;
4use std::path::Path;
5
6pub fn run(root: &Path, offline: bool, quiet: bool, no_aggressive: bool, auto_close: bool, push_default: bool, push_refs: bool) -> Result<()> {
7 if git::detect_mid_merge_state(root).is_some() {
11 eprintln!("{}", apm_core::sync_guidance::MID_MERGE_IN_PROGRESS);
12 return Ok(());
13 }
14
15 let config = Config::load(root)?;
16 let aggressive = config.sync.aggressive && !no_aggressive;
17 let is_tty = std::io::stdin().is_terminal();
18
19 if !offline {
20 let mut sync_warnings: Vec<String> = Vec::new();
21 crate::util::fetch_if_aggressive(root, true);
22
23 let ahead_refs = git::sync_non_checked_out_refs(root, &mut sync_warnings);
24 let default_is_ahead = git::sync_default_branch(root, &config.project.default_branch, &mut sync_warnings);
25
26 let wt_result = git::sync_checked_out_worktrees(root, &mut sync_warnings);
29 if !quiet {
30 for (wt_path, _branch) in &wt_result.fast_forwarded {
31 println!("fast-forwarded worktree: {}", wt_path.display());
32 }
33 }
34 if !quiet {
35 for (wt_path, branch, dirty_files) in &wt_result.skipped_dirty {
36 let files_list = dirty_files
37 .iter()
38 .map(|f| format!(" {f}"))
39 .collect::<Vec<_>>()
40 .join("\n");
41 sync_warnings.push(
42 apm_core::sync_guidance::WORKTREE_DIRTY_SKIP
43 .replace("<path>", &wt_path.display().to_string())
44 .replace("<branch>", branch)
45 .replace("<files>", &files_list),
46 );
47 }
48 for (wt_path, branch) in &wt_result.skipped_ahead {
49 sync_warnings.push(
50 apm_core::sync_guidance::WORKTREE_AHEAD
51 .replace("<path>", &wt_path.display().to_string())
52 .replace("<branch>", branch),
53 );
54 }
55 for (wt_path, branch) in &wt_result.skipped_diverged {
56 sync_warnings.push(
57 apm_core::sync_guidance::WORKTREE_DIVERGED
58 .replace("<path>", &wt_path.display().to_string())
59 .replace("<branch>", branch),
60 );
61 }
62 }
63
64 if default_is_ahead {
66 let should_push = push_default || (is_tty && !quiet && {
67 let prompt = format!("push {} to origin now? [y/N] ", config.project.default_branch);
70 crate::util::prompt_yes_no(&prompt)?
71 });
72 if should_push {
73 sync_warnings.retain(|w| !w.contains(&config.project.default_branch) || !w.contains("ahead"));
75 if let Err(e) = git::push_branch(root, &config.project.default_branch) {
76 eprintln!("warning: push failed: {e:#}");
77 } else if !quiet {
78 println!("pushed {} to origin", config.project.default_branch);
79 }
80 }
81 }
82
83 if !ahead_refs.is_empty() {
85 let n = ahead_refs.len();
86 let should_push = push_refs || (is_tty && !quiet && {
87 let prompt = format!("push {n} ahead branch{} to origin now? [y/N] ", if n == 1 { "" } else { "es" });
88 crate::util::prompt_yes_no(&prompt)?
89 });
90 if should_push {
91 for branch in &ahead_refs {
92 if let Err(e) = git::push_branch(root, branch) {
93 eprintln!("warning: push {branch} failed: {e:#}");
94 }
95 }
96 if !quiet {
97 println!("pushed {n} ahead branch{} to origin", if n == 1 { "" } else { "es" });
98 }
99 }
100 }
101
102 for w in &sync_warnings {
103 eprintln!("{w}");
104 }
105
106 let total_wt = wt_result.fast_forwarded.len()
108 + wt_result.skipped_dirty.len()
109 + wt_result.skipped_ahead.len()
110 + wt_result.skipped_diverged.len();
111 if !quiet && total_wt > 0 {
112 let mut parts: Vec<String> = Vec::new();
113 let ff = wt_result.fast_forwarded.len();
114 if ff > 0 {
115 parts.push(format!(
116 "{ff} worktree{} fast-forwarded",
117 if ff == 1 { "" } else { "s" }
118 ));
119 }
120 let dirty = wt_result.skipped_dirty.len();
121 if dirty > 0 {
122 parts.push(format!("{dirty} skipped (local changes)"));
123 }
124 let ad = wt_result.skipped_ahead.len() + wt_result.skipped_diverged.len();
125 if ad > 0 {
126 parts.push(format!("{ad} skipped (ahead/diverged)"));
127 }
128 println!("worktrees: {}", parts.join(", "));
129 }
130 }
131
132 let candidates = sync::detect(root, &config)?;
133
134 let branches = git::ticket_branches(root)?;
135 if !quiet {
136 println!(
137 "sync: {} ticket branch{} visible",
138 branches.len(),
139 if branches.len() == 1 { "" } else { "es" },
140 );
141 }
142
143 for hint in &candidates.hints {
144 eprintln!("{hint}");
145 }
146
147 if !candidates.close.is_empty() {
148 let confirmed = auto_close || (!quiet && prompt_close(&candidates.close)?);
149 if confirmed {
150 let caller = apm_core::config::resolve_caller_name();
151 let actor = format!("{}(apm-sync)", caller);
152 let apply_out = sync::apply(root, &config, &candidates, &actor, aggressive)?;
153 for (id, err) in &apply_out.failed {
154 eprintln!("warning: could not close {id:?}: {err}");
155 }
156 for msg in &apply_out.messages {
157 println!("{msg}");
158 }
159 }
160 }
161
162 Ok(())
163}
164
165fn prompt_close(candidates: &[sync::CloseCandidate]) -> Result<bool> {
166 println!("\nTickets ready to close:");
167 for c in candidates {
168 println!(" #{} {} ({})", c.ticket.frontmatter.id, c.ticket.frontmatter.title, c.reason);
169 }
170 Ok(crate::util::prompt_yes_no("\nClose all? [y/N] ")?)
171}