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/// All ticket/* branch names visible locally or remotely (deduplicated).
40/// Local branches are included even when a remote exists, so that
41/// unpushed branches (e.g. just created) are visible without a push.
42pub fn ticket_branches(root: &Path) -> Result<Vec<String>> {
43    let mut seen = std::collections::HashSet::new();
44    let mut branches = Vec::new();
45
46    let local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
47    for b in local.lines()
48        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
49        .filter(|l| !l.is_empty())
50    {
51        if seen.insert(b.to_string()) {
52            branches.push(b.to_string());
53        }
54    }
55
56    let remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"]).unwrap_or_default();
57    for b in remote.lines()
58        .map(|l| l.trim().trim_start_matches("origin/").to_string())
59        .filter(|l| !l.is_empty())
60    {
61        if seen.insert(b.clone()) {
62            branches.push(b);
63        }
64    }
65
66    Ok(branches)
67}
68
69/// ticket/* branches that are merged into the default branch (remote or local),
70/// including branches that were squash-merged (not detected by `--merged`).
71pub fn merged_into_main(root: &Path, default_branch: &str) -> Result<Vec<String>> {
72    let remote_ref = format!("refs/remotes/origin/{default_branch}");
73    let remote_merged = format!("origin/{default_branch}");
74
75    if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
76        // Regular merges via remote.
77        let regular_out = run(
78            root,
79            &["branch", "-r", "--merged", &remote_merged, "--list", "origin/ticket/*"],
80        )
81        .unwrap_or_default();
82        let mut merged: Vec<String> = regular_out
83            .lines()
84            .map(|l| l.trim().trim_start_matches("origin/").to_string())
85            .filter(|l| !l.is_empty())
86            .collect();
87        let merged_set: std::collections::HashSet<String> = merged.iter().cloned().collect();
88
89        // Squash-merge detection for remote branches not caught by --merged.
90        // Pass full origin/ refs so merge-base resolution works even without a local branch.
91        let all_remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"])
92            .unwrap_or_default();
93        let remote_candidates: Vec<String> = all_remote
94            .lines()
95            .map(|l| l.trim().to_string())
96            .filter(|l| {
97                let stripped = l.strip_prefix("origin/").unwrap_or(l.as_str());
98                !l.is_empty() && !merged_set.contains(stripped)
99            })
100            .collect();
101        let remote_squashed = squash_merged(root, &remote_merged, remote_candidates)?;
102        // Strip origin/ prefix before adding to merged.
103        merged.extend(remote_squashed.into_iter().map(|b| {
104            b.strip_prefix("origin/").unwrap_or(&b).to_string()
105        }));
106
107        // Also check local-only ticket branches whose remote tracking ref was deleted
108        // (e.g. GitHub auto-deletes the branch after squash merge).
109        let remote_stripped: std::collections::HashSet<String> = all_remote
110            .lines()
111            .map(|l| l.trim().trim_start_matches("origin/").to_string())
112            .filter(|l| !l.is_empty())
113            .collect();
114        let merged_now: std::collections::HashSet<String> = merged.iter().cloned().collect();
115        let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
116        let local_only: Vec<String> = all_local
117            .lines()
118            .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
119            .filter(|l| {
120                !l.is_empty()
121                    && !remote_stripped.contains(l)
122                    && !merged_now.contains(l)
123            })
124            .collect();
125        merged.extend(squash_merged(root, &remote_merged, local_only)?);
126
127        // Also catch local ticket branches regular-merged into local
128        // <default_branch> whose remote ref was deleted (e.g. GitHub auto-
129        // delete after merge). `git branch -r --merged origin/<main>` only
130        // returns refs that still exist on origin, and the squash-merged
131        // path above skips ancestor branches because it expects the remote
132        // `--merged` to have caught them — which it can't when the remote
133        // ref is gone.
134        let local_default_ref = format!("refs/heads/{default_branch}");
135        if run(root, &["rev-parse", "--verify", &local_default_ref]).is_ok() {
136            let already: std::collections::HashSet<String> = merged.iter().cloned().collect();
137            let local_regular = run(
138                root,
139                &["branch", "--merged", default_branch, "--list", "ticket/*"],
140            )
141            .unwrap_or_default();
142            for line in local_regular.lines() {
143                let b = line.trim().trim_start_matches(['*', '+']).trim().to_string();
144                if !b.is_empty() && !already.contains(&b) {
145                    merged.push(b);
146                }
147            }
148        }
149
150        return Ok(merged);
151    }
152
153    // Fall back to local branch.
154    let local_ref = format!("refs/heads/{default_branch}");
155    if run(root, &["rev-parse", "--verify", &local_ref]).is_err() {
156        return Ok(vec![]);
157    }
158    let regular_out = run(
159        root,
160        &["branch", "--merged", default_branch, "--list", "ticket/*"],
161    )
162    .unwrap_or_default();
163    let mut merged: Vec<String> = regular_out
164        .lines()
165        .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
166        .filter(|l| !l.is_empty())
167        .collect();
168    let merged_set: std::collections::HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
169
170    let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
171    let candidates: Vec<String> = all_local
172        .lines()
173        .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
174        .filter(|l| !l.is_empty() && !merged_set.contains(l.as_str()))
175        .collect();
176    merged.extend(squash_merged(root, default_branch, candidates)?);
177    Ok(merged)
178}
179
180/// Detect branches squash-merged into `main_ref` using the commit-tree + cherry algorithm.
181///
182/// For each candidate ref, we create a virtual squash commit whose tree equals
183/// the branch tip's tree and whose parent is the merge-base with main. Then
184/// `git cherry` compares that squash commit's patch-id against commits already
185/// in main. A `-` prefix means main has a commit with the same aggregate diff.
186fn squash_merged(root: &Path, main_ref: &str, candidates: Vec<String>) -> Result<Vec<String>> {
187    let mut result = Vec::new();
188    for branch in candidates {
189        let merge_base = match run(root, &["merge-base", main_ref, &branch]) {
190            Ok(mb) => mb,
191            Err(_) => continue,
192        };
193        let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
194            Ok(t) => t,
195            Err(_) => continue,
196        };
197        // Already an ancestor — caught by --merged.
198        if branch_tip == merge_base {
199            continue;
200        }
201        // Virtual squash commit: aggregate diff from merge_base to branch tip.
202        let squash_commit = match run(root, &[
203            "commit-tree", &format!("{branch}^{{tree}}"),
204            "-p", &merge_base,
205            "-m", "squash",
206        ]) {
207            Ok(c) => c,
208            Err(_) => continue,
209        };
210        // `git cherry main squash_commit`: prints `- sha` when main already has that patch.
211        let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
212            Ok(o) => o,
213            Err(_) => continue,
214        };
215        if cherry_out.trim().starts_with('-') {
216            result.push(branch);
217        }
218    }
219    Ok(result)
220}
221
222/// Detect whether a ticket branch's *implementation content* has been merged into
223/// `main_ref` even when state-transition commits (touching only files under
224/// `tickets_dir/`) were pushed to the branch after the merge.
225///
226/// Returns `Ok(false)` in any ambiguous case so that false positives are
227/// impossible: a branch is only reported as merged when we are certain.
228pub fn content_merged_into_main(
229    root: &Path,
230    main_ref: &str,
231    branch: &str,
232    tickets_dir: &str,
233) -> Result<bool> {
234    // 1. Common ancestor of main and branch.
235    let merge_base = match run(root, &["merge-base", main_ref, branch]) {
236        Ok(mb) => mb,
237        Err(_) => return Ok(false),
238    };
239    // 2. Current tip of the branch.
240    let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
241        Ok(t) => t,
242        Err(_) => return Ok(false),
243    };
244    // 3. Already an ancestor — `git branch --merged` handles it.
245    if branch_tip == merge_base {
246        return Ok(false);
247    }
248    // 4. Commits on branch since merge-base, newest first.
249    let log_out = match run(root, &["log", "--pretty=%H", branch, &format!("^{merge_base}")]) {
250        Ok(o) => o,
251        Err(_) => return Ok(false),
252    };
253    // 5. Walk newest-first; find the last commit that touches a non-ticket file.
254    let tickets_prefix = format!("{tickets_dir}/");
255    let mut content_tip: Option<String> = None;
256    for sha in log_out.lines() {
257        let diff_out = match run(root, &["diff-tree", "--no-commit-id", "-r", "--name-only", sha]) {
258            Ok(o) => o,
259            Err(_) => continue,
260        };
261        let has_non_ticket = diff_out.lines().any(|path| !path.starts_with(&tickets_prefix));
262        if has_non_ticket {
263            content_tip = Some(sha.to_string());
264            break;
265        }
266    }
267    // 6. All commits since merge-base touch only ticket files.
268    if content_tip.is_none() {
269        // Sub-case: regular (--no-ff) merge. After a regular merge the implementation
270        // commit C becomes the merge-base (it's now reachable from both main and branch),
271        // so it disappears from the log range above.
272        //
273        // Detection: if merge_base is on main's first-parent chain, it was a direct main
274        // commit (fork-point case — no implementation → return false). If merge_base is
275        // NOT on main's first-parent chain, it was pulled in via a merge commit's non-first
276        // parent → the ticket's implementation is in main → return true.
277        //
278        // We check by listing first-parent commits of main_ref after merge_base's first
279        // parent (`^merge_base^1`). The oldest entry in that list is the first-parent
280        // commit immediately after merge_base^1. If it equals merge_base, merge_base IS
281        // on the first-parent chain. If not, it was brought in via a side-parent merge.
282        let parent_spec = format!("{merge_base}^1");
283        if let Ok(fp_log) = run(root, &[
284            "rev-list", "--first-parent", main_ref, &format!("^{parent_spec}"),
285        ]) {
286            let oldest = fp_log.lines().last().unwrap_or("").trim();
287            if !oldest.is_empty() && oldest != merge_base {
288                // merge_base is not on the first-parent chain → was regular-merged.
289                return Ok(true);
290            }
291        }
292        return Ok(false);
293    }
294    let content_tip = content_tip.unwrap();
295    // 7. No trailing state commits: squash_merged already tried the branch tip's tree
296    //    and returned false, meaning the content really is not in main.
297    if content_tip == branch_tip {
298        return Ok(false);
299    }
300    // 8. Virtual squash commit: aggregate diff from merge_base → content_tip.
301    let squash_commit = match run(root, &[
302        "commit-tree", &format!("{content_tip}^{{tree}}"),
303        "-p", &merge_base,
304        "-m", "squash",
305    ]) {
306        Ok(c) => c,
307        Err(_) => return Ok(false),
308    };
309    // 9. `git cherry main squash`: `-` prefix means main already has that patch.
310    let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
311        Ok(o) => o,
312        Err(_) => return Ok(false),
313    };
314    Ok(cherry_out.trim().starts_with('-'))
315}
316
317/// Commit a file to a specific branch without disturbing the current working tree.
318///
319/// If a permanent worktree exists for the branch, commits there directly.
320/// If the caller is already on the target branch, commits directly.
321/// Otherwise uses a temporary git worktree.
322pub fn commit_to_branch(
323    root: &Path,
324    branch: &str,
325    rel_path: &str,
326    content: &str,
327    message: &str,
328) -> Result<()> {
329    // If the repo has no commits, write directly to the working tree (no worktree support yet).
330    if !has_commits(root) {
331        let local_path = root.join(rel_path);
332        if let Some(parent) = local_path.parent() {
333            std::fs::create_dir_all(parent)?;
334        }
335        std::fs::write(&local_path, content)?;
336        return Ok(());
337    }
338
339    // If a permanent worktree exists for this branch, commit there directly.
340    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
341        // Fast-forward to remote if remote is ahead, so our commit lands on top of it.
342        let remote_ref = format!("origin/{branch}");
343        if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
344            let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
345        }
346        let full_path = wt_path.join(rel_path);
347        if let Some(parent) = full_path.parent() {
348            std::fs::create_dir_all(parent)?;
349        }
350        std::fs::write(&full_path, content)?;
351        let _ = run(&wt_path, &["add", rel_path]);
352        let _ = run(&wt_path, &["commit", "-m", message]);
353        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
354        return Ok(());
355    }
356
357    // If already on the target branch, write to working tree and commit directly.
358    if current_branch(root).ok().as_deref() == Some(branch) {
359        let local_path = root.join(rel_path);
360        if let Some(parent) = local_path.parent() {
361            std::fs::create_dir_all(parent)?;
362        }
363        std::fs::write(&local_path, content)?;
364        let _ = run(root, &["add", rel_path]);
365        let _ = run(root, &["commit", "-m", message]);
366        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
367        return Ok(());
368    }
369
370    let result = try_worktree_commit(root, branch, rel_path, content, message);
371    if result.is_ok() {
372        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
373    }
374    result
375}
376
377fn try_worktree_commit(
378    root: &Path,
379    branch: &str,
380    rel_path: &str,
381    content: &str,
382    message: &str,
383) -> Result<()> {
384    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
385    let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
386    let wt_path = std::env::temp_dir().join(format!(
387        "apm-{}-{}-{}",
388        std::process::id(),
389        seq,
390        branch.replace('/', "-"),
391    ));
392
393    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
394    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
395
396    if has_remote {
397        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
398        let _ = run(&wt_path, &["checkout", "-B", branch]);
399    } else if has_local {
400        // Use detached approach to avoid "already checked out" errors.
401        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
402        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
403        let _ = run(&wt_path, &["checkout", "-B", branch]);
404    } else {
405        run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
406    }
407
408    let result = (|| -> Result<()> {
409        let full_path = wt_path.join(rel_path);
410        if let Some(parent) = full_path.parent() {
411            std::fs::create_dir_all(parent)?;
412        }
413        std::fs::write(&full_path, content)?;
414        run(&wt_path, &["add", rel_path])?;
415        run(&wt_path, &["commit", "-m", message])?;
416        Ok(())
417    })();
418
419    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
420    let _ = std::fs::remove_dir_all(&wt_path);
421
422    result
423}
424
425
426/// Push all local ticket/* branches that have commits not yet on origin.
427/// Non-fatal: logs warnings on push failure. No-op when no origin is configured.
428pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
429    if run(root, &["remote", "get-url", "origin"]).is_err() {
430        return;
431    }
432    let out = match run(root, &["branch", "--list", "ticket/*"]) {
433        Ok(o) => o,
434        Err(_) => return,
435    };
436    for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
437        let range = format!("origin/{branch}..{branch}");
438        let count = run(root, &["rev-list", "--count", &range])
439            .ok()
440            .and_then(|s| s.trim().parse::<u32>().ok())
441            .unwrap_or(0);
442        if count > 0 {
443            if let Err(e) = run(root, &["push", "origin", branch]) {
444                warnings.push(format!("warning: push {branch} failed: {e:#}"));
445            }
446        }
447    }
448}
449
450/// Sync non-checked-out `ticket/*` and `epic/*` local refs with their origin counterparts.
451///
452/// This replaces the old `sync_local_ticket_refs` which performed an unconditional
453/// `update-ref` that could silently rewind local refs with unpushed commits (data-loss bug).
454///
455/// State matrix — each case documents why the mapped action is correct:
456///
457///   Equal      → no-op. Local and origin are identical; nothing to do.
458///
459///   Behind     → fast-forward via `update-ref`. Safe because local is a strict ancestor
460///                of origin: the update only moves the ref forward, losing no local commits.
461///
462///   Ahead      → info line only, NO `update-ref`, NO push.
463///                CRITICAL: the old code performed an unconditional `update-ref` in this
464///                case, silently rewriting the local ref to the origin SHA and orphaning
465///                any unpushed local commits. That was the data-loss bug this function fixes.
466///                apm sync never pushes; ahead refs wait for explicit user action.
467///
468///   Diverged   → warning line, no ref change, no push. Neither side is an ancestor of
469///                the other; manual resolution is required. Clobbering either side would
470///                lose commits.
471///
472///   RemoteOnly → create local ref at origin SHA. Safe: no local commits exist to lose.
473///                Makes the branch visible locally without a checkout.
474///
475///   NoRemote   → local-only branch, leave untouched. No auto-push, no warning spam.
476///                Publishing local-only branches requires an explicit user action.
477pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
478    // Collect all branches currently checked out across all worktrees.
479    // These are never touched — they must be managed via the worktree's own git operations.
480    let checked_out: std::collections::HashSet<String> = {
481        let mut set = std::collections::HashSet::new();
482        if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
483            for line in out.lines() {
484                if let Some(b) = line.strip_prefix("branch refs/heads/") {
485                    set.insert(b.to_string());
486                }
487            }
488        }
489        set
490    };
491
492    // Two ref namespaces this sync cares about. Both get identical classification-based
493    // treatment — ticket/* and epic/* branches are managed the same way.
494    const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
495
496    // Collect all origin refs across both namespaces.
497    let mut remote_refs: Vec<String> = Vec::new();
498    for ns in MANAGED_NAMESPACES {
499        let pattern = format!("refs/remotes/origin/{ns}/");
500        if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
501            for line in out.lines().filter(|l| !l.is_empty()) {
502                remote_refs.push(line.to_string());
503            }
504        }
505    }
506
507    let mut ahead_branches: Vec<String> = Vec::new();
508
509    for remote_name in remote_refs {
510        // remote_name is like "origin/ticket/<slug>" or "origin/epic/<slug>".
511        // Strip the "origin/" prefix to get the local branch name.
512        let branch = match remote_name.strip_prefix("origin/") {
513            Some(b) => b.to_string(),
514            None => continue,
515        };
516
517        // Never touch a branch currently checked out in any worktree.
518        if checked_out.contains(&branch) {
519            continue;
520        }
521
522        let local_ref = format!("refs/heads/{branch}");
523        // Use the short remote name (e.g. "origin/ticket/abc") as classify_branch resolves it.
524        let remote_ref_full = format!("refs/remotes/{remote_name}");
525
526        // Classification drives the action. Nothing in this function pushes —
527        // ahead refs wait for explicit action via apm state transitions.
528        match classify_branch(root, &local_ref, &remote_name) {
529            BranchClass::RemoteOnly => {
530                // No local ref exists yet; create it pointing at the origin SHA.
531                // Safe: there are no local commits to clobber.
532                let sha = match run(root, &["rev-parse", &remote_ref_full]) {
533                    Ok(s) => s,
534                    Err(_) => continue,
535                };
536                if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
537                    warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
538                }
539            }
540            BranchClass::Equal => {
541                // Local and origin are identical; nothing to do.
542            }
543            BranchClass::Behind => {
544                // Local is a strict ancestor of origin — fast-forward is safe.
545                // `update-ref` moves the ref forward; no local commits are lost.
546                let sha = match run(root, &["rev-parse", &remote_ref_full]) {
547                    Ok(s) => s,
548                    Err(_) => continue,
549                };
550                if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
551                    warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
552                }
553            }
554            BranchClass::Ahead => {
555                // CRITICAL: do NOT update-ref here.
556                // The old sync_local_ticket_refs performed an unconditional update-ref that
557                // silently rewound this ref to the origin SHA, orphaning unpushed local commits.
558                // That was the data-loss bug. The correct action is an info line only —
559                // apm sync never pushes; the user must push explicitly when ready.
560                warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
561                ahead_branches.push(branch);
562            }
563            BranchClass::Diverged => {
564                // Neither side is an ancestor of the other. Manual resolution required.
565                // Clobbering either ref would silently discard commits on the other side.
566                let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
567                    .replace("<slug>", &branch);
568                warnings.push(msg);
569            }
570            BranchClass::NoRemote => {
571                // Local-only branch: no origin counterpart. Leave it alone.
572                // No auto-push, no warning — publishing requires an explicit user action.
573            }
574        }
575    }
576
577    ahead_branches
578}
579
580/// List all files in a directory on a branch (non-recursive).
581pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
582    let tree_ref = format!("{branch}:{dir}");
583    let out = run(root, &["ls-tree", "--name-only", &tree_ref])
584        .or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
585    Ok(out.lines()
586        .filter(|l| !l.is_empty())
587        .map(|l| format!("{dir}/{l}"))
588        .collect())
589}
590
591/// Commit multiple files to a branch in a single commit without disturbing the working tree.
592pub fn commit_files_to_branch(
593    root: &Path,
594    branch: &str,
595    files: &[(&str, String)],
596    message: &str,
597) -> Result<()> {
598    if !has_commits(root) {
599        for (rel_path, content) in files {
600            let local_path = root.join(rel_path);
601            if let Some(parent) = local_path.parent() {
602                std::fs::create_dir_all(parent)?;
603            }
604            std::fs::write(&local_path, content)?;
605        }
606        return Ok(());
607    }
608
609    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
610        for (rel_path, content) in files {
611            let full_path = wt_path.join(rel_path);
612            if let Some(parent) = full_path.parent() {
613                std::fs::create_dir_all(parent)?;
614            }
615            std::fs::write(&full_path, content)?;
616            let _ = run(&wt_path, &["add", rel_path]);
617        }
618        run(&wt_path, &["commit", "-m", message])?;
619        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
620        return Ok(());
621    }
622
623    if current_branch(root).ok().as_deref() == Some(branch) {
624        for (rel_path, content) in files {
625            let local_path = root.join(rel_path);
626            if let Some(parent) = local_path.parent() {
627                std::fs::create_dir_all(parent)?;
628            }
629            std::fs::write(&local_path, content)?;
630            let _ = run(root, &["add", rel_path]);
631        }
632        run(root, &["commit", "-m", message])?;
633        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
634        return Ok(());
635    }
636
637    let unique = std::time::SystemTime::now()
638        .duration_since(std::time::UNIX_EPOCH)
639        .map(|d| d.subsec_nanos())
640        .unwrap_or(0);
641    let wt_path = std::env::temp_dir().join(format!(
642        "apm-{}-{}-{}",
643        std::process::id(),
644        unique,
645        branch.replace('/', "-"),
646    ));
647
648    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
649    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
650
651    if has_remote {
652        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
653        let _ = run(&wt_path, &["checkout", "-B", branch]);
654    } else if has_local {
655        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
656        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
657        let _ = run(&wt_path, &["checkout", "-B", branch]);
658    } else {
659        run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
660    }
661
662    let result = (|| -> Result<()> {
663        for (rel_path, content) in files {
664            let full_path = wt_path.join(rel_path);
665            if let Some(parent) = full_path.parent() {
666                std::fs::create_dir_all(parent)?;
667            }
668            std::fs::write(&full_path, content)?;
669            run(&wt_path, &["add", rel_path])?;
670        }
671        run(&wt_path, &["commit", "-m", message])?;
672        Ok(())
673    })();
674
675    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
676    let _ = std::fs::remove_dir_all(&wt_path);
677
678    if result.is_ok() {
679        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
680    }
681    result
682}
683
684/// Get the commit SHA at the tip of a local branch.
685pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
686    run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
687}
688
689/// Resolve a branch name to a commit SHA.
690/// Prefers `origin/<branch>`; falls back to local `<branch>`.
691pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
692    run(root, &["rev-parse", &format!("origin/{branch}")])
693        .or_else(|_| run(root, &["rev-parse", branch]))
694        .with_context(|| format!("branch '{branch}' not found locally or on origin"))
695}
696
697/// Create a local branch pointing at a specific commit SHA.
698pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
699    run(root, &["branch", branch, sha]).map(|_| ())
700}
701
702/// Get the commit SHA at the tip of the remote tracking ref for a branch.
703pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
704    run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
705}
706
707/// Check if `commit` is a git ancestor of `of_ref` (i.e. reachable from `of_ref`).
708/// Uses `git merge-base --is-ancestor`.
709pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
710    Command::new("git")
711        .current_dir(root)
712        .args(["merge-base", "--is-ancestor", commit, of_ref])
713        .status()
714        .map(|s| s.success())
715        .unwrap_or(false)
716}
717
718/// Classification of a local branch relative to its origin counterpart.
719///
720/// Direction note: `merge-base --is-ancestor A B` returns 0 iff A is reachable from B.
721///   - local == remote                        → Equal
722///   - local ancestor-of remote (not equal)   → Behind (FF possible: remote has new commits)
723///   - remote ancestor-of local (not equal)   → Ahead  (local has unpushed commits)
724///   - neither is an ancestor of the other    → Diverged (manual resolution required)
725///   - local ref absent, remote ref present   → RemoteOnly (safe to create local ref)
726///   - remote ref cannot be resolved          → NoRemote (local-only or no origin)
727pub enum BranchClass {
728    Equal,
729    Behind,
730    Ahead,
731    Diverged,
732    /// Local ref does not exist; origin ref does. Safe to create the local ref.
733    RemoteOnly,
734    /// Remote ref cannot be resolved. Branch is local-only or origin is unreachable.
735    NoRemote,
736}
737
738/// Classify `local` branch relative to `remote` ref using SHA equality and directed ancestry.
739///
740/// `local`  — a local branch name, e.g. "main" (resolved via `refs/heads/<local>`).
741/// `remote` — a remote ref name,   e.g. "origin/main" (resolved as-is by git).
742///
743/// Every ancestry check includes a comment explaining which direction maps to which state
744/// because the mapping is not intuitive at a glance.
745pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
746    let local_sha = match run(root, &["rev-parse", local]) {
747        Ok(s) => s,
748        Err(_) => {
749            // Local ref absent. Check whether the remote side exists.
750            // If origin has the branch, this is RemoteOnly (safe to create a local ref).
751            // If origin also can't be resolved, it is truly NoRemote (local-only or no origin).
752            return if run(root, &["rev-parse", remote]).is_ok() {
753                BranchClass::RemoteOnly
754            } else {
755                BranchClass::NoRemote
756            };
757        }
758    };
759    let remote_sha = match run(root, &["rev-parse", remote]) {
760        Ok(s) => s,
761        Err(_) => return BranchClass::NoRemote,
762    };
763
764    if local_sha == remote_sha {
765        return BranchClass::Equal;
766    }
767
768    // `--is-ancestor local remote` succeeds iff local is reachable from remote.
769    // When true (and SHAs differ), remote has commits that local lacks → local is Behind.
770    let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
771
772    // `--is-ancestor remote local` succeeds iff remote is reachable from local.
773    // When true (and SHAs differ), local has commits that remote lacks → local is Ahead.
774    let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
775
776    match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
777        (true, false)  => BranchClass::Behind,   // remote has new commits; FF is safe
778        (false, true)  => BranchClass::Ahead,    // local has unpushed commits
779        (false, false) => BranchClass::Diverged, // each side has commits the other lacks
780        (true, true)   => BranchClass::Equal,    // both ancestors → same commit (guard)
781    }
782}
783
784/// Bring local `default` branch into sync with `origin/<default>` without ever pushing.
785///
786/// State matrix — each row documents why the mapped action is correct:
787///
788///   Equal     → no-op.  Local and origin are identical; nothing to do.
789///
790///   Behind    → `git merge --ff-only origin/<default>` in the main worktree.
791///               The main worktree is always checked out on <default>, so running
792///               the merge there updates both HEAD and the working tree atomically.
793///               If the merge fails (uncommitted local changes overlap with the
794///               incoming commits), we print MAIN_BEHIND_DIRTY_OVERLAP guidance and
795///               leave the working tree untouched.  git's own error detection is used
796///               rather than pre-emptively computing overlap.
797///
798///   Ahead     → Print one info line so the user knows local has unpushed commits.
799///               No network call, no ref changes.  Explicit pushes happen via
800///               `apm state <id> implemented` — apm sync NEVER pushes anything.
801///
802///   Diverged  → Print guidance (rebase/merge/push steps).  No ref changes.
803///               The dirty-aware variant is printed when the main worktree is unclean.
804///
805///   NoRemote  → Silent skip.  No origin is configured, or `origin/<default>` could
806///               not be resolved (e.g. fetch hasn't run yet).  Fetch failures are
807///               already surfaced as a warning by the existing fetch path in sync.rs.
808pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
809    let remote = format!("origin/{default}");
810    match classify_branch(root, default, &remote) {
811        BranchClass::Equal => {
812            // local == origin/main: nothing to do, print nothing.
813        }
814
815        BranchClass::Behind => {
816            // origin has new commits local lacks; attempt a fast-forward.
817            // Run in the main worktree so the working tree is updated too.
818            let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
819            if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
820                // FF refused — uncommitted local changes overlap with incoming commits.
821                // Leave the working tree untouched and print recovery guidance.
822                // Assumption: overlap is the only realistic failure mode for a strictly-behind FF merge; MAIN_BEHIND_DIRTY_OVERLAP covers any --ff-only error here.
823                let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
824                    .replace("<default>", default);
825                warnings.push(msg);
826            }
827        }
828
829        BranchClass::Ahead => {
830            // local has commits not on origin.  No push — apm sync never pushes.
831            // Count unpushed commits so the message is informative.
832            let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
833                .ok()
834                .and_then(|s| s.trim().parse::<u64>().ok())
835                .unwrap_or(0);
836            let msg = crate::sync_guidance::MAIN_AHEAD
837                .replace("<default>", default)
838                .replace("<remote>", &remote)
839                .replace("<count>", &count.to_string())
840                .replace("<commits>", if count == 1 { "commit" } else { "commits" });
841            warnings.push(msg);
842            return true;
843        }
844
845        BranchClass::Diverged => {
846            // Neither side is an ancestor of the other; manual resolution required.
847            // Print the dirty-aware variant so the user gets actionable steps.
848            let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
849            let guidance = if is_worktree_dirty(&wt) {
850                crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
851            } else {
852                crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
853            };
854            warnings.push(guidance);
855        }
856
857        BranchClass::RemoteOnly => {
858            // The default branch always exists locally in any repo with commits.
859            // RemoteOnly here would mean local branch is absent, which cannot happen
860            // during a normal sync flow. Treat it as NoRemote (silent skip).
861        }
862
863        BranchClass::NoRemote => {
864            // origin/<default> not resolvable (no remote, or fetch hasn't run yet).
865            // The fetch path in sync.rs already emits a warning on fetch failure.
866            // Nothing more to do here.
867        }
868    }
869    false
870}
871
872pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
873    let status = std::process::Command::new("git")
874        .args(["fetch", "origin", branch])
875        .current_dir(root)
876        .status()?;
877    if !status.success() {
878        anyhow::bail!("git fetch failed");
879    }
880    Ok(())
881}
882
883pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
884    let status = std::process::Command::new("git")
885        .args(["push", "origin", &format!("{branch}:{branch}")])
886        .current_dir(root)
887        .status()?;
888    if !status.success() {
889        anyhow::bail!("git push failed");
890    }
891    Ok(())
892}
893
894pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
895    let out = std::process::Command::new("git")
896        .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
897        .current_dir(root)
898        .output()?;
899    if !out.status.success() {
900        anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
901    }
902    Ok(())
903}
904
905pub fn has_remote(root: &Path) -> bool {
906    run(root, &["remote", "get-url", "origin"]).is_ok()
907}
908
909/// Merge `branch` into `default_branch` (fast-forward or merge commit).
910/// Pushes `default_branch` to origin when a remote exists.
911/// List remote ticket/* branches with their last commit date.
912/// Returns (branch_name_without_origin_prefix, commit_date) pairs.
913pub fn remote_ticket_branches_with_dates(
914    root: &Path,
915) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
916    use chrono::{TimeZone, Utc};
917    let out = Command::new("git")
918        .current_dir(root)
919        .args([
920            "for-each-ref",
921            "refs/remotes/origin/ticket/",
922            "--format=%(refname:short) %(creatordate:unix)",
923        ])
924        .output()
925        .context("git for-each-ref failed")?;
926    let stdout = String::from_utf8_lossy(&out.stdout);
927    let mut result = Vec::new();
928    for line in stdout.lines() {
929        let mut parts = line.splitn(2, ' ');
930        let refname = parts.next().unwrap_or("").trim();
931        let ts_str = parts.next().unwrap_or("").trim();
932        let branch = refname.trim_start_matches("origin/");
933        if branch.is_empty() {
934            continue;
935        }
936        if let Ok(ts) = ts_str.parse::<i64>() {
937            if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
938                result.push((branch.to_string(), dt));
939            }
940        }
941    }
942    Ok(result)
943}
944
945/// Authoritative list of remote ticket branch names via `git ls-remote`.
946/// Unlike local remote-tracking refs (which can be stale or pruned),
947/// this queries origin directly. Returns an empty set on any error
948/// (no remote, network failure, etc.) so callers treat it as "no remote
949/// branches" and skip remote deletions safely.
950pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
951    let mut set = std::collections::HashSet::new();
952    let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
953        Ok(o) => o,
954        Err(_) => return set,
955    };
956    for line in out.lines() {
957        if let Some(refname) = line.split('\t').nth(1) {
958            if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
959                set.insert(branch.to_string());
960            }
961        }
962    }
963    set
964}
965
966/// Delete a remote branch on origin.
967pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
968    let status = Command::new("git")
969        .current_dir(root)
970        .args(["push", "origin", "--delete", branch])
971        .status()
972        .context("git push origin --delete failed")?;
973    if !status.success() {
974        anyhow::bail!("git push origin --delete {branch} failed");
975    }
976    Ok(())
977}
978
979/// Move files on a branch in a single commit.
980/// Each element of `moves` is (old_rel_path, new_rel_path, content).
981/// Writes each new file, stages it, then removes each old file via `git rm`.
982/// Uses the same permanent-worktree / temp-worktree pattern as commit_files_to_branch.
983pub fn move_files_on_branch(
984    root: &Path,
985    branch: &str,
986    moves: &[(&str, &str, &str)],
987    message: &str,
988) -> Result<()> {
989    if !has_commits(root) {
990        for (old, new, content) in moves {
991            let new_path = root.join(new);
992            if let Some(parent) = new_path.parent() {
993                std::fs::create_dir_all(parent)?;
994            }
995            std::fs::write(&new_path, content)?;
996            let old_path = root.join(old);
997            let _ = std::fs::remove_file(&old_path);
998        }
999        return Ok(());
1000    }
1001
1002    let do_moves = |wt: &Path| -> Result<()> {
1003        for (old, new, content) in moves {
1004            let new_path = wt.join(new);
1005            if let Some(parent) = new_path.parent() {
1006                std::fs::create_dir_all(parent)?;
1007            }
1008            std::fs::write(&new_path, content)?;
1009            run(wt, &["add", new])?;
1010            run(wt, &["rm", "--force", "--quiet", old])?;
1011        }
1012        run(wt, &["commit", "-m", message])?;
1013        Ok(())
1014    };
1015
1016    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
1017        let remote_ref = format!("origin/{branch}");
1018        if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
1019            let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
1020        }
1021        let result = do_moves(&wt_path);
1022        if result.is_ok() {
1023            crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1024        }
1025        return result;
1026    }
1027
1028    if current_branch(root).ok().as_deref() == Some(branch) {
1029        let result = do_moves(root);
1030        if result.is_ok() {
1031            crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1032        }
1033        return result;
1034    }
1035
1036    let unique = std::time::SystemTime::now()
1037        .duration_since(std::time::UNIX_EPOCH)
1038        .map(|d| d.subsec_nanos())
1039        .unwrap_or(0);
1040    let wt_path = std::env::temp_dir().join(format!(
1041        "apm-{}-{}-{}",
1042        std::process::id(),
1043        unique,
1044        branch.replace('/', "-"),
1045    ));
1046
1047    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
1048    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
1049
1050    if has_remote {
1051        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
1052        let _ = run(&wt_path, &["checkout", "-B", branch]);
1053    } else if has_local {
1054        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
1055        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
1056        let _ = run(&wt_path, &["checkout", "-B", branch]);
1057    } else {
1058        run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
1059    }
1060
1061    let result = do_moves(&wt_path);
1062    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
1063    let _ = std::fs::remove_dir_all(&wt_path);
1064    if result.is_ok() {
1065        crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1066    }
1067    result
1068}
1069
1070pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1071    let _ = run(root, &["fetch", "origin", default_branch]);
1072
1073    let merge_dir = if current_branch(root).ok().as_deref() == Some(default_branch) {
1074        root.to_path_buf()
1075    } else {
1076        find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
1077    };
1078
1079    if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
1080        let _ = run(&merge_dir, &["merge", "--abort"]);
1081        anyhow::bail!("merge failed: {e:#}");
1082    }
1083
1084    if has_remote(root) {
1085        if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
1086            warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
1087        }
1088    }
1089    Ok(())
1090}
1091
1092pub 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<()> {
1093    let _ = std::process::Command::new("git")
1094        .args(["fetch", "origin", default_branch])
1095        .current_dir(root)
1096        .status();
1097
1098    let current = std::process::Command::new("git")
1099        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1100        .current_dir(root)
1101        .output()?;
1102    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1103
1104    let merge_dir = if current_branch == default_branch {
1105        root.to_path_buf()
1106    } else {
1107        let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1108        let worktrees_base = main_root.join(&config.worktrees.dir);
1109        ensure_worktree(root, &worktrees_base, default_branch)?
1110    };
1111
1112    let out = std::process::Command::new("git")
1113        .args(["merge", "--no-ff", branch, "--no-edit"])
1114        .current_dir(&merge_dir)
1115        .output()?;
1116
1117    if !out.status.success() {
1118        let _ = std::process::Command::new("git")
1119            .args(["merge", "--abort"])
1120            .current_dir(&merge_dir)
1121            .status();
1122        bail!(
1123            "merge conflict — resolve manually and push: {}",
1124            String::from_utf8_lossy(&out.stderr).trim()
1125        );
1126    }
1127
1128    if skip_push {
1129        messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1130    } else {
1131        push_branch(&merge_dir, default_branch)?;
1132        messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1133    }
1134    Ok(())
1135}
1136
1137pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1138    let fetch = std::process::Command::new("git")
1139        .args(["fetch", "origin", default_branch])
1140        .current_dir(root)
1141        .output();
1142
1143    match fetch {
1144        Err(e) => {
1145            warnings.push(format!("warning: fetch failed: {e:#}"));
1146            return Ok(());
1147        }
1148        Ok(out) if !out.status.success() => {
1149            warnings.push(format!(
1150                "warning: fetch failed: {}",
1151                String::from_utf8_lossy(&out.stderr).trim()
1152            ));
1153            return Ok(());
1154        }
1155        _ => {}
1156    }
1157
1158    let current = std::process::Command::new("git")
1159        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1160        .current_dir(root)
1161        .output()?;
1162    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1163
1164    let merge_dir = if current_branch == default_branch {
1165        root.to_path_buf()
1166    } else {
1167        find_worktree_for_branch(root, default_branch)
1168            .unwrap_or_else(|| root.to_path_buf())
1169    };
1170
1171    let remote_ref = format!("origin/{default_branch}");
1172    let out = std::process::Command::new("git")
1173        .args(["merge", "--ff-only", &remote_ref])
1174        .current_dir(&merge_dir)
1175        .output()?;
1176
1177    if !out.status.success() {
1178        warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1179    }
1180
1181    Ok(())
1182}
1183
1184pub fn is_worktree_dirty(path: &Path) -> bool {
1185    let Ok(out) = Command::new("git")
1186        .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1187        .output()
1188    else {
1189        return false;
1190    };
1191    !out.stdout.is_empty()
1192}
1193
1194pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1195    Command::new("git")
1196        .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1197        .output()
1198        .map(|o| o.status.success())
1199        .unwrap_or(false)
1200}
1201
1202pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1203    let Ok(out) = Command::new("git")
1204        .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1205        .output()
1206    else {
1207        warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1208        return;
1209    };
1210    if !out.status.success() {
1211        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1212        warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1213    }
1214}
1215
1216pub fn prune_remote_tracking(root: &Path, branch: &str) {
1217    let _ = Command::new("git")
1218        .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1219        .output();
1220}
1221
1222pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1223    let mut args = vec!["add"];
1224    args.extend_from_slice(files);
1225    run(root, &args).map(|_| ())
1226}
1227
1228pub fn commit(root: &Path, message: &str) -> Result<()> {
1229    run(root, &["commit", "-m", message]).map(|_| ())
1230}
1231
1232pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1233    let out = Command::new("git")
1234        .args(["-C", &root.to_string_lossy(), "config", key])
1235        .output()
1236        .ok()?;
1237    if !out.status.success() {
1238        return None;
1239    }
1240    let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1241    if value.is_empty() { None } else { Some(value) }
1242}
1243
1244pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1245    let out = match Command::new("git")
1246        .args(["-C", &dir.to_string_lossy(), "merge", refname, "--no-edit"])
1247        .output()
1248    {
1249        Ok(o) => o,
1250        Err(e) => {
1251            warnings.push(format!("warning: merge {refname} failed: {e}"));
1252            return None;
1253        }
1254    };
1255    if out.status.success() {
1256        let stdout = String::from_utf8_lossy(&out.stdout);
1257        if stdout.contains("Already up to date") {
1258            None
1259        } else {
1260            Some(format!("Merged {refname} into branch."))
1261        }
1262    } else {
1263        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1264        warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1265        None
1266    }
1267}
1268
1269pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1270    Command::new("git")
1271        .args(["ls-files", "--error-unmatch", path])
1272        .current_dir(root)
1273        .stdout(std::process::Stdio::null())
1274        .stderr(std::process::Stdio::null())
1275        .status()
1276        .map(|s| s.success())
1277        .unwrap_or(false)
1278}
1279
1280/// Describes which incomplete git operation is in progress.
1281/// Presence of the corresponding marker file/directory under `.git/` is definitive —
1282/// git creates these for the duration of the operation and removes them on commit or abort.
1283pub enum MidMergeState {
1284    /// `.git/MERGE_HEAD` exists — a `git merge` was started but not committed.
1285    Merge,
1286    /// `.git/rebase-merge/` exists — a `git rebase -i` (or merge-based rebase) is in progress.
1287    RebaseMerge,
1288    /// `.git/rebase-apply/` exists — a `git rebase` (apply-based) or `git am` is in progress.
1289    RebaseApply,
1290    /// `.git/CHERRY_PICK_HEAD` exists — a `git cherry-pick` is in progress.
1291    CherryPick,
1292}
1293
1294/// Detect whether the repo is in a mid-merge, mid-rebase, or mid-cherry-pick state.
1295///
1296/// Returns `Some` when any of the well-known git marker files/directories exist.
1297/// Uses path checks only — no subprocess calls.
1298///
1299/// Note: git worktrees store their state in a separate directory pointed to by
1300/// `.git` (which becomes a file rather than a directory). This function is safe
1301/// because `apm sync` always runs at the main repo root where `.git` is a directory.
1302pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1303    let git_dir = root.join(".git");
1304    if git_dir.join("MERGE_HEAD").exists() {
1305        return Some(MidMergeState::Merge);
1306    }
1307    if git_dir.join("rebase-merge").is_dir() {
1308        return Some(MidMergeState::RebaseMerge);
1309    }
1310    if git_dir.join("rebase-apply").is_dir() {
1311        return Some(MidMergeState::RebaseApply);
1312    }
1313    if git_dir.join("CHERRY_PICK_HEAD").exists() {
1314        return Some(MidMergeState::CherryPick);
1315    }
1316    None
1317}
1318
1319/// Run `git merge-base ref1 ref2` and return the common ancestor SHA.
1320pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1321    run(root, &["merge-base", ref1, ref2])
1322}
1323
1324pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1325    let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1326    out.lines()
1327        .next()
1328        .and_then(|line| line.strip_prefix("worktree "))
1329        .map(PathBuf::from)
1330}
1331
1332/// Returns the list of files that are both modified on `ticket_branch`
1333/// (since its merge-base with `target_branch`) AND dirty (uncommitted) in the
1334/// target worktree.  Returns an empty Vec when the check cannot be performed
1335/// (no shared history, target worktree not found on disk).
1336///
1337/// Porcelain v1 entries with `R` or `C` in either status column are skipped:
1338/// their line format (`XY old -> new`) cannot be parsed with a simple col-3
1339/// slice.  Known limitation: a leaked file staged as a rename in the target
1340/// worktree will not be detected.
1341///
1342/// `??` (untracked) entries ARE included: a file added by the ticket branch
1343/// that appears untracked in the target worktree is a genuine leak signal.
1344pub fn check_leaked_files(
1345    root: &Path,
1346    ticket_branch: &str,
1347    target_branch: &str,
1348) -> Result<Vec<String>> {
1349    // 1. Resolve the target worktree directory.
1350    let current = Command::new("git")
1351        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1352        .current_dir(root)
1353        .output()?;
1354    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1355
1356    let merge_dir = if current_branch == target_branch {
1357        root.to_path_buf()
1358    } else {
1359        match crate::worktree::find_worktree_for_branch(root, target_branch) {
1360            Some(p) => p,
1361            None => return Ok(vec![]),  // target worktree absent -> cannot be dirty
1362        }
1363    };
1364
1365    // 2. Compute merge-base between target and ticket.
1366    let base = match merge_base(root, target_branch, ticket_branch) {
1367        Ok(s) => s.trim().to_string(),
1368        Err(_) => return Ok(vec![]),  // no shared history -> don't block
1369    };
1370    if base.is_empty() {
1371        return Ok(vec![]);
1372    }
1373
1374    // 3. Files touched by the ticket branch since the merge-base (includes newly
1375    //    added files, which appear as untracked in the target if leaked).
1376    let diff_out = Command::new("git")
1377        .args(["diff", "--name-only", &base, ticket_branch])
1378        .current_dir(root)
1379        .output()?;
1380    let ticket_files: std::collections::HashSet<String> =
1381        String::from_utf8_lossy(&diff_out.stdout)
1382            .lines()
1383            .map(|s| s.to_string())
1384            .collect();
1385
1386    // 4. Dirty files in the target worktree.
1387    //    Porcelain v1 format: "XY <path>" -- path starts at column 3.
1388    //    "??" (untracked) entries are intentionally included: a file added by the
1389    //    ticket branch that sits untracked in the target is a genuine leak signal.
1390    //    "R " and "C " (staged rename/copy) entries are skipped: their line format
1391    //    is "XY orig -> dest", so col-3 slicing produces "orig -> dest", not a
1392    //    matchable path.  Known limitation: leaks of staged-renamed files are not
1393    //    detected.
1394    let status_out = Command::new("git")
1395        .args(["status", "--porcelain", "--untracked-files=all"])
1396        .current_dir(&merge_dir)
1397        .output()?;
1398    let dirty_files: std::collections::HashSet<String> =
1399        String::from_utf8_lossy(&status_out.stdout)
1400            .lines()
1401            .filter_map(|line| {
1402                if line.len() < 3 {
1403                    return None;
1404                }
1405                let x = line.as_bytes()[0] as char;
1406                let y = line.as_bytes()[1] as char;
1407                // Skip rename/copy entries: cannot be parsed with a simple col-3 slice.
1408                if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1409                    return None;
1410                }
1411                Some(line[3..].to_string())
1412            })
1413            .collect();
1414
1415    // 5. Intersection, sorted for stable output.
1416    let mut overlap: Vec<String> = ticket_files
1417        .intersection(&dirty_files)
1418        .cloned()
1419        .collect();
1420    overlap.sort();
1421    Ok(overlap)
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::*;
1427    use std::process::Command as Cmd;
1428    use tempfile::TempDir;
1429
1430    fn git_init() -> TempDir {
1431        let dir = tempfile::tempdir().unwrap();
1432        let p = dir.path();
1433        Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1434        Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1435        Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1436        dir
1437    }
1438
1439    fn git_cmd(dir: &Path, args: &[&str]) {
1440        Cmd::new("git")
1441            .args(args)
1442            .current_dir(dir)
1443            .env("GIT_AUTHOR_NAME", "test")
1444            .env("GIT_AUTHOR_EMAIL", "t@t.com")
1445            .env("GIT_COMMITTER_NAME", "test")
1446            .env("GIT_COMMITTER_EMAIL", "t@t.com")
1447            .status()
1448            .unwrap();
1449    }
1450
1451    fn make_commit(dir: &Path, filename: &str, content: &str) {
1452        std::fs::write(dir.join(filename), content).unwrap();
1453        git_cmd(dir, &["add", filename]);
1454        git_cmd(dir, &["commit", "-m", "init"]);
1455    }
1456
1457    #[test]
1458    fn is_worktree_dirty_clean() {
1459        let dir = git_init();
1460        make_commit(dir.path(), "f.txt", "hi");
1461        assert!(!is_worktree_dirty(dir.path()));
1462    }
1463
1464    #[test]
1465    fn is_worktree_dirty_dirty() {
1466        let dir = git_init();
1467        make_commit(dir.path(), "f.txt", "hi");
1468        std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1469        assert!(is_worktree_dirty(dir.path()));
1470    }
1471
1472    #[test]
1473    fn local_branch_exists_present_and_absent() {
1474        let dir = git_init();
1475        make_commit(dir.path(), "f.txt", "hi");
1476        let on_main = local_branch_exists(dir.path(), "main");
1477        let on_master = local_branch_exists(dir.path(), "master");
1478        assert!(on_main || on_master);
1479        assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1480    }
1481
1482    #[test]
1483    fn delete_local_branch_success() {
1484        let dir = git_init();
1485        make_commit(dir.path(), "f.txt", "hi");
1486        git_cmd(dir.path(), &["branch", "to-delete"]);
1487        let mut warnings = Vec::new();
1488        delete_local_branch(dir.path(), "to-delete", &mut warnings);
1489        assert!(warnings.is_empty());
1490        assert!(!local_branch_exists(dir.path(), "to-delete"));
1491    }
1492
1493    #[test]
1494    fn delete_local_branch_failure_adds_warning() {
1495        let dir = git_init();
1496        make_commit(dir.path(), "f.txt", "hi");
1497        let mut warnings = Vec::new();
1498        delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1499        assert!(!warnings.is_empty());
1500        assert!(warnings[0].contains("warning:"));
1501    }
1502
1503    #[test]
1504    fn prune_remote_tracking_no_panic() {
1505        let dir = git_init();
1506        make_commit(dir.path(), "f.txt", "hi");
1507        // Just verify it doesn't panic even when the remote ref doesn't exist.
1508        prune_remote_tracking(dir.path(), "nonexistent-branch");
1509    }
1510
1511    #[test]
1512    fn stage_files_ok_and_err() {
1513        let dir = git_init();
1514        make_commit(dir.path(), "f.txt", "hi");
1515        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1516        assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1517        assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1518    }
1519
1520    #[test]
1521    fn commit_ok_and_err() {
1522        let dir = git_init();
1523        make_commit(dir.path(), "f.txt", "hi");
1524        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1525        git_cmd(dir.path(), &["add", "new.txt"]);
1526        assert!(commit(dir.path(), "test commit").is_ok());
1527        // Nothing staged — should fail
1528        assert!(commit(dir.path(), "empty commit").is_err());
1529    }
1530
1531    #[test]
1532    fn git_config_get_some_and_none() {
1533        let dir = git_init();
1534        make_commit(dir.path(), "f.txt", "hi");
1535        let val = git_config_get(dir.path(), "user.email");
1536        assert_eq!(val, Some("t@t.com".to_string()));
1537        let missing = git_config_get(dir.path(), "no.such.key");
1538        assert!(missing.is_none());
1539    }
1540
1541    #[test]
1542    fn merge_ref_already_up_to_date() {
1543        let dir = git_init();
1544        make_commit(dir.path(), "f.txt", "hi");
1545        let branch = {
1546            let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1547            String::from_utf8_lossy(&out.stdout).trim().to_string()
1548        };
1549        let mut warnings = Vec::new();
1550        // Merging current branch into itself is already up to date
1551        let result = merge_ref(dir.path(), &branch, &mut warnings);
1552        assert!(result.is_none());
1553        assert!(warnings.is_empty());
1554    }
1555
1556    #[test]
1557    fn merge_ref_success() {
1558        let dir = git_init();
1559        make_commit(dir.path(), "f.txt", "hi");
1560        git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1561        make_commit(dir.path(), "g.txt", "there");
1562        git_cmd(dir.path(), &["checkout", "main"]);
1563        let mut warnings = Vec::new();
1564        let result = merge_ref(dir.path(), "feature", &mut warnings);
1565        assert!(result.is_some());
1566        assert!(warnings.is_empty());
1567    }
1568
1569    #[test]
1570    fn detect_mid_merge_none_on_clean_repo() {
1571        let dir = git_init();
1572        make_commit(dir.path(), "f.txt", "hi");
1573        assert!(detect_mid_merge_state(dir.path()).is_none());
1574    }
1575
1576    #[test]
1577    fn detect_mid_merge_on_merge_head() {
1578        let dir = git_init();
1579        make_commit(dir.path(), "f.txt", "hi");
1580        std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
1581        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
1582    }
1583
1584    #[test]
1585    fn detect_mid_merge_on_rebase_merge() {
1586        let dir = git_init();
1587        make_commit(dir.path(), "f.txt", "hi");
1588        std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
1589        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
1590    }
1591
1592    #[test]
1593    fn detect_mid_merge_on_rebase_apply() {
1594        let dir = git_init();
1595        make_commit(dir.path(), "f.txt", "hi");
1596        std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
1597        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
1598    }
1599
1600    #[test]
1601    fn detect_mid_merge_on_cherry_pick() {
1602        let dir = git_init();
1603        make_commit(dir.path(), "f.txt", "hi");
1604        std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
1605        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
1606    }
1607
1608    #[test]
1609    fn is_file_tracked_tracked_and_untracked() {
1610        let dir = git_init();
1611        make_commit(dir.path(), "tracked.txt", "hi");
1612        assert!(is_file_tracked(dir.path(), "tracked.txt"));
1613        std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
1614        assert!(!is_file_tracked(dir.path(), "untracked.txt"));
1615    }
1616
1617    #[test]
1618    fn check_leaked_files_detects_overlap() {
1619        let dir = git_init();
1620        let p = dir.path();
1621        std::fs::create_dir_all(p.join("src")).unwrap();
1622        std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1623        git_cmd(p, &["add", "src/foo.rs"]);
1624        git_cmd(p, &["commit", "-m", "add foo"]);
1625
1626        git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
1627        std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1628        git_cmd(p, &["add", "src/foo.rs"]);
1629        git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1630        git_cmd(p, &["checkout", "main"]);
1631
1632        // Simulate a leaked edit on main without committing.
1633        std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
1634
1635        let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
1636        assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
1637    }
1638
1639    #[test]
1640    fn check_leaked_files_no_overlap() {
1641        let dir = git_init();
1642        let p = dir.path();
1643        std::fs::create_dir_all(p.join("src")).unwrap();
1644        std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1645        std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
1646        git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
1647        git_cmd(p, &["commit", "-m", "add foo and bar"]);
1648
1649        // Ticket branch modifies only src/foo.rs.
1650        git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
1651        std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1652        git_cmd(p, &["add", "src/foo.rs"]);
1653        git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1654        git_cmd(p, &["checkout", "main"]);
1655
1656        // Main has src/bar.rs dirty — not touched by the ticket.
1657        std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
1658
1659        let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
1660        assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
1661    }
1662
1663    #[test]
1664    fn check_leaked_files_detects_untracked_overlap() {
1665        let dir = git_init();
1666        let p = dir.path();
1667        make_commit(p, "existing.rs", "base");
1668
1669        // Ticket branch adds src/new.rs as a new file.
1670        git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
1671        std::fs::create_dir_all(p.join("src")).unwrap();
1672        std::fs::write(p.join("src/new.rs"), "new file").unwrap();
1673        git_cmd(p, &["add", "src/new.rs"]);
1674        git_cmd(p, &["commit", "-m", "ticket: add new file"]);
1675        git_cmd(p, &["checkout", "main"]);
1676
1677        // Leak: src/new.rs dropped untracked on main.
1678        std::fs::create_dir_all(p.join("src")).unwrap();
1679        std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
1680
1681        let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
1682        assert_eq!(leaked, vec!["src/new.rs".to_string()]);
1683    }
1684
1685    // ---- content_merged_into_main tests ----
1686
1687    /// Helper: commit a file with given name and content on the current branch.
1688    fn commit_file(dir: &Path, name: &str, content: &str) {
1689        std::fs::write(dir.join(name), content).unwrap();
1690        git_cmd(dir, &["add", name]);
1691        git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
1692    }
1693
1694    /// Regular-merged branch with a trailing ticket-file state commit:
1695    /// `content_merged_into_main` must return true.
1696    #[test]
1697    fn content_merged_into_main_regular_merge_with_state_commit() {
1698        let dir = git_init();
1699        let p = dir.path();
1700
1701        // Base commit on main.
1702        commit_file(p, "README", "base");
1703
1704        // Ticket branch: add src/lib.rs (implementation).
1705        git_cmd(p, &["checkout", "-b", "ticket/foo"]);
1706        std::fs::create_dir_all(p.join("src")).unwrap();
1707        commit_file(p, "src/lib.rs", "impl");
1708
1709        // Regular-merge into main.
1710        git_cmd(p, &["checkout", "main"]);
1711        git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
1712
1713        // Push a state-transition commit to the ticket branch (touches only tickets/).
1714        git_cmd(p, &["checkout", "ticket/foo"]);
1715        std::fs::create_dir_all(p.join("tickets")).unwrap();
1716        commit_file(p, "tickets/foo.md", "state: implemented");
1717
1718        // After this commit the branch tip is NOT an ancestor of main.
1719        let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
1720        assert!(result, "should detect that content was merged despite trailing state commit");
1721    }
1722
1723    /// Squash-merged branch with a trailing ticket-file commit:
1724    /// `content_merged_into_main` must return true.
1725    #[test]
1726    fn content_merged_into_main_squash_merge_with_state_commit() {
1727        let dir = git_init();
1728        let p = dir.path();
1729
1730        commit_file(p, "README", "base");
1731
1732        // Ticket branch.
1733        git_cmd(p, &["checkout", "-b", "ticket/bar"]);
1734        std::fs::create_dir_all(p.join("src")).unwrap();
1735        commit_file(p, "src/lib.rs", "impl");
1736
1737        // Squash-merge into main (manually: merge --squash + commit).
1738        git_cmd(p, &["checkout", "main"]);
1739        git_cmd(p, &["merge", "--squash", "ticket/bar"]);
1740        git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
1741
1742        // State-transition commit on ticket branch.
1743        git_cmd(p, &["checkout", "ticket/bar"]);
1744        std::fs::create_dir_all(p.join("tickets")).unwrap();
1745        commit_file(p, "tickets/bar.md", "state: implemented");
1746
1747        let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
1748        assert!(result, "should detect squash-merged content despite trailing state commit");
1749    }
1750
1751    /// Branch tip is already the merge-base (i.e. is an ancestor of main):
1752    /// return false — `--merged` handles it.
1753    #[test]
1754    fn content_merged_into_main_returns_false_when_ancestor() {
1755        let dir = git_init();
1756        let p = dir.path();
1757        commit_file(p, "README", "base");
1758        // ticket/anc is just pointing at main's tip — it IS an ancestor.
1759        git_cmd(p, &["checkout", "-b", "ticket/anc"]);
1760        // No extra commits — same SHA as main.
1761        git_cmd(p, &["checkout", "main"]);
1762        let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
1763        assert!(!result);
1764    }
1765
1766    /// Branch has a non-ticket file commit after the merge point — must NOT be detected.
1767    #[test]
1768    fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
1769        let dir = git_init();
1770        let p = dir.path();
1771        commit_file(p, "README", "base");
1772
1773        // Ticket branch with implementation.
1774        git_cmd(p, &["checkout", "-b", "ticket/extra"]);
1775        std::fs::create_dir_all(p.join("src")).unwrap();
1776        commit_file(p, "src/lib.rs", "impl");
1777
1778        // Squash-merge.
1779        git_cmd(p, &["checkout", "main"]);
1780        git_cmd(p, &["merge", "--squash", "ticket/extra"]);
1781        git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
1782
1783        // After merge: push BOTH a state commit AND a non-ticket source change.
1784        git_cmd(p, &["checkout", "ticket/extra"]);
1785        std::fs::create_dir_all(p.join("tickets")).unwrap();
1786        commit_file(p, "tickets/extra.md", "state: implemented");
1787        // Non-ticket file added — this makes content_tip == branch_tip (the source change
1788        // is the newest non-ticket commit), so the function should return false.
1789        commit_file(p, "src/extra.rs", "extra code");
1790
1791        let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
1792        assert!(!result, "branch with non-ticket changes after merge must not be detected");
1793    }
1794
1795    /// Branch where every commit since merge-base touches only ticket files:
1796    /// return false (nothing to squash-check).
1797    #[test]
1798    fn content_merged_into_main_all_ticket_only_commits_returns_false() {
1799        let dir = git_init();
1800        let p = dir.path();
1801        commit_file(p, "README", "base");
1802
1803        // Ticket branch that only ever touched tickets/.
1804        git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
1805        std::fs::create_dir_all(p.join("tickets")).unwrap();
1806        commit_file(p, "tickets/ticketonly.md", "state: new");
1807        git_cmd(p, &["checkout", "main"]);
1808
1809        let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
1810        assert!(!result, "all-ticket-only commits should return false");
1811    }
1812
1813    /// Regression test: a local ticket branch regular-merged into local
1814    /// main, with the remote-tracking ref deleted (e.g. GitHub auto-delete
1815    /// after merge), must still appear in `merged_into_main`'s result.
1816    /// Previously the squash-merge detector skipped it because its tip is
1817    /// already an ancestor, while `git branch -r --merged` could not see it
1818    /// (the origin/ticket/* ref no longer exists).
1819    #[test]
1820    fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
1821        let dir = git_init();
1822        let p = dir.path();
1823        make_commit(p, "f.txt", "base");
1824
1825        // Create a ticket branch and a commit on it.
1826        git_cmd(p, &["checkout", "-b", "ticket/foo"]);
1827        std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
1828        git_cmd(p, &["add", "f.txt"]);
1829        git_cmd(p, &["commit", "-m", "ticket: change"]);
1830
1831        // Merge ticket/foo into main with --no-ff (regular merge, leaves a merge commit).
1832        git_cmd(p, &["checkout", "main"]);
1833        git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
1834
1835        // Simulate that origin/main has been updated to match local main, but
1836        // origin/ticket/foo was auto-deleted (no such remote-tracking ref).
1837        let main_sha = run(p, &["rev-parse", "main"]).unwrap();
1838        Cmd::new("git")
1839            .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
1840            .current_dir(p)
1841            .status()
1842            .unwrap();
1843        // No origin/ticket/foo ref is created — this is the auto-delete case.
1844
1845        let merged = merged_into_main(p, "main").unwrap();
1846        assert!(
1847            merged.iter().any(|b| b == "ticket/foo"),
1848            "expected ticket/foo in merged set; got {merged:?}"
1849        );
1850    }
1851}