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/// Returns the list of files that are both modified on `ticket_branch`
1238/// (since its merge-base with `target_branch`) AND dirty (uncommitted) in the
1239/// target worktree.  Returns an empty Vec when the check cannot be performed
1240/// (no shared history, target worktree not found on disk).
1241///
1242/// Porcelain v1 entries with `R` or `C` in either status column are skipped:
1243/// their line format (`XY old -> new`) cannot be parsed with a simple col-3
1244/// slice.  Known limitation: a leaked file staged as a rename in the target
1245/// worktree will not be detected.
1246///
1247/// `??` (untracked) entries ARE included: a file added by the ticket branch
1248/// that appears untracked in the target worktree is a genuine leak signal.
1249pub fn check_leaked_files(
1250    root: &Path,
1251    ticket_branch: &str,
1252    target_branch: &str,
1253) -> Result<Vec<String>> {
1254    // 1. Resolve the target worktree directory.
1255    let current = Command::new("git")
1256        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1257        .current_dir(root)
1258        .output()?;
1259    let current_branch = String::from_utf8_lossy(&current.stdout).trim().to_string();
1260
1261    let merge_dir = if current_branch == target_branch {
1262        root.to_path_buf()
1263    } else {
1264        match crate::worktree::find_worktree_for_branch(root, target_branch) {
1265            Some(p) => p,
1266            None => return Ok(vec![]),  // target worktree absent -> cannot be dirty
1267        }
1268    };
1269
1270    // 2. Compute merge-base between target and ticket.
1271    let base = match merge_base(root, target_branch, ticket_branch) {
1272        Ok(s) => s.trim().to_string(),
1273        Err(_) => return Ok(vec![]),  // no shared history -> don't block
1274    };
1275    if base.is_empty() {
1276        return Ok(vec![]);
1277    }
1278
1279    // 3. Files touched by the ticket branch since the merge-base (includes newly
1280    //    added files, which appear as untracked in the target if leaked).
1281    let diff_out = Command::new("git")
1282        .args(["diff", "--name-only", &base, ticket_branch])
1283        .current_dir(root)
1284        .output()?;
1285    let ticket_files: std::collections::HashSet<String> =
1286        String::from_utf8_lossy(&diff_out.stdout)
1287            .lines()
1288            .map(|s| s.to_string())
1289            .collect();
1290
1291    // 4. Dirty files in the target worktree.
1292    //    Porcelain v1 format: "XY <path>" -- path starts at column 3.
1293    //    "??" (untracked) entries are intentionally included: a file added by the
1294    //    ticket branch that sits untracked in the target is a genuine leak signal.
1295    //    "R " and "C " (staged rename/copy) entries are skipped: their line format
1296    //    is "XY orig -> dest", so col-3 slicing produces "orig -> dest", not a
1297    //    matchable path.  Known limitation: leaks of staged-renamed files are not
1298    //    detected.
1299    let status_out = Command::new("git")
1300        .args(["status", "--porcelain", "--untracked-files=all"])
1301        .current_dir(&merge_dir)
1302        .output()?;
1303    let dirty_files: std::collections::HashSet<String> =
1304        String::from_utf8_lossy(&status_out.stdout)
1305            .lines()
1306            .filter_map(|line| {
1307                if line.len() < 3 {
1308                    return None;
1309                }
1310                let x = line.as_bytes()[0] as char;
1311                let y = line.as_bytes()[1] as char;
1312                // Skip rename/copy entries: cannot be parsed with a simple col-3 slice.
1313                if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
1314                    return None;
1315                }
1316                Some(line[3..].to_string())
1317            })
1318            .collect();
1319
1320    // 5. Intersection, sorted for stable output.
1321    let mut overlap: Vec<String> = ticket_files
1322        .intersection(&dirty_files)
1323        .cloned()
1324        .collect();
1325    overlap.sort();
1326    Ok(overlap)
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::*;
1332    use std::process::Command as Cmd;
1333    use tempfile::TempDir;
1334
1335    fn git_init() -> TempDir {
1336        let dir = tempfile::tempdir().unwrap();
1337        let p = dir.path();
1338        Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
1339        Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
1340        Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
1341        dir
1342    }
1343
1344    fn git_cmd(dir: &Path, args: &[&str]) {
1345        Cmd::new("git")
1346            .args(args)
1347            .current_dir(dir)
1348            .env("GIT_AUTHOR_NAME", "test")
1349            .env("GIT_AUTHOR_EMAIL", "t@t.com")
1350            .env("GIT_COMMITTER_NAME", "test")
1351            .env("GIT_COMMITTER_EMAIL", "t@t.com")
1352            .status()
1353            .unwrap();
1354    }
1355
1356    fn make_commit(dir: &Path, filename: &str, content: &str) {
1357        std::fs::write(dir.join(filename), content).unwrap();
1358        git_cmd(dir, &["add", filename]);
1359        git_cmd(dir, &["commit", "-m", "init"]);
1360    }
1361
1362    #[test]
1363    fn is_worktree_dirty_clean() {
1364        let dir = git_init();
1365        make_commit(dir.path(), "f.txt", "hi");
1366        assert!(!is_worktree_dirty(dir.path()));
1367    }
1368
1369    #[test]
1370    fn is_worktree_dirty_dirty() {
1371        let dir = git_init();
1372        make_commit(dir.path(), "f.txt", "hi");
1373        std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
1374        assert!(is_worktree_dirty(dir.path()));
1375    }
1376
1377    #[test]
1378    fn local_branch_exists_present_and_absent() {
1379        let dir = git_init();
1380        make_commit(dir.path(), "f.txt", "hi");
1381        let on_main = local_branch_exists(dir.path(), "main");
1382        let on_master = local_branch_exists(dir.path(), "master");
1383        assert!(on_main || on_master);
1384        assert!(!local_branch_exists(dir.path(), "no-such-branch"));
1385    }
1386
1387    #[test]
1388    fn delete_local_branch_success() {
1389        let dir = git_init();
1390        make_commit(dir.path(), "f.txt", "hi");
1391        git_cmd(dir.path(), &["branch", "to-delete"]);
1392        let mut warnings = Vec::new();
1393        delete_local_branch(dir.path(), "to-delete", &mut warnings);
1394        assert!(warnings.is_empty());
1395        assert!(!local_branch_exists(dir.path(), "to-delete"));
1396    }
1397
1398    #[test]
1399    fn delete_local_branch_failure_adds_warning() {
1400        let dir = git_init();
1401        make_commit(dir.path(), "f.txt", "hi");
1402        let mut warnings = Vec::new();
1403        delete_local_branch(dir.path(), "nonexistent", &mut warnings);
1404        assert!(!warnings.is_empty());
1405        assert!(warnings[0].contains("warning:"));
1406    }
1407
1408    #[test]
1409    fn prune_remote_tracking_no_panic() {
1410        let dir = git_init();
1411        make_commit(dir.path(), "f.txt", "hi");
1412        // Just verify it doesn't panic even when the remote ref doesn't exist.
1413        prune_remote_tracking(dir.path(), "nonexistent-branch");
1414    }
1415
1416    #[test]
1417    fn stage_files_ok_and_err() {
1418        let dir = git_init();
1419        make_commit(dir.path(), "f.txt", "hi");
1420        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1421        assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
1422        assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
1423    }
1424
1425    #[test]
1426    fn commit_ok_and_err() {
1427        let dir = git_init();
1428        make_commit(dir.path(), "f.txt", "hi");
1429        std::fs::write(dir.path().join("new.txt"), "new").unwrap();
1430        git_cmd(dir.path(), &["add", "new.txt"]);
1431        assert!(commit(dir.path(), "test commit").is_ok());
1432        // Nothing staged — should fail
1433        assert!(commit(dir.path(), "empty commit").is_err());
1434    }
1435
1436    #[test]
1437    fn git_config_get_some_and_none() {
1438        let dir = git_init();
1439        make_commit(dir.path(), "f.txt", "hi");
1440        let val = git_config_get(dir.path(), "user.email");
1441        assert_eq!(val, Some("t@t.com".to_string()));
1442        let missing = git_config_get(dir.path(), "no.such.key");
1443        assert!(missing.is_none());
1444    }
1445
1446    #[test]
1447    fn merge_ref_already_up_to_date() {
1448        let dir = git_init();
1449        make_commit(dir.path(), "f.txt", "hi");
1450        let branch = {
1451            let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
1452            String::from_utf8_lossy(&out.stdout).trim().to_string()
1453        };
1454        let mut warnings = Vec::new();
1455        // Merging current branch into itself is already up to date
1456        let result = merge_ref(dir.path(), &branch, &mut warnings);
1457        assert!(result.is_none());
1458        assert!(warnings.is_empty());
1459    }
1460
1461    #[test]
1462    fn merge_ref_success() {
1463        let dir = git_init();
1464        make_commit(dir.path(), "f.txt", "hi");
1465        git_cmd(dir.path(), &["checkout", "-b", "feature"]);
1466        make_commit(dir.path(), "g.txt", "there");
1467        git_cmd(dir.path(), &["checkout", "main"]);
1468        let mut warnings = Vec::new();
1469        let result = merge_ref(dir.path(), "feature", &mut warnings);
1470        assert!(result.is_some());
1471        assert!(warnings.is_empty());
1472    }
1473
1474    #[test]
1475    fn detect_mid_merge_none_on_clean_repo() {
1476        let dir = git_init();
1477        make_commit(dir.path(), "f.txt", "hi");
1478        assert!(detect_mid_merge_state(dir.path()).is_none());
1479    }
1480
1481    #[test]
1482    fn detect_mid_merge_on_merge_head() {
1483        let dir = git_init();
1484        make_commit(dir.path(), "f.txt", "hi");
1485        std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
1486        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
1487    }
1488
1489    #[test]
1490    fn detect_mid_merge_on_rebase_merge() {
1491        let dir = git_init();
1492        make_commit(dir.path(), "f.txt", "hi");
1493        std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
1494        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
1495    }
1496
1497    #[test]
1498    fn detect_mid_merge_on_rebase_apply() {
1499        let dir = git_init();
1500        make_commit(dir.path(), "f.txt", "hi");
1501        std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
1502        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
1503    }
1504
1505    #[test]
1506    fn detect_mid_merge_on_cherry_pick() {
1507        let dir = git_init();
1508        make_commit(dir.path(), "f.txt", "hi");
1509        std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
1510        assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
1511    }
1512
1513    #[test]
1514    fn is_file_tracked_tracked_and_untracked() {
1515        let dir = git_init();
1516        make_commit(dir.path(), "tracked.txt", "hi");
1517        assert!(is_file_tracked(dir.path(), "tracked.txt"));
1518        std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
1519        assert!(!is_file_tracked(dir.path(), "untracked.txt"));
1520    }
1521
1522    #[test]
1523    fn check_leaked_files_detects_overlap() {
1524        let dir = git_init();
1525        let p = dir.path();
1526        std::fs::create_dir_all(p.join("src")).unwrap();
1527        std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1528        git_cmd(p, &["add", "src/foo.rs"]);
1529        git_cmd(p, &["commit", "-m", "add foo"]);
1530
1531        git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
1532        std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1533        git_cmd(p, &["add", "src/foo.rs"]);
1534        git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1535        git_cmd(p, &["checkout", "main"]);
1536
1537        // Simulate a leaked edit on main without committing.
1538        std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
1539
1540        let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
1541        assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
1542    }
1543
1544    #[test]
1545    fn check_leaked_files_no_overlap() {
1546        let dir = git_init();
1547        let p = dir.path();
1548        std::fs::create_dir_all(p.join("src")).unwrap();
1549        std::fs::write(p.join("src/foo.rs"), "original").unwrap();
1550        std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
1551        git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
1552        git_cmd(p, &["commit", "-m", "add foo and bar"]);
1553
1554        // Ticket branch modifies only src/foo.rs.
1555        git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
1556        std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
1557        git_cmd(p, &["add", "src/foo.rs"]);
1558        git_cmd(p, &["commit", "-m", "ticket: change foo"]);
1559        git_cmd(p, &["checkout", "main"]);
1560
1561        // Main has src/bar.rs dirty — not touched by the ticket.
1562        std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
1563
1564        let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
1565        assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
1566    }
1567
1568    #[test]
1569    fn check_leaked_files_detects_untracked_overlap() {
1570        let dir = git_init();
1571        let p = dir.path();
1572        make_commit(p, "existing.rs", "base");
1573
1574        // Ticket branch adds src/new.rs as a new file.
1575        git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
1576        std::fs::create_dir_all(p.join("src")).unwrap();
1577        std::fs::write(p.join("src/new.rs"), "new file").unwrap();
1578        git_cmd(p, &["add", "src/new.rs"]);
1579        git_cmd(p, &["commit", "-m", "ticket: add new file"]);
1580        git_cmd(p, &["checkout", "main"]);
1581
1582        // Leak: src/new.rs dropped untracked on main.
1583        std::fs::create_dir_all(p.join("src")).unwrap();
1584        std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
1585
1586        let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
1587        assert_eq!(leaked, vec!["src/new.rs".to_string()]);
1588    }
1589
1590    /// Regression test: a local ticket branch regular-merged into local
1591    /// main, with the remote-tracking ref deleted (e.g. GitHub auto-delete
1592    /// after merge), must still appear in `merged_into_main`'s result.
1593    /// Previously the squash-merge detector skipped it because its tip is
1594    /// already an ancestor, while `git branch -r --merged` could not see it
1595    /// (the origin/ticket/* ref no longer exists).
1596    #[test]
1597    fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
1598        let dir = git_init();
1599        let p = dir.path();
1600        make_commit(p, "f.txt", "base");
1601
1602        // Create a ticket branch and a commit on it.
1603        git_cmd(p, &["checkout", "-b", "ticket/foo"]);
1604        std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
1605        git_cmd(p, &["add", "f.txt"]);
1606        git_cmd(p, &["commit", "-m", "ticket: change"]);
1607
1608        // Merge ticket/foo into main with --no-ff (regular merge, leaves a merge commit).
1609        git_cmd(p, &["checkout", "main"]);
1610        git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
1611
1612        // Simulate that origin/main has been updated to match local main, but
1613        // origin/ticket/foo was auto-deleted (no such remote-tracking ref).
1614        let main_sha = run(p, &["rev-parse", "main"]).unwrap();
1615        Cmd::new("git")
1616            .args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
1617            .current_dir(p)
1618            .status()
1619            .unwrap();
1620        // No origin/ticket/foo ref is created — this is the auto-delete case.
1621
1622        let merged = merged_into_main(p, "main").unwrap();
1623        assert!(
1624            merged.iter().any(|b| b == "ticket/foo"),
1625            "expected ticket/foo in merged set; got {merged:?}"
1626        );
1627    }
1628}