Skip to main content

apm/cmd/
sync.rs

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    // Bail early if the repo is mid-merge, mid-rebase, or mid-cherry-pick.
8    // Any sync work done in this state would compound the incomplete operation.
9    // Let the user resolve the pending operation first.
10    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        // Reconcile ticket worktrees: fast-forward clean Behind worktrees,
27        // warn about dirty/ahead/diverged ones.
28        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        // Handle default branch push.
65        if default_is_ahead {
66            let should_push = push_default || (is_tty && !quiet && {
67                // Remove the MAIN_AHEAD warning we just pushed — if user says yes, we push instead.
68                // The warning was already added; we'll print it only if user says no.
69                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                // Remove the MAIN_AHEAD warning from sync_warnings since we're pushing.
74                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        // Handle ticket/epic branch push.
84        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        // Worktree summary line — omit when quiet or when no worktrees were processed.
107        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}