Skip to main content

git_stk/
git.rs

1use std::io::Write;
2use std::process::{Command, Stdio};
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use anyhow::{Context, Result, anyhow, bail};
6
7static VERBOSE: AtomicBool = AtomicBool::new(false);
8
9/// Pass raw git output through instead of capturing it.
10pub fn set_verbose(verbose: bool) {
11    VERBOSE.store(verbose, Ordering::Relaxed);
12}
13
14fn verbose() -> bool {
15    VERBOSE.load(Ordering::Relaxed)
16}
17
18pub fn current_branch() -> Result<String> {
19    output(&["symbolic-ref", "--quiet", "--short", "HEAD"])
20        .context("failed to determine current branch")
21}
22
23pub fn local_branches() -> Result<Vec<String>> {
24    let output = output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
25    Ok(output.lines().map(str::to_owned).collect())
26}
27
28pub fn git_path(path: &str) -> Result<String> {
29    output(&["rev-parse", "--git-path", path])
30}
31
32pub fn remote_url(remote: &str) -> Result<Option<String>> {
33    let output = Command::new("git")
34        .args(["remote", "get-url", remote])
35        .stdout(Stdio::piped())
36        .stderr(Stdio::piped())
37        .output()
38        .with_context(|| format!("failed to read git remote {remote}"))?;
39
40    match output.status.code() {
41        Some(0) => Ok(Some(
42            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
43        )),
44        Some(2) => Ok(None),
45        _ => Err(command_error("git remote get-url", &output.stderr)),
46    }
47}
48
49pub fn checkout(branch: &str) -> Result<()> {
50    status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))?;
51    anstream::println!(
52        "switched to {}",
53        crate::style::paint(crate::style::BRANCH, branch)
54    );
55    Ok(())
56}
57
58pub fn create_branch(branch: &str) -> Result<()> {
59    status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
60}
61
62/// Force-delete a branch. Callers are expected to have verified the branch
63/// landed through review state: after a squash merge its commits are not
64/// ancestry-merged, so `git branch -d` can refuse even though the work is in.
65pub fn delete_branch(branch: &str) -> Result<()> {
66    status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
67}
68
69/// Rename a branch; git moves its `branch.<name>.*` config along with it.
70pub fn rename_branch(old: &str, new: &str) -> Result<()> {
71    status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
72}
73
74/// Fast-forward a local branch from its remote without checking it out.
75pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
76    let refspec = format!("{branch}:{branch}");
77    status(&["fetch", remote, &refspec])
78        .with_context(|| format!("failed to fetch {branch} from {remote}"))
79}
80
81pub fn pull_ff_only() -> Result<()> {
82    status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
83}
84
85pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
86    let mut args = vec!["push", "--force-with-lease", remote];
87    args.extend(branches.iter().map(String::as_str));
88
89    status(&args).with_context(|| format!("failed to push branches to {remote}"))
90}
91
92/// Push branches and set upstream tracking; used before submitting so new
93/// branches exist remotely and rebased ones are safely updated.
94pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
95    let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
96    args.extend(branches.iter().map(String::as_str));
97
98    status(&args).with_context(|| format!("failed to push branches to {remote}"))
99}
100
101pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
102    let mut args = vec!["rebase"];
103    if update_refs {
104        args.push("--update-refs");
105    }
106    args.extend([parent, branch]);
107
108    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
109}
110
111/// Rebase only the commits after `base`, replaying `base..branch` onto
112/// `parent`. Used when the recorded fork point is known so commits that
113/// landed upstream by squash or rebase are not replayed.
114pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
115    let mut args = vec!["rebase"];
116    if update_refs {
117        args.push("--update-refs");
118    }
119    args.extend(["--onto", parent, base, branch]);
120
121    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
122}
123
124pub fn rev_parse(rev: &str) -> Result<String> {
125    let spec = format!("{rev}^{{commit}}");
126    output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
127}
128
129/// The commit a branch points at, or None when the branch does not exist.
130pub fn branch_sha(branch: &str) -> Option<String> {
131    rev_parse(branch).ok()
132}
133
134/// Point a branch at a commit, creating it if absent. Does not touch the
135/// worktree.
136pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
137    status(&["update-ref", &format!("refs/heads/{branch}"), sha])
138        .with_context(|| format!("failed to update {branch} to {sha}"))
139}
140
141/// Reset the worktree and index to HEAD. Safe to lose nothing only on a
142/// clean tree; callers must check [`worktree_is_clean`] first.
143pub fn reset_hard() -> Result<()> {
144    status(&["reset", "--hard"]).context("failed to reset the worktree")
145}
146
147/// Whether the worktree and index have no uncommitted changes.
148pub fn worktree_is_clean() -> Result<bool> {
149    Ok(output(&["status", "--porcelain"])?.is_empty())
150}
151
152/// Default branch of `remote` (from its locally-known HEAD symref), if any.
153pub fn remote_default_branch(remote: &str) -> Option<String> {
154    let reference = format!("refs/remotes/{remote}/HEAD");
155    let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
156    full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
157}
158
159/// How many commits `parent` has that `branch` does not: nonzero means the
160/// branch needs a restack.
161pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
162    let range = format!("{branch}..{parent}");
163    let count = output(&["rev-list", "--count", &range])
164        .with_context(|| format!("failed to count commits in {range}"))?;
165    count
166        .trim()
167        .parse()
168        .context("failed to parse rev-list count")
169}
170
171pub fn merge_base(a: &str, b: &str) -> Result<String> {
172    output(&["merge-base", a, b])
173        .with_context(|| format!("failed to find merge base of {a} and {b}"))
174}
175
176pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
177    let output = Command::new("git")
178        .args(["merge-base", "--is-ancestor", ancestor, descendant])
179        .stdout(Stdio::piped())
180        .stderr(Stdio::piped())
181        .output()
182        .context("failed to run git merge-base --is-ancestor")?;
183
184    match output.status.code() {
185        Some(0) => Ok(true),
186        Some(1) => Ok(false),
187        _ => Err(command_error(
188            "git merge-base --is-ancestor",
189            &output.stderr,
190        )),
191    }
192}
193
194pub fn supports_rebase_update_refs() -> Result<bool> {
195    let output = Command::new("git")
196        .args(["rebase", "-h"])
197        .stdout(Stdio::piped())
198        .stderr(Stdio::piped())
199        .output()
200        .context("failed to inspect git rebase help")?;
201
202    let help = format!(
203        "{}{}",
204        String::from_utf8_lossy(&output.stdout),
205        String::from_utf8_lossy(&output.stderr)
206    );
207    Ok(help_mentions_update_refs(&help))
208}
209
210/// Whether the short help advertises --update-refs. Match the option name:
211/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
212fn help_mentions_update_refs(help: &str) -> bool {
213    help.contains("update-refs")
214}
215
216pub fn rebase_continue() -> Result<()> {
217    // Passthrough: continuing a rebase can open the user's editor.
218    status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
219}
220
221pub fn rebase_abort() -> Result<()> {
222    status(&["rebase", "--abort"]).context("failed to abort rebase")
223}
224
225pub fn config_get(key: &str) -> Result<Option<String>> {
226    let output = Command::new("git")
227        .args(["config", "--get", key])
228        .stdout(Stdio::piped())
229        .stderr(Stdio::piped())
230        .output()
231        .with_context(|| format!("failed to read git config {key}"))?;
232
233    match output.status.code() {
234        Some(0) => Ok(Some(
235            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
236        )),
237        Some(1) => Ok(None),
238        _ => Err(command_error("git config --get", &output.stderr)),
239    }
240}
241
242pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
243    let output = Command::new("git")
244        .args(["config", "--type=bool", "--get", key])
245        .stdout(Stdio::piped())
246        .stderr(Stdio::piped())
247        .output()
248        .with_context(|| format!("failed to read git config {key}"))?;
249
250    match output.status.code() {
251        Some(0) => {
252            let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
253            match value.as_str() {
254                "true" => Ok(Some(true)),
255                "false" => Ok(Some(false)),
256                _ => bail!("git config {key} is not a boolean: {value}"),
257            }
258        }
259        Some(1) => Ok(None),
260        _ => Err(command_error(
261            "git config --type=bool --get",
262            &output.stderr,
263        )),
264    }
265}
266
267pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
268    let output = Command::new("git")
269        .args(["config", "--get-regexp", pattern])
270        .stdout(Stdio::piped())
271        .stderr(Stdio::piped())
272        .output()
273        .with_context(|| format!("failed to read git config matching {pattern}"))?;
274
275    match output.status.code() {
276        Some(0) => Ok(String::from_utf8_lossy(&output.stdout)
277            .lines()
278            .filter_map(|line| {
279                line.split_once(' ')
280                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
281            })
282            .collect()),
283        Some(1) => Ok(Vec::new()),
284        _ => Err(command_error("git config --get-regexp", &output.stderr)),
285    }
286}
287
288pub fn config_set(key: &str, value: &str) -> Result<()> {
289    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
290}
291
292pub fn config_unset(key: &str) -> Result<()> {
293    let output = Command::new("git")
294        .args(["config", "--unset", key])
295        .stdout(Stdio::piped())
296        .stderr(Stdio::piped())
297        .output()
298        .with_context(|| format!("failed to unset git config {key}"))?;
299
300    match output.status.code() {
301        Some(0) | Some(5) => Ok(()),
302        _ => Err(command_error("git config --unset", &output.stderr)),
303    }
304}
305
306fn output(args: &[&str]) -> Result<String> {
307    let output = Command::new("git")
308        .args(args)
309        .stdout(Stdio::piped())
310        .stderr(Stdio::piped())
311        .output()
312        .context("failed to run git")?;
313
314    if output.status.success() {
315        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
316    } else {
317        Err(command_error("git", &output.stderr))
318    }
319}
320
321/// Run git quietly: progress and advice only matter when something goes
322/// wrong, so capture them and replay on failure. `--verbose` passes
323/// everything through.
324fn status(args: &[&str]) -> Result<()> {
325    if verbose() {
326        return status_passthrough(args);
327    }
328
329    let output = Command::new("git")
330        .args(args)
331        .output()
332        .context("failed to run git")?;
333
334    if output.status.success() {
335        Ok(())
336    } else {
337        let _ = std::io::stdout().write_all(&output.stdout);
338        let _ = std::io::stderr().write_all(&output.stderr);
339        bail!("git exited with status {}", output.status)
340    }
341}
342
343/// Inherit stdio unconditionally, for git commands that may need the
344/// terminal (e.g. `rebase --continue` opening the editor).
345fn status_passthrough(args: &[&str]) -> Result<()> {
346    let status = Command::new("git")
347        .args(args)
348        .status()
349        .context("failed to run git")?;
350
351    if status.success() {
352        Ok(())
353    } else {
354        bail!("git exited with status {status}")
355    }
356}
357
358fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
359    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
360    if stderr.is_empty() {
361        anyhow!("{command} failed")
362    } else {
363        anyhow!("{command} failed: {stderr}")
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
373        assert!(help_mentions_update_refs(
374            "    --update-refs    update branches that point to commits that are being rebased"
375        ));
376    }
377
378    #[test]
379    fn help_mentions_update_refs_matches_negatable_spelling() {
380        assert!(help_mentions_update_refs(
381            "    --[no-]update-refs    update branches that point to commits that are being rebased"
382        ));
383    }
384
385    #[test]
386    fn help_mentions_update_refs_rejects_help_without_the_option() {
387        assert!(!help_mentions_update_refs(
388            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
389        ));
390    }
391
392    #[test]
393    fn detection_agrees_with_the_real_git_on_this_machine() {
394        // Ground truth: `--update-refs -h` fails with "unknown option" on a
395        // git without the flag and prints help on one that has it.
396        let probe = Command::new("git")
397            .args(["rebase", "--update-refs", "-h"])
398            .stdout(Stdio::piped())
399            .stderr(Stdio::piped())
400            .output()
401            .expect("run git rebase probe");
402        let probe_text = format!(
403            "{}{}",
404            String::from_utf8_lossy(&probe.stdout),
405            String::from_utf8_lossy(&probe.stderr)
406        );
407        let real_support = !probe_text.contains("unknown option");
408
409        assert_eq!(
410            supports_rebase_update_refs().expect("detect support"),
411            real_support
412        );
413    }
414}