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).map_err(|e| map_checkout_error(branch, e))
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        .map_err(|e| map_checkout_error(branch, e))
60}
61
62/// Turn git's raw "already checked out / used by worktree" failure into a
63/// typed, actionable error. In a worktree setup the same branch can't be
64/// checked out in two places, and git's bare fatal message doesn't tell the
65/// user what to do; `BranchCheckedOutElsewhere` carries the conflicting path
66/// and lets the CLI suggest next steps.
67fn map_checkout_error(branch: &str, err: GwError) -> GwError {
68    let GwError::GitCommandFailed(ref msg) = err else {
69        return err;
70    };
71    if msg.contains("already checked out") || msg.contains("already used by worktree") {
72        return GwError::BranchCheckedOutElsewhere {
73            branch: branch.to_string(),
74            path: extract_worktree_path(msg),
75        };
76    }
77    err
78}
79
80/// Pull the worktree path out of git's message, e.g.
81/// `fatal: 'main' is already checked out at '/path/to/wt'`.
82fn extract_worktree_path(msg: &str) -> Option<String> {
83    let start = msg.find("at '")? + "at '".len();
84    let rest = &msg[start..];
85    let end = rest.find('\'')?;
86    Some(rest[..end].to_string())
87}
88
89/// Pull from a remote branch (fast-forward only, safe)
90///
91/// Returns an error if the pull cannot be done as a fast-forward,
92/// which happens when the local branch has diverged from the remote.
93pub fn pull_ff_only(remote: &str, branch: &str, verbose: bool) -> Result<()> {
94    git_run(&["pull", remote, branch, "--ff-only", "--quiet"], verbose)
95}
96
97/// Force-move a local branch ref to `target` (`git branch -f`).
98///
99/// Git itself refuses when `branch` is checked out in ANY worktree, so this
100/// can never yank a working tree out from under anyone; callers use it to
101/// fast-forward a branch that is not checked out (see
102/// `helpers::fast_forward_home_ref`).
103pub fn force_update_branch(branch: &str, target: &str, verbose: bool) -> Result<()> {
104    git_run(&["branch", "-f", branch, target], verbose)
105}
106
107/// Delete a local branch (safe delete, requires merge)
108pub fn delete_branch(branch: &str, verbose: bool) -> Result<()> {
109    git_run(&["branch", "-d", branch], verbose)
110}
111
112/// Force delete a local branch
113pub fn force_delete_branch(branch: &str, verbose: bool) -> Result<()> {
114    git_run(&["branch", "-D", branch], verbose)
115}
116
117/// Delete a remote branch
118#[allow(dead_code)]
119pub fn delete_remote_branch(branch: &str, verbose: bool) -> Result<()> {
120    git_run(&["push", "origin", "--delete", branch], verbose)
121}
122
123/// Get commits that are in `to` but not in `from`
124pub fn log_commits(from: &str, to: &str, verbose: bool) -> Result<Vec<String>> {
125    let output = git_output(&["log", &format!("{from}..{to}"), "--oneline"], verbose)?;
126    Ok(output.lines().map(String::from).collect())
127}
128
129/// Stage all changes (including untracked files)
130pub fn add_all(verbose: bool) -> Result<()> {
131    git_run(&["add", "-A"], verbose)
132}
133
134/// Create a commit with the given message
135pub fn commit(message: &str, verbose: bool) -> Result<()> {
136    git_run(&["commit", "-m", message], verbose)
137}
138
139/// Soft reset to target (keeps changes in working directory as staged)
140pub fn reset_soft(target: &str, verbose: bool) -> Result<()> {
141    git_run(&["reset", "--soft", target], verbose)
142}
143
144/// Discard all uncommitted changes (both staged and unstaged, including untracked files)
145pub fn discard_all_changes(verbose: bool) -> Result<()> {
146    // Reset staged changes
147    git_run(&["reset", "--hard", "HEAD"], verbose)?;
148    // Remove untracked files and directories
149    git_run(&["clean", "-fd"], verbose)
150}
151
152/// Rebase current branch onto a target
153pub fn rebase(target: &str, verbose: bool) -> Result<()> {
154    git_run(&["rebase", target], verbose)
155}
156
157/// Rebase the current branch onto `new_base`, replaying only the commits after
158/// `old_base` (`git rebase --onto <new_base> <old_base>`).
159///
160/// Required for stacked PRs after the base PR merged: a plain
161/// `git rebase <new_base>` would replay the base's commits too — doubled and
162/// conflict-prone, especially after a squash merge. `--onto` replays only
163/// `old_base..HEAD`, i.e. this branch's own commits.
164pub fn rebase_onto(new_base: &str, old_base: &str, verbose: bool) -> Result<()> {
165    git_run(&["rebase", "--onto", new_base, old_base], verbose)
166}
167
168/// Force push with lease (safer than --force)
169pub fn force_push_with_lease(branch: &str, verbose: bool) -> Result<()> {
170    git_run(&["push", "--force-with-lease", "origin", branch], verbose)
171}
172
173/// Record the base branch a branch is stacked on (`branch.<name>.gwBase`).
174///
175/// Lets the workflow know a branch is stacked before its PR exists, so
176/// `gw status` can suggest `gh pr create -B <base>`. Git drops the whole
177/// `[branch "<name>"]` section when the branch is deleted, so this needs no
178/// explicit cleanup on `gw cleanup`.
179pub fn set_branch_base(branch: &str, base: &str, verbose: bool) -> Result<()> {
180    git_run(
181        &["config", &format!("branch.{branch}.gwBase"), base],
182        verbose,
183    )
184}
185
186/// Record the base tip SHA a branch was stacked on (`branch.<name>.gwBaseSha`).
187///
188/// A `git rebase --onto` boundary that survives the base branch being deleted.
189pub fn set_branch_base_sha(branch: &str, sha: &str, verbose: bool) -> Result<()> {
190    git_run(
191        &["config", &format!("branch.{branch}.gwBaseSha"), sha],
192        verbose,
193    )
194}
195
196/// Clear a branch's recorded base info (`branch.<name>.gwBase` and `.gwBaseSha`).
197///
198/// Each unset is a no-op (not an error) when the key is absent, so callers can
199/// clear unconditionally — e.g. `gw sync` after restacking a branch onto the
200/// default branch, where it is no longer stacked.
201pub fn unset_branch_base(branch: &str, verbose: bool) -> Result<()> {
202    unset_config_key(&format!("branch.{branch}.gwBase"), verbose)?;
203    unset_config_key(&format!("branch.{branch}.gwBaseSha"), verbose)
204}
205
206/// `git config --unset <key>`, treating "key absent" (exit 5) as success.
207fn unset_config_key(key: &str, verbose: bool) -> Result<()> {
208    if verbose {
209        output::action(&format!("git config --unset {key}"));
210    }
211    let output = Command::new("git")
212        .args(["config", "--unset", key])
213        .output()
214        .map_err(|e| GwError::GitCommandFailed(format!("Failed to execute git: {e}")))?;
215    // Exit code 5 = "key was not present"; treat as already-clear.
216    match output.status.code() {
217        Some(0) | Some(5) => Ok(()),
218        _ => {
219            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
220            Err(GwError::GitCommandFailed(stderr))
221        }
222    }
223}
224
225/// Add a new worktree at the given path with a new branch from a start point
226pub fn worktree_add(path: &str, branch: &str, start_point: &str, verbose: bool) -> Result<()> {
227    git_run(
228        &["worktree", "add", "-b", branch, path, start_point],
229        verbose,
230    )
231}
232
233/// Remove a worktree (with --force)
234pub fn worktree_remove(path: &str, verbose: bool) -> Result<()> {
235    git_run(&["worktree", "remove", "--force", path], verbose)
236}
237
238/// Prune stale worktree entries
239pub fn worktree_prune(verbose: bool) -> Result<()> {
240    git_run(&["worktree", "prune"], verbose)
241}
242
243/// Execute a git command in a specific directory.
244/// Sets the process working directory (not just `git -C`) so it works
245/// even if the caller's cwd has been deleted.
246pub fn git_run_in_dir(dir: &str, args: &[&str], verbose: bool) -> Result<()> {
247    if verbose {
248        output::action(&format!("git -C {} {}", dir, args.join(" ")));
249    }
250
251    let output = Command::new("git")
252        .args(args)
253        .current_dir(dir)
254        .output()
255        .map_err(|e| GwError::GitCommandFailed(format!("Failed to execute git: {e}")))?;
256
257    if output.status.success() {
258        Ok(())
259    } else {
260        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
261        Err(GwError::GitCommandFailed(stderr))
262    }
263}