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