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