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
112/// Force-push `branches` (with lease), returning the branches that actually
113/// landed. Normally that is all of them; the exception is the merge-queue
114/// backstop below, which drops a held-back branch from the returned set so the
115/// caller never reports a branch as both held and pushed.
116pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<Vec<String>> {
117    let mut args = vec!["push", "--force-with-lease", remote];
118    args.extend(branches.iter().map(String::as_str));
119
120    run_lease_push(&args, remote, branches)
121}
122
123/// Run a force-with-lease push, returning the branches that actually landed,
124/// and classifying the two rejections git-stk can explain better than raw git
125/// output:
126///
127/// - **Merge queue** (GitHub locks a queued branch): the ref is rejected with
128///   GH006 while its siblings push fine. `restack`/`sync` already freeze
129///   branches they know are queued, so this is the backstop for one enqueued
130///   mid-run - the held ref is reported and dropped from the returned set, the
131///   successful refs stand, and the push is not failed.
132/// - **Stale lease** (the remote moved on, usually because a branch in the
133///   stack merged): the lease no longer matches, so git rejects with `stale
134///   info`/`non-fast-forward`. `git stk sync` reconciles it, so say so instead
135///   of leaving the user with git's plumbing error.
136///
137/// Anything else surfaces with git's own output, unchanged.
138fn run_lease_push(args: &[&str], remote: &str, branches: &[String]) -> Result<Vec<String>> {
139    // Verbose mode streams straight through, so there is no captured stderr to
140    // classify; fall back to the plain path. (A rejection there still shows
141    // git's own message, just without the friendlier translation.)
142    if verbose() {
143        status_passthrough(args).with_context(|| format!("failed to push branches to {remote}"))?;
144        return Ok(branches.to_vec());
145    }
146
147    let output = Command::new("git")
148        .args(args)
149        .output()
150        .context("failed to run git")?;
151    if output.status.success() {
152        return Ok(branches.to_vec());
153    }
154
155    // A GitHub branch sitting in a merge queue is locked, so its ref is rejected
156    // with GH006 while its siblings push fine; git then exits non-zero even
157    // though the rest landed. `restack`/`sync` already freeze branches they know
158    // are queued, so this is the backstop for one enqueued mid-run: report the
159    // held ref, drop it from the landed set, and let the successful refs stand.
160    // Any rejection that is not purely the merge queue (a stale lease, a
161    // non-fast-forward) still surfaces as an error.
162    let stderr = String::from_utf8_lossy(&output.stderr);
163    if let Some(queued) = merge_queue_rejection(&stderr) {
164        anstream::eprintln!(
165            "{}",
166            crate::style::warn(&format!(
167                "{} {} in a merge queue and was not updated (dequeue its review to push it)",
168                queued.join(", "),
169                if queued.len() == 1 { "is" } else { "are" },
170            ))
171        );
172        return Ok(landed_branches(branches, &queued));
173    }
174
175    if let Some(stale) = stale_rejection(&stderr) {
176        // The user asked for a clean message, not raw git/GitHub noise, so the
177        // captured output is dropped in favor of the actionable guidance.
178        bail!(
179            "could not push {} to {remote}: the remote has moved on \
180             (a branch in the stack was likely merged or updated upstream)\n\
181             run `git stk sync` to reconcile your local stack with the remote, then try again",
182            stale.join(", "),
183        );
184    }
185
186    let _ = std::io::stdout().write_all(&output.stdout);
187    let _ = std::io::stderr().write_all(&output.stderr);
188    bail!(
189        "failed to push branches to {remote}: git exited with status {}",
190        output.status
191    )
192}
193
194/// The branches that landed: everything attempted except those held back by
195/// the merge queue, preserving the attempted order.
196fn landed_branches(attempted: &[String], held: &[String]) -> Vec<String> {
197    attempted
198        .iter()
199        .filter(|branch| !held.iter().any(|name| name == *branch))
200        .cloned()
201        .collect()
202}
203
204/// The rejected refs when a push failed *only* because they are in a merge
205/// queue, or None when any other failure is mixed in. A genuine lease/
206/// fast-forward rejection (`stale info`, `non-fast-forward`, `fetch first`)
207/// returns None so it is classified as stale instead; a queue rejection with
208/// no such marker returns the branch names so the caller can report them and
209/// carry on.
210fn merge_queue_rejection(stderr: &str) -> Option<Vec<String>> {
211    let lower = stderr.to_lowercase();
212    let mentions_queue = lower.contains("merge queue") || lower.contains("queued for merging");
213    if !mentions_queue {
214        return None;
215    }
216    // A lease or fast-forward failure is a real problem, not a queue lock - do
217    // not swallow a push that failed for those reasons too.
218    if ["stale info", "non-fast-forward", "fetch first"]
219        .iter()
220        .any(|marker| lower.contains(marker))
221    {
222        return None;
223    }
224    let rejected = rejected_refs(stderr);
225    if rejected.is_empty() {
226        None
227    } else {
228        Some(rejected)
229    }
230}
231
232/// The rejected refs when a push was refused because the local side is behind
233/// the remote: a `--force-with-lease` lease mismatch (`stale info`), or a plain
234/// `non-fast-forward`/`fetch first`. This is the remote having moved on - in a
235/// stack, almost always a lower branch that merged - which `git stk sync`
236/// reconciles.
237///
238/// Returns Some only when *every* rejected ref is stale: the friendly "run
239/// sync" message replaces git's raw output, so a non-stale rejection mixed in
240/// (a permission denial, a declined hook) - which sync would not fix - must
241/// fall through to git's own error instead of being hidden behind sync advice.
242/// None when nothing was rejected, or any rejection was for another reason.
243fn stale_rejection(stderr: &str) -> Option<Vec<String>> {
244    let rejected: Vec<&str> = stderr
245        .lines()
246        .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
247        .collect();
248    if rejected.is_empty() || !rejected.iter().all(|line| line_is_stale(line)) {
249        return None;
250    }
251    let names: Vec<String> = rejected
252        .iter()
253        .filter_map(|line| rejected_ref_name(line))
254        .collect();
255    if names.is_empty() { None } else { Some(names) }
256}
257
258/// Whether a rejected-ref line was refused because the local side is behind the
259/// remote (a `--force-with-lease` lease mismatch or a non-fast-forward), rather
260/// than a permission/hook refusal. The reason is in the line's trailing `(…)`.
261fn line_is_stale(line: &str) -> bool {
262    let lower = line.to_lowercase();
263    ["stale info", "non-fast-forward", "fetch first"]
264        .iter()
265        .any(|marker| lower.contains(marker))
266}
267
268/// The remote-side ref name from a single `! [remote rejected] <local> ->
269/// <remote> (reason)` line.
270fn rejected_ref_name(line: &str) -> Option<String> {
271    let after = line.split("-> ").nth(1)?;
272    Some(after.split_whitespace().next()?.to_owned())
273}
274
275/// The remote-side ref names from a push's `! [remote rejected]`/`! [rejected]`
276/// lines, regardless of reason.
277fn rejected_refs(stderr: &str) -> Vec<String> {
278    stderr
279        .lines()
280        .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
281        .filter_map(rejected_ref_name)
282        .collect()
283}
284
285/// Push branches and set upstream tracking; used before submitting so new
286/// branches exist remotely and rebased ones are safely updated.
287pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
288    let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
289    args.extend(branches.iter().map(String::as_str));
290
291    // submit does not need the landed set; a held-back branch is still warned
292    // about inside run_lease_push.
293    run_lease_push(&args, remote, branches)?;
294    Ok(())
295}
296
297/// Store `content` as a single-file commit and point `reference` at it, so the
298/// data rides along a normal ref push. Orphan each time: the ref just moves to
299/// the new commit (callers force-push it, as it is regenerable).
300pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
301    let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
302        .context("failed to hash stack metadata")?;
303    let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
304        .context("failed to write stack metadata tree")?;
305    let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
306        .context("failed to commit stack metadata")?;
307    status(&["update-ref", reference, &commit])
308        .with_context(|| format!("failed to update {reference}"))
309}
310
311/// Force-push a single ref to `remote` (the value is regenerable, so
312/// last-writer-wins is fine).
313pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
314    status(&[
315        "push",
316        "--force",
317        remote,
318        &format!("{reference}:{reference}"),
319    ])
320    .with_context(|| format!("failed to push {reference} to {remote}"))
321}
322
323/// Force-fetch a single ref from `remote` into the same local ref.
324pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
325    status(&["fetch", remote, &format!("+{reference}:{reference}")])
326        .with_context(|| format!("failed to fetch {reference} from {remote}"))
327}
328
329/// The contents of `file` in the commit `reference` points at, or None when
330/// the ref or file is absent.
331pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
332    let output = Command::new("git")
333        .args(["cat-file", "blob", &format!("{reference}:{file}")])
334        .stdout(Stdio::piped())
335        .stderr(Stdio::piped())
336        .output()
337        .context("failed to run git cat-file")?;
338    if output.status.success() {
339        Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
340    } else {
341        Ok(None)
342    }
343}
344
345pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
346    let mut args = vec!["rebase"];
347    if update_refs {
348        args.push("--update-refs");
349    }
350    args.extend([parent, branch]);
351
352    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
353}
354
355/// Rebase only the commits after `base`, replaying `base..branch` onto
356/// `parent`. Used when the recorded fork point is known so commits that
357/// landed upstream by squash or rebase are not replayed.
358pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
359    let mut args = vec!["rebase"];
360    if update_refs {
361        args.push("--update-refs");
362    }
363    args.extend(["--onto", parent, base, branch]);
364
365    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
366}
367
368pub fn rev_parse(rev: &str) -> Result<String> {
369    let spec = format!("{rev}^{{commit}}");
370    output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
371}
372
373/// The commit a branch points at, or None when the branch does not exist.
374pub fn branch_sha(branch: &str) -> Option<String> {
375    rev_parse(branch).ok()
376}
377
378/// Point a branch at a commit, creating it if absent. Does not touch the
379/// worktree.
380pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
381    status(&["update-ref", &format!("refs/heads/{branch}"), sha])
382        .with_context(|| format!("failed to update {branch} to {sha}"))
383}
384
385/// Reset the worktree and index to HEAD. Safe to lose nothing only on a
386/// clean tree; callers must check [`worktree_is_clean`] first.
387pub fn reset_hard() -> Result<()> {
388    status(&["reset", "--hard"]).context("failed to reset the worktree")
389}
390
391/// Whether the worktree and index have no uncommitted changes.
392pub fn worktree_is_clean() -> Result<bool> {
393    Ok(output(&["status", "--porcelain"])?.is_empty())
394}
395
396/// Default branch of `remote` (from its locally-known HEAD symref), if any.
397pub fn remote_default_branch(remote: &str) -> Option<String> {
398    let reference = format!("refs/remotes/{remote}/HEAD");
399    let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
400    full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
401}
402
403/// How many commits `parent` has that `branch` does not: nonzero means the
404/// branch needs a restack.
405pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
406    let range = format!("{branch}..{parent}");
407    let count = output(&["rev-list", "--count", &range])
408        .with_context(|| format!("failed to count commits in {range}"))?;
409    count
410        .trim()
411        .parse()
412        .context("failed to parse rev-list count")
413}
414
415pub fn merge_base(a: &str, b: &str) -> Result<String> {
416    output(&["merge-base", a, b])
417        .with_context(|| format!("failed to find merge base of {a} and {b}"))
418}
419
420/// A unified-0 diff against HEAD: just the staged changes when `cached`,
421/// otherwise all tracked changes (staged and unstaged). Zero context lines
422/// so each hunk's pre-image range pinpoints exactly the lines it touches.
423pub fn diff_against_head(cached: bool) -> Result<String> {
424    // Pin a/ b/ prefixes: diff.mnemonicPrefix / diff.noprefix would otherwise
425    // emit headers absorb's parser and `git apply` cannot read.
426    let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
427    if cached {
428        args.push("--cached");
429    }
430    args.push("HEAD");
431    output(&args).context("failed to diff against HEAD")
432}
433
434/// The distinct commits that last touched lines `start..start+len` of `file`
435/// in HEAD, newest blame wins per line. An empty range yields nothing.
436pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
437    if len == 0 {
438        return Ok(Vec::new());
439    }
440    let range = format!("{start},{}", start + len - 1);
441    let out = output(&[
442        "blame",
443        "HEAD",
444        "-L",
445        &range,
446        "--line-porcelain",
447        "--",
448        file,
449    ])
450    .with_context(|| format!("failed to blame {file}"))?;
451
452    let mut shas = Vec::new();
453    for line in out.lines() {
454        // Each porcelain block opens with "<40-hex sha> <orig> <final> ...";
455        // other fields (author, summary, "previous", the tab-led content) do
456        // not start with a bare 40-hex token.
457        let token = line.split(' ').next().unwrap_or_default();
458        if token.len() == 40
459            && token.bytes().all(|byte| byte.is_ascii_hexdigit())
460            && !shas.iter().any(|seen| seen == token)
461        {
462            shas.push(token.to_owned());
463        }
464    }
465    Ok(shas)
466}
467
468/// The commits in `range` (e.g. "main..HEAD"), newest first.
469pub fn rev_list(range: &str) -> Result<Vec<String>> {
470    Ok(output(&["rev-list", range])
471        .with_context(|| format!("failed to list commits in {range}"))?
472        .lines()
473        .map(str::to_owned)
474        .collect())
475}
476
477/// `(short-sha, subject)` for each commit in `range` (e.g. "main..HEAD"),
478/// newest first - one git call, for listing a branch's own commits.
479pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
480    Ok(output(&["log", "--format=%h%x09%s", range])
481        .with_context(|| format!("failed to log {range}"))?
482        .lines()
483        .filter_map(|line| {
484            line.split_once('\t')
485                .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
486        })
487        .collect())
488}
489
490/// A commit's subject line.
491pub fn commit_subject(sha: &str) -> Result<String> {
492    output(&["show", "--no-patch", "--format=%s", sha])
493        .with_context(|| format!("failed to read subject of {sha}"))
494}
495
496/// A commit's body - everything after the subject line; empty when there is none.
497pub fn commit_body(sha: &str) -> Result<String> {
498    output(&["show", "--no-patch", "--format=%b", sha])
499        .with_context(|| format!("failed to read body of {sha}"))
500}
501
502/// Stage a unified-0 patch into the index. `--unidiff-zero` is required for
503/// git to accept the zero-context hunks absorb works with.
504pub fn apply_cached(patch: &str) -> Result<()> {
505    let mut child = Command::new("git")
506        .args(["apply", "--cached", "--unidiff-zero"])
507        .stdin(Stdio::piped())
508        .stdout(Stdio::piped())
509        .stderr(Stdio::piped())
510        .spawn()
511        .context("failed to run git apply")?;
512    {
513        let mut stdin = child.stdin.take().context("git apply has no stdin")?;
514        stdin
515            .write_all(patch.as_bytes())
516            .context("failed to write patch to git apply")?;
517    }
518    let output = child
519        .wait_with_output()
520        .context("failed to run git apply")?;
521    if output.status.success() {
522        Ok(())
523    } else {
524        Err(command_error("git apply", &output.stderr))
525    }
526}
527
528/// Commit the staged index as a `fixup!` of `sha`, for a later autosquash
529/// rebase to fold in. Skips hooks: these are internal, transient commits.
530pub fn commit_fixup(sha: &str) -> Result<()> {
531    status(&["commit", "--no-verify", &format!("--fixup={sha}")])
532        .with_context(|| format!("failed to create fixup commit for {sha}"))
533}
534
535/// Unstage everything, leaving the worktree contents untouched.
536pub fn reset_index() -> Result<()> {
537    status(&["reset", "--quiet"]).context("failed to reset the index")
538}
539
540/// Move HEAD to `sha`, returning any commits after it to the index.
541pub fn reset_soft(sha: &str) -> Result<()> {
542    status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
543}
544
545/// Stash tracked worktree changes; pair with [`stash_pop`].
546pub fn stash_push() -> Result<()> {
547    status(&["stash", "push", "--quiet"]).context("failed to stash changes")
548}
549
550/// Restore the most recently stashed changes.
551pub fn stash_pop() -> Result<()> {
552    status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
553}
554
555/// Rebase `base..HEAD`, folding `fixup!` commits into their targets. The
556/// generated todo is accepted unedited, so it needs no terminal.
557pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
558    let mut args = vec!["rebase", "--interactive", "--autosquash"];
559    if update_refs {
560        args.push("--update-refs");
561    }
562    args.push(base);
563
564    let output = Command::new("git")
565        .args(&args)
566        .env("GIT_SEQUENCE_EDITOR", "true")
567        .env("GIT_EDITOR", "true")
568        .output()
569        .context("failed to run git rebase")?;
570    if output.status.success() {
571        Ok(())
572    } else {
573        Err(command_error("git rebase --autosquash", &output.stderr))
574    }
575}
576
577pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
578    // merge-base --is-ancestor exits 0 when it is, 1 when it is not.
579    Ok(output_codes(
580        &["merge-base", "--is-ancestor", ancestor, descendant],
581        &[1],
582        "git merge-base --is-ancestor",
583    )?
584    .is_some())
585}
586
587/// Lines added and deleted in `branch` relative to `base`, over the symmetric
588/// `base...branch` range a forge uses for a review diff (the branch's own work
589/// since it diverged). Binary files, which `--numstat` marks with `-`, count
590/// as zero.
591pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
592    let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
593    let mut added = 0;
594    let mut deleted = 0;
595    for line in output.lines() {
596        let mut columns = line.split('\t');
597        added += column_count(columns.next());
598        deleted += column_count(columns.next());
599    }
600    Ok((added, deleted))
601}
602
603/// A `--numstat` count column: a number, or 0 for `-` (binary) or anything
604/// unparseable.
605fn column_count(column: Option<&str>) -> usize {
606    column
607        .and_then(|value| value.parse::<usize>().ok())
608        .unwrap_or(0)
609}
610
611pub fn supports_rebase_update_refs() -> Result<bool> {
612    let output = Command::new("git")
613        .args(["rebase", "-h"])
614        .stdout(Stdio::piped())
615        .stderr(Stdio::piped())
616        .output()
617        .context("failed to inspect git rebase help")?;
618
619    let help = format!(
620        "{}{}",
621        String::from_utf8_lossy(&output.stdout),
622        String::from_utf8_lossy(&output.stderr)
623    );
624    Ok(help_mentions_update_refs(&help))
625}
626
627/// Whether the short help advertises --update-refs. Match the option name:
628/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
629fn help_mentions_update_refs(help: &str) -> bool {
630    help.contains("update-refs")
631}
632
633pub fn rebase_continue() -> Result<()> {
634    // Passthrough: continuing a rebase can open the user's editor.
635    status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
636}
637
638pub fn rebase_abort() -> Result<()> {
639    status(&["rebase", "--abort"]).context("failed to abort rebase")
640}
641
642pub fn config_get(key: &str) -> Result<Option<String>> {
643    // git config --get exits 1 when the key is unset.
644    output_codes(&["config", "--get", key], &[1], "git config --get")
645}
646
647pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
648    let Some(value) = output_codes(
649        &["config", "--type=bool", "--get", key],
650        &[1],
651        "git config --type=bool --get",
652    )?
653    else {
654        return Ok(None);
655    };
656    match value.as_str() {
657        "true" => Ok(Some(true)),
658        "false" => Ok(Some(false)),
659        _ => bail!("git config {key} is not a boolean: {value}"),
660    }
661}
662
663pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
664    // git config --get-regexp exits 1 when nothing matches.
665    let Some(text) = output_codes(
666        &["config", "--get-regexp", pattern],
667        &[1],
668        "git config --get-regexp",
669    )?
670    else {
671        return Ok(Vec::new());
672    };
673    Ok(text
674        .lines()
675        .filter_map(|line| {
676            line.split_once(' ')
677                .map(|(key, value)| (key.to_owned(), value.to_owned()))
678        })
679        .collect())
680}
681
682pub fn config_set(key: &str, value: &str) -> Result<()> {
683    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
684}
685
686pub fn config_unset(key: &str) -> Result<()> {
687    // git config --unset exits 5 when the key was not set; either way it is now
688    // gone, so treat that as success.
689    output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
690}
691
692/// Run a git command and map its exit code: trimmed stdout on success, `None`
693/// for any code in `ok_empty` (an expected "nothing here" - e.g. `config
694/// --get`'s 1, or `config --unset`'s 5), and an error otherwise. `label` names
695/// the command for the error message.
696fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
697    let output = Command::new("git")
698        .args(args)
699        .stdout(Stdio::piped())
700        .stderr(Stdio::piped())
701        .output()
702        .context("failed to run git")?;
703
704    match output.status.code() {
705        Some(0) => Ok(Some(
706            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
707        )),
708        Some(code) if ok_empty.contains(&code) => Ok(None),
709        _ => Err(command_error(label, &output.stderr)),
710    }
711}
712
713fn output(args: &[&str]) -> Result<String> {
714    let output = Command::new("git")
715        .args(args)
716        .stdout(Stdio::piped())
717        .stderr(Stdio::piped())
718        .output()
719        .context("failed to run git")?;
720
721    if output.status.success() {
722        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
723    } else {
724        Err(command_error("git", &output.stderr))
725    }
726}
727
728/// Like [`output`], but feeds `input` to the command on stdin (for plumbing
729/// such as `hash-object --stdin` and `mktree`).
730fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
731    let mut child = Command::new("git")
732        .args(args)
733        .stdin(Stdio::piped())
734        .stdout(Stdio::piped())
735        .stderr(Stdio::piped())
736        .spawn()
737        .context("failed to run git")?;
738    {
739        let mut stdin = child.stdin.take().context("git has no stdin")?;
740        stdin
741            .write_all(input.as_bytes())
742            .context("failed to write to git")?;
743    }
744    let output = child.wait_with_output().context("failed to run git")?;
745    if output.status.success() {
746        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
747    } else {
748        Err(command_error("git", &output.stderr))
749    }
750}
751
752/// Run git quietly: progress and advice only matter when something goes
753/// wrong, so capture them and replay on failure. `--verbose` passes
754/// everything through.
755fn status(args: &[&str]) -> Result<()> {
756    if verbose() {
757        return status_passthrough(args);
758    }
759
760    let output = Command::new("git")
761        .args(args)
762        .output()
763        .context("failed to run git")?;
764
765    if output.status.success() {
766        Ok(())
767    } else {
768        let _ = std::io::stdout().write_all(&output.stdout);
769        let _ = std::io::stderr().write_all(&output.stderr);
770        bail!("git exited with status {}", output.status)
771    }
772}
773
774/// Inherit stdio unconditionally, for git commands that may need the
775/// terminal (e.g. `rebase --continue` opening the editor).
776fn status_passthrough(args: &[&str]) -> Result<()> {
777    let status = Command::new("git")
778        .args(args)
779        .status()
780        .context("failed to run git")?;
781
782    if status.success() {
783        Ok(())
784    } else {
785        bail!("git exited with status {status}")
786    }
787}
788
789fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
790    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
791    if stderr.is_empty() {
792        anyhow!("{command} failed")
793    } else {
794        anyhow!("{command} failed: {stderr}")
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[test]
803    fn a_merge_queue_rejection_is_downgraded_to_the_queued_refs() {
804        // The exact shape git prints when one ref of a multi-ref push is locked
805        // by a GitHub merge queue while its sibling pushes fine.
806        let stderr = "\
807remote: error: GH006: Protected branch update failed for refs/heads/feat/tf-deploy.
808remote: - A pull request for this branch has been added to a merge queue. Branches that
809remote:   are queued for merging cannot be updated. To modify this branch, dequeue the
810remote:   associated pull request.
811To github.com:higharc/product
812 + 016bb37...3a94024 feat/spa-env -> feat/spa-env (forced update)
813 ! [remote rejected]         feat/tf-deploy -> feat/tf-deploy (protected branch hook declined)
814error: failed to push some refs to 'github.com:higharc/product'";
815        assert_eq!(
816            merge_queue_rejection(stderr),
817            Some(vec!["feat/tf-deploy".to_owned()])
818        );
819    }
820
821    #[test]
822    fn a_stale_lease_rejection_is_not_swallowed_even_with_a_queue_mention() {
823        // A force-with-lease failure is a real problem; the queue wording in the
824        // dependabot banner must not mask it.
825        let stderr = "\
826remote: GitHub found 270 vulnerabilities ... merge queue notes ...
827 ! [rejected]        feat/tf-deploy -> feat/tf-deploy (stale info)
828error: failed to push some refs";
829        assert_eq!(merge_queue_rejection(stderr), None);
830    }
831
832    #[test]
833    fn no_queue_mention_is_not_a_queue_rejection() {
834        let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
835        assert_eq!(merge_queue_rejection(stderr), None);
836    }
837
838    #[test]
839    fn landed_branches_drops_only_the_held_ones() {
840        let attempted = [
841            "feat/a".to_owned(),
842            "feat/b".to_owned(),
843            "feat/c".to_owned(),
844        ];
845        // A branch held back by the queue is dropped; order is preserved so the
846        // "pushed ..." line never names a branch warned as held.
847        assert_eq!(
848            landed_branches(&attempted, &["feat/b".to_owned()]),
849            vec!["feat/a".to_owned(), "feat/c".to_owned()]
850        );
851        // Nothing held: everything landed.
852        assert_eq!(landed_branches(&attempted, &[]), attempted.to_vec());
853        // Every branch held: nothing landed.
854        assert!(landed_branches(&attempted, &attempted).is_empty());
855    }
856
857    #[test]
858    fn a_stale_lease_push_names_the_rejected_branch() {
859        // The exact shape from a submit after a lower branch merged: one ref
860        // pushes, the stale one is rejected by --force-with-lease.
861        let stderr = "\
862To github.com:higharc/product
863   3a94024..d63a2b2  feat/spa-env -> feat/spa-env
864 ! [rejected]                feat/tf-deploy -> feat/tf-deploy (stale info)
865error: failed to push some refs to 'github.com:higharc/product'";
866        assert_eq!(
867            stale_rejection(stderr),
868            Some(vec!["feat/tf-deploy".to_owned()])
869        );
870    }
871
872    #[test]
873    fn a_non_fast_forward_push_is_treated_as_stale() {
874        let stderr = " ! [rejected]  feat/x -> feat/x (non-fast-forward)";
875        assert_eq!(stale_rejection(stderr), Some(vec!["feat/x".to_owned()]));
876    }
877
878    #[test]
879    fn an_unrelated_push_failure_is_not_classified_as_stale() {
880        // Permission/network failures must keep their own error, not "run sync".
881        let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
882        assert_eq!(stale_rejection(stderr), None);
883        assert_eq!(stale_rejection("fatal: could not read from remote"), None);
884    }
885
886    #[test]
887    fn a_mixed_stale_and_non_stale_rejection_is_not_classified_as_stale() {
888        // One ref is stale, another was refused for a reason `git stk sync`
889        // will not fix; the clean message replaces git's output, so it must not
890        // claim sync resolves the permission failure - fall through to raw git.
891        let stderr = "\
892 ! [rejected]                feat/tf-deploy -> feat/tf-deploy (stale info)
893 ! [remote rejected]         feat/locked -> feat/locked (permission denied)
894error: failed to push some refs";
895        assert_eq!(stale_rejection(stderr), None);
896    }
897
898    #[test]
899    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
900        assert!(help_mentions_update_refs(
901            "    --update-refs    update branches that point to commits that are being rebased"
902        ));
903    }
904
905    #[test]
906    fn help_mentions_update_refs_matches_negatable_spelling() {
907        assert!(help_mentions_update_refs(
908            "    --[no-]update-refs    update branches that point to commits that are being rebased"
909        ));
910    }
911
912    #[test]
913    fn help_mentions_update_refs_rejects_help_without_the_option() {
914        assert!(!help_mentions_update_refs(
915            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
916        ));
917    }
918
919    #[test]
920    fn detection_agrees_with_the_real_git_on_this_machine() {
921        // Ground truth: `--update-refs -h` fails with "unknown option" on a
922        // git without the flag and prints help on one that has it.
923        let probe = Command::new("git")
924            .args(["rebase", "--update-refs", "-h"])
925            .stdout(Stdio::piped())
926            .stderr(Stdio::piped())
927            .output()
928            .expect("run git rebase probe");
929        let probe_text = format!(
930            "{}{}",
931            String::from_utf8_lossy(&probe.stdout),
932            String::from_utf8_lossy(&probe.stderr)
933        );
934        let real_support = !probe_text.contains("unknown option");
935
936        assert_eq!(
937            supports_rebase_update_refs().expect("detect support"),
938            real_support
939        );
940    }
941}