Skip to main content

git_stk/
git.rs

1use std::io::Write;
2use std::process::{Command, Stdio};
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use anyhow::{Context, Result, anyhow, bail};
6
7static VERBOSE: AtomicBool = AtomicBool::new(false);
8
9/// Pass raw git output through instead of capturing it.
10pub fn set_verbose(verbose: bool) {
11    VERBOSE.store(verbose, Ordering::Relaxed);
12}
13
14fn verbose() -> bool {
15    VERBOSE.load(Ordering::Relaxed)
16}
17
18pub fn current_branch() -> Result<String> {
19    output(&["symbolic-ref", "--quiet", "--short", "HEAD"])
20        .context("failed to determine current branch")
21}
22
23pub fn local_branches() -> Result<Vec<String>> {
24    let output = output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
25    Ok(output.lines().map(str::to_owned).collect())
26}
27
28pub fn git_path(path: &str) -> Result<String> {
29    output(&["rev-parse", "--git-path", path])
30}
31
32pub fn remote_url(remote: &str) -> Result<Option<String>> {
33    let output = Command::new("git")
34        .args(["remote", "get-url", remote])
35        .stdout(Stdio::piped())
36        .stderr(Stdio::piped())
37        .output()
38        .with_context(|| format!("failed to read git remote {remote}"))?;
39
40    match output.status.code() {
41        Some(0) => Ok(Some(
42            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
43        )),
44        Some(2) => Ok(None),
45        _ => Err(command_error("git remote get-url", &output.stderr)),
46    }
47}
48
49pub fn checkout(branch: &str) -> Result<()> {
50    status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))?;
51    anstream::println!(
52        "switched to {}",
53        crate::style::paint(crate::style::BRANCH, branch)
54    );
55    Ok(())
56}
57
58pub fn create_branch(branch: &str) -> Result<()> {
59    status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
60}
61
62/// Force-delete a branch. 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
101pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
102    let mut args = vec!["rebase"];
103    if update_refs {
104        args.push("--update-refs");
105    }
106    args.extend([parent, branch]);
107
108    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
109}
110
111/// Rebase only the commits after `base`, replaying `base..branch` onto
112/// `parent`. Used when the recorded fork point is known so commits that
113/// landed upstream by squash or rebase are not replayed.
114pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
115    let mut args = vec!["rebase"];
116    if update_refs {
117        args.push("--update-refs");
118    }
119    args.extend(["--onto", parent, base, branch]);
120
121    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
122}
123
124pub fn rev_parse(rev: &str) -> Result<String> {
125    let spec = format!("{rev}^{{commit}}");
126    output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
127}
128
129/// The commit a branch points at, or None when the branch does not exist.
130pub fn branch_sha(branch: &str) -> Option<String> {
131    rev_parse(branch).ok()
132}
133
134/// Point a branch at a commit, creating it if absent. Does not touch the
135/// worktree.
136pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
137    status(&["update-ref", &format!("refs/heads/{branch}"), sha])
138        .with_context(|| format!("failed to update {branch} to {sha}"))
139}
140
141/// Reset the worktree and index to HEAD. Safe to lose nothing only on a
142/// clean tree; callers must check [`worktree_is_clean`] first.
143pub fn reset_hard() -> Result<()> {
144    status(&["reset", "--hard"]).context("failed to reset the worktree")
145}
146
147/// Whether the worktree and index have no uncommitted changes.
148pub fn worktree_is_clean() -> Result<bool> {
149    Ok(output(&["status", "--porcelain"])?.is_empty())
150}
151
152/// Default branch of `remote` (from its locally-known HEAD symref), if any.
153pub fn remote_default_branch(remote: &str) -> Option<String> {
154    let reference = format!("refs/remotes/{remote}/HEAD");
155    let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
156    full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
157}
158
159/// How many commits `parent` has that `branch` does not: nonzero means the
160/// branch needs a restack.
161pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
162    let range = format!("{branch}..{parent}");
163    let count = output(&["rev-list", "--count", &range])
164        .with_context(|| format!("failed to count commits in {range}"))?;
165    count
166        .trim()
167        .parse()
168        .context("failed to parse rev-list count")
169}
170
171pub fn merge_base(a: &str, b: &str) -> Result<String> {
172    output(&["merge-base", a, b])
173        .with_context(|| format!("failed to find merge base of {a} and {b}"))
174}
175
176/// A unified-0 diff against HEAD: just the staged changes when `cached`,
177/// otherwise all tracked changes (staged and unstaged). Zero context lines
178/// so each hunk's pre-image range pinpoints exactly the lines it touches.
179pub fn diff_against_head(cached: bool) -> Result<String> {
180    // Pin a/ b/ prefixes: diff.mnemonicPrefix / diff.noprefix would otherwise
181    // emit headers absorb's parser and `git apply` cannot read.
182    let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
183    if cached {
184        args.push("--cached");
185    }
186    args.push("HEAD");
187    output(&args).context("failed to diff against HEAD")
188}
189
190/// The distinct commits that last touched lines `start..start+len` of `file`
191/// in HEAD, newest blame wins per line. An empty range yields nothing.
192pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
193    if len == 0 {
194        return Ok(Vec::new());
195    }
196    let range = format!("{start},{}", start + len - 1);
197    let out = output(&[
198        "blame",
199        "HEAD",
200        "-L",
201        &range,
202        "--line-porcelain",
203        "--",
204        file,
205    ])
206    .with_context(|| format!("failed to blame {file}"))?;
207
208    let mut shas = Vec::new();
209    for line in out.lines() {
210        // Each porcelain block opens with "<40-hex sha> <orig> <final> ...";
211        // other fields (author, summary, "previous", the tab-led content) do
212        // not start with a bare 40-hex token.
213        let token = line.split(' ').next().unwrap_or_default();
214        if token.len() == 40
215            && token.bytes().all(|byte| byte.is_ascii_hexdigit())
216            && !shas.iter().any(|seen| seen == token)
217        {
218            shas.push(token.to_owned());
219        }
220    }
221    Ok(shas)
222}
223
224/// The commits in `range` (e.g. "main..HEAD"), newest first.
225pub fn rev_list(range: &str) -> Result<Vec<String>> {
226    Ok(output(&["rev-list", range])
227        .with_context(|| format!("failed to list commits in {range}"))?
228        .lines()
229        .map(str::to_owned)
230        .collect())
231}
232
233/// A commit's subject line.
234pub fn commit_subject(sha: &str) -> Result<String> {
235    output(&["show", "--no-patch", "--format=%s", sha])
236        .with_context(|| format!("failed to read subject of {sha}"))
237}
238
239/// Stage a unified-0 patch into the index. `--unidiff-zero` is required for
240/// git to accept the zero-context hunks absorb works with.
241pub fn apply_cached(patch: &str) -> Result<()> {
242    let mut child = Command::new("git")
243        .args(["apply", "--cached", "--unidiff-zero"])
244        .stdin(Stdio::piped())
245        .stdout(Stdio::piped())
246        .stderr(Stdio::piped())
247        .spawn()
248        .context("failed to run git apply")?;
249    {
250        let mut stdin = child.stdin.take().context("git apply has no stdin")?;
251        stdin
252            .write_all(patch.as_bytes())
253            .context("failed to write patch to git apply")?;
254    }
255    let output = child
256        .wait_with_output()
257        .context("failed to run git apply")?;
258    if output.status.success() {
259        Ok(())
260    } else {
261        Err(command_error("git apply", &output.stderr))
262    }
263}
264
265/// Commit the staged index as a `fixup!` of `sha`, for a later autosquash
266/// rebase to fold in. Skips hooks: these are internal, transient commits.
267pub fn commit_fixup(sha: &str) -> Result<()> {
268    status(&["commit", "--no-verify", &format!("--fixup={sha}")])
269        .with_context(|| format!("failed to create fixup commit for {sha}"))
270}
271
272/// Unstage everything, leaving the worktree contents untouched.
273pub fn reset_index() -> Result<()> {
274    status(&["reset", "--quiet"]).context("failed to reset the index")
275}
276
277/// Move HEAD to `sha`, returning any commits after it to the index.
278pub fn reset_soft(sha: &str) -> Result<()> {
279    status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
280}
281
282/// Stash tracked worktree changes; pair with [`stash_pop`].
283pub fn stash_push() -> Result<()> {
284    status(&["stash", "push", "--quiet"]).context("failed to stash changes")
285}
286
287/// Restore the most recently stashed changes.
288pub fn stash_pop() -> Result<()> {
289    status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
290}
291
292/// Rebase `base..HEAD`, folding `fixup!` commits into their targets. The
293/// generated todo is accepted unedited, so it needs no terminal.
294pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
295    let mut args = vec!["rebase", "--interactive", "--autosquash"];
296    if update_refs {
297        args.push("--update-refs");
298    }
299    args.push(base);
300
301    let output = Command::new("git")
302        .args(&args)
303        .env("GIT_SEQUENCE_EDITOR", "true")
304        .env("GIT_EDITOR", "true")
305        .output()
306        .context("failed to run git rebase")?;
307    if output.status.success() {
308        Ok(())
309    } else {
310        Err(command_error("git rebase --autosquash", &output.stderr))
311    }
312}
313
314pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
315    let output = Command::new("git")
316        .args(["merge-base", "--is-ancestor", ancestor, descendant])
317        .stdout(Stdio::piped())
318        .stderr(Stdio::piped())
319        .output()
320        .context("failed to run git merge-base --is-ancestor")?;
321
322    match output.status.code() {
323        Some(0) => Ok(true),
324        Some(1) => Ok(false),
325        _ => Err(command_error(
326            "git merge-base --is-ancestor",
327            &output.stderr,
328        )),
329    }
330}
331
332pub fn supports_rebase_update_refs() -> Result<bool> {
333    let output = Command::new("git")
334        .args(["rebase", "-h"])
335        .stdout(Stdio::piped())
336        .stderr(Stdio::piped())
337        .output()
338        .context("failed to inspect git rebase help")?;
339
340    let help = format!(
341        "{}{}",
342        String::from_utf8_lossy(&output.stdout),
343        String::from_utf8_lossy(&output.stderr)
344    );
345    Ok(help_mentions_update_refs(&help))
346}
347
348/// Whether the short help advertises --update-refs. Match the option name:
349/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
350fn help_mentions_update_refs(help: &str) -> bool {
351    help.contains("update-refs")
352}
353
354pub fn rebase_continue() -> Result<()> {
355    // Passthrough: continuing a rebase can open the user's editor.
356    status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
357}
358
359pub fn rebase_abort() -> Result<()> {
360    status(&["rebase", "--abort"]).context("failed to abort rebase")
361}
362
363pub fn config_get(key: &str) -> Result<Option<String>> {
364    let output = Command::new("git")
365        .args(["config", "--get", key])
366        .stdout(Stdio::piped())
367        .stderr(Stdio::piped())
368        .output()
369        .with_context(|| format!("failed to read git config {key}"))?;
370
371    match output.status.code() {
372        Some(0) => Ok(Some(
373            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
374        )),
375        Some(1) => Ok(None),
376        _ => Err(command_error("git config --get", &output.stderr)),
377    }
378}
379
380pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
381    let output = Command::new("git")
382        .args(["config", "--type=bool", "--get", key])
383        .stdout(Stdio::piped())
384        .stderr(Stdio::piped())
385        .output()
386        .with_context(|| format!("failed to read git config {key}"))?;
387
388    match output.status.code() {
389        Some(0) => {
390            let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
391            match value.as_str() {
392                "true" => Ok(Some(true)),
393                "false" => Ok(Some(false)),
394                _ => bail!("git config {key} is not a boolean: {value}"),
395            }
396        }
397        Some(1) => Ok(None),
398        _ => Err(command_error(
399            "git config --type=bool --get",
400            &output.stderr,
401        )),
402    }
403}
404
405pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
406    let output = Command::new("git")
407        .args(["config", "--get-regexp", pattern])
408        .stdout(Stdio::piped())
409        .stderr(Stdio::piped())
410        .output()
411        .with_context(|| format!("failed to read git config matching {pattern}"))?;
412
413    match output.status.code() {
414        Some(0) => Ok(String::from_utf8_lossy(&output.stdout)
415            .lines()
416            .filter_map(|line| {
417                line.split_once(' ')
418                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
419            })
420            .collect()),
421        Some(1) => Ok(Vec::new()),
422        _ => Err(command_error("git config --get-regexp", &output.stderr)),
423    }
424}
425
426pub fn config_set(key: &str, value: &str) -> Result<()> {
427    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
428}
429
430pub fn config_unset(key: &str) -> Result<()> {
431    let output = Command::new("git")
432        .args(["config", "--unset", key])
433        .stdout(Stdio::piped())
434        .stderr(Stdio::piped())
435        .output()
436        .with_context(|| format!("failed to unset git config {key}"))?;
437
438    match output.status.code() {
439        Some(0) | Some(5) => Ok(()),
440        _ => Err(command_error("git config --unset", &output.stderr)),
441    }
442}
443
444fn output(args: &[&str]) -> Result<String> {
445    let output = Command::new("git")
446        .args(args)
447        .stdout(Stdio::piped())
448        .stderr(Stdio::piped())
449        .output()
450        .context("failed to run git")?;
451
452    if output.status.success() {
453        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
454    } else {
455        Err(command_error("git", &output.stderr))
456    }
457}
458
459/// Run git quietly: progress and advice only matter when something goes
460/// wrong, so capture them and replay on failure. `--verbose` passes
461/// everything through.
462fn status(args: &[&str]) -> Result<()> {
463    if verbose() {
464        return status_passthrough(args);
465    }
466
467    let output = Command::new("git")
468        .args(args)
469        .output()
470        .context("failed to run git")?;
471
472    if output.status.success() {
473        Ok(())
474    } else {
475        let _ = std::io::stdout().write_all(&output.stdout);
476        let _ = std::io::stderr().write_all(&output.stderr);
477        bail!("git exited with status {}", output.status)
478    }
479}
480
481/// Inherit stdio unconditionally, for git commands that may need the
482/// terminal (e.g. `rebase --continue` opening the editor).
483fn status_passthrough(args: &[&str]) -> Result<()> {
484    let status = Command::new("git")
485        .args(args)
486        .status()
487        .context("failed to run git")?;
488
489    if status.success() {
490        Ok(())
491    } else {
492        bail!("git exited with status {status}")
493    }
494}
495
496fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
497    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
498    if stderr.is_empty() {
499        anyhow!("{command} failed")
500    } else {
501        anyhow!("{command} failed: {stderr}")
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
511        assert!(help_mentions_update_refs(
512            "    --update-refs    update branches that point to commits that are being rebased"
513        ));
514    }
515
516    #[test]
517    fn help_mentions_update_refs_matches_negatable_spelling() {
518        assert!(help_mentions_update_refs(
519            "    --[no-]update-refs    update branches that point to commits that are being rebased"
520        ));
521    }
522
523    #[test]
524    fn help_mentions_update_refs_rejects_help_without_the_option() {
525        assert!(!help_mentions_update_refs(
526            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
527        ));
528    }
529
530    #[test]
531    fn detection_agrees_with_the_real_git_on_this_machine() {
532        // Ground truth: `--update-refs -h` fails with "unknown option" on a
533        // git without the flag and prints help on one that has it.
534        let probe = Command::new("git")
535            .args(["rebase", "--update-refs", "-h"])
536            .stdout(Stdio::piped())
537            .stderr(Stdio::piped())
538            .output()
539            .expect("run git rebase probe");
540        let probe_text = format!(
541            "{}{}",
542            String::from_utf8_lossy(&probe.stdout),
543            String::from_utf8_lossy(&probe.stderr)
544        );
545        let real_support = !probe_text.contains("unknown option");
546
547        assert_eq!(
548            supports_rebase_update_refs().expect("detect support"),
549            real_support
550        );
551    }
552}