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