Skip to main content

git_workflow/git/
mutation.rs

1//! State-changing git operations
2
3use std::process::Command;
4
5use crate::error::{GwError, Result};
6use crate::output;
7
8/// Execute a git command, showing the command if verbose
9fn git_run(args: &[&str], verbose: bool) -> Result<()> {
10    if verbose {
11        output::action(&format!("git {}", args.join(" ")));
12    }
13
14    let output = Command::new("git")
15        .args(args)
16        .output()
17        .map_err(|e| GwError::GitCommandFailed(format!("Failed to execute git: {e}")))?;
18
19    if output.status.success() {
20        Ok(())
21    } else {
22        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
23        Err(GwError::GitCommandFailed(stderr))
24    }
25}
26
27/// Execute a git command and return stdout
28fn git_output(args: &[&str], verbose: bool) -> Result<String> {
29    if verbose {
30        output::action(&format!("git {}", args.join(" ")));
31    }
32
33    let output = Command::new("git")
34        .args(args)
35        .output()
36        .map_err(|e| GwError::GitCommandFailed(format!("Failed to execute git: {e}")))?;
37
38    if output.status.success() {
39        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
40    } else {
41        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
42        Err(GwError::GitCommandFailed(stderr))
43    }
44}
45
46/// Fetch from origin with prune
47pub fn fetch_prune(verbose: bool) -> Result<()> {
48    git_run(&["fetch", "--prune", "--quiet"], verbose)
49}
50
51/// Checkout an existing branch
52pub fn checkout(branch: &str, verbose: bool) -> Result<()> {
53    git_run(&["checkout", branch, "--quiet"], verbose)
54}
55
56/// Create and checkout a new branch from a starting point
57pub fn checkout_new_branch(branch: &str, start_point: &str, verbose: bool) -> Result<()> {
58    git_run(&["checkout", "-b", branch, start_point, "--quiet"], verbose)
59}
60
61/// Pull from a remote branch (fast-forward only, safe)
62///
63/// Returns an error if the pull cannot be done as a fast-forward,
64/// which happens when the local branch has diverged from the remote.
65pub fn pull_ff_only(remote: &str, branch: &str, verbose: bool) -> Result<()> {
66    git_run(&["pull", remote, branch, "--ff-only", "--quiet"], verbose)
67}
68
69/// Pull from a remote branch
70pub fn pull(remote: &str, branch: &str, verbose: bool) -> Result<()> {
71    // Try ff-only first (safer), then fallback to normal pull
72    let result = git_run(&["pull", remote, branch, "--ff-only", "--quiet"], verbose);
73    if result.is_err() {
74        // If ff-only fails (diverged history), try normal pull
75        git_run(&["pull", remote, branch, "--quiet"], verbose)?;
76    }
77    Ok(())
78}
79
80/// Delete a local branch (safe delete, requires merge)
81pub fn delete_branch(branch: &str, verbose: bool) -> Result<()> {
82    git_run(&["branch", "-d", branch], verbose)
83}
84
85/// Force delete a local branch
86pub fn force_delete_branch(branch: &str, verbose: bool) -> Result<()> {
87    git_run(&["branch", "-D", branch], verbose)
88}
89
90/// Delete a remote branch
91#[allow(dead_code)]
92pub fn delete_remote_branch(branch: &str, verbose: bool) -> Result<()> {
93    git_run(&["push", "origin", "--delete", branch], verbose)
94}
95
96/// Get commits that are in `to` but not in `from`
97pub fn log_commits(from: &str, to: &str, verbose: bool) -> Result<Vec<String>> {
98    let output = git_output(&["log", &format!("{from}..{to}"), "--oneline"], verbose)?;
99    Ok(output.lines().map(String::from).collect())
100}
101
102/// Stage all changes (including untracked files)
103pub fn add_all(verbose: bool) -> Result<()> {
104    git_run(&["add", "-A"], verbose)
105}
106
107/// Create a commit with the given message
108pub fn commit(message: &str, verbose: bool) -> Result<()> {
109    git_run(&["commit", "-m", message], verbose)
110}
111
112/// Soft reset to target (keeps changes in working directory as staged)
113pub fn reset_soft(target: &str, verbose: bool) -> Result<()> {
114    git_run(&["reset", "--soft", target], verbose)
115}
116
117/// Discard all uncommitted changes (both staged and unstaged, including untracked files)
118pub fn discard_all_changes(verbose: bool) -> Result<()> {
119    // Reset staged changes
120    git_run(&["reset", "--hard", "HEAD"], verbose)?;
121    // Remove untracked files and directories
122    git_run(&["clean", "-fd"], verbose)
123}
124
125/// Rebase current branch onto a target
126pub fn rebase(target: &str, verbose: bool) -> Result<()> {
127    git_run(&["rebase", target], verbose)
128}
129
130/// Force push with lease (safer than --force)
131pub fn force_push_with_lease(branch: &str, verbose: bool) -> Result<()> {
132    git_run(&["push", "--force-with-lease", "origin", branch], verbose)
133}