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/// Commit a file to a specific branch without disturbing the current working tree.
223///
224/// If a permanent worktree exists for the branch, commits there directly.
225/// If the caller is already on the target branch, commits directly.
226/// Otherwise uses a temporary git worktree.
227pub fn commit_to_branch(
228    root: &Path,
229    branch: &str,
230    rel_path: &str,
231    content: &str,
232    message: &str,
233) -> Result<()> {
234    // If the repo has no commits, write directly to the working tree (no worktree support yet).
235    if !has_commits(root) {
236        let local_path = root.join(rel_path);
237        if let Some(parent) = local_path.parent() {
238            std::fs::create_dir_all(parent)?;
239        }
240        std::fs::write(&local_path, content)?;
241        return Ok(());
242    }
243
244    // If a permanent worktree exists for this branch, commit there directly.
245    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
246        // Fast-forward to remote if remote is ahead, so our commit lands on top of it.
247        let remote_ref = format!("origin/{branch}");
248        if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
249            let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
250        }
251        let full_path = wt_path.join(rel_path);
252        if let Some(parent) = full_path.parent() {
253            std::fs::create_dir_all(parent)?;
254        }
255        std::fs::write(&full_path, content)?;
256        let _ = run(&wt_path, &["add", rel_path]);
257        let _ = run(&wt_path, &["commit", "-m", message]);
258        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
259        return Ok(());
260    }
261
262    // If already on the target branch, write to working tree and commit directly.
263    if current_branch(root).ok().as_deref() == Some(branch) {
264        let local_path = root.join(rel_path);
265        if let Some(parent) = local_path.parent() {
266            std::fs::create_dir_all(parent)?;
267        }
268        std::fs::write(&local_path, content)?;
269        let _ = run(root, &["add", rel_path]);
270        let _ = run(root, &["commit", "-m", message]);
271        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
272        return Ok(());
273    }
274
275    let result = try_worktree_commit(root, branch, rel_path, content, message);
276    if result.is_ok() {
277        crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
278    }
279    result
280}
281
282fn try_worktree_commit(
283    root: &Path,
284    branch: &str,
285    rel_path: &str,
286    content: &str,
287    message: &str,
288) -> Result<()> {
289    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
290    let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
291    let wt_path = std::env::temp_dir().join(format!(
292        "apm-{}-{}-{}",
293        std::process::id(),
294        seq,
295        branch.replace('/', "-"),
296    ));
297
298    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
299    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
300
301    if has_remote {
302        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
303        let _ = run(&wt_path, &["checkout", "-B", branch]);
304    } else if has_local {
305        // Use detached approach to avoid "already checked out" errors.
306        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
307        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
308        let _ = run(&wt_path, &["checkout", "-B", branch]);
309    } else {
310        run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
311    }
312
313    let result = (|| -> Result<()> {
314        let full_path = wt_path.join(rel_path);
315        if let Some(parent) = full_path.parent() {
316            std::fs::create_dir_all(parent)?;
317        }
318        std::fs::write(&full_path, content)?;
319        run(&wt_path, &["add", rel_path])?;
320        run(&wt_path, &["commit", "-m", message])?;
321        Ok(())
322    })();
323
324    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
325    let _ = std::fs::remove_dir_all(&wt_path);
326
327    result
328}
329
330
331/// Push all local ticket/* branches that have commits not yet on origin.
332/// Non-fatal: logs warnings on push failure. No-op when no origin is configured.
333pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
334    if run(root, &["remote", "get-url", "origin"]).is_err() {
335        return;
336    }
337    let out = match run(root, &["branch", "--list", "ticket/*"]) {
338        Ok(o) => o,
339        Err(_) => return,
340    };
341    for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
342        let range = format!("origin/{branch}..{branch}");
343        let count = run(root, &["rev-list", "--count", &range])
344            .ok()
345            .and_then(|s| s.trim().parse::<u32>().ok())
346            .unwrap_or(0);
347        if count > 0 {
348            if let Err(e) = run(root, &["push", "origin", branch]) {
349                warnings.push(format!("warning: push {branch} failed: {e:#}"));
350            }
351        }
352    }
353}
354
355/// Sync non-checked-out `ticket/*` and `epic/*` local refs with their origin counterparts.
356///
357/// This replaces the old `sync_local_ticket_refs` which performed an unconditional
358/// `update-ref` that could silently rewind local refs with unpushed commits (data-loss bug).
359///
360/// State matrix — each case documents why the mapped action is correct:
361///
362///   Equal      → no-op. Local and origin are identical; nothing to do.
363///
364///   Behind     → fast-forward via `update-ref`. Safe because local is a strict ancestor
365///                of origin: the update only moves the ref forward, losing no local commits.
366///
367///   Ahead      → info line only, NO `update-ref`, NO push.
368///                CRITICAL: the old code performed an unconditional `update-ref` in this
369///                case, silently rewriting the local ref to the origin SHA and orphaning
370///                any unpushed local commits. That was the data-loss bug this function fixes.
371///                apm sync never pushes; ahead refs wait for explicit user action.
372///
373///   Diverged   → warning line, no ref change, no push. Neither side is an ancestor of
374///                the other; manual resolution is required. Clobbering either side would
375///                lose commits.
376///
377///   RemoteOnly → create local ref at origin SHA. Safe: no local commits exist to lose.
378///                Makes the branch visible locally without a checkout.
379///
380///   NoRemote   → local-only branch, leave untouched. No auto-push, no warning spam.
381///                Publishing local-only branches requires an explicit user action.
382pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
383    // Collect all branches currently checked out across all worktrees.
384    // These are never touched — they must be managed via the worktree's own git operations.
385    let checked_out: std::collections::HashSet<String> = {
386        let mut set = std::collections::HashSet::new();
387        if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
388            for line in out.lines() {
389                if let Some(b) = line.strip_prefix("branch refs/heads/") {
390                    set.insert(b.to_string());
391                }
392            }
393        }
394        set
395    };
396
397    // Two ref namespaces this sync cares about. Both get identical classification-based
398    // treatment — ticket/* and epic/* branches are managed the same way.
399    const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
400
401    // Collect all origin refs across both namespaces.
402    let mut remote_refs: Vec<String> = Vec::new();
403    for ns in MANAGED_NAMESPACES {
404        let pattern = format!("refs/remotes/origin/{ns}/");
405        if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
406            for line in out.lines().filter(|l| !l.is_empty()) {
407                remote_refs.push(line.to_string());
408            }
409        }
410    }
411
412    let mut ahead_branches: Vec<String> = Vec::new();
413
414    for remote_name in remote_refs {
415        // remote_name is like "origin/ticket/<slug>" or "origin/epic/<slug>".
416        // Strip the "origin/" prefix to get the local branch name.
417        let branch = match remote_name.strip_prefix("origin/") {
418            Some(b) => b.to_string(),
419            None => continue,
420        };
421
422        // Never touch a branch currently checked out in any worktree.
423        if checked_out.contains(&branch) {
424            continue;
425        }
426
427        let local_ref = format!("refs/heads/{branch}");
428        // Use the short remote name (e.g. "origin/ticket/abc") as classify_branch resolves it.
429        let remote_ref_full = format!("refs/remotes/{remote_name}");
430
431        // Classification drives the action. Nothing in this function pushes —
432        // ahead refs wait for explicit action via apm state transitions.
433        match classify_branch(root, &local_ref, &remote_name) {
434            BranchClass::RemoteOnly => {
435                // No local ref exists yet; create it pointing at the origin SHA.
436                // Safe: there are no local commits to clobber.
437                let sha = match run(root, &["rev-parse", &remote_ref_full]) {
438                    Ok(s) => s,
439                    Err(_) => continue,
440                };
441                if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
442                    warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
443                }
444            }
445            BranchClass::Equal => {
446                // Local and origin are identical; nothing to do.
447            }
448            BranchClass::Behind => {
449                // Local is a strict ancestor of origin — fast-forward is safe.
450                // `update-ref` moves the ref forward; no local commits are lost.
451                let sha = match run(root, &["rev-parse", &remote_ref_full]) {
452                    Ok(s) => s,
453                    Err(_) => continue,
454                };
455                if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
456                    warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
457                }
458            }
459            BranchClass::Ahead => {
460                // CRITICAL: do NOT update-ref here.
461                // The old sync_local_ticket_refs performed an unconditional update-ref that
462                // silently rewound this ref to the origin SHA, orphaning unpushed local commits.
463                // That was the data-loss bug. The correct action is an info line only —
464                // apm sync never pushes; the user must push explicitly when ready.
465                warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
466                ahead_branches.push(branch);
467            }
468            BranchClass::Diverged => {
469                // Neither side is an ancestor of the other. Manual resolution required.
470                // Clobbering either ref would silently discard commits on the other side.
471                let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
472                    .replace("<slug>", &branch);
473                warnings.push(msg);
474            }
475            BranchClass::NoRemote => {
476                // Local-only branch: no origin counterpart. Leave it alone.
477                // No auto-push, no warning — publishing requires an explicit user action.
478            }
479        }
480    }
481
482    ahead_branches
483}
484
485/// List all files in a directory on a branch (non-recursive).
486pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
487    let tree_ref = format!("{branch}:{dir}");
488    let out = run(root, &["ls-tree", "--name-only", &tree_ref])
489        .or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
490    Ok(out.lines()
491        .filter(|l| !l.is_empty())
492        .map(|l| format!("{dir}/{l}"))
493        .collect())
494}
495
496/// Commit multiple files to a branch in a single commit without disturbing the working tree.
497pub fn commit_files_to_branch(
498    root: &Path,
499    branch: &str,
500    files: &[(&str, String)],
501    message: &str,
502) -> Result<()> {
503    if !has_commits(root) {
504        for (rel_path, content) in files {
505            let local_path = root.join(rel_path);
506            if let Some(parent) = local_path.parent() {
507                std::fs::create_dir_all(parent)?;
508            }
509            std::fs::write(&local_path, content)?;
510        }
511        return Ok(());
512    }
513
514    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
515        for (rel_path, content) in files {
516            let full_path = wt_path.join(rel_path);
517            if let Some(parent) = full_path.parent() {
518                std::fs::create_dir_all(parent)?;
519            }
520            std::fs::write(&full_path, content)?;
521            let _ = run(&wt_path, &["add", rel_path]);
522        }
523        run(&wt_path, &["commit", "-m", message])?;
524        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
525        return Ok(());
526    }
527
528    if current_branch(root).ok().as_deref() == Some(branch) {
529        for (rel_path, content) in files {
530            let local_path = root.join(rel_path);
531            if let Some(parent) = local_path.parent() {
532                std::fs::create_dir_all(parent)?;
533            }
534            std::fs::write(&local_path, content)?;
535            let _ = run(root, &["add", rel_path]);
536        }
537        run(root, &["commit", "-m", message])?;
538        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
539        return Ok(());
540    }
541
542    let unique = std::time::SystemTime::now()
543        .duration_since(std::time::UNIX_EPOCH)
544        .map(|d| d.subsec_nanos())
545        .unwrap_or(0);
546    let wt_path = std::env::temp_dir().join(format!(
547        "apm-{}-{}-{}",
548        std::process::id(),
549        unique,
550        branch.replace('/', "-"),
551    ));
552
553    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
554    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
555
556    if has_remote {
557        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
558        let _ = run(&wt_path, &["checkout", "-B", branch]);
559    } else if has_local {
560        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
561        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
562        let _ = run(&wt_path, &["checkout", "-B", branch]);
563    } else {
564        run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
565    }
566
567    let result = (|| -> Result<()> {
568        for (rel_path, content) in files {
569            let full_path = wt_path.join(rel_path);
570            if let Some(parent) = full_path.parent() {
571                std::fs::create_dir_all(parent)?;
572            }
573            std::fs::write(&full_path, content)?;
574            run(&wt_path, &["add", rel_path])?;
575        }
576        run(&wt_path, &["commit", "-m", message])?;
577        Ok(())
578    })();
579
580    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
581    let _ = std::fs::remove_dir_all(&wt_path);
582
583    if result.is_ok() {
584        crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
585    }
586    result
587}
588
589/// Get the commit SHA at the tip of a local branch.
590pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
591    run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
592}
593
594/// Resolve a branch name to a commit SHA.
595/// Prefers `origin/<branch>`; falls back to local `<branch>`.
596pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
597    run(root, &["rev-parse", &format!("origin/{branch}")])
598        .or_else(|_| run(root, &["rev-parse", branch]))
599        .with_context(|| format!("branch '{branch}' not found locally or on origin"))
600}
601
602/// Create a local branch pointing at a specific commit SHA.
603pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
604    run(root, &["branch", branch, sha]).map(|_| ())
605}
606
607/// Get the commit SHA at the tip of the remote tracking ref for a branch.
608pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
609    run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
610}
611
612/// Check if `commit` is a git ancestor of `of_ref` (i.e. reachable from `of_ref`).
613/// Uses `git merge-base --is-ancestor`.
614pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
615    Command::new("git")
616        .current_dir(root)
617        .args(["merge-base", "--is-ancestor", commit, of_ref])
618        .status()
619        .map(|s| s.success())
620        .unwrap_or(false)
621}
622
623/// Classification of a local branch relative to its origin counterpart.
624///
625/// Direction note: `merge-base --is-ancestor A B` returns 0 iff A is reachable from B.
626///   - local == remote                        → Equal
627///   - local ancestor-of remote (not equal)   → Behind (FF possible: remote has new commits)
628///   - remote ancestor-of local (not equal)   → Ahead  (local has unpushed commits)
629///   - neither is an ancestor of the other    → Diverged (manual resolution required)
630///   - local ref absent, remote ref present   → RemoteOnly (safe to create local ref)
631///   - remote ref cannot be resolved          → NoRemote (local-only or no origin)
632pub enum BranchClass {
633    Equal,
634    Behind,
635    Ahead,
636    Diverged,
637    /// Local ref does not exist; origin ref does. Safe to create the local ref.
638    RemoteOnly,
639    /// Remote ref cannot be resolved. Branch is local-only or origin is unreachable.
640    NoRemote,
641}
642
643/// Classify `local` branch relative to `remote` ref using SHA equality and directed ancestry.
644///
645/// `local`  — a local branch name, e.g. "main" (resolved via `refs/heads/<local>`).
646/// `remote` — a remote ref name,   e.g. "origin/main" (resolved as-is by git).
647///
648/// Every ancestry check includes a comment explaining which direction maps to which state
649/// because the mapping is not intuitive at a glance.
650pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
651    let local_sha = match run(root, &["rev-parse", local]) {
652        Ok(s) => s,
653        Err(_) => {
654            // Local ref absent. Check whether the remote side exists.
655            // If origin has the branch, this is RemoteOnly (safe to create a local ref).
656            // If origin also can't be resolved, it is truly NoRemote (local-only or no origin).
657            return if run(root, &["rev-parse", remote]).is_ok() {
658                BranchClass::RemoteOnly
659            } else {
660                BranchClass::NoRemote
661            };
662        }
663    };
664    let remote_sha = match run(root, &["rev-parse", remote]) {
665        Ok(s) => s,
666        Err(_) => return BranchClass::NoRemote,
667    };
668
669    if local_sha == remote_sha {
670        return BranchClass::Equal;
671    }
672
673    // `--is-ancestor local remote` succeeds iff local is reachable from remote.
674    // When true (and SHAs differ), remote has commits that local lacks → local is Behind.
675    let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
676
677    // `--is-ancestor remote local` succeeds iff remote is reachable from local.
678    // When true (and SHAs differ), local has commits that remote lacks → local is Ahead.
679    let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
680
681    match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
682        (true, false)  => BranchClass::Behind,   // remote has new commits; FF is safe
683        (false, true)  => BranchClass::Ahead,    // local has unpushed commits
684        (false, false) => BranchClass::Diverged, // each side has commits the other lacks
685        (true, true)   => BranchClass::Equal,    // both ancestors → same commit (guard)
686    }
687}
688
689/// Bring local `default` branch into sync with `origin/<default>` without ever pushing.
690///
691/// State matrix — each row documents why the mapped action is correct:
692///
693///   Equal     → no-op.  Local and origin are identical; nothing to do.
694///
695///   Behind    → `git merge --ff-only origin/<default>` in the main worktree.
696///               The main worktree is always checked out on <default>, so running
697///               the merge there updates both HEAD and the working tree atomically.
698///               If the merge fails (uncommitted local changes overlap with the
699///               incoming commits), we print MAIN_BEHIND_DIRTY_OVERLAP guidance and
700///               leave the working tree untouched.  git's own error detection is used
701///               rather than pre-emptively computing overlap.
702///
703///   Ahead     → Print one info line so the user knows local has unpushed commits.
704///               No network call, no ref changes.  Explicit pushes happen via
705///               `apm state <id> implemented` — apm sync NEVER pushes anything.
706///
707///   Diverged  → Print guidance (rebase/merge/push steps).  No ref changes.
708///               The dirty-aware variant is printed when the main worktree is unclean.
709///
710///   NoRemote  → Silent skip.  No origin is configured, or `origin/<default>` could
711///               not be resolved (e.g. fetch hasn't run yet).  Fetch failures are
712///               already surfaced as a warning by the existing fetch path in sync.rs.
713pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
714    let remote = format!("origin/{default}");
715    match classify_branch(root, default, &remote) {
716        BranchClass::Equal => {
717            // local == origin/main: nothing to do, print nothing.
718        }
719
720        BranchClass::Behind => {
721            // origin has new commits local lacks; attempt a fast-forward.
722            // Run in the main worktree so the working tree is updated too.
723            let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
724            if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
725                // FF refused — uncommitted local changes overlap with incoming commits.
726                // Leave the working tree untouched and print recovery guidance.
727                // Assumption: overlap is the only realistic failure mode for a strictly-behind FF merge; MAIN_BEHIND_DIRTY_OVERLAP covers any --ff-only error here.
728                let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
729                    .replace("<default>", default);
730                warnings.push(msg);
731            }
732        }
733
734        BranchClass::Ahead => {
735            // local has commits not on origin.  No push — apm sync never pushes.
736            // Count unpushed commits so the message is informative.
737            let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
738                .ok()
739                .and_then(|s| s.trim().parse::<u64>().ok())
740                .unwrap_or(0);
741            let msg = crate::sync_guidance::MAIN_AHEAD
742                .replace("<default>", default)
743                .replace("<remote>", &remote)
744                .replace("<count>", &count.to_string())
745                .replace("<commits>", if count == 1 { "commit" } else { "commits" });
746            warnings.push(msg);
747            return true;
748        }
749
750        BranchClass::Diverged => {
751            // Neither side is an ancestor of the other; manual resolution required.
752            // Print the dirty-aware variant so the user gets actionable steps.
753            let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
754            let guidance = if is_worktree_dirty(&wt) {
755                crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
756            } else {
757                crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
758            };
759            warnings.push(guidance);
760        }
761
762        BranchClass::RemoteOnly => {
763            // The default branch always exists locally in any repo with commits.
764            // RemoteOnly here would mean local branch is absent, which cannot happen
765            // during a normal sync flow. Treat it as NoRemote (silent skip).
766        }
767
768        BranchClass::NoRemote => {
769            // origin/<default> not resolvable (no remote, or fetch hasn't run yet).
770            // The fetch path in sync.rs already emits a warning on fetch failure.
771            // Nothing more to do here.
772        }
773    }
774    false
775}
776
777pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
778    let status = std::process::Command::new("git")
779        .args(["fetch", "origin", branch])
780        .current_dir(root)
781        .status()?;
782    if !status.success() {
783        anyhow::bail!("git fetch failed");
784    }
785    Ok(())
786}
787
788pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
789    let status = std::process::Command::new("git")
790        .args(["push", "origin", &format!("{branch}:{branch}")])
791        .current_dir(root)
792        .status()?;
793    if !status.success() {
794        anyhow::bail!("git push failed");
795    }
796    Ok(())
797}
798
799pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
800    let out = std::process::Command::new("git")
801        .args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
802        .current_dir(root)
803        .output()?;
804    if !out.status.success() {
805        anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
806    }
807    Ok(())
808}
809
810pub fn has_remote(root: &Path) -> bool {
811    run(root, &["remote", "get-url", "origin"]).is_ok()
812}
813
814/// Merge `branch` into `default_branch` (fast-forward or merge commit).
815/// Pushes `default_branch` to origin when a remote exists.
816/// List remote ticket/* branches with their last commit date.
817/// Returns (branch_name_without_origin_prefix, commit_date) pairs.
818pub fn remote_ticket_branches_with_dates(
819    root: &Path,
820) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
821    use chrono::{TimeZone, Utc};
822    let out = Command::new("git")
823        .current_dir(root)
824        .args([
825            "for-each-ref",
826            "refs/remotes/origin/ticket/",
827            "--format=%(refname:short) %(creatordate:unix)",
828        ])
829        .output()
830        .context("git for-each-ref failed")?;
831    let stdout = String::from_utf8_lossy(&out.stdout);
832    let mut result = Vec::new();
833    for line in stdout.lines() {
834        let mut parts = line.splitn(2, ' ');
835        let refname = parts.next().unwrap_or("").trim();
836        let ts_str = parts.next().unwrap_or("").trim();
837        let branch = refname.trim_start_matches("origin/");
838        if branch.is_empty() {
839            continue;
840        }
841        if let Ok(ts) = ts_str.parse::<i64>() {
842            if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
843                result.push((branch.to_string(), dt));
844            }
845        }
846    }
847    Ok(result)
848}
849
850/// Authoritative list of remote ticket branch names via `git ls-remote`.
851/// Unlike local remote-tracking refs (which can be stale or pruned),
852/// this queries origin directly. Returns an empty set on any error
853/// (no remote, network failure, etc.) so callers treat it as "no remote
854/// branches" and skip remote deletions safely.
855pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
856    let mut set = std::collections::HashSet::new();
857    let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
858        Ok(o) => o,
859        Err(_) => return set,
860    };
861    for line in out.lines() {
862        if let Some(refname) = line.split('\t').nth(1) {
863            if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
864                set.insert(branch.to_string());
865            }
866        }
867    }
868    set
869}
870
871/// Delete a remote branch on origin.
872pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
873    let status = Command::new("git")
874        .current_dir(root)
875        .args(["push", "origin", "--delete", branch])
876        .status()
877        .context("git push origin --delete failed")?;
878    if !status.success() {
879        anyhow::bail!("git push origin --delete {branch} failed");
880    }
881    Ok(())
882}
883
884/// Move files on a branch in a single commit.
885/// Each element of `moves` is (old_rel_path, new_rel_path, content).
886/// Writes each new file, stages it, then removes each old file via `git rm`.
887/// Uses the same permanent-worktree / temp-worktree pattern as commit_files_to_branch.
888pub fn move_files_on_branch(
889    root: &Path,
890    branch: &str,
891    moves: &[(&str, &str, &str)],
892    message: &str,
893) -> Result<()> {
894    if !has_commits(root) {
895        for (old, new, content) in moves {
896            let new_path = root.join(new);
897            if let Some(parent) = new_path.parent() {
898                std::fs::create_dir_all(parent)?;
899            }
900            std::fs::write(&new_path, content)?;
901            let old_path = root.join(old);
902            let _ = std::fs::remove_file(&old_path);
903        }
904        return Ok(());
905    }
906
907    let do_moves = |wt: &Path| -> Result<()> {
908        for (old, new, content) in moves {
909            let new_path = wt.join(new);
910            if let Some(parent) = new_path.parent() {
911                std::fs::create_dir_all(parent)?;
912            }
913            std::fs::write(&new_path, content)?;
914            run(wt, &["add", new])?;
915            run(wt, &["rm", "--force", "--quiet", old])?;
916        }
917        run(wt, &["commit", "-m", message])?;
918        Ok(())
919    };
920
921    if let Some(wt_path) = find_worktree_for_branch(root, branch) {
922        let remote_ref = format!("origin/{branch}");
923        if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
924            let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
925        }
926        let result = do_moves(&wt_path);
927        if result.is_ok() {
928            crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
929        }
930        return result;
931    }
932
933    if current_branch(root).ok().as_deref() == Some(branch) {
934        let result = do_moves(root);
935        if result.is_ok() {
936            crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
937        }
938        return result;
939    }
940
941    let unique = std::time::SystemTime::now()
942        .duration_since(std::time::UNIX_EPOCH)
943        .map(|d| d.subsec_nanos())
944        .unwrap_or(0);
945    let wt_path = std::env::temp_dir().join(format!(
946        "apm-{}-{}-{}",
947        std::process::id(),
948        unique,
949        branch.replace('/', "-"),
950    ));
951
952    let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
953    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
954
955    if has_remote {
956        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
957        let _ = run(&wt_path, &["checkout", "-B", branch]);
958    } else if has_local {
959        let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
960        run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
961        let _ = run(&wt_path, &["checkout", "-B", branch]);
962    } else {
963        run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
964    }
965
966    let result = do_moves(&wt_path);
967    let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
968    let _ = std::fs::remove_dir_all(&wt_path);
969    if result.is_ok() {
970        crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
971    }
972    result
973}
974
975pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
976    let _ = run(root, &["fetch", "origin", default_branch]);
977
978    let merge_dir = if current_branch(root).ok().as_deref() == Some(default_branch) {
979        root.to_path_buf()
980    } else {
981        find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
982    };
983
984    if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
985        let _ = run(&merge_dir, &["merge", "--abort"]);
986        anyhow::bail!("merge failed: {e:#}");
987    }
988
989    if has_remote(root) {
990        if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
991            warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
992        }
993    }
994    Ok(())
995}
996
997pub 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<()> {
998    let _ = std::process::Command::new("git")
999        .args(["fetch", "origin", default_branch])
1000        .current_dir(root)
1001        .status();
1002
1003    let current = std::process::Command::new("git")
1004        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1005        .current_dir(root)
1006        .output()?;
1007    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1008
1009    let merge_dir = if current_branch == default_branch {
1010        root.to_path_buf()
1011    } else {
1012        let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1013        let worktrees_base = main_root.join(&config.worktrees.dir);
1014        ensure_worktree(root, &worktrees_base, default_branch)?
1015    };
1016
1017    let out = std::process::Command::new("git")
1018        .args(["merge", "--no-ff", branch, "--no-edit"])
1019        .current_dir(&merge_dir)
1020        .output()?;
1021
1022    if !out.status.success() {
1023        let _ = std::process::Command::new("git")
1024            .args(["merge", "--abort"])
1025            .current_dir(&merge_dir)
1026            .status();
1027        bail!(
1028            "merge conflict — resolve manually and push: {}",
1029            String::from_utf8_lossy(&out.stderr).trim()
1030        );
1031    }
1032
1033    if skip_push {
1034        messages.push(format!("Merged {branch} into {default_branch} (local only)."));
1035    } else {
1036        push_branch(&merge_dir, default_branch)?;
1037        messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
1038    }
1039    Ok(())
1040}
1041
1042pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
1043    let fetch = std::process::Command::new("git")
1044        .args(["fetch", "origin", default_branch])
1045        .current_dir(root)
1046        .output();
1047
1048    match fetch {
1049        Err(e) => {
1050            warnings.push(format!("warning: fetch failed: {e:#}"));
1051            return Ok(());
1052        }
1053        Ok(out) if !out.status.success() => {
1054            warnings.push(format!(
1055                "warning: fetch failed: {}",
1056                String::from_utf8_lossy(&out.stderr).trim()
1057            ));
1058            return Ok(());
1059        }
1060        _ => {}
1061    }
1062
1063    let current = std::process::Command::new("git")
1064        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1065        .current_dir(root)
1066        .output()?;
1067    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1068
1069    let merge_dir = if current_branch == default_branch {
1070        root.to_path_buf()
1071    } else {
1072        find_worktree_for_branch(root, default_branch)
1073            .unwrap_or_else(|| root.to_path_buf())
1074    };
1075
1076    let remote_ref = format!("origin/{default_branch}");
1077    let out = std::process::Command::new("git")
1078        .args(["merge", "--ff-only", &remote_ref])
1079        .current_dir(&merge_dir)
1080        .output()?;
1081
1082    if !out.status.success() {
1083        warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
1084    }
1085
1086    Ok(())
1087}
1088
1089pub fn is_worktree_dirty(path: &Path) -> bool {
1090    let Ok(out) = Command::new("git")
1091        .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
1092        .output()
1093    else {
1094        return false;
1095    };
1096    !out.stdout.is_empty()
1097}
1098
1099pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
1100    Command::new("git")
1101        .args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
1102        .output()
1103        .map(|o| o.status.success())
1104        .unwrap_or(false)
1105}
1106
1107pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
1108    let Ok(out) = Command::new("git")
1109        .args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
1110        .output()
1111    else {
1112        warnings.push(format!("warning: could not delete branch {branch}: command failed"));
1113        return;
1114    };
1115    if !out.status.success() {
1116        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1117        warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
1118    }
1119}
1120
1121pub fn prune_remote_tracking(root: &Path, branch: &str) {
1122    let _ = Command::new("git")
1123        .args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
1124        .output();
1125}
1126
1127pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
1128    let mut args = vec!["add"];
1129    args.extend_from_slice(files);
1130    run(root, &args).map(|_| ())
1131}
1132
1133pub fn commit(root: &Path, message: &str) -> Result<()> {
1134    run(root, &["commit", "-m", message]).map(|_| ())
1135}
1136
1137pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
1138    let out = Command::new("git")
1139        .args(["-C", &root.to_string_lossy(), "config", key])
1140        .output()
1141        .ok()?;
1142    if !out.status.success() {
1143        return None;
1144    }
1145    let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1146    if value.is_empty() { None } else { Some(value) }
1147}
1148
1149pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
1150    let out = match Command::new("git")
1151        .args(["-C", &dir.to_string_lossy(), "merge", refname, "--no-edit"])
1152        .output()
1153    {
1154        Ok(o) => o,
1155        Err(e) => {
1156            warnings.push(format!("warning: merge {refname} failed: {e}"));
1157            return None;
1158        }
1159    };
1160    if out.status.success() {
1161        let stdout = String::from_utf8_lossy(&out.stdout);
1162        if stdout.contains("Already up to date") {
1163            None
1164        } else {
1165            Some(format!("Merged {refname} into branch."))
1166        }
1167    } else {
1168        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1169        warnings.push(format!("warning: merge {refname} failed: {stderr}"));
1170        None
1171    }
1172}
1173
1174pub fn is_file_tracked(root: &Path, path: &str) -> bool {
1175    Command::new("git")
1176        .args(["ls-files", "--error-unmatch", path])
1177        .current_dir(root)
1178        .stdout(std::process::Stdio::null())
1179        .stderr(std::process::Stdio::null())
1180        .status()
1181        .map(|s| s.success())
1182        .unwrap_or(false)
1183}
1184
1185/// Describes which incomplete git operation is in progress.
1186/// Presence of the corresponding marker file/directory under `.git/` is definitive —
1187/// git creates these for the duration of the operation and removes them on commit or abort.
1188pub enum MidMergeState {
1189    /// `.git/MERGE_HEAD` exists — a `git merge` was started but not committed.
1190    Merge,
1191    /// `.git/rebase-merge/` exists — a `git rebase -i` (or merge-based rebase) is in progress.
1192    RebaseMerge,
1193    /// `.git/rebase-apply/` exists — a `git rebase` (apply-based) or `git am` is in progress.
1194    RebaseApply,
1195    /// `.git/CHERRY_PICK_HEAD` exists — a `git cherry-pick` is in progress.
1196    CherryPick,
1197}
1198
1199/// Detect whether the repo is in a mid-merge, mid-rebase, or mid-cherry-pick state.
1200///
1201/// Returns `Some` when any of the well-known git marker files/directories exist.
1202/// Uses path checks only — no subprocess calls.
1203///
1204/// Note: git worktrees store their state in a separate directory pointed to by
1205/// `.git` (which becomes a file rather than a directory). This function is safe
1206/// because `apm sync` always runs at the main repo root where `.git` is a directory.
1207pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
1208    let git_dir = root.join(".git");
1209    if git_dir.join("MERGE_HEAD").exists() {
1210        return Some(MidMergeState::Merge);
1211    }
1212    if git_dir.join("rebase-merge").is_dir() {
1213        return Some(MidMergeState::RebaseMerge);
1214    }
1215    if git_dir.join("rebase-apply").is_dir() {
1216        return Some(MidMergeState::RebaseApply);
1217    }
1218    if git_dir.join("CHERRY_PICK_HEAD").exists() {
1219        return Some(MidMergeState::CherryPick);
1220    }
1221    None
1222}
1223
1224/// Run `git merge-base ref1 ref2` and return the common ancestor SHA.
1225pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
1226    run(root, &["merge-base", ref1, ref2])
1227}
1228
1229pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
1230    let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
1231    out.lines()
1232        .next()
1233        .and_then(|line| line.strip_prefix("worktree "))
1234        .map(PathBuf::from)
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240    use std::process::Command as Cmd;
1241    use tempfile::TempDir;
1242
1243    fn git_init() -> TempDir {
1244        let dir = tempfile::tempdir().unwrap();
1245        let p = dir.path();
1246        Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1247        Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1248        Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1249        dir
1250    }
1251
1252    fn git_cmd(dir: &Path, args: &[&str]) {
1253        Cmd::new("git")
1254            .args(args)
1255            .current_dir(dir)
1256            .env("GIT_AUTHOR_NAME", "test")
1257            .env("GIT_AUTHOR_EMAIL", "t@t.com")
1258            .env("GIT_COMMITTER_NAME", "test")
1259            .env("GIT_COMMITTER_EMAIL", "t@t.com")
1260            .status()
1261            .unwrap();
1262    }
1263
1264    fn make_commit(dir: &Path, filename: &str, content: &str) {
1265        std::fs::write(dir.join(filename), content).unwrap();
1266        git_cmd(dir, &["add", filename]);
1267        git_cmd(dir, &["commit", "-m", "init"]);
1268    }
1269
1270    #[test]
1271    fn is_worktree_dirty_clean() {
1272        let dir = git_init();
1273        make_commit(dir.path(), "f.txt", "hi");
1274        assert!(!is_worktree_dirty(dir.path()));
1275    }
1276
1277    #[test]
1278    fn is_worktree_dirty_dirty() {
1279        let dir = git_init();
1280        make_commit(dir.path(), "f.txt", "hi");
1281        std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1282        assert!(is_worktree_dirty(dir.path()));
1283    }
1284
1285    #[test]
1286    fn local_branch_exists_present_and_absent() {
1287        let dir = git_init();
1288        make_commit(dir.path(), "f.txt", "hi");
1289        let on_main = local_branch_exists(dir.path(), "main");
1290        let on_master = local_branch_exists(dir.path(), "master");
1291        assert!(on_main || on_master);
1292        assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1293    }
1294
1295    #[test]
1296    fn delete_local_branch_success() {
1297        let dir = git_init();
1298        make_commit(dir.path(), "f.txt", "hi");
1299        git_cmd(dir.path(), &["branch", "to-delete"]);
1300        let mut warnings = Vec::new();
1301        delete_local_branch(dir.path(), "to-delete", &mut warnings);
1302        assert!(warnings.is_empty());
1303        assert!(!local_branch_exists(dir.path(), "to-delete"));
1304    }
1305
1306    #[test]
1307    fn delete_local_branch_failure_adds_warning() {
1308        let dir = git_init();
1309        make_commit(dir.path(), "f.txt", "hi");
1310        let mut warnings = Vec::new();
1311        delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1312        assert!(!warnings.is_empty());
1313        assert!(warnings[0].contains("warning:"));
1314    }
1315
1316    #[test]
1317    fn prune_remote_tracking_no_panic() {
1318        let dir = git_init();
1319        make_commit(dir.path(), "f.txt", "hi");
1320        // Just verify it doesn't panic even when the remote ref doesn't exist.
1321        prune_remote_tracking(dir.path(), "nonexistent-branch");
1322    }
1323
1324    #[test]
1325    fn stage_files_ok_and_err() {
1326        let dir = git_init();
1327        make_commit(dir.path(), "f.txt", "hi");
1328        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1329        assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1330        assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1331    }
1332
1333    #[test]
1334    fn commit_ok_and_err() {
1335        let dir = git_init();
1336        make_commit(dir.path(), "f.txt", "hi");
1337        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1338        git_cmd(dir.path(), &["add", "new.txt"]);
1339        assert!(commit(dir.path(), "test commit").is_ok());
1340        // Nothing staged — should fail
1341        assert!(commit(dir.path(), "empty commit").is_err());
1342    }
1343
1344    #[test]
1345    fn git_config_get_some_and_none() {
1346        let dir = git_init();
1347        make_commit(dir.path(), "f.txt", "hi");
1348        let val = git_config_get(dir.path(), "user.email");
1349        assert_eq!(val, Some("t@t.com".to_string()));
1350        let missing = git_config_get(dir.path(), "no.such.key");
1351        assert!(missing.is_none());
1352    }
1353
1354    #[test]
1355    fn merge_ref_already_up_to_date() {
1356        let dir = git_init();
1357        make_commit(dir.path(), "f.txt", "hi");
1358        let branch = {
1359            let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1360            String::from_utf8_lossy(&out.stdout).trim().to_string()
1361        };
1362        let mut warnings = Vec::new();
1363        // Merging current branch into itself is already up to date
1364        let result = merge_ref(dir.path(), &branch, &mut warnings);
1365        assert!(result.is_none());
1366        assert!(warnings.is_empty());
1367    }
1368
1369    #[test]
1370    fn merge_ref_success() {
1371        let dir = git_init();
1372        make_commit(dir.path(), "f.txt", "hi");
1373        git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1374        make_commit(dir.path(), "g.txt", "there");
1375        git_cmd(dir.path(), &["checkout", "main"]);
1376        let mut warnings = Vec::new();
1377        let result = merge_ref(dir.path(), "feature", &mut warnings);
1378        assert!(result.is_some());
1379        assert!(warnings.is_empty());
1380    }
1381
1382    #[test]
1383    fn detect_mid_merge_none_on_clean_repo() {
1384        let dir = git_init();
1385        make_commit(dir.path(), "f.txt", "hi");
1386        assert!(detect_mid_merge_state(dir.path()).is_none());
1387    }
1388
1389    #[test]
1390    fn detect_mid_merge_on_merge_head() {
1391        let dir = git_init();
1392        make_commit(dir.path(), "f.txt", "hi");
1393        std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
1394        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
1395    }
1396
1397    #[test]
1398    fn detect_mid_merge_on_rebase_merge() {
1399        let dir = git_init();
1400        make_commit(dir.path(), "f.txt", "hi");
1401        std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
1402        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
1403    }
1404
1405    #[test]
1406    fn detect_mid_merge_on_rebase_apply() {
1407        let dir = git_init();
1408        make_commit(dir.path(), "f.txt", "hi");
1409        std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
1410        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
1411    }
1412
1413    #[test]
1414    fn detect_mid_merge_on_cherry_pick() {
1415        let dir = git_init();
1416        make_commit(dir.path(), "f.txt", "hi");
1417        std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
1418        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
1419    }
1420
1421    #[test]
1422    fn is_file_tracked_tracked_and_untracked() {
1423        let dir = git_init();
1424        make_commit(dir.path(), "tracked.txt", "hi");
1425        assert!(is_file_tracked(dir.path(), "tracked.txt"));
1426        std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
1427        assert!(!is_file_tracked(dir.path(), "untracked.txt"));
1428    }
1429
1430    /// Regression test: a local ticket branch regular-merged into local
1431    /// main, with the remote-tracking ref deleted (e.g. GitHub auto-delete
1432    /// after merge), must still appear in `merged_into_main`'s result.
1433    /// Previously the squash-merge detector skipped it because its tip is
1434    /// already an ancestor, while `git branch -r --merged` could not see it
1435    /// (the origin/ticket/* ref no longer exists).
1436    #[test]
1437    fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
1438        let dir = git_init();
1439        let p = dir.path();
1440        make_commit(p, "f.txt", "base");
1441
1442        // Create a ticket branch and a commit on it.
1443        git_cmd(p, &["checkout", "-b", "ticket/foo"]);
1444        std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
1445        git_cmd(p, &["add", "f.txt"]);
1446        git_cmd(p, &["commit", "-m", "ticket: change"]);
1447
1448        // Merge ticket/foo into main with --no-ff (regular merge, leaves a merge commit).
1449        git_cmd(p, &["checkout", "main"]);
1450        git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
1451
1452        // Simulate that origin/main has been updated to match local main, but
1453        // origin/ticket/foo was auto-deleted (no such remote-tracking ref).
1454        let main_sha = run(p, &["rev-parse", "main"]).unwrap();
1455        Cmd::new("git")
1456            .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
1457            .current_dir(p)
1458            .status()
1459            .unwrap();
1460        // No origin/ticket/foo ref is created — this is the auto-delete case.
1461
1462        let merged = merged_into_main(p, "main").unwrap();
1463        assert!(
1464            merged.iter().any(|b| b == "ticket/foo"),
1465            "expected ticket/foo in merged set; got {merged:?}"
1466        );
1467    }
1468}