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