Skip to main content

chant/
git_ops.rs

1//! Low-level git operations and wrappers.
2//!
3//! This module provides pure git command wrappers without dependencies on
4//! spec, config, or operations modules. For high-level merge orchestration,
5//! see the `git` module.
6
7use anyhow::{Context, Result};
8use std::fmt;
9use std::process::Command;
10
11/// Run a git command with arguments and return stdout on success.
12///
13/// # Errors
14///
15/// Returns an error if the command fails to execute or exits with non-zero status.
16fn run_git(args: &[&str]) -> Result<String> {
17    let output = Command::new("git")
18        .args(args)
19        .output()
20        .context(format!("Failed to run git {}", args.join(" ")))?;
21
22    if !output.status.success() {
23        let stderr = String::from_utf8_lossy(&output.stderr);
24        anyhow::bail!("git {} failed: {}", args.join(" "), stderr);
25    }
26
27    Ok(String::from_utf8_lossy(&output.stdout).to_string())
28}
29
30/// Get a git config value by key.
31///
32/// Returns `Some(value)` if the config key exists and has a non-empty value,
33/// `None` otherwise.
34pub fn get_git_config(key: &str) -> Option<String> {
35    let output = Command::new("git").args(["config", key]).output().ok()?;
36
37    if !output.status.success() {
38        return None;
39    }
40
41    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
42    if value.is_empty() {
43        None
44    } else {
45        Some(value)
46    }
47}
48
49/// Get git user name and email from config.
50///
51/// Returns a tuple of (user.name, user.email), where each is `Some` if configured.
52pub fn get_git_user_info() -> (Option<String>, Option<String>) {
53    (get_git_config("user.name"), get_git_config("user.email"))
54}
55
56/// Get the current branch name.
57/// Returns the branch name for the current HEAD, including "HEAD" for detached HEAD state.
58pub fn get_current_branch() -> Result<String> {
59    let branch = run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
60    Ok(branch.trim().to_string())
61}
62
63/// Check if a branch exists in the repository.
64pub fn branch_exists(branch_name: &str) -> Result<bool> {
65    let stdout = run_git(&["branch", "--list", branch_name])?;
66    Ok(!stdout.trim().is_empty())
67}
68
69/// Check if a branch has been merged into a target branch.
70///
71/// # Arguments
72/// * `branch_name` - The branch to check
73/// * `target_branch` - The target branch to check against (e.g., "main")
74///
75/// # Returns
76/// * `Ok(true)` if the branch has been merged into the target
77/// * `Ok(false)` if the branch exists but hasn't been merged
78/// * `Err(_)` if git operations fail
79pub fn is_branch_merged(branch_name: &str, target_branch: &str) -> Result<bool> {
80    // Use git branch --merged to check if the branch is in the list of merged branches
81    let stdout = run_git(&["branch", "--merged", target_branch, "--list", branch_name])?;
82    Ok(!stdout.trim().is_empty())
83}
84
85/// Checkout a specific branch or commit.
86/// If branch is "HEAD", it's a detached HEAD checkout.
87pub fn checkout_branch(branch: &str, dry_run: bool) -> Result<()> {
88    if dry_run {
89        return Ok(());
90    }
91
92    run_git(&["checkout", branch]).with_context(|| format!("Failed to checkout {}", branch))?;
93
94    Ok(())
95}
96
97/// Check if branches have diverged (i.e., fast-forward is not possible).
98///
99/// Returns true if branches have diverged (fast-forward not possible).
100/// Returns false if a fast-forward merge is possible.
101///
102/// Fast-forward is possible when HEAD is an ancestor of or equal to spec_branch.
103/// Branches have diverged when HEAD has commits not in spec_branch.
104pub fn branches_have_diverged(spec_branch: &str) -> Result<bool> {
105    let output = Command::new("git")
106        .args(["merge-base", "--is-ancestor", "HEAD", spec_branch])
107        .output()
108        .context("Failed to check if branches have diverged")?;
109
110    // merge-base --is-ancestor returns 0 if HEAD is ancestor of spec_branch (fast-forward possible)
111    // Returns non-zero if HEAD is not ancestor of spec_branch (branches have diverged)
112    Ok(!output.status.success())
113}
114
115/// Result of a merge attempt with conflict details.
116#[derive(Debug)]
117pub struct MergeAttemptResult {
118    /// Whether merge succeeded
119    pub success: bool,
120    /// Type of conflict if any
121    pub conflict_type: Option<ConflictType>,
122    /// Files with conflicts if any
123    pub conflicting_files: Vec<String>,
124    /// Git stderr output
125    pub stderr: String,
126}
127
128/// Type of merge conflict encountered.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum ConflictType {
131    /// Content conflict in file(s)
132    Content,
133    /// Fast-forward only merge failed due to diverged branches
134    FastForward,
135    /// Tree conflict (file vs directory, rename conflicts, etc)
136    Tree,
137    /// Unknown or unclassified conflict
138    Unknown,
139}
140
141impl fmt::Display for ConflictType {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            ConflictType::Content => write!(f, "content"),
145            ConflictType::FastForward => write!(f, "fast-forward"),
146            ConflictType::Tree => write!(f, "tree"),
147            ConflictType::Unknown => write!(f, "unknown"),
148        }
149    }
150}
151
152/// Merge a branch using appropriate strategy based on divergence.
153///
154/// Strategy:
155/// 1. Check if branches have diverged
156/// 2. If diverged: Use --no-ff to create a merge commit
157/// 3. If clean fast-forward possible: Use fast-forward merge
158/// 4. If conflicts exist: Return details about the conflict
159///
160/// Returns MergeAttemptResult with success status and conflict details.
161pub fn merge_branch_ff_only(spec_branch: &str, dry_run: bool) -> Result<MergeAttemptResult> {
162    if dry_run {
163        return Ok(MergeAttemptResult {
164            success: true,
165            conflict_type: None,
166            conflicting_files: vec![],
167            stderr: String::new(),
168        });
169    }
170
171    // Check if branches have diverged
172    let diverged = branches_have_diverged(spec_branch)?;
173
174    let merge_message = format!("Merge {}", spec_branch);
175
176    let mut cmd = Command::new("git");
177    if diverged {
178        // Branches have diverged - use --no-ff to create a merge commit
179        cmd.args(["merge", "--no-ff", spec_branch, "-m", &merge_message]);
180    } else {
181        // Can do fast-forward merge
182        cmd.args(["merge", "--ff-only", spec_branch]);
183    }
184
185    let output = cmd.output().context("Failed to run git merge")?;
186
187    if !output.status.success() {
188        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
189
190        // Get git status to classify conflict and find files
191        let status_output = Command::new("git")
192            .args(["status", "--porcelain"])
193            .output()
194            .ok()
195            .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
196
197        let conflict_type = classify_conflict_type(&stderr, status_output.as_deref());
198
199        let conflicting_files = status_output
200            .as_deref()
201            .map(parse_conflicting_files)
202            .unwrap_or_default();
203
204        // Abort the merge to restore clean state
205        let _ = Command::new("git").args(["merge", "--abort"]).output();
206
207        return Ok(MergeAttemptResult {
208            success: false,
209            conflict_type: Some(conflict_type),
210            conflicting_files,
211            stderr,
212        });
213    }
214
215    Ok(MergeAttemptResult {
216        success: true,
217        conflict_type: None,
218        conflicting_files: vec![],
219        stderr: String::new(),
220    })
221}
222
223/// Classify the type of merge conflict from stderr and status output.
224pub fn classify_conflict_type(stderr: &str, status_output: Option<&str>) -> ConflictType {
225    let stderr_lower = stderr.to_lowercase();
226
227    if stderr_lower.contains("not possible to fast-forward")
228        || stderr_lower.contains("cannot fast-forward")
229        || stderr_lower.contains("refusing to merge unrelated histories")
230    {
231        return ConflictType::FastForward;
232    }
233
234    if stderr_lower.contains("conflict (rename/delete)")
235        || stderr_lower.contains("conflict (modify/delete)")
236        || stderr_lower.contains("deleted in")
237        || stderr_lower.contains("renamed in")
238        || stderr_lower.contains("conflict (add/add)")
239    {
240        return ConflictType::Tree;
241    }
242
243    if let Some(status) = status_output {
244        if status.lines().any(|line| {
245            let prefix = line.get(..2).unwrap_or("");
246            matches!(prefix, "DD" | "AU" | "UD" | "UA" | "DU")
247        }) {
248            return ConflictType::Tree;
249        }
250
251        if status.lines().any(|line| {
252            let prefix = line.get(..2).unwrap_or("");
253            matches!(prefix, "UU" | "AA")
254        }) {
255            return ConflictType::Content;
256        }
257    }
258
259    if stderr_lower.contains("conflict") || stderr_lower.contains("merge conflict") {
260        return ConflictType::Content;
261    }
262
263    ConflictType::Unknown
264}
265
266/// Parse conflicting files from git status --porcelain output.
267pub fn parse_conflicting_files(status_output: &str) -> Vec<String> {
268    let mut files = Vec::new();
269
270    for line in status_output.lines() {
271        if line.len() >= 3 {
272            let status = &line[0..2];
273            // Conflict markers: UU, AA, DD, AU, UD, UA, DU
274            if status.contains('U') || status == "AA" || status == "DD" {
275                let file = line[3..].trim();
276                files.push(file.to_string());
277            }
278        }
279    }
280
281    files
282}
283
284/// Remove all worktrees associated with a branch.
285/// This is idempotent and won't fail if no worktrees exist.
286pub fn remove_worktrees_for_branch(branch_name: &str) -> Result<()> {
287    // List all worktrees
288    let output = Command::new("git")
289        .args(["worktree", "list", "--porcelain"])
290        .output()
291        .context("Failed to list worktrees")?;
292
293    if !output.status.success() {
294        // If git worktree list fails, just continue (maybe not a worktree-enabled repo)
295        return Ok(());
296    }
297
298    let worktree_list = String::from_utf8_lossy(&output.stdout);
299    let mut current_path: Option<String> = None;
300    let mut worktrees_to_remove = Vec::new();
301
302    // Parse the porcelain output to find worktrees for this branch
303    for line in worktree_list.lines() {
304        if line.starts_with("worktree ") {
305            current_path = Some(line.trim_start_matches("worktree ").to_string());
306        } else if line.starts_with("branch ") {
307            let branch = line
308                .trim_start_matches("branch ")
309                .trim_start_matches("refs/heads/");
310            if branch == branch_name {
311                if let Some(path) = current_path.take() {
312                    worktrees_to_remove.push(path);
313                }
314            }
315        }
316    }
317
318    // Remove each worktree associated with this branch
319    for path in worktrees_to_remove {
320        // Try with --force to handle any uncommitted changes
321        let _ = Command::new("git")
322            .args(["worktree", "remove", &path, "--force"])
323            .output();
324
325        // Also try to remove the directory if it still exists (in case git worktree remove failed)
326        let _ = std::fs::remove_dir_all(&path);
327    }
328
329    Ok(())
330}
331
332/// Delete a branch, removing associated worktrees first.
333/// Returns Ok(()) on success, or an error if deletion fails.
334pub fn delete_branch(branch_name: &str, dry_run: bool) -> Result<()> {
335    if dry_run {
336        return Ok(());
337    }
338
339    // Remove any worktrees associated with this branch before deleting it
340    remove_worktrees_for_branch(branch_name)?;
341
342    run_git(&["branch", "-d", branch_name])
343        .with_context(|| format!("Failed to delete branch {}", branch_name))?;
344
345    Ok(())
346}
347
348/// Result of a rebase operation
349#[derive(Debug)]
350pub struct RebaseResult {
351    /// Whether rebase succeeded
352    pub success: bool,
353    /// Files with conflicts (if any)
354    pub conflicting_files: Vec<String>,
355}
356
357/// Rebase a branch onto another branch.
358/// Returns RebaseResult with success status and any conflicting files.
359pub fn rebase_branch(spec_branch: &str, onto_branch: &str) -> Result<RebaseResult> {
360    // First checkout the spec branch
361    checkout_branch(spec_branch, false)?;
362
363    // Attempt rebase
364    let output = Command::new("git")
365        .args(["rebase", onto_branch])
366        .output()
367        .context("Failed to run git rebase")?;
368
369    if output.status.success() {
370        return Ok(RebaseResult {
371            success: true,
372            conflicting_files: vec![],
373        });
374    }
375
376    // Rebase failed - check for conflicts
377    let stderr = String::from_utf8_lossy(&output.stderr);
378    if stderr.contains("CONFLICT") || stderr.contains("conflict") {
379        // Get list of conflicting files
380        let conflicting_files = get_conflicting_files()?;
381
382        // Abort rebase to restore clean state
383        let _ = Command::new("git").args(["rebase", "--abort"]).output();
384
385        return Ok(RebaseResult {
386            success: false,
387            conflicting_files,
388        });
389    }
390
391    // Other rebase error
392    let _ = Command::new("git").args(["rebase", "--abort"]).output();
393    anyhow::bail!("Rebase failed: {}", stderr);
394}
395
396/// Get list of files with conflicts from git status
397pub fn get_conflicting_files() -> Result<Vec<String>> {
398    let output = Command::new("git")
399        .args(["status", "--porcelain"])
400        .output()
401        .context("Failed to run git status")?;
402
403    let stdout = String::from_utf8_lossy(&output.stdout);
404    let mut files = Vec::new();
405
406    for line in stdout.lines() {
407        // Conflict markers: UU, AA, DD, AU, UD, UA, DU
408        if line.len() >= 3 {
409            let status = &line[0..2];
410            if status.contains('U') || status == "AA" || status == "DD" {
411                let file = line[3..].trim();
412                files.push(file.to_string());
413            }
414        }
415    }
416
417    Ok(files)
418}
419
420/// Continue a rebase after conflicts have been resolved
421pub fn rebase_continue() -> Result<bool> {
422    let output = Command::new("git")
423        .args(["rebase", "--continue"])
424        .env("GIT_EDITOR", "true") // Skip editor for commit message
425        .output()
426        .context("Failed to run git rebase --continue")?;
427
428    Ok(output.status.success())
429}
430
431/// Abort an in-progress rebase
432pub fn rebase_abort() -> Result<()> {
433    let _ = Command::new("git").args(["rebase", "--abort"]).output();
434    Ok(())
435}
436
437/// Stage a file for commit
438pub fn stage_file(file_path: &str) -> Result<()> {
439    run_git(&["add", file_path]).with_context(|| format!("Failed to stage file {}", file_path))?;
440    Ok(())
441}
442
443/// Check if branch can be fast-forward merged into target branch.
444/// Returns true if the merge can be done as a fast-forward (no divergence).
445pub fn can_fast_forward_merge(branch: &str, target: &str) -> Result<bool> {
446    // Get merge base between branch and target
447    let output = Command::new("git")
448        .args(["merge-base", target, branch])
449        .output()
450        .context("Failed to find merge base")?;
451
452    if !output.status.success() {
453        return Ok(false);
454    }
455
456    let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
457
458    // Get the commit hash of target
459    let output = Command::new("git")
460        .args(["rev-parse", target])
461        .output()
462        .context("Failed to get target commit")?;
463
464    if !output.status.success() {
465        return Ok(false);
466    }
467
468    let target_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
469
470    // If merge base equals target, then branch is ahead and can ff-merge
471    Ok(merge_base == target_commit)
472}
473
474/// Check if branch is behind target branch.
475/// Returns true if target has commits that branch doesn't have.
476pub fn is_branch_behind(branch: &str, target: &str) -> Result<bool> {
477    // Get merge base
478    let output = Command::new("git")
479        .args(["merge-base", branch, target])
480        .output()
481        .context("Failed to find merge base")?;
482
483    if !output.status.success() {
484        return Ok(false);
485    }
486
487    let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
488
489    // Get branch commit
490    let output = Command::new("git")
491        .args(["rev-parse", branch])
492        .output()
493        .context("Failed to get branch commit")?;
494
495    if !output.status.success() {
496        return Ok(false);
497    }
498
499    let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
500
501    // If merge base equals branch commit, then branch is behind target
502    Ok(merge_base == branch_commit)
503}
504
505/// Count number of commits in branch.
506pub fn count_commits(branch: &str) -> Result<usize> {
507    let output = Command::new("git")
508        .args(["rev-list", "--count", branch])
509        .output()
510        .context("Failed to count commits")?;
511
512    if !output.status.success() {
513        return Ok(0);
514    }
515
516    let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
517    Ok(count_str.parse().unwrap_or(0))
518}
519
520/// Information about a single git commit.
521#[derive(Debug, Clone)]
522pub struct CommitInfo {
523    pub hash: String,
524    pub message: String,
525    pub author: String,
526    pub timestamp: i64,
527}
528
529/// Get commits in a range between two refs.
530///
531/// Returns commits between `from_ref` and `to_ref` (inclusive of `to_ref`, exclusive of `from_ref`).
532/// Uses `git log from_ref..to_ref` format.
533///
534/// # Errors
535/// Returns error if refs are invalid or git command fails.
536pub fn get_commits_in_range(from_ref: &str, to_ref: &str) -> Result<Vec<CommitInfo>> {
537    let range = format!("{}..{}", from_ref, to_ref);
538
539    let output = Command::new("git")
540        .args(["log", &range, "--format=%H|%an|%at|%s", "--reverse"])
541        .output()
542        .context("Failed to execute git log")?;
543
544    if !output.status.success() {
545        let stderr = String::from_utf8_lossy(&output.stderr);
546        anyhow::bail!("Invalid git refs {}: {}", range, stderr);
547    }
548
549    let stdout = String::from_utf8_lossy(&output.stdout);
550    let mut commits = Vec::new();
551
552    for line in stdout.lines() {
553        if line.is_empty() {
554            continue;
555        }
556
557        let parts: Vec<&str> = line.splitn(4, '|').collect();
558        if parts.len() != 4 {
559            continue;
560        }
561
562        commits.push(CommitInfo {
563            hash: parts[0].to_string(),
564            author: parts[1].to_string(),
565            timestamp: parts[2].parse().unwrap_or(0),
566            message: parts[3].to_string(),
567        });
568    }
569
570    Ok(commits)
571}
572
573/// Get files changed in a specific commit.
574///
575/// Returns a list of file paths that were modified in the commit.
576///
577/// # Errors
578/// Returns error if commit hash is invalid or git command fails.
579pub fn get_commit_changed_files(hash: &str) -> Result<Vec<String>> {
580    let output = Command::new("git")
581        .args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
582        .output()
583        .context("Failed to execute git diff-tree")?;
584
585    if !output.status.success() {
586        let stderr = String::from_utf8_lossy(&output.stderr);
587        anyhow::bail!("Invalid commit hash {}: {}", hash, stderr);
588    }
589
590    let stdout = String::from_utf8_lossy(&output.stdout);
591    let files: Vec<String> = stdout
592        .lines()
593        .filter(|line| !line.is_empty())
594        .map(|line| line.to_string())
595        .collect();
596
597    Ok(files)
598}
599
600/// Get files changed in a commit with their status (A/M/D).
601///
602/// Returns a list of strings in the format "STATUS:filename" (e.g., "A:file.txt", "M:file.txt").
603///
604/// # Errors
605/// Returns error if commit hash is invalid or git command fails.
606pub fn get_commit_files_with_status(hash: &str) -> Result<Vec<String>> {
607    let output = Command::new("git")
608        .args(["diff-tree", "--no-commit-id", "--name-status", "-r", hash])
609        .output()
610        .context("Failed to execute git diff-tree")?;
611
612    if !output.status.success() {
613        return Ok(Vec::new());
614    }
615
616    let stdout = String::from_utf8_lossy(&output.stdout);
617    let mut files = Vec::new();
618
619    for line in stdout.lines() {
620        let parts: Vec<&str> = line.split('\t').collect();
621        if parts.len() >= 2 {
622            // parts[0] is status (A, M, D), parts[1] is filename
623            files.push(format!("{}:{}", parts[0], parts[1]));
624        }
625    }
626
627    Ok(files)
628}
629
630/// Get file content at a specific commit.
631///
632/// Returns the file content as a string, or an empty string if the file doesn't exist at that commit.
633///
634/// # Errors
635/// Returns error if git command fails.
636pub fn get_file_at_commit(commit: &str, file: &str) -> Result<String> {
637    let output = Command::new("git")
638        .args(["show", &format!("{}:{}", commit, file)])
639        .output()
640        .context("Failed to get file at commit")?;
641
642    if !output.status.success() {
643        return Ok(String::new());
644    }
645
646    Ok(String::from_utf8_lossy(&output.stdout).to_string())
647}
648
649/// Get file content at parent commit.
650///
651/// Returns the file content as a string, or an empty string if the file doesn't exist at parent commit.
652///
653/// # Errors
654/// Returns error if git command fails.
655pub fn get_file_at_parent(commit: &str, file: &str) -> Result<String> {
656    let output = Command::new("git")
657        .args(["show", &format!("{}^:{}", commit, file)])
658        .output()
659        .context("Failed to get file at parent")?;
660
661    if !output.status.success() {
662        return Ok(String::new());
663    }
664
665    Ok(String::from_utf8_lossy(&output.stdout).to_string())
666}
667
668/// Get the N most recent commits.
669///
670/// # Errors
671/// Returns error if git command fails.
672pub fn get_recent_commits(count: usize) -> Result<Vec<CommitInfo>> {
673    let count_str = count.to_string();
674
675    let output = Command::new("git")
676        .args(["log", "-n", &count_str, "--format=%H|%an|%at|%s"])
677        .output()
678        .context("Failed to execute git log")?;
679
680    if !output.status.success() {
681        let stderr = String::from_utf8_lossy(&output.stderr);
682        anyhow::bail!("Failed to get recent commits: {}", stderr);
683    }
684
685    let stdout = String::from_utf8_lossy(&output.stdout);
686    let mut commits = Vec::new();
687
688    for line in stdout.lines() {
689        if line.is_empty() {
690            continue;
691        }
692
693        let parts: Vec<&str> = line.splitn(4, '|').collect();
694        if parts.len() != 4 {
695            continue;
696        }
697
698        commits.push(CommitInfo {
699            hash: parts[0].to_string(),
700            author: parts[1].to_string(),
701            timestamp: parts[2].parse().unwrap_or(0),
702            message: parts[3].to_string(),
703        });
704    }
705
706    Ok(commits)
707}
708
709/// Get commits that modified a specific path.
710///
711/// # Arguments
712/// * `path` - File or directory path to filter by
713///
714/// # Errors
715/// Returns error if git command fails.
716pub fn get_commits_for_path(path: &str) -> Result<Vec<CommitInfo>> {
717    let output = Command::new("git")
718        .args(["log", "--all", "--format=%H|%an|%at|%s", "--", path])
719        .output()
720        .context("Failed to execute git log")?;
721
722    if !output.status.success() {
723        let stderr = String::from_utf8_lossy(&output.stderr);
724        anyhow::bail!("git log failed: {}", stderr);
725    }
726
727    let stdout = String::from_utf8_lossy(&output.stdout);
728    let mut commits = Vec::new();
729
730    for line in stdout.lines() {
731        if line.is_empty() {
732            continue;
733        }
734
735        let parts: Vec<&str> = line.splitn(4, '|').collect();
736        if parts.len() != 4 {
737            continue;
738        }
739
740        commits.push(CommitInfo {
741            hash: parts[0].to_string(),
742            author: parts[1].to_string(),
743            timestamp: parts[2].parse().unwrap_or(0),
744            message: parts[3].to_string(),
745        });
746    }
747
748    Ok(commits)
749}