Skip to main content

apm_core/
git_util.rs

1use anyhow::{bail, Context, Result};
2use crate::config::Config;
3use crate::worktree::{find_worktree_for_branch, ensure_worktree};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7pub(crate) fn run(dir: &Path, args: &[&str]) -> Result<String> {
8    let out = Command::new("git")
9        .current_dir(dir)
10        .args(args)
11        .output()
12        .context("git not found")?;
13    if !out.status.success() {
14        anyhow::bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
15    }
16    Ok(String::from_utf8(out.stdout)?.trim().to_string())
17}
18
19pub fn current_branch(root: &Path) -> Result<String> {
20    run(root, &["branch", "--show-current"])
21}
22
23pub fn has_commits(root: &Path) -> bool {
24    run(root, &["rev-parse", "HEAD"]).is_ok()
25}
26
27pub fn fetch_all(root: &Path) -> Result<()> {
28    run(root, &["fetch", "--all", "--quiet"]).map(|_| ())
29}
30
31/// Read a file's content from a branch ref without changing working tree.
32/// Prefers the local ref (reflects recent commits before push);
33/// falls back to origin when no local ref exists.
34pub fn read_from_branch(root: &Path, branch: &str, rel_path: &str) -> Result<String> {
35    run(root, &["show", &format!("{branch}:{rel_path}")])
36        .or_else(|_| run(root, &["show", &format!("origin/{branch}:{rel_path}")]))
37}
38
39/// Read a file's content from a branch ref, also returning a `BranchClass`
40/// that describes the relationship between the local and remote ref.
41///
42/// When local is strictly behind origin (`Behind`) or origin only (`RemoteOnly`)
43/// or equal (`Equal`), content is read from origin so callers see the latest state.
44/// When local is ahead (`Ahead`), diverged (`Diverged`), or has no remote
45/// (`NoRemote`), content is read from the local ref (preserving unpushed state).
46///
47/// This is the classification-aware alternative to `read_from_branch`, used by
48/// callers that want to signal staleness to the user without modifying any refs.
49pub fn read_from_branch_with_class(
50    root: &Path,
51    branch: &str,
52    rel_path: &str,
53) -> Result<(String, BranchClass)> {
54    let local_ref = format!("refs/heads/{branch}");
55    let remote_ref = format!("origin/{branch}");
56    let class = classify_branch(root, &local_ref, &remote_ref);
57    let content = match &class {
58        BranchClass::Behind | BranchClass::RemoteOnly | BranchClass::Equal => {
59            run(root, &["show", &format!("{remote_ref}:{rel_path}")])
60                .or_else(|_| run(root, &["show", &format!("{branch}:{rel_path}")]))?
61        }
62        BranchClass::Ahead | BranchClass::NoRemote | BranchClass::Diverged => {
63            run(root, &["show", &format!("{branch}:{rel_path}")])
64                .or_else(|_| run(root, &["show", &format!("{remote_ref}:{rel_path}")]))?
65        }
66    };
67    Ok((content, class))
68}
69
70/// All ticket/* branch names visible locally or remotely (deduplicated).
71/// Local branches are included even when a remote exists, so that
72/// unpushed branches (e.g. just created) are visible without a push.
73pub fn ticket_branches(root: &Path) -> Result<Vec<String>> {
74    let mut seen = std::collections::HashSet::new();
75    let mut branches = Vec::new();
76
77    let local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
78    for b in local.lines()
79        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
80        .filter(|l| !l.is_empty())
81    {
82        if seen.insert(b.to_string()) {
83            branches.push(b.to_string());
84        }
85    }
86
87    let remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"]).unwrap_or_default();
88    for b in remote.lines()
89        .map(|l| l.trim().trim_start_matches("origin/").to_string())
90        .filter(|l| !l.is_empty())
91    {
92        if seen.insert(b.clone()) {
93            branches.push(b);
94        }
95    }
96
97    Ok(branches)
98}
99
100/// ticket/* branches that are merged into the default branch (remote or local),
101/// including branches that were squash-merged (not detected by `--merged`).
102pub fn merged_into_main(root: &Path, default_branch: &str) -> Result<Vec<String>> {
103    let remote_ref = format!("refs/remotes/origin/{default_branch}");
104    let remote_merged = format!("origin/{default_branch}");
105
106    if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
107        // Regular merges via remote.
108        let regular_out = run(
109            root,
110            &["branch", "-r", "--merged", &remote_merged, "--list", "origin/ticket/*"],
111        )
112        .unwrap_or_default();
113        let mut merged: Vec<String> = regular_out
114            .lines()
115            .map(|l| l.trim().trim_start_matches("origin/").to_string())
116            .filter(|l| !l.is_empty())
117            .collect();
118        let merged_set: std::collections::HashSet<String> = merged.iter().cloned().collect();
119
120        // Squash-merge detection for remote branches not caught by --merged.
121        // Pass full origin/ refs so merge-base resolution works even without a local branch.
122        let all_remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"])
123            .unwrap_or_default();
124        let remote_candidates: Vec<String> = all_remote
125            .lines()
126            .map(|l| l.trim().to_string())
127            .filter(|l| {
128                let stripped = l.strip_prefix("origin/").unwrap_or(l.as_str());
129                !l.is_empty() && !merged_set.contains(stripped)
130            })
131            .collect();
132        let remote_squashed = squash_merged(root, &remote_merged, remote_candidates)?;
133        // Strip origin/ prefix before adding to merged.
134        merged.extend(remote_squashed.into_iter().map(|b| {
135            b.strip_prefix("origin/").unwrap_or(&b).to_string()
136        }));
137
138        // Also check local-only ticket branches whose remote tracking ref was deleted
139        // (e.g. GitHub auto-deletes the branch after squash merge).
140        let remote_stripped: std::collections::HashSet<String> = all_remote
141            .lines()
142            .map(|l| l.trim().trim_start_matches("origin/").to_string())
143            .filter(|l| !l.is_empty())
144            .collect();
145        let merged_now: std::collections::HashSet<String> = merged.iter().cloned().collect();
146        let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
147        let local_only: Vec<String> = all_local
148            .lines()
149            .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
150            .filter(|l| {
151                !l.is_empty()
152                    && !remote_stripped.contains(l)
153                    && !merged_now.contains(l)
154            })
155            .collect();
156        merged.extend(squash_merged(root, &remote_merged, local_only)?);
157
158        // Also catch local ticket branches regular-merged into local
159        // <default_branch> whose remote ref was deleted (e.g. GitHub auto-
160        // delete after merge). `git branch -r --merged origin/<main>` only
161        // returns refs that still exist on origin, and the squash-merged
162        // path above skips ancestor branches because it expects the remote
163        // `--merged` to have caught them — which it can't when the remote
164        // ref is gone.
165        let local_default_ref = format!("refs/heads/{default_branch}");
166        if run(root, &["rev-parse", "--verify", &local_default_ref]).is_ok() {
167            let already: std::collections::HashSet<String> = merged.iter().cloned().collect();
168            let local_regular = run(
169                root,
170                &["branch", "--merged", default_branch, "--list", "ticket/*"],
171            )
172            .unwrap_or_default();
173            for line in local_regular.lines() {
174                let b = line.trim().trim_start_matches(['*', '+']).trim().to_string();
175                if !b.is_empty() && !already.contains(&b) {
176                    merged.push(b);
177                }
178            }
179        }
180
181        return Ok(merged);
182    }
183
184    // Fall back to local branch.
185    let local_ref = format!("refs/heads/{default_branch}");
186    if run(root, &["rev-parse", "--verify", &local_ref]).is_err() {
187        return Ok(vec![]);
188    }
189    let regular_out = run(
190        root,
191        &["branch", "--merged", default_branch, "--list", "ticket/*"],
192    )
193    .unwrap_or_default();
194    let mut merged: Vec<String> = regular_out
195        .lines()
196        .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
197        .filter(|l| !l.is_empty())
198        .collect();
199    let merged_set: std::collections::HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
200
201    let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
202    let candidates: Vec<String> = all_local
203        .lines()
204        .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
205        .filter(|l| !l.is_empty() && !merged_set.contains(l.as_str()))
206        .collect();
207    merged.extend(squash_merged(root, default_branch, candidates)?);
208    Ok(merged)
209}
210
211/// Detect branches squash-merged into `main_ref` using the commit-tree + cherry algorithm.
212///
213/// For each candidate ref, we create a virtual squash commit whose tree equals
214/// the branch tip's tree and whose parent is the merge-base with main. Then
215/// `git cherry` compares that squash commit's patch-id against commits already
216/// in main. A `-` prefix means main has a commit with the same aggregate diff.
217fn squash_merged(root: &Path, main_ref: &str, candidates: Vec<String>) -> Result<Vec<String>> {
218    let mut result = Vec::new();
219    for branch in candidates {
220        let merge_base = match run(root, &["merge-base", main_ref, &branch]) {
221            Ok(mb) => mb,
222            Err(_) => continue,
223        };
224        let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
225            Ok(t) => t,
226            Err(_) => continue,
227        };
228        // Already an ancestor — caught by --merged.
229        if branch_tip == merge_base {
230            continue;
231        }
232        // Virtual squash commit: aggregate diff from merge_base to branch tip.
233        let squash_commit = match run(root, &[
234            "commit-tree", &format!("{branch}^{{tree}}"),
235            "-p", &merge_base,
236            "-m", "squash",
237        ]) {
238            Ok(c) => c,
239            Err(_) => continue,
240        };
241        // `git cherry main squash_commit`: prints `- sha` when main already has that patch.
242        let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
243            Ok(o) => o,
244            Err(_) => continue,
245        };
246        if cherry_out.trim().starts_with('-') {
247            result.push(branch);
248        }
249    }
250    Ok(result)
251}
252
253/// Detect whether a ticket branch's *implementation content* has been merged into
254/// `main_ref` even when state-transition commits (touching only files under
255/// `tickets_dir/`) were pushed to the branch after the merge.
256///
257/// Returns `Ok(false)` in any ambiguous case so that false positives are
258/// impossible: a branch is only reported as merged when we are certain.
259pub fn content_merged_into_main(
260    root: &Path,
261    main_ref: &str,
262    branch: &str,
263    tickets_dir: &str,
264) -> Result<bool> {
265    // 1. Common ancestor of main and branch.
266    let merge_base = match run(root, &["merge-base", main_ref, branch]) {
267        Ok(mb) => mb,
268        Err(_) => return Ok(false),
269    };
270    // 2. Current tip of the branch.
271    let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
272        Ok(t) => t,
273        Err(_) => return Ok(false),
274    };
275    // 3. Already an ancestor — `git branch --merged` handles it.
276    if branch_tip == merge_base {
277        return Ok(false);
278    }
279    // 4. Commits on branch since merge-base, newest first.
280    let log_out = match run(root, &["log", "--pretty=%H", branch, &format!("^{merge_base}")]) {
281        Ok(o) => o,
282        Err(_) => return Ok(false),
283    };
284    // 5. Walk newest-first; find the last commit that touches a non-ticket file.
285    let tickets_prefix = format!("{tickets_dir}/");
286    let mut content_tip: Option<String> = None;
287    for sha in log_out.lines() {
288        let diff_out = match run(root, &["diff-tree", "--no-commit-id", "-r", "--name-only", sha]) {
289            Ok(o) => o,
290            Err(_) => continue,
291        };
292        let has_non_ticket = diff_out.lines().any(|path| !path.starts_with(&tickets_prefix));
293        if has_non_ticket {
294            content_tip = Some(sha.to_string());
295            break;
296        }
297    }
298    // 6. All commits since merge-base touch only ticket files.
299    if content_tip.is_none() {
300        // Sub-case: regular (--no-ff) merge. After a regular merge the implementation
301        // commit C becomes the merge-base (it's now reachable from both main and branch),
302        // so it disappears from the log range above.
303        //
304        // Detection: if merge_base is on main's first-parent chain, it was a direct main
305        // commit (fork-point case — no implementation → return false). If merge_base is
306        // NOT on main's first-parent chain, it was pulled in via a merge commit's non-first
307        // parent → the ticket's implementation is in main → return true.
308        //
309        // We check by listing first-parent commits of main_ref after merge_base's first
310        // parent (`^merge_base^1`). The oldest entry in that list is the first-parent
311        // commit immediately after merge_base^1. If it equals merge_base, merge_base IS
312        // on the first-parent chain. If not, it was brought in via a side-parent merge.
313        let parent_spec = format!("{merge_base}^1");
314        if let Ok(fp_log) = run(root, &[
315            "rev-list", "--first-parent", main_ref, &format!("^{parent_spec}"),
316        ]) {
317            let oldest = fp_log.lines().last().unwrap_or("").trim();
318            if !oldest.is_empty() && oldest != merge_base {
319                // merge_base is not on the first-parent chain → was regular-merged.
320                return Ok(true);
321            }
322        }
323        return Ok(false);
324    }
325    let content_tip = content_tip.unwrap();
326    // 7. No trailing state commits: squash_merged already tried the branch tip's tree
327    //    and returned false, meaning the content really is not in main.
328    if content_tip == branch_tip {
329        return Ok(false);
330    }
331    // 8. Virtual squash commit: aggregate diff from merge_base → content_tip.
332    let squash_commit = match run(root, &[
333        "commit-tree", &format!("{content_tip}^{{tree}}"),
334        "-p", &merge_base,
335        "-m", "squash",
336    ]) {
337        Ok(c) => c,
338        Err(_) => return Ok(false),
339    };
340    // 9. `git cherry main squash`: `-` prefix means main already has that patch.
341    let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
342        Ok(o) => o,
343        Err(_) => return Ok(false),
344    };
345    Ok(cherry_out.trim().starts_with('-'))
346}
347
348/// Commit a file to a specific branch without disturbing the current working tree.
349///
350/// If a permanent worktree exists for the branch, commits there directly.
351/// If the caller is already on the target branch, commits directly.
352/// Otherwise uses a temporary git worktree.
353pub fn commit_to_branch(
354    root: &Path,
355    branch: &str,
356    rel_path: &str,
357    content: &str,
358    message: &str,
359) -> Result<()> {
360    // If the repo has no commits, write directly to the working tree (no worktree support yet).
361    if !has_commits(root) {
362        let local_path = root.join(rel_path);
363        if let Some(parent) = local_path.parent() {
364            std::fs::create_dir_all(parent)?;
365        }
366        std::fs::write(&local_path, content)?;
367        return Ok(());
368    }
369
370    // If a permanent worktree exists for this branch, commit there directly.
371    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
372        // Fast-forward to remote if remote is ahead, so our commit lands on top of it.
373        // The fast-forward is best-effort — diverged history is acceptable and
374        // we still proceed with the commit.
375        let remote_ref = format!("origin/{branch}");
376        if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
377            let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
378        }
379        let full_path = wt_path.join(rel_path);
380        if let Some(parent) = full_path.parent() {
381            std::fs::create_dir_all(parent)?;
382        }
383        std::fs::write(&full_path, content)?;
384        run(&wt_path, &["add", rel_path])
385            .with_context(|| format!("git add {rel_path} in worktree {} failed", wt_path.display()))?;
386        run(&wt_path, &["commit", "-m", message, "--", rel_path])
387            .with_context(|| format!("git commit on {branch} in worktree {} failed", wt_path.display()))?;
388        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
389        return Ok(());
390    }
391
392    // If already on the target branch, write to working tree and commit directly.
393    if current_branch(root).ok().as_deref() == Some(branch) {
394        let local_path = root.join(rel_path);
395        if let Some(parent) = local_path.parent() {
396            std::fs::create_dir_all(parent)?;
397        }
398        std::fs::write(&local_path, content)?;
399        run(root, &["add", rel_path])
400            .with_context(|| format!("git add {rel_path} failed"))?;
401        run(root, &["commit", "-m", message, "--", rel_path])
402            .with_context(|| format!("git commit on {branch} failed"))?;
403        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
404        return Ok(());
405    }
406
407    let result = try_worktree_commit(root, branch, rel_path, content, message);
408    if result.is_ok() {
409        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
410    }
411    result
412}
413
414fn try_worktree_commit(
415    root: &Path,
416    branch: &str,
417    rel_path: &str,
418    content: &str,
419    message: &str,
420) -> Result<()> {
421    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
422    let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
423    let wt_path = std::env::temp_dir().join(format!(
424        "apm-{}-{}-{}",
425        std::process::id(),
426        seq,
427        branch.replace('/', "-"),
428    ));
429
430    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
431    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
432
433    if has_remote {
434        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
435        let _ = run(&wt_path, &["checkout", "-B", branch]);
436    } else if has_local {
437        // Use detached approach to avoid "already checked out" errors.
438        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
439        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
440        let _ = run(&wt_path, &["checkout", "-B", branch]);
441    } else {
442        run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
443    }
444
445    let result = (|| -> Result<()> {
446        let full_path = wt_path.join(rel_path);
447        if let Some(parent) = full_path.parent() {
448            std::fs::create_dir_all(parent)?;
449        }
450        std::fs::write(&full_path, content)?;
451        run(&wt_path, &["add", rel_path])?;
452        run(&wt_path, &["commit", "-m", message, "--", rel_path])?;
453        Ok(())
454    })();
455
456    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
457    let _ = std::fs::remove_dir_all(&wt_path);
458
459    result
460}
461
462
463/// Push all local ticket/* branches that have commits not yet on origin.
464/// Non-fatal: logs warnings on push failure. No-op when no origin is configured.
465pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
466    if run(root, &["remote", "get-url", "origin"]).is_err() {
467        return;
468    }
469    let out = match run(root, &["branch", "--list", "ticket/*"]) {
470        Ok(o) => o,
471        Err(_) => return,
472    };
473    for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
474        let range = format!("origin/{branch}..{branch}");
475        let count = run(root, &["rev-list", "--count", &range])
476            .ok()
477            .and_then(|s| s.trim().parse::<u32>().ok())
478            .unwrap_or(0);
479        if count > 0 {
480            if let Err(e) = run(root, &["push", "origin", branch]) {
481                warnings.push(format!("warning: push {branch} failed: {e:#}"));
482            }
483        }
484    }
485}
486
487/// Sync non-checked-out `ticket/*` and `epic/*` local refs with their origin counterparts.
488///
489/// This replaces the old `sync_local_ticket_refs` which performed an unconditional
490/// `update-ref` that could silently rewind local refs with unpushed commits (data-loss bug).
491///
492/// State matrix — each case documents why the mapped action is correct:
493///
494///   Equal      → no-op. Local and origin are identical; nothing to do.
495///
496///   Behind     → fast-forward via `update-ref`. Safe because local is a strict ancestor
497///                of origin: the update only moves the ref forward, losing no local commits.
498///
499///   Ahead      → info line only, NO `update-ref`, NO push.
500///                CRITICAL: the old code performed an unconditional `update-ref` in this
501///                case, silently rewriting the local ref to the origin SHA and orphaning
502///                any unpushed local commits. That was the data-loss bug this function fixes.
503///                apm sync never pushes; ahead refs wait for explicit user action.
504///
505///   Diverged   → warning line, no ref change, no push. Neither side is an ancestor of
506///                the other; manual resolution is required. Clobbering either side would
507///                lose commits.
508///
509///   RemoteOnly → create local ref at origin SHA. Safe: no local commits exist to lose.
510///                Makes the branch visible locally without a checkout.
511///
512///   NoRemote   → local-only branch, leave untouched. No auto-push, no warning spam.
513///                Publishing local-only branches requires an explicit user action.
514pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
515    // Collect all branches currently checked out across all worktrees.
516    // These are never touched — they must be managed via the worktree's own git operations.
517    let checked_out: std::collections::HashSet<String> = {
518        let mut set = std::collections::HashSet::new();
519        if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
520            for line in out.lines() {
521                if let Some(b) = line.strip_prefix("branch refs/heads/") {
522                    set.insert(b.to_string());
523                }
524            }
525        }
526        set
527    };
528
529    // Two ref namespaces this sync cares about. Both get identical classification-based
530    // treatment — ticket/* and epic/* branches are managed the same way.
531    const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
532
533    // Collect all origin refs across both namespaces.
534    let mut remote_refs: Vec<String> = Vec::new();
535    for ns in MANAGED_NAMESPACES {
536        let pattern = format!("refs/remotes/origin/{ns}/");
537        if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
538            for line in out.lines().filter(|l| !l.is_empty()) {
539                remote_refs.push(line.to_string());
540            }
541        }
542    }
543
544    let mut ahead_branches: Vec<String> = Vec::new();
545
546    for remote_name in remote_refs {
547        // remote_name is like "origin/ticket/<slug>" or "origin/epic/<slug>".
548        // Strip the "origin/" prefix to get the local branch name.
549        let branch = match remote_name.strip_prefix("origin/") {
550            Some(b) => b.to_string(),
551            None => continue,
552        };
553
554        // Never touch a branch currently checked out in any worktree.
555        if checked_out.contains(&branch) {
556            continue;
557        }
558
559        let local_ref = format!("refs/heads/{branch}");
560        // Use the short remote name (e.g. "origin/ticket/abc") as classify_branch resolves it.
561        let remote_ref_full = format!("refs/remotes/{remote_name}");
562
563        // Classification drives the action. Nothing in this function pushes —
564        // ahead refs wait for explicit action via apm state transitions.
565        match classify_branch(root, &local_ref, &remote_name) {
566            BranchClass::RemoteOnly => {
567                // No local ref exists yet; create it pointing at the origin SHA.
568                // Safe: there are no local commits to clobber.
569                let sha = match run(root, &["rev-parse", &remote_ref_full]) {
570                    Ok(s) => s,
571                    Err(_) => continue,
572                };
573                if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
574                    warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
575                }
576            }
577            BranchClass::Equal => {
578                // Local and origin are identical; nothing to do.
579            }
580            BranchClass::Behind => {
581                // Local is a strict ancestor of origin — fast-forward is safe.
582                // `update-ref` moves the ref forward; no local commits are lost.
583                let sha = match run(root, &["rev-parse", &remote_ref_full]) {
584                    Ok(s) => s,
585                    Err(_) => continue,
586                };
587                if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
588                    warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
589                }
590            }
591            BranchClass::Ahead => {
592                // CRITICAL: do NOT update-ref here.
593                // The old sync_local_ticket_refs performed an unconditional update-ref that
594                // silently rewound this ref to the origin SHA, orphaning unpushed local commits.
595                // That was the data-loss bug. The correct action is an info line only —
596                // apm sync never pushes; the user must push explicitly when ready.
597                warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
598                ahead_branches.push(branch);
599            }
600            BranchClass::Diverged => {
601                // Neither side is an ancestor of the other. Manual resolution required.
602                // Clobbering either ref would silently discard commits on the other side.
603                let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
604                    .replace("<slug>", &branch);
605                warnings.push(msg);
606            }
607            BranchClass::NoRemote => {
608                // Local-only branch: no origin counterpart. Leave it alone.
609                // No auto-push, no warning — publishing requires an explicit user action.
610            }
611        }
612    }
613
614    ahead_branches
615}
616
617/// List all files in a directory on a branch (non-recursive).
618pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
619    let tree_ref = format!("{branch}:{dir}");
620    let out = run(root, &["ls-tree", "--name-only", &tree_ref])
621        .or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
622    Ok(out.lines()
623        .filter(|l| !l.is_empty())
624        .map(|l| format!("{dir}/{l}"))
625        .collect())
626}
627
628/// Commit multiple files to a branch in a single commit without disturbing the working tree.
629pub fn commit_files_to_branch(
630    root: &Path,
631    branch: &str,
632    files: &[(&str, String)],
633    message: &str,
634) -> Result<()> {
635    if !has_commits(root) {
636        for (rel_path, content) in files {
637            let local_path = root.join(rel_path);
638            if let Some(parent) = local_path.parent() {
639                std::fs::create_dir_all(parent)?;
640            }
641            std::fs::write(&local_path, content)?;
642        }
643        return Ok(());
644    }
645
646    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
647        for (rel_path, content) in files {
648            let full_path = wt_path.join(rel_path);
649            if let Some(parent) = full_path.parent() {
650                std::fs::create_dir_all(parent)?;
651            }
652            std::fs::write(&full_path, content)?;
653            let _ = run(&wt_path, &["add", rel_path]);
654        }
655        run(&wt_path, &["commit", "-m", message])?;
656        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
657        return Ok(());
658    }
659
660    if current_branch(root).ok().as_deref() == Some(branch) {
661        for (rel_path, content) in files {
662            let local_path = root.join(rel_path);
663            if let Some(parent) = local_path.parent() {
664                std::fs::create_dir_all(parent)?;
665            }
666            std::fs::write(&local_path, content)?;
667            let _ = run(root, &["add", rel_path]);
668        }
669        run(root, &["commit", "-m", message])?;
670        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
671        return Ok(());
672    }
673
674    let unique = std::time::SystemTime::now()
675        .duration_since(std::time::UNIX_EPOCH)
676        .map(|d| d.subsec_nanos())
677        .unwrap_or(0);
678    let wt_path = std::env::temp_dir().join(format!(
679        "apm-{}-{}-{}",
680        std::process::id(),
681        unique,
682        branch.replace('/', "-"),
683    ));
684
685    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
686    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
687
688    if has_remote {
689        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
690        let _ = run(&wt_path, &["checkout", "-B", branch]);
691    } else if has_local {
692        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
693        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
694        let _ = run(&wt_path, &["checkout", "-B", branch]);
695    } else {
696        run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
697    }
698
699    let result = (|| -> Result<()> {
700        for (rel_path, content) in files {
701            let full_path = wt_path.join(rel_path);
702            if let Some(parent) = full_path.parent() {
703                std::fs::create_dir_all(parent)?;
704            }
705            std::fs::write(&full_path, content)?;
706            run(&wt_path, &["add", rel_path])?;
707        }
708        run(&wt_path, &["commit", "-m", message])?;
709        Ok(())
710    })();
711
712    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
713    let _ = std::fs::remove_dir_all(&wt_path);
714
715    if result.is_ok() {
716        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
717    }
718    result
719}
720
721/// Get the commit SHA at the tip of a local branch.
722pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
723    run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
724}
725
726/// Resolve a branch name to a commit SHA.
727/// Prefers `origin/<branch>`; falls back to local `<branch>`.
728pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
729    run(root, &["rev-parse", &format!("origin/{branch}")])
730        .or_else(|_| run(root, &["rev-parse", branch]))
731        .with_context(|| format!("branch '{branch}' not found locally or on origin"))
732}
733
734/// Create a local branch pointing at a specific commit SHA.
735pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
736    run(root, &["branch", branch, sha]).map(|_| ())
737}
738
739/// Get the commit SHA at the tip of the remote tracking ref for a branch.
740pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
741    run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
742}
743
744/// Check if `commit` is a git ancestor of `of_ref` (i.e. reachable from `of_ref`).
745/// Uses `git merge-base --is-ancestor`.
746pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
747    run(root, &["merge-base", "--is-ancestor", commit, of_ref]).is_ok()
748}
749
750/// Return `true` when `branch` has been merged into `target_ref` — either by a
751/// regular merge (fast-forward or --no-ff) or by a squash merge.
752///
753/// Returns `Ok(false)` in any ambiguous case (unknown ref, git error, etc.) so
754/// that false positives are impossible.
755pub fn is_branch_merged_into(root: &Path, branch: &str, target_ref: &str) -> Result<bool> {
756    // Regular-merge check: branch is reachable from target_ref.
757    if is_ancestor(root, branch, target_ref) {
758        return Ok(true);
759    }
760    // Squash-merge check — mirrors the private squash_merged() helper.
761    let merge_base = match run(root, &["merge-base", target_ref, branch]) {
762        Ok(mb) => mb,
763        Err(_) => return Ok(false),
764    };
765    let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
766        Ok(t) => t,
767        Err(_) => return Ok(false),
768    };
769    // Already an ancestor — belt-and-suspenders, caught above.
770    if branch_tip == merge_base {
771        return Ok(true);
772    }
773    // Virtual squash commit: aggregate diff from merge_base to branch tip.
774    let squash_commit = match run(root, &[
775        "commit-tree", &format!("{branch}^{{tree}}"),
776        "-p", &merge_base,
777        "-m", "squash",
778    ]) {
779        Ok(c) => c,
780        Err(_) => return Ok(false),
781    };
782    // `git cherry target squash_commit`: `-` prefix means target already has that patch.
783    let cherry_out = match run(root, &["cherry", target_ref, &squash_commit]) {
784        Ok(o) => o,
785        Err(_) => return Ok(false),
786    };
787    Ok(cherry_out.trim().starts_with('-'))
788}
789
790/// Return `true` when `branch`'s content has been merged into `default_branch` —
791/// either by a regular merge (fast-forward or --no-ff) or by a squash merge.
792///
793/// Checks local `default_branch` first (covers `submit --merge` before push),
794/// then also checks `origin/<default_branch>` (covers merge-via-PR before local
795/// fetch). Returns `true` if the content is present in either ref.
796pub fn is_branch_content_merged(root: &Path, default_branch: &str, branch: &str) -> Result<bool> {
797    // Check local branch first — covers submit --merge before push.
798    if is_branch_merged_into(root, branch, default_branch)? {
799        return Ok(true);
800    }
801    // Also check origin/<default_branch> — covers merge-via-PR before local fetch.
802    let remote_ref = format!("refs/remotes/origin/{default_branch}");
803    if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
804        return is_branch_merged_into(root, branch, &format!("origin/{default_branch}"));
805    }
806    Ok(false)
807}
808
809/// Classification of a local branch relative to its origin counterpart.
810///
811/// Direction note: `merge-base --is-ancestor A B` returns 0 iff A is reachable from B.
812///   - local == remote                        → Equal
813///   - local ancestor-of remote (not equal)   → Behind (FF possible: remote has new commits)
814///   - remote ancestor-of local (not equal)   → Ahead  (local has unpushed commits)
815///   - neither is an ancestor of the other    → Diverged (manual resolution required)
816///   - local ref absent, remote ref present   → RemoteOnly (safe to create local ref)
817///   - remote ref cannot be resolved          → NoRemote (local-only or no origin)
818pub enum BranchClass {
819    Equal,
820    Behind,
821    Ahead,
822    Diverged,
823    /// Local ref does not exist; origin ref does. Safe to create the local ref.
824    RemoteOnly,
825    /// Remote ref cannot be resolved. Branch is local-only or origin is unreachable.
826    NoRemote,
827}
828
829/// Classify `local` branch relative to `remote` ref using SHA equality and directed ancestry.
830///
831/// `local`  — a local branch name, e.g. "main" (resolved via `refs/heads/<local>`).
832/// `remote` — a remote ref name,   e.g. "origin/main" (resolved as-is by git).
833///
834/// Every ancestry check includes a comment explaining which direction maps to which state
835/// because the mapping is not intuitive at a glance.
836pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
837    let local_sha = match run(root, &["rev-parse", local]) {
838        Ok(s) => s,
839        Err(_) => {
840            // Local ref absent. Check whether the remote side exists.
841            // If origin has the branch, this is RemoteOnly (safe to create a local ref).
842            // If origin also can't be resolved, it is truly NoRemote (local-only or no origin).
843            return if run(root, &["rev-parse", remote]).is_ok() {
844                BranchClass::RemoteOnly
845            } else {
846                BranchClass::NoRemote
847            };
848        }
849    };
850    let remote_sha = match run(root, &["rev-parse", remote]) {
851        Ok(s) => s,
852        Err(_) => return BranchClass::NoRemote,
853    };
854
855    if local_sha == remote_sha {
856        return BranchClass::Equal;
857    }
858
859    // `--is-ancestor local remote` succeeds iff local is reachable from remote.
860    // When true (and SHAs differ), remote has commits that local lacks → local is Behind.
861    let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
862
863    // `--is-ancestor remote local` succeeds iff remote is reachable from local.
864    // When true (and SHAs differ), local has commits that remote lacks → local is Ahead.
865    let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
866
867    match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
868        (true, false)  => BranchClass::Behind,   // remote has new commits; FF is safe
869        (false, true)  => BranchClass::Ahead,    // local has unpushed commits
870        (false, false) => BranchClass::Diverged, // each side has commits the other lacks
871        (true, true)   => BranchClass::Equal,    // both ancestors → same commit (guard)
872    }
873}
874
875/// Bring local `default` branch into sync with `origin/<default>` without ever pushing.
876///
877/// State matrix — each row documents why the mapped action is correct:
878///
879///   Equal     → no-op.  Local and origin are identical; nothing to do.
880///
881///   Behind    → `git merge --ff-only origin/<default>` in the main worktree.
882///               The main worktree is always checked out on <default>, so running
883///               the merge there updates both HEAD and the working tree atomically.
884///               If the merge fails (uncommitted local changes overlap with the
885///               incoming commits), we print MAIN_BEHIND_DIRTY_OVERLAP guidance and
886///               leave the working tree untouched.  git's own error detection is used
887///               rather than pre-emptively computing overlap.
888///
889///   Ahead     → Print one info line so the user knows local has unpushed commits.
890///               No network call, no ref changes.  Explicit pushes happen via
891///               `apm state <id> implemented` — apm sync NEVER pushes anything.
892///
893///   Diverged  → Print guidance (rebase/merge/push steps).  No ref changes.
894///               The dirty-aware variant is printed when the main worktree is unclean.
895///
896///   NoRemote  → Silent skip.  No origin is configured, or `origin/<default>` could
897///               not be resolved (e.g. fetch hasn't run yet).  Fetch failures are
898///               already surfaced as a warning by the existing fetch path in sync.rs.
899pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
900    let remote = format!("origin/{default}");
901    match classify_branch(root, default, &remote) {
902        BranchClass::Equal => {
903            // local == origin/main: nothing to do, print nothing.
904        }
905
906        BranchClass::Behind => {
907            // origin has new commits local lacks; attempt a fast-forward.
908            // Run in the main worktree so the working tree is updated too.
909            let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
910            if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
911                // FF refused — uncommitted local changes overlap with incoming commits.
912                // Leave the working tree untouched and print recovery guidance.
913                // Assumption: overlap is the only realistic failure mode for a strictly-behind FF merge; MAIN_BEHIND_DIRTY_OVERLAP covers any --ff-only error here.
914                let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
915                    .replace("<default>", default);
916                warnings.push(msg);
917            }
918        }
919
920        BranchClass::Ahead => {
921            // local has commits not on origin.  No push — apm sync never pushes.
922            // Count unpushed commits so the message is informative.
923            let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
924                .ok()
925                .and_then(|s| s.trim().parse::<u64>().ok())
926                .unwrap_or(0);
927            let msg = crate::sync_guidance::MAIN_AHEAD
928                .replace("<default>", default)
929                .replace("<remote>", &remote)
930                .replace("<count>", &count.to_string())
931                .replace("<commits>", if count == 1 { "commit" } else { "commits" });
932            warnings.push(msg);
933            return true;
934        }
935
936        BranchClass::Diverged => {
937            // Neither side is an ancestor of the other; manual resolution required.
938            // Print the dirty-aware variant so the user gets actionable steps.
939            let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
940            let guidance = if is_worktree_dirty(&wt) {
941                crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
942            } else {
943                crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
944            };
945            warnings.push(guidance);
946        }
947
948        BranchClass::RemoteOnly => {
949            // The default branch always exists locally in any repo with commits.
950            // RemoteOnly here would mean local branch is absent, which cannot happen
951            // during a normal sync flow. Treat it as NoRemote (silent skip).
952        }
953
954        BranchClass::NoRemote => {
955            // origin/<default> not resolvable (no remote, or fetch hasn't run yet).
956            // The fetch path in sync.rs already emits a warning on fetch failure.
957            // Nothing more to do here.
958        }
959    }
960    false
961}
962
963pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
964    run(root, &["fetch", "origin", branch]).map(|_| ())
965}
966
967pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
968    run(root, &["push", "origin", &format!("{branch}:{branch}")]).map(|_| ())
969}
970
971pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
972    let out = std::process::Command::new("git")
973        .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
974        .current_dir(root)
975        .output()?;
976    if !out.status.success() {
977        anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
978    }
979    Ok(())
980}
981
982pub fn has_remote(root: &Path) -> bool {
983    run(root, &["remote", "get-url", "origin"]).is_ok()
984}
985
986/// Merge `branch` into `default_branch` (fast-forward or merge commit).
987/// Pushes `default_branch` to origin when a remote exists.
988/// List remote ticket/* branches with their last commit date.
989/// Returns (branch_name_without_origin_prefix, commit_date) pairs.
990pub fn remote_ticket_branches_with_dates(
991    root: &Path,
992) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
993    use chrono::{TimeZone, Utc};
994    let out = Command::new("git")
995        .current_dir(root)
996        .args([
997            "for-each-ref",
998            "refs/remotes/origin/ticket/",
999            "--format=%(refname:short) %(creatordate:unix)",
1000        ])
1001        .output()
1002        .context("git for-each-ref failed")?;
1003    let stdout = String::from_utf8_lossy(&out.stdout);
1004    let mut result = Vec::new();
1005    for line in stdout.lines() {
1006        let mut parts = line.splitn(2, ' ');
1007        let refname = parts.next().unwrap_or("").trim();
1008        let ts_str = parts.next().unwrap_or("").trim();
1009        let branch = refname.trim_start_matches("origin/");
1010        if branch.is_empty() {
1011            continue;
1012        }
1013        if let Ok(ts) = ts_str.parse::<i64>() {
1014            if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
1015                result.push((branch.to_string(), dt));
1016            }
1017        }
1018    }
1019    Ok(result)
1020}
1021
1022/// Authoritative list of remote ticket branch names via `git ls-remote`.
1023/// Unlike local remote-tracking refs (which can be stale or pruned),
1024/// this queries origin directly. Returns an empty set on any error
1025/// (no remote, network failure, etc.) so callers treat it as "no remote
1026/// branches" and skip remote deletions safely.
1027pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
1028    let mut set = std::collections::HashSet::new();
1029    let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
1030        Ok(o) => o,
1031        Err(_) => return set,
1032    };
1033    for line in out.lines() {
1034        if let Some(refname) = line.split('\t').nth(1) {
1035            if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
1036                set.insert(branch.to_string());
1037            }
1038        }
1039    }
1040    set
1041}
1042
1043/// Delete a remote branch on origin.
1044pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
1045    run(root, &["push", "origin", "--delete", branch])
1046        .map(|_| ())
1047        .context("git push origin --delete failed")
1048}
1049
1050pub struct DeleteBranchesOutput {
1051    pub deleted: Vec<String>,
1052    pub failed: Vec<(String, String)>,
1053}
1054
1055/// Delete multiple remote branches in a single `git push` call.
1056///
1057/// Empty input returns `Ok` immediately without spawning git.
1058/// Uses `--porcelain` to parse per-ref success/failure so that a single
1059/// rejected ref does not abort the rest of the batch.
1060/// Returns `Err` only when git cannot be spawned at all.
1061pub fn delete_remote_branches(root: &Path, branches: &[&str]) -> Result<DeleteBranchesOutput> {
1062    if branches.is_empty() {
1063        return Ok(DeleteBranchesOutput { deleted: vec![], failed: vec![] });
1064    }
1065
1066    let mut cmd = Command::new("git");
1067    cmd.current_dir(root)
1068        .args(["push", "--porcelain", "origin", "--delete"]);
1069    for b in branches {
1070        cmd.arg(format!("refs/heads/{b}"));
1071    }
1072
1073    let out = cmd.output().context("git not found")?;
1074
1075    let stdout = String::from_utf8_lossy(&out.stdout);
1076    let mut deleted = Vec::new();
1077    let mut failed = Vec::new();
1078
1079    for line in stdout.lines() {
1080        if let Some(rest) = line.strip_prefix("-\t") {
1081            let mut parts = rest.splitn(2, '\t');
1082            if let Some(ref_field) = parts.next() {
1083                let branch = ref_field
1084                    .trim_start_matches(':')
1085                    .trim_start_matches("refs/heads/")
1086                    .to_string();
1087                if !branch.is_empty() {
1088                    deleted.push(branch);
1089                }
1090            }
1091        } else if let Some(rest) = line.strip_prefix("!\t") {
1092            let mut parts = rest.splitn(2, '\t');
1093            let ref_field = parts.next().unwrap_or("")
1094                .trim_start_matches(':')
1095                .trim_start_matches("refs/heads/")
1096                .to_string();
1097            let reason = parts.next().unwrap_or("").to_string();
1098            if !ref_field.is_empty() {
1099                failed.push((ref_field, reason));
1100            }
1101        }
1102    }
1103
1104    // Total failure: no lines parsed and git exited non-zero (e.g. network error).
1105    if deleted.is_empty() && failed.is_empty() && !out.status.success() {
1106        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1107        for b in branches {
1108            failed.push((b.to_string(), stderr.clone()));
1109        }
1110    }
1111
1112    Ok(DeleteBranchesOutput { deleted, failed })
1113}
1114
1115/// Move files on a branch in a single commit.
1116/// Each element of `moves` is (old_rel_path, new_rel_path, content).
1117/// Writes each new file, stages it, then removes each old file via `git rm`.
1118/// Uses the same permanent-worktree / temp-worktree pattern as commit_files_to_branch.
1119pub fn move_files_on_branch(
1120    root: &Path,
1121    branch: &str,
1122    moves: &[(&str, &str, &str)],
1123    message: &str,
1124) -> Result<()> {
1125    if !has_commits(root) {
1126        for (old, new, content) in moves {
1127            let new_path = root.join(new);
1128            if let Some(parent) = new_path.parent() {
1129                std::fs::create_dir_all(parent)?;
1130            }
1131            std::fs::write(&new_path, content)?;
1132            let old_path = root.join(old);
1133            let _ = std::fs::remove_file(&old_path);
1134        }
1135        return Ok(());
1136    }
1137
1138    let do_moves = |wt: &Path| -> Result<()> {
1139        for (old, new, content) in moves {
1140            let new_path = wt.join(new);
1141            if let Some(parent) = new_path.parent() {
1142                std::fs::create_dir_all(parent)?;
1143            }
1144            std::fs::write(&new_path, content)?;
1145            run(wt, &["add", new])?;
1146            run(wt, &["rm", "--force", "--quiet", old])?;
1147        }
1148        run(wt, &["commit", "-m", message])?;
1149        Ok(())
1150    };
1151
1152    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
1153        let remote_ref = format!("origin/{branch}");
1154        if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
1155            let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
1156        }
1157        let result = do_moves(&wt_path);
1158        if result.is_ok() {
1159            crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1160        }
1161        return result;
1162    }
1163
1164    if current_branch(root).ok().as_deref() == Some(branch) {
1165        let result = do_moves(root);
1166        if result.is_ok() {
1167            crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1168        }
1169        return result;
1170    }
1171
1172    let unique = std::time::SystemTime::now()
1173        .duration_since(std::time::UNIX_EPOCH)
1174        .map(|d| d.subsec_nanos())
1175        .unwrap_or(0);
1176    let wt_path = std::env::temp_dir().join(format!(
1177        "apm-{}-{}-{}",
1178        std::process::id(),
1179        unique,
1180        branch.replace('/', "-"),
1181    ));
1182
1183    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
1184    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
1185
1186    if has_remote {
1187        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
1188        let _ = run(&wt_path, &["checkout", "-B", branch]);
1189    } else if has_local {
1190        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
1191        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
1192        let _ = run(&wt_path, &["checkout", "-B", branch]);
1193    } else {
1194        run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
1195    }
1196
1197    let result = do_moves(&wt_path);
1198    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
1199    let _ = std::fs::remove_dir_all(&wt_path);
1200    if result.is_ok() {
1201        crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1202    }
1203    result
1204}
1205
1206pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1207    let _ = run(root, &["fetch", "origin", default_branch]);
1208
1209    let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1210    let merge_dir = if current_branch(&main_root).ok().as_deref() == Some(default_branch) {
1211        main_root
1212    } else {
1213        find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
1214    };
1215
1216    if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
1217        let _ = run(&merge_dir, &["merge", "--abort"]);
1218        anyhow::bail!("merge failed: {e:#}");
1219    }
1220
1221    if has_remote(root) {
1222        if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
1223            warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
1224        }
1225    }
1226    Ok(())
1227}
1228
1229pub fn merge_into_default(root: &Path, config: &Config, branch: &str, default_branch: &str, skip_push: bool, messages: &mut Vec<String>, _warnings: &mut Vec<String>) -> Result<()> {
1230    let _ = run(root, &["fetch", "origin", default_branch]);
1231
1232    let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1233    let merge_dir = if current_branch(&main_root).ok().as_deref() == Some(default_branch) {
1234        main_root.clone()
1235    } else {
1236        let worktrees_base = main_root.join(&config.worktrees.dir);
1237        ensure_worktree(root, &worktrees_base, default_branch)?
1238    };
1239
1240    let out = std::process::Command::new("git")
1241        .args(["merge", "--no-ff", branch, "--no-edit"])
1242        .current_dir(&merge_dir)
1243        .output()?;
1244
1245    if !out.status.success() {
1246        let _ = run(&merge_dir, &["merge", "--abort"]);
1247        bail!(
1248            "merge conflict — resolve manually and push: {}",
1249            String::from_utf8_lossy(&out.stderr).trim()
1250        );
1251    }
1252
1253    if skip_push {
1254        messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1255    } else {
1256        push_branch(&merge_dir, default_branch)?;
1257        messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1258    }
1259    Ok(())
1260}
1261
1262pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1263    let fetch = std::process::Command::new("git")
1264        .args(["fetch", "origin", default_branch])
1265        .current_dir(root)
1266        .output();
1267
1268    match fetch {
1269        Err(e) => {
1270            warnings.push(format!("warning: fetch failed: {e:#}"));
1271            return Ok(());
1272        }
1273        Ok(out) if !out.status.success() => {
1274            warnings.push(format!(
1275                "warning: fetch failed: {}",
1276                String::from_utf8_lossy(&out.stderr).trim()
1277            ));
1278            return Ok(());
1279        }
1280        _ => {}
1281    }
1282
1283    let current = std::process::Command::new("git")
1284        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1285        .current_dir(root)
1286        .output()?;
1287    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1288
1289    let merge_dir = if current_branch == default_branch {
1290        root.to_path_buf()
1291    } else {
1292        find_worktree_for_branch(root, default_branch)
1293            .unwrap_or_else(|| root.to_path_buf())
1294    };
1295
1296    let remote_ref = format!("origin/{default_branch}");
1297    let out = std::process::Command::new("git")
1298        .args(["merge", "--ff-only", &remote_ref])
1299        .current_dir(&merge_dir)
1300        .output()?;
1301
1302    if !out.status.success() {
1303        warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1304    }
1305
1306    Ok(())
1307}
1308
1309pub fn is_worktree_dirty(path: &Path) -> bool {
1310    let Ok(out) = Command::new("git")
1311        .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1312        .output()
1313    else {
1314        return false;
1315    };
1316    !out.stdout.is_empty()
1317}
1318
1319/// Like `is_worktree_dirty` but ignores known APM temp files so that an
1320/// in-progress worker's log/pid files do not prevent a safe fast-forward.
1321pub fn is_worktree_dirty_for_sync(path: &Path) -> bool {
1322    const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1323    let Ok(out) = Command::new("git")
1324        .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1325        .output()
1326    else {
1327        return false;
1328    };
1329    let stdout = String::from_utf8_lossy(&out.stdout);
1330    stdout.lines().filter(|l| !l.is_empty()).any(|l| {
1331        // Porcelain v1 format: "XY filename" — 3-char prefix then filename.
1332        let fname = l.get(3..).unwrap_or("").trim();
1333        !TEMP_FILES.contains(&fname)
1334    })
1335}
1336
1337/// Returns the list of dirty (non-temp) filenames in a worktree.
1338/// Separating the check (returns bool) from the collection (returns Vec) avoids
1339/// allocating on the clean-worktree hot path.
1340fn dirty_files_for_sync(path: &Path) -> Vec<String> {
1341    const TEMP_FILES: &[&str] = &[".apm-worker.log", ".apm-worker.pid"];
1342    let Ok(out) = Command::new("git")
1343        .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1344        .output()
1345    else {
1346        return Vec::new();
1347    };
1348    let stdout = String::from_utf8_lossy(&out.stdout);
1349    stdout
1350        .lines()
1351        .filter(|l| !l.is_empty())
1352        .filter_map(|l| {
1353            let fname = l.get(3..)?.trim();
1354            if TEMP_FILES.contains(&fname) { None } else { Some(fname.to_string()) }
1355        })
1356        .collect()
1357}
1358
1359/// Result of `sync_checked_out_worktrees`.
1360pub struct WorktreeSyncResult {
1361    /// Worktrees that were successfully fast-forwarded: (path, branch).
1362    pub fast_forwarded: Vec<(PathBuf, String)>,
1363    /// Worktrees skipped due to uncommitted changes: (path, branch, dirty_files).
1364    pub skipped_dirty: Vec<(PathBuf, String, Vec<String>)>,
1365    /// Worktrees whose local branch is ahead of origin: (path, branch).
1366    pub skipped_ahead: Vec<(PathBuf, String)>,
1367    /// Worktrees whose local branch has diverged from origin: (path, branch).
1368    pub skipped_diverged: Vec<(PathBuf, String)>,
1369}
1370
1371/// Fast-forward each ticket worktree that is `Behind` origin and has no
1372/// uncommitted changes (excluding `.apm-worker.log` and `.apm-worker.pid`).
1373///
1374/// State matrix:
1375///   Behind + clean  → `git merge --ff-only origin/<branch>` in the worktree.
1376///   Behind + dirty  → skip; record in `skipped_dirty` for a warning.
1377///   Ahead           → skip; record in `skipped_ahead` for an info line.
1378///   Diverged        → skip; record in `skipped_diverged` for a warning.
1379///   Equal / NoRemote / RemoteOnly → silent no-op.
1380///
1381/// Unexpected merge failures (e.g. ff refused for a reason other than dirty
1382/// working tree) are appended to `warnings`.
1383pub fn sync_checked_out_worktrees(root: &Path, warnings: &mut Vec<String>) -> WorktreeSyncResult {
1384    let mut result = WorktreeSyncResult {
1385        fast_forwarded: Vec::new(),
1386        skipped_dirty: Vec::new(),
1387        skipped_ahead: Vec::new(),
1388        skipped_diverged: Vec::new(),
1389    };
1390
1391    let worktrees = match crate::worktree::list_ticket_worktrees(root) {
1392        Ok(w) => w,
1393        Err(_) => return result,
1394    };
1395
1396    for (wt_path, branch) in worktrees {
1397        let local_ref = format!("refs/heads/{branch}");
1398        let remote_ref = format!("origin/{branch}");
1399        match classify_branch(root, &local_ref, &remote_ref) {
1400            BranchClass::Behind => {
1401                if is_worktree_dirty_for_sync(&wt_path) {
1402                    let dirty = dirty_files_for_sync(&wt_path);
1403                    result.skipped_dirty.push((wt_path, branch, dirty));
1404                } else {
1405                    match run(&wt_path, &["merge", "--ff-only", &remote_ref]) {
1406                        Ok(_) => result.fast_forwarded.push((wt_path, branch)),
1407                        Err(e) => warnings.push(format!(
1408                            "warning: fast-forward {} failed: {e:#}",
1409                            wt_path.display()
1410                        )),
1411                    }
1412                }
1413            }
1414            BranchClass::Ahead => {
1415                result.skipped_ahead.push((wt_path, branch));
1416            }
1417            BranchClass::Diverged => {
1418                result.skipped_diverged.push((wt_path, branch));
1419            }
1420            BranchClass::Equal | BranchClass::NoRemote | BranchClass::RemoteOnly => {
1421                // Silent no-op.
1422            }
1423        }
1424    }
1425
1426    result
1427}
1428
1429pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1430    Command::new("git")
1431        .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1432        .output()
1433        .map(|o| o.status.success())
1434        .unwrap_or(false)
1435}
1436
1437pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1438    let Ok(out) = Command::new("git")
1439        .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1440        .output()
1441    else {
1442        warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1443        return;
1444    };
1445    if !out.status.success() {
1446        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1447        warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1448    }
1449}
1450
1451pub fn prune_remote_tracking(root: &Path, branch: &str) {
1452    let _ = Command::new("git")
1453        .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1454        .output();
1455}
1456
1457pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1458    let mut args = vec!["add"];
1459    args.extend_from_slice(files);
1460    run(root, &args).map(|_| ())
1461}
1462
1463pub fn commit(root: &Path, message: &str) -> Result<()> {
1464    run(root, &["commit", "-m", message]).map(|_| ())
1465}
1466
1467pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1468    let out = Command::new("git")
1469        .args(["-C", &root.to_string_lossy(), "config", key])
1470        .output()
1471        .ok()?;
1472    if !out.status.success() {
1473        return None;
1474    }
1475    let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1476    if value.is_empty() { None } else { Some(value) }
1477}
1478
1479/// Remove any unmerged (stage 1/2/3) entries from the index of `dir`.
1480/// `git ls-files -u` lists them; `git reset HEAD -- <path>` clears each.
1481/// No-op when the index is clean. Warnings are appended on failure; we
1482/// never bail because the caller's next operation will surface any real
1483/// problem.
1484fn clear_stale_unmerged_entries(dir: &Path, warnings: &mut Vec<String>) {
1485    let out = match Command::new("git")
1486        .args(["-C", &dir.to_string_lossy(), "ls-files", "-u"])
1487        .output()
1488    {
1489        Ok(o) if o.status.success() => o,
1490        _ => return,
1491    };
1492    let stdout = String::from_utf8_lossy(&out.stdout);
1493    let mut paths: std::collections::HashSet<String> = std::collections::HashSet::new();
1494    for line in stdout.lines() {
1495        // Format: "<mode> <sha> <stage>\t<path>"
1496        if let Some(path) = line.split('\t').nth(1) {
1497            paths.insert(path.to_string());
1498        }
1499    }
1500    if paths.is_empty() {
1501        return;
1502    }
1503    warnings.push(format!(
1504        "warning: clearing {} stale unmerged index entr{} in {} (left by an earlier failed merge)",
1505        paths.len(),
1506        if paths.len() == 1 { "y" } else { "ies" },
1507        dir.display(),
1508    ));
1509    for path in &paths {
1510        let _ = Command::new("git")
1511            .args(["-C", &dir.to_string_lossy(), "reset", "HEAD", "--", path])
1512            .output();
1513    }
1514}
1515
1516pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1517    // Preflight: clear any stage-2/3 unmerged index entries left behind by an
1518    // earlier failed merge whose abort didn't fully clean up. Without this,
1519    // git refuses with "Merging is not possible because you have unmerged
1520    // files." before even attempting the merge.
1521    clear_stale_unmerged_entries(dir, warnings);
1522
1523    // `merge.directoryRenames=false` disables git's heuristic that infers
1524    // file renames when many siblings in a directory are renamed elsewhere.
1525    // For apm, that heuristic mis-fires during the post-provision merge:
1526    // when main archives a sweep of `tickets/*.md` into `archive/tickets/`,
1527    // git would speculatively rename the ticket branch's own active ticket
1528    // file into archive/ too, creating a phantom conflict.
1529    let out = match Command::new("git")
1530        .args([
1531            "-C", &dir.to_string_lossy(),
1532            "-c", "merge.directoryRenames=false",
1533            "merge", refname, "--no-edit",
1534        ])
1535        .output()
1536    {
1537        Ok(o) => o,
1538        Err(e) => {
1539            warnings.push(format!("warning: merge {refname} failed: {e}"));
1540            return None;
1541        }
1542    };
1543    if out.status.success() {
1544        let stdout = String::from_utf8_lossy(&out.stdout);
1545        if stdout.contains("Already up to date") {
1546            None
1547        } else {
1548            Some(format!("Merged {refname} into branch."))
1549        }
1550    } else {
1551        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1552        warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1553        // Abort the merge so the worktree returns to a clean state. Without
1554        // this, MERGE_HEAD persists and subsequent partial commits (e.g.
1555        // `apm state`, `apm set`) fail silently with "cannot do a partial
1556        // commit during a merge". Best-effort: an abort failure is reported
1557        // as a warning but doesn't change the return value.
1558        if detect_mid_merge_state(dir).is_some() {
1559            let abort = Command::new("git")
1560                .args(["-C", &dir.to_string_lossy(), "merge", "--abort"])
1561                .output();
1562            match abort {
1563                Ok(o) if !o.status.success() => {
1564                    let aborterr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1565                    warnings.push(format!(
1566                        "warning: could not abort merge of {refname} in {}: {aborterr}",
1567                        dir.display()
1568                    ));
1569                }
1570                Err(e) => {
1571                    warnings.push(format!(
1572                        "warning: could not abort merge of {refname} in {}: {e}",
1573                        dir.display()
1574                    ));
1575                }
1576                Ok(_) => {}
1577            }
1578        }
1579        None
1580    }
1581}
1582
1583pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1584    Command::new("git")
1585        .args(["ls-files", "--error-unmatch", path])
1586        .current_dir(root)
1587        .stdout(std::process::Stdio::null())
1588        .stderr(std::process::Stdio::null())
1589        .status()
1590        .map(|s| s.success())
1591        .unwrap_or(false)
1592}
1593
1594/// Describes which incomplete git operation is in progress.
1595/// Presence of the corresponding marker file/directory under `.git/` is definitive —
1596/// git creates these for the duration of the operation and removes them on commit or abort.
1597pub enum MidMergeState {
1598    /// `.git/MERGE_HEAD` exists — a `git merge` was started but not committed.
1599    Merge,
1600    /// `.git/rebase-merge/` exists — a `git rebase -i` (or merge-based rebase) is in progress.
1601    RebaseMerge,
1602    /// `.git/rebase-apply/` exists — a `git rebase` (apply-based) or `git am` is in progress.
1603    RebaseApply,
1604    /// `.git/CHERRY_PICK_HEAD` exists — a `git cherry-pick` is in progress.
1605    CherryPick,
1606}
1607
1608/// Detect whether the repo is in a mid-merge, mid-rebase, or mid-cherry-pick state.
1609///
1610/// Returns `Some` when any of the well-known git marker files/directories exist.
1611/// Uses path checks only — no subprocess calls.
1612///
1613/// Note: git worktrees store their state in a separate directory pointed to by
1614/// `.git` (which becomes a file rather than a directory). This function is safe
1615/// because `apm sync` always runs at the main repo root where `.git` is a directory.
1616pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1617    let git_dir = root.join(".git");
1618    if git_dir.join("MERGE_HEAD").exists() {
1619        return Some(MidMergeState::Merge);
1620    }
1621    if git_dir.join("rebase-merge").is_dir() {
1622        return Some(MidMergeState::RebaseMerge);
1623    }
1624    if git_dir.join("rebase-apply").is_dir() {
1625        return Some(MidMergeState::RebaseApply);
1626    }
1627    if git_dir.join("CHERRY_PICK_HEAD").exists() {
1628        return Some(MidMergeState::CherryPick);
1629    }
1630    None
1631}
1632
1633/// Run `git merge-base ref1 ref2` and return the common ancestor SHA.
1634pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1635    run(root, &["merge-base", ref1, ref2])
1636}
1637
1638pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1639    let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1640    out.lines()
1641        .next()
1642        .and_then(|line| line.strip_prefix("worktree "))
1643        .map(PathBuf::from)
1644}
1645
1646/// Returns the list of files that are both modified on `ticket_branch`
1647/// (since its merge-base with `target_branch`) AND dirty (uncommitted) in the
1648/// target worktree.  Returns an empty Vec when the check cannot be performed
1649/// (no shared history, target worktree not found on disk).
1650///
1651/// Porcelain v1 entries with `R` or `C` in either status column are skipped:
1652/// their line format (`XY old -> new`) cannot be parsed with a simple col-3
1653/// slice.  Known limitation: a leaked file staged as a rename in the target
1654/// worktree will not be detected.
1655///
1656/// `??` (untracked) entries ARE included: a file added by the ticket branch
1657/// that appears untracked in the target worktree is a genuine leak signal.
1658pub fn check_leaked_files(
1659    root: &Path,
1660    ticket_branch: &str,
1661    target_branch: &str,
1662) -> Result<Vec<String>> {
1663    // 1. Resolve the target worktree directory.
1664    let current = Command::new("git")
1665        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1666        .current_dir(root)
1667        .output()?;
1668    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1669
1670    let merge_dir = if current_branch == target_branch {
1671        root.to_path_buf()
1672    } else {
1673        match crate::worktree::find_worktree_for_branch(root, target_branch) {
1674            Some(p) => p,
1675            None => return Ok(vec![]),  // target worktree absent -> cannot be dirty
1676        }
1677    };
1678
1679    // 2. Compute merge-base between target and ticket.
1680    let base = match merge_base(root, target_branch, ticket_branch) {
1681        Ok(s) => s.trim().to_string(),
1682        Err(_) => return Ok(vec![]),  // no shared history -> don't block
1683    };
1684    if base.is_empty() {
1685        return Ok(vec![]);
1686    }
1687
1688    // 3. Files touched by the ticket branch since the merge-base (includes newly
1689    //    added files, which appear as untracked in the target if leaked).
1690    let diff_out = Command::new("git")
1691        .args(["diff", "--name-only", &base, ticket_branch])
1692        .current_dir(root)
1693        .output()?;
1694    let ticket_files: std::collections::HashSet<String> =
1695        String::from_utf8_lossy(&diff_out.stdout)
1696            .lines()
1697            .map(|s| s.to_string())
1698            .collect();
1699
1700    // 4. Dirty files in the target worktree.
1701    //    Porcelain v1 format: "XY <path>" -- path starts at column 3.
1702    //    "??" (untracked) entries are intentionally included: a file added by the
1703    //    ticket branch that sits untracked in the target is a genuine leak signal.
1704    //    "R " and "C " (staged rename/copy) entries are skipped: their line format
1705    //    is "XY orig -> dest", so col-3 slicing produces "orig -> dest", not a
1706    //    matchable path.  Known limitation: leaks of staged-renamed files are not
1707    //    detected.
1708    let status_out = Command::new("git")
1709        .args(["status", "--porcelain", "--untracked-files=all"])
1710        .current_dir(&merge_dir)
1711        .output()?;
1712    let dirty_files: std::collections::HashSet<String> =
1713        String::from_utf8_lossy(&status_out.stdout)
1714            .lines()
1715            .filter_map(|line| {
1716                if line.len() < 3 {
1717                    return None;
1718                }
1719                let x = line.as_bytes()[0] as char;
1720                let y = line.as_bytes()[1] as char;
1721                // Skip rename/copy entries: cannot be parsed with a simple col-3 slice.
1722                if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1723                    return None;
1724                }
1725                Some(line[3..].to_string())
1726            })
1727            .collect();
1728
1729    // 5. Intersection, sorted for stable output.
1730    let mut overlap: Vec<String> = ticket_files
1731        .intersection(&dirty_files)
1732        .cloned()
1733        .collect();
1734    overlap.sort();
1735    Ok(overlap)
1736}
1737
1738#[cfg(test)]
1739mod tests {
1740    use super::*;
1741    use std::process::Command as Cmd;
1742    use tempfile::TempDir;
1743
1744    fn git_init() -> TempDir {
1745        let dir = tempfile::tempdir().unwrap();
1746        let p = dir.path();
1747        Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1748        Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1749        Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1750        dir
1751    }
1752
1753    fn git_cmd(dir: &Path, args: &[&str]) {
1754        Cmd::new("git")
1755            .args(args)
1756            .current_dir(dir)
1757            .env("GIT_AUTHOR_NAME", "test")
1758            .env("GIT_AUTHOR_EMAIL", "t@t.com")
1759            .env("GIT_COMMITTER_NAME", "test")
1760            .env("GIT_COMMITTER_EMAIL", "t@t.com")
1761            .status()
1762            .unwrap();
1763    }
1764
1765    fn make_commit(dir: &Path, filename: &str, content: &str) {
1766        let full = dir.join(filename);
1767        if let Some(parent) = full.parent() {
1768            std::fs::create_dir_all(parent).unwrap();
1769        }
1770        std::fs::write(full, content).unwrap();
1771        git_cmd(dir, &["add", filename]);
1772        git_cmd(dir, &["commit", "-m", "init"]);
1773    }
1774
1775    #[test]
1776    fn is_worktree_dirty_clean() {
1777        let dir = git_init();
1778        make_commit(dir.path(), "f.txt", "hi");
1779        assert!(!is_worktree_dirty(dir.path()));
1780    }
1781
1782    #[test]
1783    fn is_worktree_dirty_dirty() {
1784        let dir = git_init();
1785        make_commit(dir.path(), "f.txt", "hi");
1786        std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1787        assert!(is_worktree_dirty(dir.path()));
1788    }
1789
1790    #[test]
1791    fn is_worktree_dirty_for_sync_clean() {
1792        let dir = git_init();
1793        make_commit(dir.path(), "f.txt", "hi");
1794        assert!(!is_worktree_dirty_for_sync(dir.path()));
1795    }
1796
1797    #[test]
1798    fn is_worktree_dirty_for_sync_temp_files_only_is_clean() {
1799        let dir = git_init();
1800        make_commit(dir.path(), "f.txt", "hi");
1801        // Temp files should not count as dirty.
1802        std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1803        std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1804        assert!(!is_worktree_dirty_for_sync(dir.path()));
1805        // But is_worktree_dirty sees them.
1806        assert!(is_worktree_dirty(dir.path()));
1807    }
1808
1809    #[test]
1810    fn is_worktree_dirty_for_sync_real_change_is_dirty() {
1811        let dir = git_init();
1812        make_commit(dir.path(), "f.txt", "hi");
1813        std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1814        assert!(is_worktree_dirty_for_sync(dir.path()));
1815    }
1816
1817    #[test]
1818    fn is_worktree_dirty_for_sync_temp_plus_real_is_dirty() {
1819        let dir = git_init();
1820        make_commit(dir.path(), "f.txt", "hi");
1821        std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1822        std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1823        assert!(is_worktree_dirty_for_sync(dir.path()));
1824    }
1825
1826    #[test]
1827    fn dirty_files_for_sync_excludes_temp_files() {
1828        let dir = git_init();
1829        make_commit(dir.path(), "f.txt", "hi");
1830        std::fs::write(dir.path().join(".apm-worker.log"), "log").unwrap();
1831        std::fs::write(dir.path().join(".apm-worker.pid"), "123").unwrap();
1832        std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1833        let dirty = dirty_files_for_sync(dir.path());
1834        assert!(dirty.contains(&"f.txt".to_string()), "f.txt should be in dirty; got {dirty:?}");
1835        assert!(!dirty.iter().any(|f| f.contains(".apm-worker")), "temp files should be excluded; got {dirty:?}");
1836    }
1837
1838    #[test]
1839    fn sync_checked_out_worktrees_behind_clean_fast_forwards() {
1840        // Set up: local repo (acts as "origin"), clone ("local") with a ticket worktree.
1841        let origin_tmp = git_init();
1842        let origin = origin_tmp.path();
1843        make_commit(origin, "README", "v1");
1844        // Create a ticket branch on origin.
1845        git_cmd(origin, &["checkout", "-b", "ticket/test-ff"]);
1846        make_commit(origin, "impl.rs", "v1");
1847        git_cmd(origin, &["checkout", "main"]);
1848
1849        // Clone the origin.
1850        let clone_tmp = tempfile::tempdir().unwrap();
1851        let clone = clone_tmp.path();
1852        Cmd::new("git")
1853            .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1854            .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1855            .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1856            .status().unwrap();
1857        // Set up clone identity.
1858        git_cmd(clone, &["config", "user.email", "t@t.com"]);
1859        git_cmd(clone, &["config", "user.name", "test"]);
1860        // Check out ticket branch in clone so list_ticket_worktrees finds it as a worktree.
1861        let wt_path = clone.join("wt-test-ff");
1862        Cmd::new("git")
1863            .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-ff"])
1864            .current_dir(clone).status().unwrap();
1865
1866        // Now push a new commit on origin's ticket branch (so clone is Behind).
1867        git_cmd(origin, &["checkout", "ticket/test-ff"]);
1868        make_commit(origin, "impl.rs", "v2");
1869        git_cmd(origin, &["checkout", "main"]);
1870        // Fetch in clone so origin/ticket/test-ff advances.
1871        git_cmd(clone, &["fetch", "origin"]);
1872
1873        let mut warnings = Vec::new();
1874        let result = sync_checked_out_worktrees(clone, &mut warnings);
1875
1876        assert_eq!(result.fast_forwarded.len(), 1, "should have fast-forwarded 1 worktree; warnings: {warnings:?}");
1877        assert!(result.skipped_dirty.is_empty());
1878        assert!(result.skipped_ahead.is_empty());
1879        assert!(result.skipped_diverged.is_empty());
1880        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1881
1882        // Verify the working tree was updated.
1883        let content = std::fs::read_to_string(wt_path.join("impl.rs")).unwrap();
1884        assert_eq!(content.trim(), "v2", "worktree should have v2 after fast-forward");
1885    }
1886
1887    #[test]
1888    fn sync_checked_out_worktrees_dirty_skips() {
1889        let origin_tmp = git_init();
1890        let origin = origin_tmp.path();
1891        make_commit(origin, "README", "v1");
1892        git_cmd(origin, &["checkout", "-b", "ticket/test-dirty"]);
1893        make_commit(origin, "impl.rs", "v1");
1894        git_cmd(origin, &["checkout", "main"]);
1895
1896        let clone_tmp = tempfile::tempdir().unwrap();
1897        let clone = clone_tmp.path();
1898        Cmd::new("git")
1899            .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1900            .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1901            .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1902            .status().unwrap();
1903        git_cmd(clone, &["config", "user.email", "t@t.com"]);
1904        git_cmd(clone, &["config", "user.name", "test"]);
1905        let wt_path = clone.join("wt-test-dirty");
1906        Cmd::new("git")
1907            .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-dirty"])
1908            .current_dir(clone).status().unwrap();
1909
1910        // Advance origin branch.
1911        git_cmd(origin, &["checkout", "ticket/test-dirty"]);
1912        make_commit(origin, "impl.rs", "v2");
1913        git_cmd(origin, &["checkout", "main"]);
1914        git_cmd(clone, &["fetch", "origin"]);
1915
1916        // Make the worktree dirty (non-temp file).
1917        std::fs::write(wt_path.join("impl.rs"), "local change").unwrap();
1918
1919        let mut warnings = Vec::new();
1920        let result = sync_checked_out_worktrees(clone, &mut warnings);
1921
1922        assert!(result.fast_forwarded.is_empty(), "should not fast-forward dirty worktree");
1923        assert_eq!(result.skipped_dirty.len(), 1);
1924        let (_, _, ref dirty_files) = result.skipped_dirty[0];
1925        assert!(dirty_files.contains(&"impl.rs".to_string()), "impl.rs should be in dirty files; got {dirty_files:?}");
1926    }
1927
1928    #[test]
1929    fn sync_checked_out_worktrees_temp_only_is_clean() {
1930        let origin_tmp = git_init();
1931        let origin = origin_tmp.path();
1932        make_commit(origin, "README", "v1");
1933        git_cmd(origin, &["checkout", "-b", "ticket/test-temponly"]);
1934        make_commit(origin, "impl.rs", "v1");
1935        git_cmd(origin, &["checkout", "main"]);
1936
1937        let clone_tmp = tempfile::tempdir().unwrap();
1938        let clone = clone_tmp.path();
1939        Cmd::new("git")
1940            .args(["clone", &origin.to_string_lossy(), &clone.to_string_lossy()])
1941            .env("GIT_AUTHOR_NAME", "test").env("GIT_AUTHOR_EMAIL", "t@t.com")
1942            .env("GIT_COMMITTER_NAME", "test").env("GIT_COMMITTER_EMAIL", "t@t.com")
1943            .status().unwrap();
1944        git_cmd(clone, &["config", "user.email", "t@t.com"]);
1945        git_cmd(clone, &["config", "user.name", "test"]);
1946        let wt_path = clone.join("wt-test-temponly");
1947        Cmd::new("git")
1948            .args(["worktree", "add", &wt_path.to_string_lossy(), "ticket/test-temponly"])
1949            .current_dir(clone).status().unwrap();
1950
1951        // Advance origin branch.
1952        git_cmd(origin, &["checkout", "ticket/test-temponly"]);
1953        make_commit(origin, "impl.rs", "v2");
1954        git_cmd(origin, &["checkout", "main"]);
1955        git_cmd(clone, &["fetch", "origin"]);
1956
1957        // Temp-only files should not block the fast-forward.
1958        std::fs::write(wt_path.join(".apm-worker.log"), "log").unwrap();
1959        std::fs::write(wt_path.join(".apm-worker.pid"), "123").unwrap();
1960
1961        let mut warnings = Vec::new();
1962        let result = sync_checked_out_worktrees(clone, &mut warnings);
1963
1964        assert_eq!(result.fast_forwarded.len(), 1, "temp-only worktree should be fast-forwarded; warnings: {warnings:?}");
1965        assert!(result.skipped_dirty.is_empty());
1966    }
1967
1968    #[test]
1969    fn sync_checked_out_worktrees_no_worktrees_returns_empty() {
1970        let dir = git_init();
1971        make_commit(dir.path(), "f.txt", "hi");
1972        let mut warnings = Vec::new();
1973        let result = sync_checked_out_worktrees(dir.path(), &mut warnings);
1974        assert!(result.fast_forwarded.is_empty());
1975        assert!(result.skipped_dirty.is_empty());
1976        assert!(result.skipped_ahead.is_empty());
1977        assert!(result.skipped_diverged.is_empty());
1978        assert!(warnings.is_empty());
1979    }
1980
1981    #[test]
1982    fn local_branch_exists_present_and_absent() {
1983        let dir = git_init();
1984        make_commit(dir.path(), "f.txt", "hi");
1985        let on_main = local_branch_exists(dir.path(), "main");
1986        let on_master = local_branch_exists(dir.path(), "master");
1987        assert!(on_main || on_master);
1988        assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1989    }
1990
1991    #[test]
1992    fn delete_local_branch_success() {
1993        let dir = git_init();
1994        make_commit(dir.path(), "f.txt", "hi");
1995        git_cmd(dir.path(), &["branch", "to-delete"]);
1996        let mut warnings = Vec::new();
1997        delete_local_branch(dir.path(), "to-delete", &mut warnings);
1998        assert!(warnings.is_empty());
1999        assert!(!local_branch_exists(dir.path(), "to-delete"));
2000    }
2001
2002    #[test]
2003    fn delete_local_branch_failure_adds_warning() {
2004        let dir = git_init();
2005        make_commit(dir.path(), "f.txt", "hi");
2006        let mut warnings = Vec::new();
2007        delete_local_branch(dir.path(), "nonexistent", &mut warnings);
2008        assert!(!warnings.is_empty());
2009        assert!(warnings[0].contains("warning:"));
2010    }
2011
2012    #[test]
2013    fn prune_remote_tracking_no_panic() {
2014        let dir = git_init();
2015        make_commit(dir.path(), "f.txt", "hi");
2016        // Just verify it doesn't panic even when the remote ref doesn't exist.
2017        prune_remote_tracking(dir.path(), "nonexistent-branch");
2018    }
2019
2020    #[test]
2021    fn stage_files_ok_and_err() {
2022        let dir = git_init();
2023        make_commit(dir.path(), "f.txt", "hi");
2024        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
2025        assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
2026        assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
2027    }
2028
2029    #[test]
2030    fn commit_ok_and_err() {
2031        let dir = git_init();
2032        make_commit(dir.path(), "f.txt", "hi");
2033        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
2034        git_cmd(dir.path(), &["add", "new.txt"]);
2035        assert!(commit(dir.path(), "test commit").is_ok());
2036        // Nothing staged — should fail
2037        assert!(commit(dir.path(), "empty commit").is_err());
2038    }
2039
2040    #[test]
2041    fn git_config_get_some_and_none() {
2042        let dir = git_init();
2043        make_commit(dir.path(), "f.txt", "hi");
2044        let val = git_config_get(dir.path(), "user.email");
2045        assert_eq!(val, Some("t@t.com".to_string()));
2046        let missing = git_config_get(dir.path(), "no.such.key");
2047        assert!(missing.is_none());
2048    }
2049
2050    #[test]
2051    fn merge_ref_already_up_to_date() {
2052        let dir = git_init();
2053        make_commit(dir.path(), "f.txt", "hi");
2054        let branch = {
2055            let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
2056            String::from_utf8_lossy(&out.stdout).trim().to_string()
2057        };
2058        let mut warnings = Vec::new();
2059        // Merging current branch into itself is already up to date
2060        let result = merge_ref(dir.path(), &branch, &mut warnings);
2061        assert!(result.is_none());
2062        assert!(warnings.is_empty());
2063    }
2064
2065    #[test]
2066    fn merge_ref_success() {
2067        let dir = git_init();
2068        make_commit(dir.path(), "f.txt", "hi");
2069        git_cmd(dir.path(), &["checkout", "-b", "feature"]);
2070        make_commit(dir.path(), "g.txt", "there");
2071        git_cmd(dir.path(), &["checkout", "main"]);
2072        let mut warnings = Vec::new();
2073        let result = merge_ref(dir.path(), "feature", &mut warnings);
2074        assert!(result.is_some());
2075        assert!(warnings.is_empty());
2076    }
2077
2078    #[test]
2079    fn merge_ref_does_not_speculate_directory_renames() {
2080        // Reproduce the apm archive-sweep scenario: many siblings move from
2081        // dir A to dir B on main; the feature branch adds a *new* file in
2082        // dir A. With directory rename detection on, git would speculatively
2083        // place the new file under dir B too, conflicting with the feature
2084        // branch. With it off (our fix), the file stays at A on the merged
2085        // branch.
2086        let dir = git_init();
2087        let p = dir.path();
2088        // Seed: dir A has several files
2089        std::fs::create_dir_all(p.join("a")).unwrap();
2090        for name in &["1.md", "2.md", "3.md", "4.md"] {
2091            std::fs::write(p.join("a").join(name), "seed\n").unwrap();
2092        }
2093        git_cmd(p, &["add", "a"]);
2094        git_cmd(p, &["commit", "-m", "seed"]);
2095
2096        // main: move all of a/* into b/*
2097        std::fs::create_dir_all(p.join("b")).unwrap();
2098        for name in &["1.md", "2.md", "3.md", "4.md"] {
2099            std::fs::rename(p.join("a").join(name), p.join("b").join(name)).unwrap();
2100        }
2101        git_cmd(p, &["add", "-A"]);
2102        git_cmd(p, &["commit", "-m", "archive sweep"]);
2103
2104        // feature branch: branch off seed, add new file in a/
2105        git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
2106        std::fs::write(p.join("a/new.md"), "active\n").unwrap();
2107        git_cmd(p, &["add", "a/new.md"]);
2108        git_cmd(p, &["commit", "-m", "add active ticket"]);
2109
2110        let mut warnings = Vec::new();
2111        let result = merge_ref(p, "main", &mut warnings);
2112
2113        assert!(result.is_some(), "merge should succeed without directory-rename inference; warnings: {warnings:?}");
2114        assert!(detect_mid_merge_state(p).is_none(), "should not be left mid-merge");
2115        // The new file should still be at a/new.md, not b/new.md.
2116        assert!(p.join("a/new.md").exists(), "new.md should stay at a/");
2117        assert!(!p.join("b/new.md").exists(), "new.md must NOT have been speculatively renamed into b/");
2118    }
2119
2120    #[test]
2121    fn merge_ref_clears_stale_unmerged_index_entries() {
2122        // Reproduce a stage-2 leftover from an earlier failed merge that
2123        // was incompletely aborted. Without preflight cleanup, git refuses:
2124        // "Merging is not possible because you have unmerged files."
2125        let dir = git_init();
2126        let p = dir.path();
2127        make_commit(p, "f.txt", "hi");
2128
2129        // Branch "other" with a non-conflicting commit — we'll merge this
2130        // *later*, after polluting the index.
2131        git_cmd(p, &["checkout", "-b", "other"]);
2132        make_commit(p, "g.txt", "there");
2133        git_cmd(p, &["checkout", "main"]);
2134
2135        // Now create a real merge conflict against a third branch "feature".
2136        std::fs::write(p.join("conflict.md"), "main\n").unwrap();
2137        git_cmd(p, &["add", "conflict.md"]);
2138        git_cmd(p, &["commit", "-m", "main version"]);
2139
2140        git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
2141        std::fs::write(p.join("conflict.md"), "feature\n").unwrap();
2142        git_cmd(p, &["add", "conflict.md"]);
2143        git_cmd(p, &["commit", "-m", "feature version"]);
2144
2145        git_cmd(p, &["checkout", "main"]);
2146        let _ = Cmd::new("git")
2147            .args(["-C", &p.to_string_lossy(), "merge", "feature", "--no-edit"])
2148            .output();
2149        // Simulate a botched abort: clear MERGE_HEAD but leave stage entries.
2150        let _ = std::fs::remove_file(p.join(".git/MERGE_HEAD"));
2151        let _ = std::fs::remove_file(p.join(".git/MERGE_MSG"));
2152
2153        let pre = String::from_utf8_lossy(
2154            &Cmd::new("git").args(["-C", &p.to_string_lossy(), "ls-files", "-u"])
2155                .output().unwrap().stdout
2156        ).to_string();
2157        assert!(!pre.trim().is_empty(), "precondition: unmerged index entries should be present; got: {pre:?}");
2158
2159        // Without preflight cleanup, this would fail with "unmerged files".
2160        let mut warnings = Vec::new();
2161        let result = merge_ref(p, "other", &mut warnings);
2162
2163        assert!(result.is_some(), "merge should succeed after preflight; warnings: {warnings:?}");
2164        assert!(
2165            warnings.iter().any(|w| w.contains("stale unmerged index")),
2166            "expected stale-entry warning; got: {warnings:?}"
2167        );
2168    }
2169
2170    #[test]
2171    fn merge_ref_conflict_aborts_and_warns() {
2172        let dir = git_init();
2173        let p = dir.path();
2174        // Create the same file on both branches with different content so
2175        // the merge has a non-trivial conflict.
2176        make_commit(p, "f.txt", "main version\n");
2177        git_cmd(p, &["checkout", "-b", "feature"]);
2178        std::fs::write(p.join("f.txt"), "feature version\n").unwrap();
2179        git_cmd(p, &["add", "f.txt"]);
2180        git_cmd(p, &["commit", "-m", "feature change"]);
2181        git_cmd(p, &["checkout", "main"]);
2182        std::fs::write(p.join("f.txt"), "main change\n").unwrap();
2183        git_cmd(p, &["add", "f.txt"]);
2184        git_cmd(p, &["commit", "-m", "main change"]);
2185
2186        let mut warnings = Vec::new();
2187        let result = merge_ref(p, "feature", &mut warnings);
2188
2189        assert!(result.is_none(), "merge should report failure");
2190        assert!(
2191            warnings.iter().any(|w| w.contains("merge feature failed")),
2192            "expected merge-failure warning; got: {warnings:?}"
2193        );
2194        // Critical: worktree must NOT be left mid-merge.
2195        assert!(
2196            detect_mid_merge_state(p).is_none(),
2197            "merge_ref must abort on conflict so MERGE_HEAD does not persist"
2198        );
2199    }
2200
2201    #[test]
2202    fn detect_mid_merge_none_on_clean_repo() {
2203        let dir = git_init();
2204        make_commit(dir.path(), "f.txt", "hi");
2205        assert!(detect_mid_merge_state(dir.path()).is_none());
2206    }
2207
2208    #[test]
2209    fn detect_mid_merge_on_merge_head() {
2210        let dir = git_init();
2211        make_commit(dir.path(), "f.txt", "hi");
2212        std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
2213        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
2214    }
2215
2216    #[test]
2217    fn detect_mid_merge_on_rebase_merge() {
2218        let dir = git_init();
2219        make_commit(dir.path(), "f.txt", "hi");
2220        std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
2221        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
2222    }
2223
2224    #[test]
2225    fn detect_mid_merge_on_rebase_apply() {
2226        let dir = git_init();
2227        make_commit(dir.path(), "f.txt", "hi");
2228        std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
2229        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
2230    }
2231
2232    #[test]
2233    fn detect_mid_merge_on_cherry_pick() {
2234        let dir = git_init();
2235        make_commit(dir.path(), "f.txt", "hi");
2236        std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
2237        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
2238    }
2239
2240    #[test]
2241    fn is_file_tracked_tracked_and_untracked() {
2242        let dir = git_init();
2243        make_commit(dir.path(), "tracked.txt", "hi");
2244        assert!(is_file_tracked(dir.path(), "tracked.txt"));
2245        std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
2246        assert!(!is_file_tracked(dir.path(), "untracked.txt"));
2247    }
2248
2249    #[test]
2250    fn check_leaked_files_detects_overlap() {
2251        let dir = git_init();
2252        let p = dir.path();
2253        std::fs::create_dir_all(p.join("src")).unwrap();
2254        std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2255        git_cmd(p, &["add", "src/foo.rs"]);
2256        git_cmd(p, &["commit", "-m", "add foo"]);
2257
2258        git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
2259        std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2260        git_cmd(p, &["add", "src/foo.rs"]);
2261        git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2262        git_cmd(p, &["checkout", "main"]);
2263
2264        // Simulate a leaked edit on main without committing.
2265        std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
2266
2267        let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
2268        assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
2269    }
2270
2271    #[test]
2272    fn check_leaked_files_no_overlap() {
2273        let dir = git_init();
2274        let p = dir.path();
2275        std::fs::create_dir_all(p.join("src")).unwrap();
2276        std::fs::write(p.join("src/foo.rs"), "original").unwrap();
2277        std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
2278        git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
2279        git_cmd(p, &["commit", "-m", "add foo and bar"]);
2280
2281        // Ticket branch modifies only src/foo.rs.
2282        git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
2283        std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
2284        git_cmd(p, &["add", "src/foo.rs"]);
2285        git_cmd(p, &["commit", "-m", "ticket: change foo"]);
2286        git_cmd(p, &["checkout", "main"]);
2287
2288        // Main has src/bar.rs dirty — not touched by the ticket.
2289        std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
2290
2291        let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
2292        assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
2293    }
2294
2295    #[test]
2296    fn check_leaked_files_detects_untracked_overlap() {
2297        let dir = git_init();
2298        let p = dir.path();
2299        make_commit(p, "existing.rs", "base");
2300
2301        // Ticket branch adds src/new.rs as a new file.
2302        git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
2303        std::fs::create_dir_all(p.join("src")).unwrap();
2304        std::fs::write(p.join("src/new.rs"), "new file").unwrap();
2305        git_cmd(p, &["add", "src/new.rs"]);
2306        git_cmd(p, &["commit", "-m", "ticket: add new file"]);
2307        git_cmd(p, &["checkout", "main"]);
2308
2309        // Leak: src/new.rs dropped untracked on main.
2310        std::fs::create_dir_all(p.join("src")).unwrap();
2311        std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
2312
2313        let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
2314        assert_eq!(leaked, vec!["src/new.rs".to_string()]);
2315    }
2316
2317    // ---- content_merged_into_main tests ----
2318
2319    /// Helper: commit a file with given name and content on the current branch.
2320    fn commit_file(dir: &Path, name: &str, content: &str) {
2321        std::fs::write(dir.join(name), content).unwrap();
2322        git_cmd(dir, &["add", name]);
2323        git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
2324    }
2325
2326    /// Regular-merged branch with a trailing ticket-file state commit:
2327    /// `content_merged_into_main` must return true.
2328    #[test]
2329    fn content_merged_into_main_regular_merge_with_state_commit() {
2330        let dir = git_init();
2331        let p = dir.path();
2332
2333        // Base commit on main.
2334        commit_file(p, "README", "base");
2335
2336        // Ticket branch: add src/lib.rs (implementation).
2337        git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2338        std::fs::create_dir_all(p.join("src")).unwrap();
2339        commit_file(p, "src/lib.rs", "impl");
2340
2341        // Regular-merge into main.
2342        git_cmd(p, &["checkout", "main"]);
2343        git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2344
2345        // Push a state-transition commit to the ticket branch (touches only tickets/).
2346        git_cmd(p, &["checkout", "ticket/foo"]);
2347        std::fs::create_dir_all(p.join("tickets")).unwrap();
2348        commit_file(p, "tickets/foo.md", "state: implemented");
2349
2350        // After this commit the branch tip is NOT an ancestor of main.
2351        let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
2352        assert!(result, "should detect that content was merged despite trailing state commit");
2353    }
2354
2355    /// Squash-merged branch with a trailing ticket-file commit:
2356    /// `content_merged_into_main` must return true.
2357    #[test]
2358    fn content_merged_into_main_squash_merge_with_state_commit() {
2359        let dir = git_init();
2360        let p = dir.path();
2361
2362        commit_file(p, "README", "base");
2363
2364        // Ticket branch.
2365        git_cmd(p, &["checkout", "-b", "ticket/bar"]);
2366        std::fs::create_dir_all(p.join("src")).unwrap();
2367        commit_file(p, "src/lib.rs", "impl");
2368
2369        // Squash-merge into main (manually: merge --squash + commit).
2370        git_cmd(p, &["checkout", "main"]);
2371        git_cmd(p, &["merge", "--squash", "ticket/bar"]);
2372        git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
2373
2374        // State-transition commit on ticket branch.
2375        git_cmd(p, &["checkout", "ticket/bar"]);
2376        std::fs::create_dir_all(p.join("tickets")).unwrap();
2377        commit_file(p, "tickets/bar.md", "state: implemented");
2378
2379        let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
2380        assert!(result, "should detect squash-merged content despite trailing state commit");
2381    }
2382
2383    /// Branch tip is already the merge-base (i.e. is an ancestor of main):
2384    /// return false — `--merged` handles it.
2385    #[test]
2386    fn content_merged_into_main_returns_false_when_ancestor() {
2387        let dir = git_init();
2388        let p = dir.path();
2389        commit_file(p, "README", "base");
2390        // ticket/anc is just pointing at main's tip — it IS an ancestor.
2391        git_cmd(p, &["checkout", "-b", "ticket/anc"]);
2392        // No extra commits — same SHA as main.
2393        git_cmd(p, &["checkout", "main"]);
2394        let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
2395        assert!(!result);
2396    }
2397
2398    /// Branch has a non-ticket file commit after the merge point — must NOT be detected.
2399    #[test]
2400    fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
2401        let dir = git_init();
2402        let p = dir.path();
2403        commit_file(p, "README", "base");
2404
2405        // Ticket branch with implementation.
2406        git_cmd(p, &["checkout", "-b", "ticket/extra"]);
2407        std::fs::create_dir_all(p.join("src")).unwrap();
2408        commit_file(p, "src/lib.rs", "impl");
2409
2410        // Squash-merge.
2411        git_cmd(p, &["checkout", "main"]);
2412        git_cmd(p, &["merge", "--squash", "ticket/extra"]);
2413        git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
2414
2415        // After merge: push BOTH a state commit AND a non-ticket source change.
2416        git_cmd(p, &["checkout", "ticket/extra"]);
2417        std::fs::create_dir_all(p.join("tickets")).unwrap();
2418        commit_file(p, "tickets/extra.md", "state: implemented");
2419        // Non-ticket file added — this makes content_tip == branch_tip (the source change
2420        // is the newest non-ticket commit), so the function should return false.
2421        commit_file(p, "src/extra.rs", "extra code");
2422
2423        let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
2424        assert!(!result, "branch with non-ticket changes after merge must not be detected");
2425    }
2426
2427    /// Branch where every commit since merge-base touches only ticket files:
2428    /// return false (nothing to squash-check).
2429    #[test]
2430    fn content_merged_into_main_all_ticket_only_commits_returns_false() {
2431        let dir = git_init();
2432        let p = dir.path();
2433        commit_file(p, "README", "base");
2434
2435        // Ticket branch that only ever touched tickets/.
2436        git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
2437        std::fs::create_dir_all(p.join("tickets")).unwrap();
2438        commit_file(p, "tickets/ticketonly.md", "state: new");
2439        git_cmd(p, &["checkout", "main"]);
2440
2441        let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
2442        assert!(!result, "all-ticket-only commits should return false");
2443    }
2444
2445    /// Regression test: a local ticket branch regular-merged into local
2446    /// main, with the remote-tracking ref deleted (e.g. GitHub auto-delete
2447    /// after merge), must still appear in `merged_into_main`'s result.
2448    /// Previously the squash-merge detector skipped it because its tip is
2449    /// already an ancestor, while `git branch -r --merged` could not see it
2450    /// (the origin/ticket/* ref no longer exists).
2451    #[test]
2452    fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
2453        let dir = git_init();
2454        let p = dir.path();
2455        make_commit(p, "f.txt", "base");
2456
2457        // Create a ticket branch and a commit on it.
2458        git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2459        std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
2460        git_cmd(p, &["add", "f.txt"]);
2461        git_cmd(p, &["commit", "-m", "ticket: change"]);
2462
2463        // Merge ticket/foo into main with --no-ff (regular merge, leaves a merge commit).
2464        git_cmd(p, &["checkout", "main"]);
2465        git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2466
2467        // Simulate that origin/main has been updated to match local main, but
2468        // origin/ticket/foo was auto-deleted (no such remote-tracking ref).
2469        let main_sha = run(p, &["rev-parse", "main"]).unwrap();
2470        Cmd::new("git")
2471            .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
2472            .current_dir(p)
2473            .status()
2474            .unwrap();
2475        // No origin/ticket/foo ref is created — this is the auto-delete case.
2476
2477        let merged = merged_into_main(p, "main").unwrap();
2478        assert!(
2479            merged.iter().any(|b| b == "ticket/foo"),
2480            "expected ticket/foo in merged set; got {merged:?}"
2481        );
2482    }
2483
2484    // ---- read_from_branch_with_class tests ----
2485
2486    /// Build a bare + local pair where `origin` acts as the remote.
2487    /// Returns (bare_dir, local_dir). Both must be kept alive.
2488    fn git_init_with_remote() -> (TempDir, TempDir) {
2489        // bare origin
2490        let bare = tempfile::tempdir().unwrap();
2491        Cmd::new("git")
2492            .args(["init", "--bare", "-q"])
2493            .current_dir(bare.path())
2494            .status()
2495            .unwrap();
2496
2497        // local clone
2498        let local = tempfile::tempdir().unwrap();
2499        let p = local.path();
2500        Cmd::new("git")
2501            .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2502            .current_dir(p)
2503            .env("GIT_AUTHOR_NAME", "test")
2504            .env("GIT_AUTHOR_EMAIL", "t@t.com")
2505            .env("GIT_COMMITTER_NAME", "test")
2506            .env("GIT_COMMITTER_EMAIL", "t@t.com")
2507            .status()
2508            .unwrap();
2509        git_cmd(p, &["config", "user.name", "test"]);
2510        git_cmd(p, &["config", "user.email", "t@t.com"]);
2511
2512        (bare, local)
2513    }
2514
2515    #[test]
2516    fn read_from_branch_with_class_behind_returns_origin_content() {
2517        let (bare, local) = git_init_with_remote();
2518        let p = local.path();
2519
2520        // Initial commit on main + push.
2521        make_commit(p, "README", "base");
2522        git_cmd(p, &["push", "origin", "main"]);
2523
2524        // Create ticket branch, push it.
2525        git_cmd(p, &["checkout", "-b", "ticket/abc"]);
2526        make_commit(p, "tickets/abc.md", "state: ready\n");
2527        git_cmd(p, &["push", "origin", "ticket/abc"]);
2528
2529        // Simulate remote machine: update the bare branch directly via a second clone.
2530        let remote2 = tempfile::tempdir().unwrap();
2531        let r2 = remote2.path();
2532        Cmd::new("git")
2533            .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2534            .current_dir(r2)
2535            .env("GIT_AUTHOR_NAME", "test")
2536            .env("GIT_AUTHOR_EMAIL", "t@t.com")
2537            .env("GIT_COMMITTER_NAME", "test")
2538            .env("GIT_COMMITTER_EMAIL", "t@t.com")
2539            .status()
2540            .unwrap();
2541        git_cmd(r2, &["config", "user.name", "test"]);
2542        git_cmd(r2, &["config", "user.email", "t@t.com"]);
2543        git_cmd(r2, &["checkout", "ticket/abc"]);
2544        make_commit(r2, "tickets/abc.md", "state: in_progress\n");
2545        git_cmd(r2, &["push", "origin", "ticket/abc"]);
2546
2547        // Fetch in local so origin/ticket/abc advances but local ticket/abc stays stale.
2548        git_cmd(p, &["fetch", "--all", "--quiet"]);
2549
2550        // read_from_branch_with_class should return origin content (in_progress) and Behind.
2551        let (content, class) = read_from_branch_with_class(p, "ticket/abc", "tickets/abc.md").unwrap();
2552        assert!(
2553            matches!(class, BranchClass::Behind),
2554            "expected Behind; got something else"
2555        );
2556        assert!(
2557            content.contains("in_progress"),
2558            "expected origin content 'in_progress'; got: {content:?}"
2559        );
2560    }
2561
2562    #[test]
2563    fn read_from_branch_with_class_ahead_returns_local_content() {
2564        let (_bare, local) = git_init_with_remote();
2565        let p = local.path();
2566
2567        make_commit(p, "README", "base");
2568        git_cmd(p, &["push", "origin", "main"]);
2569
2570        git_cmd(p, &["checkout", "-b", "ticket/xyz"]);
2571        make_commit(p, "tickets/xyz.md", "state: ready\n");
2572        git_cmd(p, &["push", "origin", "ticket/xyz"]);
2573
2574        // Local adds a new commit not yet pushed.
2575        make_commit(p, "tickets/xyz.md", "state: in_progress\n");
2576
2577        // fetch to make sure origin ref is set.
2578        git_cmd(p, &["fetch", "--all", "--quiet"]);
2579
2580        let (content, class) = read_from_branch_with_class(p, "ticket/xyz", "tickets/xyz.md").unwrap();
2581        assert!(
2582            matches!(class, BranchClass::Ahead),
2583            "expected Ahead"
2584        );
2585        assert!(
2586            content.contains("in_progress"),
2587            "expected local content; got: {content:?}"
2588        );
2589    }
2590
2591    #[test]
2592    fn read_from_branch_with_class_equal_returns_content() {
2593        let (_bare, local) = git_init_with_remote();
2594        let p = local.path();
2595
2596        make_commit(p, "README", "base");
2597        git_cmd(p, &["push", "origin", "main"]);
2598
2599        git_cmd(p, &["checkout", "-b", "ticket/eq"]);
2600        make_commit(p, "tickets/eq.md", "state: ready\n");
2601        git_cmd(p, &["push", "origin", "ticket/eq"]);
2602
2603        let (content, class) = read_from_branch_with_class(p, "ticket/eq", "tickets/eq.md").unwrap();
2604        assert!(
2605            matches!(class, BranchClass::Equal),
2606            "expected Equal"
2607        );
2608        assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2609    }
2610
2611    // ---- is_branch_content_merged tests ----
2612
2613    #[test]
2614    fn is_branch_content_merged_regular_merge_returns_true() {
2615        let dir = git_init();
2616        let p = dir.path();
2617        make_commit(p, "README", "base");
2618        // Create a branch with a commit.
2619        git_cmd(p, &["checkout", "-b", "epic/aa000001-feature"]);
2620        make_commit(p, "feature.md", "feature");
2621        git_cmd(p, &["checkout", "main"]);
2622        // No-ff merge into main.
2623        git_cmd(p, &["merge", "--no-ff", "epic/aa000001-feature", "-m", "merge epic"]);
2624        assert!(is_branch_content_merged(p, "main", "epic/aa000001-feature").unwrap());
2625    }
2626
2627    #[test]
2628    fn is_branch_content_merged_squash_merge_returns_true() {
2629        let dir = git_init();
2630        let p = dir.path();
2631        make_commit(p, "README", "base");
2632        // Create a branch with a commit.
2633        git_cmd(p, &["checkout", "-b", "epic/bb000002-feature"]);
2634        make_commit(p, "feature.md", "feature");
2635        git_cmd(p, &["checkout", "main"]);
2636        // Squash merge: commit-tree approach.
2637        git_cmd(p, &["merge", "--squash", "epic/bb000002-feature"]);
2638        git_cmd(p, &["commit", "-m", "squash merge epic"]);
2639        assert!(is_branch_content_merged(p, "main", "epic/bb000002-feature").unwrap());
2640    }
2641
2642    #[test]
2643    fn is_branch_content_merged_unmerged_returns_false() {
2644        let dir = git_init();
2645        let p = dir.path();
2646        make_commit(p, "README", "base");
2647        git_cmd(p, &["checkout", "-b", "epic/cc000003-feature"]);
2648        make_commit(p, "feature.md", "feature");
2649        git_cmd(p, &["checkout", "main"]);
2650        // Do NOT merge.
2651        assert!(!is_branch_content_merged(p, "main", "epic/cc000003-feature").unwrap());
2652    }
2653
2654    #[test]
2655    fn is_branch_content_merged_no_remote_falls_back_to_local() {
2656        // No origin remote: function should fall back to local main ref.
2657        let dir = git_init();
2658        let p = dir.path();
2659        make_commit(p, "README", "base");
2660        git_cmd(p, &["checkout", "-b", "epic/dd000004-feature"]);
2661        make_commit(p, "feature.md", "feature");
2662        git_cmd(p, &["checkout", "main"]);
2663        git_cmd(p, &["merge", "--no-ff", "epic/dd000004-feature", "-m", "merge epic"]);
2664        // No origin/main exists — function must still return true via local main.
2665        assert!(is_branch_content_merged(p, "main", "epic/dd000004-feature").unwrap());
2666    }
2667
2668    #[test]
2669    fn is_branch_content_merged_merged_into_both_returns_true() {
2670        let (bare, local) = git_init_with_remote();
2671        let p = local.path();
2672        make_commit(p, "README", "base");
2673        git_cmd(p, &["push", "origin", "main"]);
2674        // Create epic branch and push it.
2675        git_cmd(p, &["checkout", "-b", "epic/ee000005-feature"]);
2676        make_commit(p, "feature.md", "feature");
2677        git_cmd(p, &["push", "origin", "epic/ee000005-feature"]);
2678        git_cmd(p, &["checkout", "main"]);
2679        // Merge into main and push main to origin.
2680        git_cmd(p, &["merge", "--no-ff", "epic/ee000005-feature", "-m", "merge epic"]);
2681        git_cmd(p, &["push", "origin", "main"]);
2682        // origin/main exists and has the merge — should return true.
2683        assert!(is_branch_content_merged(p, "main", "epic/ee000005-feature").unwrap());
2684        drop(bare); // keep alive
2685    }
2686
2687    #[test]
2688    fn is_branch_content_merged_local_merge_origin_behind_returns_true() {
2689        // Simulates: apm epic submit --merge completed, but push not yet done.
2690        // local main has the merge; origin/main does not.
2691        let (bare, local) = git_init_with_remote();
2692        let p = local.path();
2693        make_commit(p, "README", "base");
2694        git_cmd(p, &["push", "origin", "main"]);
2695        // Create epic branch and merge it into local main without pushing.
2696        git_cmd(p, &["checkout", "-b", "epic/ff000006-feature"]);
2697        make_commit(p, "feature.md", "feature");
2698        git_cmd(p, &["checkout", "main"]);
2699        git_cmd(p, &["merge", "--no-ff", "epic/ff000006-feature", "-m", "merge epic"]);
2700        // Do NOT push — origin/main is behind local main.
2701        // is_branch_content_merged must return true because local main has the merge.
2702        assert!(is_branch_content_merged(p, "main", "epic/ff000006-feature").unwrap());
2703        drop(bare); // keep alive
2704    }
2705
2706    #[test]
2707    fn read_from_branch_with_class_remote_only_returns_origin_content() {
2708        let (bare, local) = git_init_with_remote();
2709        let p = local.path();
2710
2711        make_commit(p, "README", "base");
2712        git_cmd(p, &["push", "origin", "main"]);
2713
2714        // Create and push a branch from a second clone — local has no local branch.
2715        let remote2 = tempfile::tempdir().unwrap();
2716        let r2 = remote2.path();
2717        Cmd::new("git")
2718            .args(["clone", "-q", &bare.path().to_string_lossy(), "."])
2719            .current_dir(r2)
2720            .env("GIT_AUTHOR_NAME", "test")
2721            .env("GIT_AUTHOR_EMAIL", "t@t.com")
2722            .env("GIT_COMMITTER_NAME", "test")
2723            .env("GIT_COMMITTER_EMAIL", "t@t.com")
2724            .status()
2725            .unwrap();
2726        git_cmd(r2, &["config", "user.name", "test"]);
2727        git_cmd(r2, &["config", "user.email", "t@t.com"]);
2728        git_cmd(r2, &["checkout", "-b", "ticket/ro"]);
2729        make_commit(r2, "tickets/ro.md", "state: ready\n");
2730        git_cmd(r2, &["push", "origin", "ticket/ro"]);
2731
2732        // Fetch so local knows about origin/ticket/ro, but no local branch exists.
2733        git_cmd(p, &["fetch", "--all", "--quiet"]);
2734
2735        let (content, class) = read_from_branch_with_class(p, "ticket/ro", "tickets/ro.md").unwrap();
2736        assert!(
2737            matches!(class, BranchClass::RemoteOnly),
2738            "expected RemoteOnly"
2739        );
2740        assert!(content.contains("ready"), "expected ready content; got: {content:?}");
2741    }
2742
2743    #[test]
2744    fn delete_remote_branches_empty_slice_returns_ok() {
2745        let dir = git_init();
2746        let result = delete_remote_branches(dir.path(), &[]);
2747        let out = result.expect("empty slice should return Ok");
2748        assert!(out.deleted.is_empty(), "no branches should be deleted");
2749        assert!(out.failed.is_empty(), "no failures expected");
2750    }
2751}