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        // The fast-forward is best-effort — diverged history is acceptable and
343        // we still proceed with the commit.
344        let remote_ref = format!("origin/{branch}");
345        if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
346            let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
347        }
348        let full_path = wt_path.join(rel_path);
349        if let Some(parent) = full_path.parent() {
350            std::fs::create_dir_all(parent)?;
351        }
352        std::fs::write(&full_path, content)?;
353        run(&wt_path, &["add", rel_path])
354            .with_context(|| format!("git add {rel_path} in worktree {} failed", wt_path.display()))?;
355        run(&wt_path, &["commit", "-m", message])
356            .with_context(|| format!("git commit on {branch} in worktree {} failed", wt_path.display()))?;
357        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
358        return Ok(());
359    }
360
361    // If already on the target branch, write to working tree and commit directly.
362    if current_branch(root).ok().as_deref() == Some(branch) {
363        let local_path = root.join(rel_path);
364        if let Some(parent) = local_path.parent() {
365            std::fs::create_dir_all(parent)?;
366        }
367        std::fs::write(&local_path, content)?;
368        run(root, &["add", rel_path])
369            .with_context(|| format!("git add {rel_path} failed"))?;
370        run(root, &["commit", "-m", message])
371            .with_context(|| format!("git commit on {branch} failed"))?;
372        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
373        return Ok(());
374    }
375
376    let result = try_worktree_commit(root, branch, rel_path, content, message);
377    if result.is_ok() {
378        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
379    }
380    result
381}
382
383fn try_worktree_commit(
384    root: &Path,
385    branch: &str,
386    rel_path: &str,
387    content: &str,
388    message: &str,
389) -> Result<()> {
390    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
391    let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
392    let wt_path = std::env::temp_dir().join(format!(
393        "apm-{}-{}-{}",
394        std::process::id(),
395        seq,
396        branch.replace('/', "-"),
397    ));
398
399    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
400    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
401
402    if has_remote {
403        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
404        let _ = run(&wt_path, &["checkout", "-B", branch]);
405    } else if has_local {
406        // Use detached approach to avoid "already checked out" errors.
407        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
408        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
409        let _ = run(&wt_path, &["checkout", "-B", branch]);
410    } else {
411        run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
412    }
413
414    let result = (|| -> Result<()> {
415        let full_path = wt_path.join(rel_path);
416        if let Some(parent) = full_path.parent() {
417            std::fs::create_dir_all(parent)?;
418        }
419        std::fs::write(&full_path, content)?;
420        run(&wt_path, &["add", rel_path])?;
421        run(&wt_path, &["commit", "-m", message])?;
422        Ok(())
423    })();
424
425    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
426    let _ = std::fs::remove_dir_all(&wt_path);
427
428    result
429}
430
431
432/// Push all local ticket/* branches that have commits not yet on origin.
433/// Non-fatal: logs warnings on push failure. No-op when no origin is configured.
434pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
435    if run(root, &["remote", "get-url", "origin"]).is_err() {
436        return;
437    }
438    let out = match run(root, &["branch", "--list", "ticket/*"]) {
439        Ok(o) => o,
440        Err(_) => return,
441    };
442    for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
443        let range = format!("origin/{branch}..{branch}");
444        let count = run(root, &["rev-list", "--count", &range])
445            .ok()
446            .and_then(|s| s.trim().parse::<u32>().ok())
447            .unwrap_or(0);
448        if count > 0 {
449            if let Err(e) = run(root, &["push", "origin", branch]) {
450                warnings.push(format!("warning: push {branch} failed: {e:#}"));
451            }
452        }
453    }
454}
455
456/// Sync non-checked-out `ticket/*` and `epic/*` local refs with their origin counterparts.
457///
458/// This replaces the old `sync_local_ticket_refs` which performed an unconditional
459/// `update-ref` that could silently rewind local refs with unpushed commits (data-loss bug).
460///
461/// State matrix — each case documents why the mapped action is correct:
462///
463///   Equal      → no-op. Local and origin are identical; nothing to do.
464///
465///   Behind     → fast-forward via `update-ref`. Safe because local is a strict ancestor
466///                of origin: the update only moves the ref forward, losing no local commits.
467///
468///   Ahead      → info line only, NO `update-ref`, NO push.
469///                CRITICAL: the old code performed an unconditional `update-ref` in this
470///                case, silently rewriting the local ref to the origin SHA and orphaning
471///                any unpushed local commits. That was the data-loss bug this function fixes.
472///                apm sync never pushes; ahead refs wait for explicit user action.
473///
474///   Diverged   → warning line, no ref change, no push. Neither side is an ancestor of
475///                the other; manual resolution is required. Clobbering either side would
476///                lose commits.
477///
478///   RemoteOnly → create local ref at origin SHA. Safe: no local commits exist to lose.
479///                Makes the branch visible locally without a checkout.
480///
481///   NoRemote   → local-only branch, leave untouched. No auto-push, no warning spam.
482///                Publishing local-only branches requires an explicit user action.
483pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
484    // Collect all branches currently checked out across all worktrees.
485    // These are never touched — they must be managed via the worktree's own git operations.
486    let checked_out: std::collections::HashSet<String> = {
487        let mut set = std::collections::HashSet::new();
488        if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
489            for line in out.lines() {
490                if let Some(b) = line.strip_prefix("branch refs/heads/") {
491                    set.insert(b.to_string());
492                }
493            }
494        }
495        set
496    };
497
498    // Two ref namespaces this sync cares about. Both get identical classification-based
499    // treatment — ticket/* and epic/* branches are managed the same way.
500    const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
501
502    // Collect all origin refs across both namespaces.
503    let mut remote_refs: Vec<String> = Vec::new();
504    for ns in MANAGED_NAMESPACES {
505        let pattern = format!("refs/remotes/origin/{ns}/");
506        if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
507            for line in out.lines().filter(|l| !l.is_empty()) {
508                remote_refs.push(line.to_string());
509            }
510        }
511    }
512
513    let mut ahead_branches: Vec<String> = Vec::new();
514
515    for remote_name in remote_refs {
516        // remote_name is like "origin/ticket/<slug>" or "origin/epic/<slug>".
517        // Strip the "origin/" prefix to get the local branch name.
518        let branch = match remote_name.strip_prefix("origin/") {
519            Some(b) => b.to_string(),
520            None => continue,
521        };
522
523        // Never touch a branch currently checked out in any worktree.
524        if checked_out.contains(&branch) {
525            continue;
526        }
527
528        let local_ref = format!("refs/heads/{branch}");
529        // Use the short remote name (e.g. "origin/ticket/abc") as classify_branch resolves it.
530        let remote_ref_full = format!("refs/remotes/{remote_name}");
531
532        // Classification drives the action. Nothing in this function pushes —
533        // ahead refs wait for explicit action via apm state transitions.
534        match classify_branch(root, &local_ref, &remote_name) {
535            BranchClass::RemoteOnly => {
536                // No local ref exists yet; create it pointing at the origin SHA.
537                // Safe: there are no local commits to clobber.
538                let sha = match run(root, &["rev-parse", &remote_ref_full]) {
539                    Ok(s) => s,
540                    Err(_) => continue,
541                };
542                if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
543                    warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
544                }
545            }
546            BranchClass::Equal => {
547                // Local and origin are identical; nothing to do.
548            }
549            BranchClass::Behind => {
550                // Local is a strict ancestor of origin — fast-forward is safe.
551                // `update-ref` moves the ref forward; no local commits are lost.
552                let sha = match run(root, &["rev-parse", &remote_ref_full]) {
553                    Ok(s) => s,
554                    Err(_) => continue,
555                };
556                if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
557                    warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
558                }
559            }
560            BranchClass::Ahead => {
561                // CRITICAL: do NOT update-ref here.
562                // The old sync_local_ticket_refs performed an unconditional update-ref that
563                // silently rewound this ref to the origin SHA, orphaning unpushed local commits.
564                // That was the data-loss bug. The correct action is an info line only —
565                // apm sync never pushes; the user must push explicitly when ready.
566                warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
567                ahead_branches.push(branch);
568            }
569            BranchClass::Diverged => {
570                // Neither side is an ancestor of the other. Manual resolution required.
571                // Clobbering either ref would silently discard commits on the other side.
572                let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
573                    .replace("<slug>", &branch);
574                warnings.push(msg);
575            }
576            BranchClass::NoRemote => {
577                // Local-only branch: no origin counterpart. Leave it alone.
578                // No auto-push, no warning — publishing requires an explicit user action.
579            }
580        }
581    }
582
583    ahead_branches
584}
585
586/// List all files in a directory on a branch (non-recursive).
587pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
588    let tree_ref = format!("{branch}:{dir}");
589    let out = run(root, &["ls-tree", "--name-only", &tree_ref])
590        .or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
591    Ok(out.lines()
592        .filter(|l| !l.is_empty())
593        .map(|l| format!("{dir}/{l}"))
594        .collect())
595}
596
597/// Commit multiple files to a branch in a single commit without disturbing the working tree.
598pub fn commit_files_to_branch(
599    root: &Path,
600    branch: &str,
601    files: &[(&str, String)],
602    message: &str,
603) -> Result<()> {
604    if !has_commits(root) {
605        for (rel_path, content) in files {
606            let local_path = root.join(rel_path);
607            if let Some(parent) = local_path.parent() {
608                std::fs::create_dir_all(parent)?;
609            }
610            std::fs::write(&local_path, content)?;
611        }
612        return Ok(());
613    }
614
615    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
616        for (rel_path, content) in files {
617            let full_path = wt_path.join(rel_path);
618            if let Some(parent) = full_path.parent() {
619                std::fs::create_dir_all(parent)?;
620            }
621            std::fs::write(&full_path, content)?;
622            let _ = run(&wt_path, &["add", rel_path]);
623        }
624        run(&wt_path, &["commit", "-m", message])?;
625        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
626        return Ok(());
627    }
628
629    if current_branch(root).ok().as_deref() == Some(branch) {
630        for (rel_path, content) in files {
631            let local_path = root.join(rel_path);
632            if let Some(parent) = local_path.parent() {
633                std::fs::create_dir_all(parent)?;
634            }
635            std::fs::write(&local_path, content)?;
636            let _ = run(root, &["add", rel_path]);
637        }
638        run(root, &["commit", "-m", message])?;
639        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
640        return Ok(());
641    }
642
643    let unique = std::time::SystemTime::now()
644        .duration_since(std::time::UNIX_EPOCH)
645        .map(|d| d.subsec_nanos())
646        .unwrap_or(0);
647    let wt_path = std::env::temp_dir().join(format!(
648        "apm-{}-{}-{}",
649        std::process::id(),
650        unique,
651        branch.replace('/', "-"),
652    ));
653
654    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
655    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
656
657    if has_remote {
658        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
659        let _ = run(&wt_path, &["checkout", "-B", branch]);
660    } else if has_local {
661        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
662        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
663        let _ = run(&wt_path, &["checkout", "-B", branch]);
664    } else {
665        run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
666    }
667
668    let result = (|| -> Result<()> {
669        for (rel_path, content) in files {
670            let full_path = wt_path.join(rel_path);
671            if let Some(parent) = full_path.parent() {
672                std::fs::create_dir_all(parent)?;
673            }
674            std::fs::write(&full_path, content)?;
675            run(&wt_path, &["add", rel_path])?;
676        }
677        run(&wt_path, &["commit", "-m", message])?;
678        Ok(())
679    })();
680
681    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
682    let _ = std::fs::remove_dir_all(&wt_path);
683
684    if result.is_ok() {
685        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
686    }
687    result
688}
689
690/// Get the commit SHA at the tip of a local branch.
691pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
692    run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
693}
694
695/// Resolve a branch name to a commit SHA.
696/// Prefers `origin/<branch>`; falls back to local `<branch>`.
697pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
698    run(root, &["rev-parse", &format!("origin/{branch}")])
699        .or_else(|_| run(root, &["rev-parse", branch]))
700        .with_context(|| format!("branch '{branch}' not found locally or on origin"))
701}
702
703/// Create a local branch pointing at a specific commit SHA.
704pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
705    run(root, &["branch", branch, sha]).map(|_| ())
706}
707
708/// Get the commit SHA at the tip of the remote tracking ref for a branch.
709pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
710    run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
711}
712
713/// Check if `commit` is a git ancestor of `of_ref` (i.e. reachable from `of_ref`).
714/// Uses `git merge-base --is-ancestor`.
715pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
716    Command::new("git")
717        .current_dir(root)
718        .args(["merge-base", "--is-ancestor", commit, of_ref])
719        .status()
720        .map(|s| s.success())
721        .unwrap_or(false)
722}
723
724/// Classification of a local branch relative to its origin counterpart.
725///
726/// Direction note: `merge-base --is-ancestor A B` returns 0 iff A is reachable from B.
727///   - local == remote                        → Equal
728///   - local ancestor-of remote (not equal)   → Behind (FF possible: remote has new commits)
729///   - remote ancestor-of local (not equal)   → Ahead  (local has unpushed commits)
730///   - neither is an ancestor of the other    → Diverged (manual resolution required)
731///   - local ref absent, remote ref present   → RemoteOnly (safe to create local ref)
732///   - remote ref cannot be resolved          → NoRemote (local-only or no origin)
733pub enum BranchClass {
734    Equal,
735    Behind,
736    Ahead,
737    Diverged,
738    /// Local ref does not exist; origin ref does. Safe to create the local ref.
739    RemoteOnly,
740    /// Remote ref cannot be resolved. Branch is local-only or origin is unreachable.
741    NoRemote,
742}
743
744/// Classify `local` branch relative to `remote` ref using SHA equality and directed ancestry.
745///
746/// `local`  — a local branch name, e.g. "main" (resolved via `refs/heads/<local>`).
747/// `remote` — a remote ref name,   e.g. "origin/main" (resolved as-is by git).
748///
749/// Every ancestry check includes a comment explaining which direction maps to which state
750/// because the mapping is not intuitive at a glance.
751pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
752    let local_sha = match run(root, &["rev-parse", local]) {
753        Ok(s) => s,
754        Err(_) => {
755            // Local ref absent. Check whether the remote side exists.
756            // If origin has the branch, this is RemoteOnly (safe to create a local ref).
757            // If origin also can't be resolved, it is truly NoRemote (local-only or no origin).
758            return if run(root, &["rev-parse", remote]).is_ok() {
759                BranchClass::RemoteOnly
760            } else {
761                BranchClass::NoRemote
762            };
763        }
764    };
765    let remote_sha = match run(root, &["rev-parse", remote]) {
766        Ok(s) => s,
767        Err(_) => return BranchClass::NoRemote,
768    };
769
770    if local_sha == remote_sha {
771        return BranchClass::Equal;
772    }
773
774    // `--is-ancestor local remote` succeeds iff local is reachable from remote.
775    // When true (and SHAs differ), remote has commits that local lacks → local is Behind.
776    let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
777
778    // `--is-ancestor remote local` succeeds iff remote is reachable from local.
779    // When true (and SHAs differ), local has commits that remote lacks → local is Ahead.
780    let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
781
782    match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
783        (true, false)  => BranchClass::Behind,   // remote has new commits; FF is safe
784        (false, true)  => BranchClass::Ahead,    // local has unpushed commits
785        (false, false) => BranchClass::Diverged, // each side has commits the other lacks
786        (true, true)   => BranchClass::Equal,    // both ancestors → same commit (guard)
787    }
788}
789
790/// Bring local `default` branch into sync with `origin/<default>` without ever pushing.
791///
792/// State matrix — each row documents why the mapped action is correct:
793///
794///   Equal     → no-op.  Local and origin are identical; nothing to do.
795///
796///   Behind    → `git merge --ff-only origin/<default>` in the main worktree.
797///               The main worktree is always checked out on <default>, so running
798///               the merge there updates both HEAD and the working tree atomically.
799///               If the merge fails (uncommitted local changes overlap with the
800///               incoming commits), we print MAIN_BEHIND_DIRTY_OVERLAP guidance and
801///               leave the working tree untouched.  git's own error detection is used
802///               rather than pre-emptively computing overlap.
803///
804///   Ahead     → Print one info line so the user knows local has unpushed commits.
805///               No network call, no ref changes.  Explicit pushes happen via
806///               `apm state <id> implemented` — apm sync NEVER pushes anything.
807///
808///   Diverged  → Print guidance (rebase/merge/push steps).  No ref changes.
809///               The dirty-aware variant is printed when the main worktree is unclean.
810///
811///   NoRemote  → Silent skip.  No origin is configured, or `origin/<default>` could
812///               not be resolved (e.g. fetch hasn't run yet).  Fetch failures are
813///               already surfaced as a warning by the existing fetch path in sync.rs.
814pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
815    let remote = format!("origin/{default}");
816    match classify_branch(root, default, &remote) {
817        BranchClass::Equal => {
818            // local == origin/main: nothing to do, print nothing.
819        }
820
821        BranchClass::Behind => {
822            // origin has new commits local lacks; attempt a fast-forward.
823            // Run in the main worktree so the working tree is updated too.
824            let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
825            if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
826                // FF refused — uncommitted local changes overlap with incoming commits.
827                // Leave the working tree untouched and print recovery guidance.
828                // Assumption: overlap is the only realistic failure mode for a strictly-behind FF merge; MAIN_BEHIND_DIRTY_OVERLAP covers any --ff-only error here.
829                let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
830                    .replace("<default>", default);
831                warnings.push(msg);
832            }
833        }
834
835        BranchClass::Ahead => {
836            // local has commits not on origin.  No push — apm sync never pushes.
837            // Count unpushed commits so the message is informative.
838            let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
839                .ok()
840                .and_then(|s| s.trim().parse::<u64>().ok())
841                .unwrap_or(0);
842            let msg = crate::sync_guidance::MAIN_AHEAD
843                .replace("<default>", default)
844                .replace("<remote>", &remote)
845                .replace("<count>", &count.to_string())
846                .replace("<commits>", if count == 1 { "commit" } else { "commits" });
847            warnings.push(msg);
848            return true;
849        }
850
851        BranchClass::Diverged => {
852            // Neither side is an ancestor of the other; manual resolution required.
853            // Print the dirty-aware variant so the user gets actionable steps.
854            let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
855            let guidance = if is_worktree_dirty(&wt) {
856                crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
857            } else {
858                crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
859            };
860            warnings.push(guidance);
861        }
862
863        BranchClass::RemoteOnly => {
864            // The default branch always exists locally in any repo with commits.
865            // RemoteOnly here would mean local branch is absent, which cannot happen
866            // during a normal sync flow. Treat it as NoRemote (silent skip).
867        }
868
869        BranchClass::NoRemote => {
870            // origin/<default> not resolvable (no remote, or fetch hasn't run yet).
871            // The fetch path in sync.rs already emits a warning on fetch failure.
872            // Nothing more to do here.
873        }
874    }
875    false
876}
877
878pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
879    let status = std::process::Command::new("git")
880        .args(["fetch", "origin", branch])
881        .current_dir(root)
882        .status()?;
883    if !status.success() {
884        anyhow::bail!("git fetch failed");
885    }
886    Ok(())
887}
888
889pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
890    let status = std::process::Command::new("git")
891        .args(["push", "origin", &format!("{branch}:{branch}")])
892        .current_dir(root)
893        .status()?;
894    if !status.success() {
895        anyhow::bail!("git push failed");
896    }
897    Ok(())
898}
899
900pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
901    let out = std::process::Command::new("git")
902        .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
903        .current_dir(root)
904        .output()?;
905    if !out.status.success() {
906        anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
907    }
908    Ok(())
909}
910
911pub fn has_remote(root: &Path) -> bool {
912    run(root, &["remote", "get-url", "origin"]).is_ok()
913}
914
915/// Merge `branch` into `default_branch` (fast-forward or merge commit).
916/// Pushes `default_branch` to origin when a remote exists.
917/// List remote ticket/* branches with their last commit date.
918/// Returns (branch_name_without_origin_prefix, commit_date) pairs.
919pub fn remote_ticket_branches_with_dates(
920    root: &Path,
921) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
922    use chrono::{TimeZone, Utc};
923    let out = Command::new("git")
924        .current_dir(root)
925        .args([
926            "for-each-ref",
927            "refs/remotes/origin/ticket/",
928            "--format=%(refname:short) %(creatordate:unix)",
929        ])
930        .output()
931        .context("git for-each-ref failed")?;
932    let stdout = String::from_utf8_lossy(&out.stdout);
933    let mut result = Vec::new();
934    for line in stdout.lines() {
935        let mut parts = line.splitn(2, ' ');
936        let refname = parts.next().unwrap_or("").trim();
937        let ts_str = parts.next().unwrap_or("").trim();
938        let branch = refname.trim_start_matches("origin/");
939        if branch.is_empty() {
940            continue;
941        }
942        if let Ok(ts) = ts_str.parse::<i64>() {
943            if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
944                result.push((branch.to_string(), dt));
945            }
946        }
947    }
948    Ok(result)
949}
950
951/// Authoritative list of remote ticket branch names via `git ls-remote`.
952/// Unlike local remote-tracking refs (which can be stale or pruned),
953/// this queries origin directly. Returns an empty set on any error
954/// (no remote, network failure, etc.) so callers treat it as "no remote
955/// branches" and skip remote deletions safely.
956pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
957    let mut set = std::collections::HashSet::new();
958    let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
959        Ok(o) => o,
960        Err(_) => return set,
961    };
962    for line in out.lines() {
963        if let Some(refname) = line.split('\t').nth(1) {
964            if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
965                set.insert(branch.to_string());
966            }
967        }
968    }
969    set
970}
971
972/// Delete a remote branch on origin.
973pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
974    let status = Command::new("git")
975        .current_dir(root)
976        .args(["push", "origin", "--delete", branch])
977        .status()
978        .context("git push origin --delete failed")?;
979    if !status.success() {
980        anyhow::bail!("git push origin --delete {branch} failed");
981    }
982    Ok(())
983}
984
985/// Move files on a branch in a single commit.
986/// Each element of `moves` is (old_rel_path, new_rel_path, content).
987/// Writes each new file, stages it, then removes each old file via `git rm`.
988/// Uses the same permanent-worktree / temp-worktree pattern as commit_files_to_branch.
989pub fn move_files_on_branch(
990    root: &Path,
991    branch: &str,
992    moves: &[(&str, &str, &str)],
993    message: &str,
994) -> Result<()> {
995    if !has_commits(root) {
996        for (old, new, content) in moves {
997            let new_path = root.join(new);
998            if let Some(parent) = new_path.parent() {
999                std::fs::create_dir_all(parent)?;
1000            }
1001            std::fs::write(&new_path, content)?;
1002            let old_path = root.join(old);
1003            let _ = std::fs::remove_file(&old_path);
1004        }
1005        return Ok(());
1006    }
1007
1008    let do_moves = |wt: &Path| -> Result<()> {
1009        for (old, new, content) in moves {
1010            let new_path = wt.join(new);
1011            if let Some(parent) = new_path.parent() {
1012                std::fs::create_dir_all(parent)?;
1013            }
1014            std::fs::write(&new_path, content)?;
1015            run(wt, &["add", new])?;
1016            run(wt, &["rm", "--force", "--quiet", old])?;
1017        }
1018        run(wt, &["commit", "-m", message])?;
1019        Ok(())
1020    };
1021
1022    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
1023        let remote_ref = format!("origin/{branch}");
1024        if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
1025            let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
1026        }
1027        let result = do_moves(&wt_path);
1028        if result.is_ok() {
1029            crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1030        }
1031        return result;
1032    }
1033
1034    if current_branch(root).ok().as_deref() == Some(branch) {
1035        let result = do_moves(root);
1036        if result.is_ok() {
1037            crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1038        }
1039        return result;
1040    }
1041
1042    let unique = std::time::SystemTime::now()
1043        .duration_since(std::time::UNIX_EPOCH)
1044        .map(|d| d.subsec_nanos())
1045        .unwrap_or(0);
1046    let wt_path = std::env::temp_dir().join(format!(
1047        "apm-{}-{}-{}",
1048        std::process::id(),
1049        unique,
1050        branch.replace('/', "-"),
1051    ));
1052
1053    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
1054    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
1055
1056    if has_remote {
1057        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
1058        let _ = run(&wt_path, &["checkout", "-B", branch]);
1059    } else if has_local {
1060        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
1061        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
1062        let _ = run(&wt_path, &["checkout", "-B", branch]);
1063    } else {
1064        run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
1065    }
1066
1067    let result = do_moves(&wt_path);
1068    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
1069    let _ = std::fs::remove_dir_all(&wt_path);
1070    if result.is_ok() {
1071        crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
1072    }
1073    result
1074}
1075
1076pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1077    let _ = run(root, &["fetch", "origin", default_branch]);
1078
1079    let merge_dir = if current_branch(root).ok().as_deref() == Some(default_branch) {
1080        root.to_path_buf()
1081    } else {
1082        find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
1083    };
1084
1085    if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
1086        let _ = run(&merge_dir, &["merge", "--abort"]);
1087        anyhow::bail!("merge failed: {e:#}");
1088    }
1089
1090    if has_remote(root) {
1091        if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
1092            warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
1093        }
1094    }
1095    Ok(())
1096}
1097
1098pub 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<()> {
1099    let _ = std::process::Command::new("git")
1100        .args(["fetch", "origin", default_branch])
1101        .current_dir(root)
1102        .status();
1103
1104    let current = std::process::Command::new("git")
1105        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1106        .current_dir(root)
1107        .output()?;
1108    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1109
1110    let merge_dir = if current_branch == default_branch {
1111        root.to_path_buf()
1112    } else {
1113        let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1114        let worktrees_base = main_root.join(&config.worktrees.dir);
1115        ensure_worktree(root, &worktrees_base, default_branch)?
1116    };
1117
1118    let out = std::process::Command::new("git")
1119        .args(["merge", "--no-ff", branch, "--no-edit"])
1120        .current_dir(&merge_dir)
1121        .output()?;
1122
1123    if !out.status.success() {
1124        let _ = std::process::Command::new("git")
1125            .args(["merge", "--abort"])
1126            .current_dir(&merge_dir)
1127            .status();
1128        bail!(
1129            "merge conflict — resolve manually and push: {}",
1130            String::from_utf8_lossy(&out.stderr).trim()
1131        );
1132    }
1133
1134    if skip_push {
1135        messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1136    } else {
1137        push_branch(&merge_dir, default_branch)?;
1138        messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1139    }
1140    Ok(())
1141}
1142
1143pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1144    let fetch = std::process::Command::new("git")
1145        .args(["fetch", "origin", default_branch])
1146        .current_dir(root)
1147        .output();
1148
1149    match fetch {
1150        Err(e) => {
1151            warnings.push(format!("warning: fetch failed: {e:#}"));
1152            return Ok(());
1153        }
1154        Ok(out) if !out.status.success() => {
1155            warnings.push(format!(
1156                "warning: fetch failed: {}",
1157                String::from_utf8_lossy(&out.stderr).trim()
1158            ));
1159            return Ok(());
1160        }
1161        _ => {}
1162    }
1163
1164    let current = std::process::Command::new("git")
1165        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1166        .current_dir(root)
1167        .output()?;
1168    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1169
1170    let merge_dir = if current_branch == default_branch {
1171        root.to_path_buf()
1172    } else {
1173        find_worktree_for_branch(root, default_branch)
1174            .unwrap_or_else(|| root.to_path_buf())
1175    };
1176
1177    let remote_ref = format!("origin/{default_branch}");
1178    let out = std::process::Command::new("git")
1179        .args(["merge", "--ff-only", &remote_ref])
1180        .current_dir(&merge_dir)
1181        .output()?;
1182
1183    if !out.status.success() {
1184        warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1185    }
1186
1187    Ok(())
1188}
1189
1190pub fn is_worktree_dirty(path: &Path) -> bool {
1191    let Ok(out) = Command::new("git")
1192        .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1193        .output()
1194    else {
1195        return false;
1196    };
1197    !out.stdout.is_empty()
1198}
1199
1200pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1201    Command::new("git")
1202        .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1203        .output()
1204        .map(|o| o.status.success())
1205        .unwrap_or(false)
1206}
1207
1208pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1209    let Ok(out) = Command::new("git")
1210        .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1211        .output()
1212    else {
1213        warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1214        return;
1215    };
1216    if !out.status.success() {
1217        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1218        warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1219    }
1220}
1221
1222pub fn prune_remote_tracking(root: &Path, branch: &str) {
1223    let _ = Command::new("git")
1224        .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1225        .output();
1226}
1227
1228pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1229    let mut args = vec!["add"];
1230    args.extend_from_slice(files);
1231    run(root, &args).map(|_| ())
1232}
1233
1234pub fn commit(root: &Path, message: &str) -> Result<()> {
1235    run(root, &["commit", "-m", message]).map(|_| ())
1236}
1237
1238pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1239    let out = Command::new("git")
1240        .args(["-C", &root.to_string_lossy(), "config", key])
1241        .output()
1242        .ok()?;
1243    if !out.status.success() {
1244        return None;
1245    }
1246    let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1247    if value.is_empty() { None } else { Some(value) }
1248}
1249
1250/// Remove any unmerged (stage 1/2/3) entries from the index of `dir`.
1251/// `git ls-files -u` lists them; `git reset HEAD -- <path>` clears each.
1252/// No-op when the index is clean. Warnings are appended on failure; we
1253/// never bail because the caller's next operation will surface any real
1254/// problem.
1255fn clear_stale_unmerged_entries(dir: &Path, warnings: &mut Vec<String>) {
1256    let out = match Command::new("git")
1257        .args(["-C", &dir.to_string_lossy(), "ls-files", "-u"])
1258        .output()
1259    {
1260        Ok(o) if o.status.success() => o,
1261        _ => return,
1262    };
1263    let stdout = String::from_utf8_lossy(&out.stdout);
1264    let mut paths: std::collections::HashSet<String> = std::collections::HashSet::new();
1265    for line in stdout.lines() {
1266        // Format: "<mode> <sha> <stage>\t<path>"
1267        if let Some(path) = line.split('\t').nth(1) {
1268            paths.insert(path.to_string());
1269        }
1270    }
1271    if paths.is_empty() {
1272        return;
1273    }
1274    warnings.push(format!(
1275        "warning: clearing {} stale unmerged index entr{} in {} (left by an earlier failed merge)",
1276        paths.len(),
1277        if paths.len() == 1 { "y" } else { "ies" },
1278        dir.display(),
1279    ));
1280    for path in &paths {
1281        let _ = Command::new("git")
1282            .args(["-C", &dir.to_string_lossy(), "reset", "HEAD", "--", path])
1283            .output();
1284    }
1285}
1286
1287pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1288    // Preflight: clear any stage-2/3 unmerged index entries left behind by an
1289    // earlier failed merge whose abort didn't fully clean up. Without this,
1290    // git refuses with "Merging is not possible because you have unmerged
1291    // files." before even attempting the merge.
1292    clear_stale_unmerged_entries(dir, warnings);
1293
1294    // `merge.directoryRenames=false` disables git's heuristic that infers
1295    // file renames when many siblings in a directory are renamed elsewhere.
1296    // For apm, that heuristic mis-fires during the post-provision merge:
1297    // when main archives a sweep of `tickets/*.md` into `archive/tickets/`,
1298    // git would speculatively rename the ticket branch's own active ticket
1299    // file into archive/ too, creating a phantom conflict.
1300    let out = match Command::new("git")
1301        .args([
1302            "-C", &dir.to_string_lossy(),
1303            "-c", "merge.directoryRenames=false",
1304            "merge", refname, "--no-edit",
1305        ])
1306        .output()
1307    {
1308        Ok(o) => o,
1309        Err(e) => {
1310            warnings.push(format!("warning: merge {refname} failed: {e}"));
1311            return None;
1312        }
1313    };
1314    if out.status.success() {
1315        let stdout = String::from_utf8_lossy(&out.stdout);
1316        if stdout.contains("Already up to date") {
1317            None
1318        } else {
1319            Some(format!("Merged {refname} into branch."))
1320        }
1321    } else {
1322        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1323        warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1324        // Abort the merge so the worktree returns to a clean state. Without
1325        // this, MERGE_HEAD persists and subsequent partial commits (e.g.
1326        // `apm state`, `apm set`) fail silently with "cannot do a partial
1327        // commit during a merge". Best-effort: an abort failure is reported
1328        // as a warning but doesn't change the return value.
1329        if detect_mid_merge_state(dir).is_some() {
1330            let abort = Command::new("git")
1331                .args(["-C", &dir.to_string_lossy(), "merge", "--abort"])
1332                .output();
1333            match abort {
1334                Ok(o) if !o.status.success() => {
1335                    let aborterr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1336                    warnings.push(format!(
1337                        "warning: could not abort merge of {refname} in {}: {aborterr}",
1338                        dir.display()
1339                    ));
1340                }
1341                Err(e) => {
1342                    warnings.push(format!(
1343                        "warning: could not abort merge of {refname} in {}: {e}",
1344                        dir.display()
1345                    ));
1346                }
1347                Ok(_) => {}
1348            }
1349        }
1350        None
1351    }
1352}
1353
1354pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1355    Command::new("git")
1356        .args(["ls-files", "--error-unmatch", path])
1357        .current_dir(root)
1358        .stdout(std::process::Stdio::null())
1359        .stderr(std::process::Stdio::null())
1360        .status()
1361        .map(|s| s.success())
1362        .unwrap_or(false)
1363}
1364
1365/// Describes which incomplete git operation is in progress.
1366/// Presence of the corresponding marker file/directory under `.git/` is definitive —
1367/// git creates these for the duration of the operation and removes them on commit or abort.
1368pub enum MidMergeState {
1369    /// `.git/MERGE_HEAD` exists — a `git merge` was started but not committed.
1370    Merge,
1371    /// `.git/rebase-merge/` exists — a `git rebase -i` (or merge-based rebase) is in progress.
1372    RebaseMerge,
1373    /// `.git/rebase-apply/` exists — a `git rebase` (apply-based) or `git am` is in progress.
1374    RebaseApply,
1375    /// `.git/CHERRY_PICK_HEAD` exists — a `git cherry-pick` is in progress.
1376    CherryPick,
1377}
1378
1379/// Detect whether the repo is in a mid-merge, mid-rebase, or mid-cherry-pick state.
1380///
1381/// Returns `Some` when any of the well-known git marker files/directories exist.
1382/// Uses path checks only — no subprocess calls.
1383///
1384/// Note: git worktrees store their state in a separate directory pointed to by
1385/// `.git` (which becomes a file rather than a directory). This function is safe
1386/// because `apm sync` always runs at the main repo root where `.git` is a directory.
1387pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1388    let git_dir = root.join(".git");
1389    if git_dir.join("MERGE_HEAD").exists() {
1390        return Some(MidMergeState::Merge);
1391    }
1392    if git_dir.join("rebase-merge").is_dir() {
1393        return Some(MidMergeState::RebaseMerge);
1394    }
1395    if git_dir.join("rebase-apply").is_dir() {
1396        return Some(MidMergeState::RebaseApply);
1397    }
1398    if git_dir.join("CHERRY_PICK_HEAD").exists() {
1399        return Some(MidMergeState::CherryPick);
1400    }
1401    None
1402}
1403
1404/// Run `git merge-base ref1 ref2` and return the common ancestor SHA.
1405pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1406    run(root, &["merge-base", ref1, ref2])
1407}
1408
1409pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1410    let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1411    out.lines()
1412        .next()
1413        .and_then(|line| line.strip_prefix("worktree "))
1414        .map(PathBuf::from)
1415}
1416
1417/// Returns the list of files that are both modified on `ticket_branch`
1418/// (since its merge-base with `target_branch`) AND dirty (uncommitted) in the
1419/// target worktree.  Returns an empty Vec when the check cannot be performed
1420/// (no shared history, target worktree not found on disk).
1421///
1422/// Porcelain v1 entries with `R` or `C` in either status column are skipped:
1423/// their line format (`XY old -> new`) cannot be parsed with a simple col-3
1424/// slice.  Known limitation: a leaked file staged as a rename in the target
1425/// worktree will not be detected.
1426///
1427/// `??` (untracked) entries ARE included: a file added by the ticket branch
1428/// that appears untracked in the target worktree is a genuine leak signal.
1429pub fn check_leaked_files(
1430    root: &Path,
1431    ticket_branch: &str,
1432    target_branch: &str,
1433) -> Result<Vec<String>> {
1434    // 1. Resolve the target worktree directory.
1435    let current = Command::new("git")
1436        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1437        .current_dir(root)
1438        .output()?;
1439    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1440
1441    let merge_dir = if current_branch == target_branch {
1442        root.to_path_buf()
1443    } else {
1444        match crate::worktree::find_worktree_for_branch(root, target_branch) {
1445            Some(p) => p,
1446            None => return Ok(vec![]),  // target worktree absent -> cannot be dirty
1447        }
1448    };
1449
1450    // 2. Compute merge-base between target and ticket.
1451    let base = match merge_base(root, target_branch, ticket_branch) {
1452        Ok(s) => s.trim().to_string(),
1453        Err(_) => return Ok(vec![]),  // no shared history -> don't block
1454    };
1455    if base.is_empty() {
1456        return Ok(vec![]);
1457    }
1458
1459    // 3. Files touched by the ticket branch since the merge-base (includes newly
1460    //    added files, which appear as untracked in the target if leaked).
1461    let diff_out = Command::new("git")
1462        .args(["diff", "--name-only", &base, ticket_branch])
1463        .current_dir(root)
1464        .output()?;
1465    let ticket_files: std::collections::HashSet<String> =
1466        String::from_utf8_lossy(&diff_out.stdout)
1467            .lines()
1468            .map(|s| s.to_string())
1469            .collect();
1470
1471    // 4. Dirty files in the target worktree.
1472    //    Porcelain v1 format: "XY <path>" -- path starts at column 3.
1473    //    "??" (untracked) entries are intentionally included: a file added by the
1474    //    ticket branch that sits untracked in the target is a genuine leak signal.
1475    //    "R " and "C " (staged rename/copy) entries are skipped: their line format
1476    //    is "XY orig -> dest", so col-3 slicing produces "orig -> dest", not a
1477    //    matchable path.  Known limitation: leaks of staged-renamed files are not
1478    //    detected.
1479    let status_out = Command::new("git")
1480        .args(["status", "--porcelain", "--untracked-files=all"])
1481        .current_dir(&merge_dir)
1482        .output()?;
1483    let dirty_files: std::collections::HashSet<String> =
1484        String::from_utf8_lossy(&status_out.stdout)
1485            .lines()
1486            .filter_map(|line| {
1487                if line.len() < 3 {
1488                    return None;
1489                }
1490                let x = line.as_bytes()[0] as char;
1491                let y = line.as_bytes()[1] as char;
1492                // Skip rename/copy entries: cannot be parsed with a simple col-3 slice.
1493                if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1494                    return None;
1495                }
1496                Some(line[3..].to_string())
1497            })
1498            .collect();
1499
1500    // 5. Intersection, sorted for stable output.
1501    let mut overlap: Vec<String> = ticket_files
1502        .intersection(&dirty_files)
1503        .cloned()
1504        .collect();
1505    overlap.sort();
1506    Ok(overlap)
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511    use super::*;
1512    use std::process::Command as Cmd;
1513    use tempfile::TempDir;
1514
1515    fn git_init() -> TempDir {
1516        let dir = tempfile::tempdir().unwrap();
1517        let p = dir.path();
1518        Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1519        Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1520        Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1521        dir
1522    }
1523
1524    fn git_cmd(dir: &Path, args: &[&str]) {
1525        Cmd::new("git")
1526            .args(args)
1527            .current_dir(dir)
1528            .env("GIT_AUTHOR_NAME", "test")
1529            .env("GIT_AUTHOR_EMAIL", "t@t.com")
1530            .env("GIT_COMMITTER_NAME", "test")
1531            .env("GIT_COMMITTER_EMAIL", "t@t.com")
1532            .status()
1533            .unwrap();
1534    }
1535
1536    fn make_commit(dir: &Path, filename: &str, content: &str) {
1537        std::fs::write(dir.join(filename), content).unwrap();
1538        git_cmd(dir, &["add", filename]);
1539        git_cmd(dir, &["commit", "-m", "init"]);
1540    }
1541
1542    #[test]
1543    fn is_worktree_dirty_clean() {
1544        let dir = git_init();
1545        make_commit(dir.path(), "f.txt", "hi");
1546        assert!(!is_worktree_dirty(dir.path()));
1547    }
1548
1549    #[test]
1550    fn is_worktree_dirty_dirty() {
1551        let dir = git_init();
1552        make_commit(dir.path(), "f.txt", "hi");
1553        std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1554        assert!(is_worktree_dirty(dir.path()));
1555    }
1556
1557    #[test]
1558    fn local_branch_exists_present_and_absent() {
1559        let dir = git_init();
1560        make_commit(dir.path(), "f.txt", "hi");
1561        let on_main = local_branch_exists(dir.path(), "main");
1562        let on_master = local_branch_exists(dir.path(), "master");
1563        assert!(on_main || on_master);
1564        assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1565    }
1566
1567    #[test]
1568    fn delete_local_branch_success() {
1569        let dir = git_init();
1570        make_commit(dir.path(), "f.txt", "hi");
1571        git_cmd(dir.path(), &["branch", "to-delete"]);
1572        let mut warnings = Vec::new();
1573        delete_local_branch(dir.path(), "to-delete", &mut warnings);
1574        assert!(warnings.is_empty());
1575        assert!(!local_branch_exists(dir.path(), "to-delete"));
1576    }
1577
1578    #[test]
1579    fn delete_local_branch_failure_adds_warning() {
1580        let dir = git_init();
1581        make_commit(dir.path(), "f.txt", "hi");
1582        let mut warnings = Vec::new();
1583        delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1584        assert!(!warnings.is_empty());
1585        assert!(warnings[0].contains("warning:"));
1586    }
1587
1588    #[test]
1589    fn prune_remote_tracking_no_panic() {
1590        let dir = git_init();
1591        make_commit(dir.path(), "f.txt", "hi");
1592        // Just verify it doesn't panic even when the remote ref doesn't exist.
1593        prune_remote_tracking(dir.path(), "nonexistent-branch");
1594    }
1595
1596    #[test]
1597    fn stage_files_ok_and_err() {
1598        let dir = git_init();
1599        make_commit(dir.path(), "f.txt", "hi");
1600        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1601        assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1602        assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1603    }
1604
1605    #[test]
1606    fn commit_ok_and_err() {
1607        let dir = git_init();
1608        make_commit(dir.path(), "f.txt", "hi");
1609        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1610        git_cmd(dir.path(), &["add", "new.txt"]);
1611        assert!(commit(dir.path(), "test commit").is_ok());
1612        // Nothing staged — should fail
1613        assert!(commit(dir.path(), "empty commit").is_err());
1614    }
1615
1616    #[test]
1617    fn git_config_get_some_and_none() {
1618        let dir = git_init();
1619        make_commit(dir.path(), "f.txt", "hi");
1620        let val = git_config_get(dir.path(), "user.email");
1621        assert_eq!(val, Some("t@t.com".to_string()));
1622        let missing = git_config_get(dir.path(), "no.such.key");
1623        assert!(missing.is_none());
1624    }
1625
1626    #[test]
1627    fn merge_ref_already_up_to_date() {
1628        let dir = git_init();
1629        make_commit(dir.path(), "f.txt", "hi");
1630        let branch = {
1631            let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1632            String::from_utf8_lossy(&out.stdout).trim().to_string()
1633        };
1634        let mut warnings = Vec::new();
1635        // Merging current branch into itself is already up to date
1636        let result = merge_ref(dir.path(), &branch, &mut warnings);
1637        assert!(result.is_none());
1638        assert!(warnings.is_empty());
1639    }
1640
1641    #[test]
1642    fn merge_ref_success() {
1643        let dir = git_init();
1644        make_commit(dir.path(), "f.txt", "hi");
1645        git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1646        make_commit(dir.path(), "g.txt", "there");
1647        git_cmd(dir.path(), &["checkout", "main"]);
1648        let mut warnings = Vec::new();
1649        let result = merge_ref(dir.path(), "feature", &mut warnings);
1650        assert!(result.is_some());
1651        assert!(warnings.is_empty());
1652    }
1653
1654    #[test]
1655    fn merge_ref_does_not_speculate_directory_renames() {
1656        // Reproduce the apm archive-sweep scenario: many siblings move from
1657        // dir A to dir B on main; the feature branch adds a *new* file in
1658        // dir A. With directory rename detection on, git would speculatively
1659        // place the new file under dir B too, conflicting with the feature
1660        // branch. With it off (our fix), the file stays at A on the merged
1661        // branch.
1662        let dir = git_init();
1663        let p = dir.path();
1664        // Seed: dir A has several files
1665        std::fs::create_dir_all(p.join("a")).unwrap();
1666        for name in &["1.md", "2.md", "3.md", "4.md"] {
1667            std::fs::write(p.join("a").join(name), "seed\n").unwrap();
1668        }
1669        git_cmd(p, &["add", "a"]);
1670        git_cmd(p, &["commit", "-m", "seed"]);
1671
1672        // main: move all of a/* into b/*
1673        std::fs::create_dir_all(p.join("b")).unwrap();
1674        for name in &["1.md", "2.md", "3.md", "4.md"] {
1675            std::fs::rename(p.join("a").join(name), p.join("b").join(name)).unwrap();
1676        }
1677        git_cmd(p, &["add", "-A"]);
1678        git_cmd(p, &["commit", "-m", "archive sweep"]);
1679
1680        // feature branch: branch off seed, add new file in a/
1681        git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
1682        std::fs::write(p.join("a/new.md"), "active\n").unwrap();
1683        git_cmd(p, &["add", "a/new.md"]);
1684        git_cmd(p, &["commit", "-m", "add active ticket"]);
1685
1686        let mut warnings = Vec::new();
1687        let result = merge_ref(p, "main", &mut warnings);
1688
1689        assert!(result.is_some(), "merge should succeed without directory-rename inference; warnings: {warnings:?}");
1690        assert!(detect_mid_merge_state(p).is_none(), "should not be left mid-merge");
1691        // The new file should still be at a/new.md, not b/new.md.
1692        assert!(p.join("a/new.md").exists(), "new.md should stay at a/");
1693        assert!(!p.join("b/new.md").exists(), "new.md must NOT have been speculatively renamed into b/");
1694    }
1695
1696    #[test]
1697    fn merge_ref_clears_stale_unmerged_index_entries() {
1698        // Reproduce a stage-2 leftover from an earlier failed merge that
1699        // was incompletely aborted. Without preflight cleanup, git refuses:
1700        // "Merging is not possible because you have unmerged files."
1701        let dir = git_init();
1702        let p = dir.path();
1703        make_commit(p, "f.txt", "hi");
1704
1705        // Branch "other" with a non-conflicting commit — we'll merge this
1706        // *later*, after polluting the index.
1707        git_cmd(p, &["checkout", "-b", "other"]);
1708        make_commit(p, "g.txt", "there");
1709        git_cmd(p, &["checkout", "main"]);
1710
1711        // Now create a real merge conflict against a third branch "feature".
1712        std::fs::write(p.join("conflict.md"), "main\n").unwrap();
1713        git_cmd(p, &["add", "conflict.md"]);
1714        git_cmd(p, &["commit", "-m", "main version"]);
1715
1716        git_cmd(p, &["checkout", "-b", "feature", "HEAD~1"]);
1717        std::fs::write(p.join("conflict.md"), "feature\n").unwrap();
1718        git_cmd(p, &["add", "conflict.md"]);
1719        git_cmd(p, &["commit", "-m", "feature version"]);
1720
1721        git_cmd(p, &["checkout", "main"]);
1722        let _ = Cmd::new("git")
1723            .args(["-C", &p.to_string_lossy(), "merge", "feature", "--no-edit"])
1724            .output();
1725        // Simulate a botched abort: clear MERGE_HEAD but leave stage entries.
1726        let _ = std::fs::remove_file(p.join(".git/MERGE_HEAD"));
1727        let _ = std::fs::remove_file(p.join(".git/MERGE_MSG"));
1728
1729        let pre = String::from_utf8_lossy(
1730            &Cmd::new("git").args(["-C", &p.to_string_lossy(), "ls-files", "-u"])
1731                .output().unwrap().stdout
1732        ).to_string();
1733        assert!(!pre.trim().is_empty(), "precondition: unmerged index entries should be present; got: {pre:?}");
1734
1735        // Without preflight cleanup, this would fail with "unmerged files".
1736        let mut warnings = Vec::new();
1737        let result = merge_ref(p, "other", &mut warnings);
1738
1739        assert!(result.is_some(), "merge should succeed after preflight; warnings: {warnings:?}");
1740        assert!(
1741            warnings.iter().any(|w| w.contains("stale unmerged index")),
1742            "expected stale-entry warning; got: {warnings:?}"
1743        );
1744    }
1745
1746    #[test]
1747    fn merge_ref_conflict_aborts_and_warns() {
1748        let dir = git_init();
1749        let p = dir.path();
1750        // Create the same file on both branches with different content so
1751        // the merge has a non-trivial conflict.
1752        make_commit(p, "f.txt", "main version\n");
1753        git_cmd(p, &["checkout", "-b", "feature"]);
1754        std::fs::write(p.join("f.txt"), "feature version\n").unwrap();
1755        git_cmd(p, &["add", "f.txt"]);
1756        git_cmd(p, &["commit", "-m", "feature change"]);
1757        git_cmd(p, &["checkout", "main"]);
1758        std::fs::write(p.join("f.txt"), "main change\n").unwrap();
1759        git_cmd(p, &["add", "f.txt"]);
1760        git_cmd(p, &["commit", "-m", "main change"]);
1761
1762        let mut warnings = Vec::new();
1763        let result = merge_ref(p, "feature", &mut warnings);
1764
1765        assert!(result.is_none(), "merge should report failure");
1766        assert!(
1767            warnings.iter().any(|w| w.contains("merge feature failed")),
1768            "expected merge-failure warning; got: {warnings:?}"
1769        );
1770        // Critical: worktree must NOT be left mid-merge.
1771        assert!(
1772            detect_mid_merge_state(p).is_none(),
1773            "merge_ref must abort on conflict so MERGE_HEAD does not persist"
1774        );
1775    }
1776
1777    #[test]
1778    fn detect_mid_merge_none_on_clean_repo() {
1779        let dir = git_init();
1780        make_commit(dir.path(), "f.txt", "hi");
1781        assert!(detect_mid_merge_state(dir.path()).is_none());
1782    }
1783
1784    #[test]
1785    fn detect_mid_merge_on_merge_head() {
1786        let dir = git_init();
1787        make_commit(dir.path(), "f.txt", "hi");
1788        std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
1789        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
1790    }
1791
1792    #[test]
1793    fn detect_mid_merge_on_rebase_merge() {
1794        let dir = git_init();
1795        make_commit(dir.path(), "f.txt", "hi");
1796        std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
1797        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
1798    }
1799
1800    #[test]
1801    fn detect_mid_merge_on_rebase_apply() {
1802        let dir = git_init();
1803        make_commit(dir.path(), "f.txt", "hi");
1804        std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
1805        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
1806    }
1807
1808    #[test]
1809    fn detect_mid_merge_on_cherry_pick() {
1810        let dir = git_init();
1811        make_commit(dir.path(), "f.txt", "hi");
1812        std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
1813        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
1814    }
1815
1816    #[test]
1817    fn is_file_tracked_tracked_and_untracked() {
1818        let dir = git_init();
1819        make_commit(dir.path(), "tracked.txt", "hi");
1820        assert!(is_file_tracked(dir.path(), "tracked.txt"));
1821        std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
1822        assert!(!is_file_tracked(dir.path(), "untracked.txt"));
1823    }
1824
1825    #[test]
1826    fn check_leaked_files_detects_overlap() {
1827        let dir = git_init();
1828        let p = dir.path();
1829        std::fs::create_dir_all(p.join("src")).unwrap();
1830        std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1831        git_cmd(p, &["add", "src/foo.rs"]);
1832        git_cmd(p, &["commit", "-m", "add foo"]);
1833
1834        git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
1835        std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1836        git_cmd(p, &["add", "src/foo.rs"]);
1837        git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1838        git_cmd(p, &["checkout", "main"]);
1839
1840        // Simulate a leaked edit on main without committing.
1841        std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
1842
1843        let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
1844        assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
1845    }
1846
1847    #[test]
1848    fn check_leaked_files_no_overlap() {
1849        let dir = git_init();
1850        let p = dir.path();
1851        std::fs::create_dir_all(p.join("src")).unwrap();
1852        std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1853        std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
1854        git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
1855        git_cmd(p, &["commit", "-m", "add foo and bar"]);
1856
1857        // Ticket branch modifies only src/foo.rs.
1858        git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
1859        std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1860        git_cmd(p, &["add", "src/foo.rs"]);
1861        git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1862        git_cmd(p, &["checkout", "main"]);
1863
1864        // Main has src/bar.rs dirty — not touched by the ticket.
1865        std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
1866
1867        let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
1868        assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
1869    }
1870
1871    #[test]
1872    fn check_leaked_files_detects_untracked_overlap() {
1873        let dir = git_init();
1874        let p = dir.path();
1875        make_commit(p, "existing.rs", "base");
1876
1877        // Ticket branch adds src/new.rs as a new file.
1878        git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
1879        std::fs::create_dir_all(p.join("src")).unwrap();
1880        std::fs::write(p.join("src/new.rs"), "new file").unwrap();
1881        git_cmd(p, &["add", "src/new.rs"]);
1882        git_cmd(p, &["commit", "-m", "ticket: add new file"]);
1883        git_cmd(p, &["checkout", "main"]);
1884
1885        // Leak: src/new.rs dropped untracked on main.
1886        std::fs::create_dir_all(p.join("src")).unwrap();
1887        std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
1888
1889        let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
1890        assert_eq!(leaked, vec!["src/new.rs".to_string()]);
1891    }
1892
1893    // ---- content_merged_into_main tests ----
1894
1895    /// Helper: commit a file with given name and content on the current branch.
1896    fn commit_file(dir: &Path, name: &str, content: &str) {
1897        std::fs::write(dir.join(name), content).unwrap();
1898        git_cmd(dir, &["add", name]);
1899        git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
1900    }
1901
1902    /// Regular-merged branch with a trailing ticket-file state commit:
1903    /// `content_merged_into_main` must return true.
1904    #[test]
1905    fn content_merged_into_main_regular_merge_with_state_commit() {
1906        let dir = git_init();
1907        let p = dir.path();
1908
1909        // Base commit on main.
1910        commit_file(p, "README", "base");
1911
1912        // Ticket branch: add src/lib.rs (implementation).
1913        git_cmd(p, &["checkout", "-b", "ticket/foo"]);
1914        std::fs::create_dir_all(p.join("src")).unwrap();
1915        commit_file(p, "src/lib.rs", "impl");
1916
1917        // Regular-merge into main.
1918        git_cmd(p, &["checkout", "main"]);
1919        git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
1920
1921        // Push a state-transition commit to the ticket branch (touches only tickets/).
1922        git_cmd(p, &["checkout", "ticket/foo"]);
1923        std::fs::create_dir_all(p.join("tickets")).unwrap();
1924        commit_file(p, "tickets/foo.md", "state: implemented");
1925
1926        // After this commit the branch tip is NOT an ancestor of main.
1927        let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
1928        assert!(result, "should detect that content was merged despite trailing state commit");
1929    }
1930
1931    /// Squash-merged branch with a trailing ticket-file commit:
1932    /// `content_merged_into_main` must return true.
1933    #[test]
1934    fn content_merged_into_main_squash_merge_with_state_commit() {
1935        let dir = git_init();
1936        let p = dir.path();
1937
1938        commit_file(p, "README", "base");
1939
1940        // Ticket branch.
1941        git_cmd(p, &["checkout", "-b", "ticket/bar"]);
1942        std::fs::create_dir_all(p.join("src")).unwrap();
1943        commit_file(p, "src/lib.rs", "impl");
1944
1945        // Squash-merge into main (manually: merge --squash + commit).
1946        git_cmd(p, &["checkout", "main"]);
1947        git_cmd(p, &["merge", "--squash", "ticket/bar"]);
1948        git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
1949
1950        // State-transition commit on ticket branch.
1951        git_cmd(p, &["checkout", "ticket/bar"]);
1952        std::fs::create_dir_all(p.join("tickets")).unwrap();
1953        commit_file(p, "tickets/bar.md", "state: implemented");
1954
1955        let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
1956        assert!(result, "should detect squash-merged content despite trailing state commit");
1957    }
1958
1959    /// Branch tip is already the merge-base (i.e. is an ancestor of main):
1960    /// return false — `--merged` handles it.
1961    #[test]
1962    fn content_merged_into_main_returns_false_when_ancestor() {
1963        let dir = git_init();
1964        let p = dir.path();
1965        commit_file(p, "README", "base");
1966        // ticket/anc is just pointing at main's tip — it IS an ancestor.
1967        git_cmd(p, &["checkout", "-b", "ticket/anc"]);
1968        // No extra commits — same SHA as main.
1969        git_cmd(p, &["checkout", "main"]);
1970        let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
1971        assert!(!result);
1972    }
1973
1974    /// Branch has a non-ticket file commit after the merge point — must NOT be detected.
1975    #[test]
1976    fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
1977        let dir = git_init();
1978        let p = dir.path();
1979        commit_file(p, "README", "base");
1980
1981        // Ticket branch with implementation.
1982        git_cmd(p, &["checkout", "-b", "ticket/extra"]);
1983        std::fs::create_dir_all(p.join("src")).unwrap();
1984        commit_file(p, "src/lib.rs", "impl");
1985
1986        // Squash-merge.
1987        git_cmd(p, &["checkout", "main"]);
1988        git_cmd(p, &["merge", "--squash", "ticket/extra"]);
1989        git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
1990
1991        // After merge: push BOTH a state commit AND a non-ticket source change.
1992        git_cmd(p, &["checkout", "ticket/extra"]);
1993        std::fs::create_dir_all(p.join("tickets")).unwrap();
1994        commit_file(p, "tickets/extra.md", "state: implemented");
1995        // Non-ticket file added — this makes content_tip == branch_tip (the source change
1996        // is the newest non-ticket commit), so the function should return false.
1997        commit_file(p, "src/extra.rs", "extra code");
1998
1999        let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
2000        assert!(!result, "branch with non-ticket changes after merge must not be detected");
2001    }
2002
2003    /// Branch where every commit since merge-base touches only ticket files:
2004    /// return false (nothing to squash-check).
2005    #[test]
2006    fn content_merged_into_main_all_ticket_only_commits_returns_false() {
2007        let dir = git_init();
2008        let p = dir.path();
2009        commit_file(p, "README", "base");
2010
2011        // Ticket branch that only ever touched tickets/.
2012        git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
2013        std::fs::create_dir_all(p.join("tickets")).unwrap();
2014        commit_file(p, "tickets/ticketonly.md", "state: new");
2015        git_cmd(p, &["checkout", "main"]);
2016
2017        let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
2018        assert!(!result, "all-ticket-only commits should return false");
2019    }
2020
2021    /// Regression test: a local ticket branch regular-merged into local
2022    /// main, with the remote-tracking ref deleted (e.g. GitHub auto-delete
2023    /// after merge), must still appear in `merged_into_main`'s result.
2024    /// Previously the squash-merge detector skipped it because its tip is
2025    /// already an ancestor, while `git branch -r --merged` could not see it
2026    /// (the origin/ticket/* ref no longer exists).
2027    #[test]
2028    fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
2029        let dir = git_init();
2030        let p = dir.path();
2031        make_commit(p, "f.txt", "base");
2032
2033        // Create a ticket branch and a commit on it.
2034        git_cmd(p, &["checkout", "-b", "ticket/foo"]);
2035        std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
2036        git_cmd(p, &["add", "f.txt"]);
2037        git_cmd(p, &["commit", "-m", "ticket: change"]);
2038
2039        // Merge ticket/foo into main with --no-ff (regular merge, leaves a merge commit).
2040        git_cmd(p, &["checkout", "main"]);
2041        git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
2042
2043        // Simulate that origin/main has been updated to match local main, but
2044        // origin/ticket/foo was auto-deleted (no such remote-tracking ref).
2045        let main_sha = run(p, &["rev-parse", "main"]).unwrap();
2046        Cmd::new("git")
2047            .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
2048            .current_dir(p)
2049            .status()
2050            .unwrap();
2051        // No origin/ticket/foo ref is created — this is the auto-delete case.
2052
2053        let merged = merged_into_main(p, "main").unwrap();
2054        assert!(
2055            merged.iter().any(|b| b == "ticket/foo"),
2056            "expected ticket/foo in merged set; got {merged:?}"
2057        );
2058    }
2059}