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
23/// Whether the working directory is inside a git work tree. Used for a clean
24/// "not a git repository" message instead of letting git's raw error surface
25/// from the first command that needs the repo.
26pub fn is_in_repo() -> bool {
27    Command::new("git")
28        .args(["rev-parse", "--is-inside-work-tree"])
29        .stdout(Stdio::piped())
30        .stderr(Stdio::piped())
31        .output()
32        .is_ok_and(|out| out.status.success() && out.stdout.starts_with(b"true"))
33}
34
35pub fn local_branches() -> Result<Vec<String>> {
36    let output = output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
37    Ok(output.lines().map(str::to_owned).collect())
38}
39
40pub fn git_path(path: &str) -> Result<String> {
41    output(&["rev-parse", "--git-path", path])
42}
43
44/// The repository's top-level working-tree directory.
45pub fn repo_root() -> Result<std::path::PathBuf> {
46    Ok(std::path::PathBuf::from(output(&[
47        "rev-parse",
48        "--show-toplevel",
49    ])?))
50}
51
52/// Resolve `path` under the repo's *common* git dir, which all linked
53/// worktrees share, rather than the per-worktree dir `git_path` returns. Use
54/// this for state that guards or mirrors the shared config (`branch.*`), so
55/// every worktree of a repo agrees on one file.
56pub fn git_common_path(path: &str) -> Result<String> {
57    let common_dir = output(&["rev-parse", "--git-common-dir"])?;
58    Ok(std::path::Path::new(&common_dir)
59        .join(path)
60        .to_string_lossy()
61        .into_owned())
62}
63
64pub fn remote_url(remote: &str) -> Result<Option<String>> {
65    // git remote get-url exits 2 when the remote does not exist.
66    output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
67}
68
69pub fn checkout(branch: &str) -> Result<()> {
70    status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))?;
71    anstream::println!(
72        "switched to {}",
73        crate::style::paint(crate::style::BRANCH, branch)
74    );
75    Ok(())
76}
77
78pub fn create_branch(branch: &str) -> Result<()> {
79    status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
80}
81
82/// Create a branch pointing at `sha` without checking it out or touching the
83/// working tree - used by `split` to point new branches at existing commits.
84pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
85    status(&["branch", branch, sha])
86        .with_context(|| format!("failed to create branch {branch} at {sha}"))
87}
88
89/// Force-delete a branch. Use only once review state confirms it landed: a
90/// squash merge leaves the commits non-ancestry-merged, so `git branch -d`
91/// would refuse even though the work is in.
92pub fn delete_branch(branch: &str) -> Result<()> {
93    status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
94}
95
96/// Rename a branch; git moves its `branch.<name>.*` config along with it.
97pub fn rename_branch(old: &str, new: &str) -> Result<()> {
98    status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
99}
100
101/// Fast-forward a local branch from its remote without checking it out.
102pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
103    let refspec = format!("{branch}:{branch}");
104    status(&["fetch", remote, &refspec])
105        .with_context(|| format!("failed to fetch {branch} from {remote}"))
106}
107
108pub fn pull_ff_only() -> Result<()> {
109    status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
110}
111
112pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
113    let mut args = vec!["push", "--force-with-lease", remote];
114    args.extend(branches.iter().map(String::as_str));
115
116    status(&args).with_context(|| format!("failed to push branches to {remote}"))
117}
118
119/// Push branches and set upstream tracking; used before submitting so new
120/// branches exist remotely and rebased ones are safely updated.
121pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
122    let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
123    args.extend(branches.iter().map(String::as_str));
124
125    status(&args).with_context(|| format!("failed to push branches to {remote}"))
126}
127
128/// Store `content` as a single-file commit and point `reference` at it, so the
129/// data rides along a normal ref push. Orphan each time: the ref just moves to
130/// the new commit (callers force-push it, as it is regenerable).
131pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
132    let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
133        .context("failed to hash stack metadata")?;
134    let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
135        .context("failed to write stack metadata tree")?;
136    let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
137        .context("failed to commit stack metadata")?;
138    status(&["update-ref", reference, &commit])
139        .with_context(|| format!("failed to update {reference}"))
140}
141
142/// Force-push a single ref to `remote` (the value is regenerable, so
143/// last-writer-wins is fine).
144pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
145    status(&[
146        "push",
147        "--force",
148        remote,
149        &format!("{reference}:{reference}"),
150    ])
151    .with_context(|| format!("failed to push {reference} to {remote}"))
152}
153
154/// Force-fetch a single ref from `remote` into the same local ref.
155pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
156    status(&["fetch", remote, &format!("+{reference}:{reference}")])
157        .with_context(|| format!("failed to fetch {reference} from {remote}"))
158}
159
160/// The contents of `file` in the commit `reference` points at, or None when
161/// the ref or file is absent.
162pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
163    let output = Command::new("git")
164        .args(["cat-file", "blob", &format!("{reference}:{file}")])
165        .stdout(Stdio::piped())
166        .stderr(Stdio::piped())
167        .output()
168        .context("failed to run git cat-file")?;
169    if output.status.success() {
170        Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
171    } else {
172        Ok(None)
173    }
174}
175
176pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
177    let mut args = vec!["rebase"];
178    if update_refs {
179        args.push("--update-refs");
180    }
181    args.extend([parent, branch]);
182
183    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
184}
185
186/// Rebase only the commits after `base`, replaying `base..branch` onto
187/// `parent`. Used when the recorded fork point is known so commits that
188/// landed upstream by squash or rebase are not replayed.
189pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
190    let mut args = vec!["rebase"];
191    if update_refs {
192        args.push("--update-refs");
193    }
194    args.extend(["--onto", parent, base, branch]);
195
196    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
197}
198
199pub fn rev_parse(rev: &str) -> Result<String> {
200    let spec = format!("{rev}^{{commit}}");
201    output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
202}
203
204/// The commit a branch points at, or None when the branch does not exist.
205pub fn branch_sha(branch: &str) -> Option<String> {
206    rev_parse(branch).ok()
207}
208
209/// Point a branch at a commit, creating it if absent. Does not touch the
210/// worktree.
211pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
212    status(&["update-ref", &format!("refs/heads/{branch}"), sha])
213        .with_context(|| format!("failed to update {branch} to {sha}"))
214}
215
216/// Reset the worktree and index to HEAD. Safe to lose nothing only on a
217/// clean tree; callers must check [`worktree_is_clean`] first.
218pub fn reset_hard() -> Result<()> {
219    status(&["reset", "--hard"]).context("failed to reset the worktree")
220}
221
222/// Whether the worktree and index have no uncommitted changes.
223pub fn worktree_is_clean() -> Result<bool> {
224    Ok(output(&["status", "--porcelain"])?.is_empty())
225}
226
227/// Default branch of `remote` (from its locally-known HEAD symref), if any.
228pub fn remote_default_branch(remote: &str) -> Option<String> {
229    let reference = format!("refs/remotes/{remote}/HEAD");
230    let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
231    full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
232}
233
234/// How many commits `parent` has that `branch` does not: nonzero means the
235/// branch needs a restack.
236pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
237    let range = format!("{branch}..{parent}");
238    let count = output(&["rev-list", "--count", &range])
239        .with_context(|| format!("failed to count commits in {range}"))?;
240    count
241        .trim()
242        .parse()
243        .context("failed to parse rev-list count")
244}
245
246pub fn merge_base(a: &str, b: &str) -> Result<String> {
247    output(&["merge-base", a, b])
248        .with_context(|| format!("failed to find merge base of {a} and {b}"))
249}
250
251/// A unified-0 diff against HEAD: just the staged changes when `cached`,
252/// otherwise all tracked changes (staged and unstaged). Zero context lines
253/// so each hunk's pre-image range pinpoints exactly the lines it touches.
254pub fn diff_against_head(cached: bool) -> Result<String> {
255    // Pin a/ b/ prefixes: diff.mnemonicPrefix / diff.noprefix would otherwise
256    // emit headers absorb's parser and `git apply` cannot read.
257    let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
258    if cached {
259        args.push("--cached");
260    }
261    args.push("HEAD");
262    output(&args).context("failed to diff against HEAD")
263}
264
265/// The distinct commits that last touched lines `start..start+len` of `file`
266/// in HEAD, newest blame wins per line. An empty range yields nothing.
267pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
268    if len == 0 {
269        return Ok(Vec::new());
270    }
271    let range = format!("{start},{}", start + len - 1);
272    let out = output(&[
273        "blame",
274        "HEAD",
275        "-L",
276        &range,
277        "--line-porcelain",
278        "--",
279        file,
280    ])
281    .with_context(|| format!("failed to blame {file}"))?;
282
283    let mut shas = Vec::new();
284    for line in out.lines() {
285        // Each porcelain block opens with "<40-hex sha> <orig> <final> ...";
286        // other fields (author, summary, "previous", the tab-led content) do
287        // not start with a bare 40-hex token.
288        let token = line.split(' ').next().unwrap_or_default();
289        if token.len() == 40
290            && token.bytes().all(|byte| byte.is_ascii_hexdigit())
291            && !shas.iter().any(|seen| seen == token)
292        {
293            shas.push(token.to_owned());
294        }
295    }
296    Ok(shas)
297}
298
299/// The commits in `range` (e.g. "main..HEAD"), newest first.
300pub fn rev_list(range: &str) -> Result<Vec<String>> {
301    Ok(output(&["rev-list", range])
302        .with_context(|| format!("failed to list commits in {range}"))?
303        .lines()
304        .map(str::to_owned)
305        .collect())
306}
307
308/// `(short-sha, subject)` for each commit in `range` (e.g. "main..HEAD"),
309/// newest first - one git call, for listing a branch's own commits.
310pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
311    Ok(output(&["log", "--format=%h%x09%s", range])
312        .with_context(|| format!("failed to log {range}"))?
313        .lines()
314        .filter_map(|line| {
315            line.split_once('\t')
316                .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
317        })
318        .collect())
319}
320
321/// A commit's subject line.
322pub fn commit_subject(sha: &str) -> Result<String> {
323    output(&["show", "--no-patch", "--format=%s", sha])
324        .with_context(|| format!("failed to read subject of {sha}"))
325}
326
327/// A commit's body - everything after the subject line; empty when there is none.
328pub fn commit_body(sha: &str) -> Result<String> {
329    output(&["show", "--no-patch", "--format=%b", sha])
330        .with_context(|| format!("failed to read body of {sha}"))
331}
332
333/// Stage a unified-0 patch into the index. `--unidiff-zero` is required for
334/// git to accept the zero-context hunks absorb works with.
335pub fn apply_cached(patch: &str) -> Result<()> {
336    let mut child = Command::new("git")
337        .args(["apply", "--cached", "--unidiff-zero"])
338        .stdin(Stdio::piped())
339        .stdout(Stdio::piped())
340        .stderr(Stdio::piped())
341        .spawn()
342        .context("failed to run git apply")?;
343    {
344        let mut stdin = child.stdin.take().context("git apply has no stdin")?;
345        stdin
346            .write_all(patch.as_bytes())
347            .context("failed to write patch to git apply")?;
348    }
349    let output = child
350        .wait_with_output()
351        .context("failed to run git apply")?;
352    if output.status.success() {
353        Ok(())
354    } else {
355        Err(command_error("git apply", &output.stderr))
356    }
357}
358
359/// Commit the staged index as a `fixup!` of `sha`, for a later autosquash
360/// rebase to fold in. Skips hooks: these are internal, transient commits.
361pub fn commit_fixup(sha: &str) -> Result<()> {
362    status(&["commit", "--no-verify", &format!("--fixup={sha}")])
363        .with_context(|| format!("failed to create fixup commit for {sha}"))
364}
365
366/// Unstage everything, leaving the worktree contents untouched.
367pub fn reset_index() -> Result<()> {
368    status(&["reset", "--quiet"]).context("failed to reset the index")
369}
370
371/// Move HEAD to `sha`, returning any commits after it to the index.
372pub fn reset_soft(sha: &str) -> Result<()> {
373    status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
374}
375
376/// Stash tracked worktree changes; pair with [`stash_pop`].
377pub fn stash_push() -> Result<()> {
378    status(&["stash", "push", "--quiet"]).context("failed to stash changes")
379}
380
381/// Restore the most recently stashed changes.
382pub fn stash_pop() -> Result<()> {
383    status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
384}
385
386/// Rebase `base..HEAD`, folding `fixup!` commits into their targets. The
387/// generated todo is accepted unedited, so it needs no terminal.
388pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
389    let mut args = vec!["rebase", "--interactive", "--autosquash"];
390    if update_refs {
391        args.push("--update-refs");
392    }
393    args.push(base);
394
395    let output = Command::new("git")
396        .args(&args)
397        .env("GIT_SEQUENCE_EDITOR", "true")
398        .env("GIT_EDITOR", "true")
399        .output()
400        .context("failed to run git rebase")?;
401    if output.status.success() {
402        Ok(())
403    } else {
404        Err(command_error("git rebase --autosquash", &output.stderr))
405    }
406}
407
408pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
409    // merge-base --is-ancestor exits 0 when it is, 1 when it is not.
410    Ok(output_codes(
411        &["merge-base", "--is-ancestor", ancestor, descendant],
412        &[1],
413        "git merge-base --is-ancestor",
414    )?
415    .is_some())
416}
417
418/// Lines added and deleted in `branch` relative to `base`, over the symmetric
419/// `base...branch` range a forge uses for a review diff (the branch's own work
420/// since it diverged). Binary files, which `--numstat` marks with `-`, count
421/// as zero.
422pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
423    let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
424    let mut added = 0;
425    let mut deleted = 0;
426    for line in output.lines() {
427        let mut columns = line.split('\t');
428        added += column_count(columns.next());
429        deleted += column_count(columns.next());
430    }
431    Ok((added, deleted))
432}
433
434/// A `--numstat` count column: a number, or 0 for `-` (binary) or anything
435/// unparseable.
436fn column_count(column: Option<&str>) -> usize {
437    column
438        .and_then(|value| value.parse::<usize>().ok())
439        .unwrap_or(0)
440}
441
442pub fn supports_rebase_update_refs() -> Result<bool> {
443    let output = Command::new("git")
444        .args(["rebase", "-h"])
445        .stdout(Stdio::piped())
446        .stderr(Stdio::piped())
447        .output()
448        .context("failed to inspect git rebase help")?;
449
450    let help = format!(
451        "{}{}",
452        String::from_utf8_lossy(&output.stdout),
453        String::from_utf8_lossy(&output.stderr)
454    );
455    Ok(help_mentions_update_refs(&help))
456}
457
458/// Whether the short help advertises --update-refs. Match the option name:
459/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
460fn help_mentions_update_refs(help: &str) -> bool {
461    help.contains("update-refs")
462}
463
464pub fn rebase_continue() -> Result<()> {
465    // Passthrough: continuing a rebase can open the user's editor.
466    status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
467}
468
469pub fn rebase_abort() -> Result<()> {
470    status(&["rebase", "--abort"]).context("failed to abort rebase")
471}
472
473pub fn config_get(key: &str) -> Result<Option<String>> {
474    // git config --get exits 1 when the key is unset.
475    output_codes(&["config", "--get", key], &[1], "git config --get")
476}
477
478pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
479    let Some(value) = output_codes(
480        &["config", "--type=bool", "--get", key],
481        &[1],
482        "git config --type=bool --get",
483    )?
484    else {
485        return Ok(None);
486    };
487    match value.as_str() {
488        "true" => Ok(Some(true)),
489        "false" => Ok(Some(false)),
490        _ => bail!("git config {key} is not a boolean: {value}"),
491    }
492}
493
494pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
495    // git config --get-regexp exits 1 when nothing matches.
496    let Some(text) = output_codes(
497        &["config", "--get-regexp", pattern],
498        &[1],
499        "git config --get-regexp",
500    )?
501    else {
502        return Ok(Vec::new());
503    };
504    Ok(text
505        .lines()
506        .filter_map(|line| {
507            line.split_once(' ')
508                .map(|(key, value)| (key.to_owned(), value.to_owned()))
509        })
510        .collect())
511}
512
513pub fn config_set(key: &str, value: &str) -> Result<()> {
514    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
515}
516
517pub fn config_unset(key: &str) -> Result<()> {
518    // git config --unset exits 5 when the key was not set; either way it is now
519    // gone, so treat that as success.
520    output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
521}
522
523/// Run a git command and map its exit code: trimmed stdout on success, `None`
524/// for any code in `ok_empty` (an expected "nothing here" - e.g. `config
525/// --get`'s 1, or `config --unset`'s 5), and an error otherwise. `label` names
526/// the command for the error message.
527fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
528    let output = Command::new("git")
529        .args(args)
530        .stdout(Stdio::piped())
531        .stderr(Stdio::piped())
532        .output()
533        .context("failed to run git")?;
534
535    match output.status.code() {
536        Some(0) => Ok(Some(
537            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
538        )),
539        Some(code) if ok_empty.contains(&code) => Ok(None),
540        _ => Err(command_error(label, &output.stderr)),
541    }
542}
543
544fn output(args: &[&str]) -> Result<String> {
545    let output = Command::new("git")
546        .args(args)
547        .stdout(Stdio::piped())
548        .stderr(Stdio::piped())
549        .output()
550        .context("failed to run git")?;
551
552    if output.status.success() {
553        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
554    } else {
555        Err(command_error("git", &output.stderr))
556    }
557}
558
559/// Like [`output`], but feeds `input` to the command on stdin (for plumbing
560/// such as `hash-object --stdin` and `mktree`).
561fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
562    let mut child = Command::new("git")
563        .args(args)
564        .stdin(Stdio::piped())
565        .stdout(Stdio::piped())
566        .stderr(Stdio::piped())
567        .spawn()
568        .context("failed to run git")?;
569    {
570        let mut stdin = child.stdin.take().context("git has no stdin")?;
571        stdin
572            .write_all(input.as_bytes())
573            .context("failed to write to git")?;
574    }
575    let output = child.wait_with_output().context("failed to run git")?;
576    if output.status.success() {
577        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
578    } else {
579        Err(command_error("git", &output.stderr))
580    }
581}
582
583/// Run git quietly: progress and advice only matter when something goes
584/// wrong, so capture them and replay on failure. `--verbose` passes
585/// everything through.
586fn status(args: &[&str]) -> Result<()> {
587    if verbose() {
588        return status_passthrough(args);
589    }
590
591    let output = Command::new("git")
592        .args(args)
593        .output()
594        .context("failed to run git")?;
595
596    if output.status.success() {
597        Ok(())
598    } else {
599        let _ = std::io::stdout().write_all(&output.stdout);
600        let _ = std::io::stderr().write_all(&output.stderr);
601        bail!("git exited with status {}", output.status)
602    }
603}
604
605/// Inherit stdio unconditionally, for git commands that may need the
606/// terminal (e.g. `rebase --continue` opening the editor).
607fn status_passthrough(args: &[&str]) -> Result<()> {
608    let status = Command::new("git")
609        .args(args)
610        .status()
611        .context("failed to run git")?;
612
613    if status.success() {
614        Ok(())
615    } else {
616        bail!("git exited with status {status}")
617    }
618}
619
620fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
621    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
622    if stderr.is_empty() {
623        anyhow!("{command} failed")
624    } else {
625        anyhow!("{command} failed: {stderr}")
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
635        assert!(help_mentions_update_refs(
636            "    --update-refs    update branches that point to commits that are being rebased"
637        ));
638    }
639
640    #[test]
641    fn help_mentions_update_refs_matches_negatable_spelling() {
642        assert!(help_mentions_update_refs(
643            "    --[no-]update-refs    update branches that point to commits that are being rebased"
644        ));
645    }
646
647    #[test]
648    fn help_mentions_update_refs_rejects_help_without_the_option() {
649        assert!(!help_mentions_update_refs(
650            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
651        ));
652    }
653
654    #[test]
655    fn detection_agrees_with_the_real_git_on_this_machine() {
656        // Ground truth: `--update-refs -h` fails with "unknown option" on a
657        // git without the flag and prints help on one that has it.
658        let probe = Command::new("git")
659            .args(["rebase", "--update-refs", "-h"])
660            .stdout(Stdio::piped())
661            .stderr(Stdio::piped())
662            .output()
663            .expect("run git rebase probe");
664        let probe_text = format!(
665            "{}{}",
666            String::from_utf8_lossy(&probe.stdout),
667            String::from_utf8_lossy(&probe.stderr)
668        );
669        let real_support = !probe_text.contains("unknown option");
670
671        assert_eq!(
672            supports_rebase_update_refs().expect("detect support"),
673            real_support
674        );
675    }
676}