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. Callers are expected to have verified the branch
63/// landed through review state: after a squash merge its commits are not
64/// ancestry-merged, so `git branch -d` can 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    let mut args = vec!["diff", "--unified=0"];
181    if cached {
182        args.push("--cached");
183    }
184    args.push("HEAD");
185    output(&args).context("failed to diff against HEAD")
186}
187
188/// The distinct commits that last touched lines `start..start+len` of `file`
189/// in HEAD, newest blame wins per line. An empty range yields nothing.
190pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
191    if len == 0 {
192        return Ok(Vec::new());
193    }
194    let range = format!("{start},{}", start + len - 1);
195    let out = output(&[
196        "blame",
197        "HEAD",
198        "-L",
199        &range,
200        "--line-porcelain",
201        "--",
202        file,
203    ])
204    .with_context(|| format!("failed to blame {file}"))?;
205
206    let mut shas = Vec::new();
207    for line in out.lines() {
208        // Each porcelain block opens with "<40-hex sha> <orig> <final> ...";
209        // other fields (author, summary, "previous", the tab-led content) do
210        // not start with a bare 40-hex token.
211        let token = line.split(' ').next().unwrap_or_default();
212        if token.len() == 40
213            && token.bytes().all(|byte| byte.is_ascii_hexdigit())
214            && !shas.iter().any(|seen| seen == token)
215        {
216            shas.push(token.to_owned());
217        }
218    }
219    Ok(shas)
220}
221
222/// The commits in `range` (e.g. "main..HEAD"), newest first.
223pub fn rev_list(range: &str) -> Result<Vec<String>> {
224    Ok(output(&["rev-list", range])
225        .with_context(|| format!("failed to list commits in {range}"))?
226        .lines()
227        .map(str::to_owned)
228        .collect())
229}
230
231/// A commit's subject line.
232pub fn commit_subject(sha: &str) -> Result<String> {
233    output(&["show", "--no-patch", "--format=%s", sha])
234        .with_context(|| format!("failed to read subject of {sha}"))
235}
236
237/// Stage a unified-0 patch into the index. `--unidiff-zero` is required for
238/// git to accept the zero-context hunks absorb works with.
239pub fn apply_cached(patch: &str) -> Result<()> {
240    let mut child = Command::new("git")
241        .args(["apply", "--cached", "--unidiff-zero"])
242        .stdin(Stdio::piped())
243        .stdout(Stdio::piped())
244        .stderr(Stdio::piped())
245        .spawn()
246        .context("failed to run git apply")?;
247    {
248        let mut stdin = child.stdin.take().context("git apply has no stdin")?;
249        stdin
250            .write_all(patch.as_bytes())
251            .context("failed to write patch to git apply")?;
252    }
253    let output = child
254        .wait_with_output()
255        .context("failed to run git apply")?;
256    if output.status.success() {
257        Ok(())
258    } else {
259        Err(command_error("git apply", &output.stderr))
260    }
261}
262
263/// Commit the staged index as a `fixup!` of `sha`, for a later autosquash
264/// rebase to fold in. Skips hooks: these are internal, transient commits.
265pub fn commit_fixup(sha: &str) -> Result<()> {
266    status(&["commit", "--no-verify", &format!("--fixup={sha}")])
267        .with_context(|| format!("failed to create fixup commit for {sha}"))
268}
269
270/// Unstage everything, leaving the worktree contents untouched.
271pub fn reset_index() -> Result<()> {
272    status(&["reset", "--quiet"]).context("failed to reset the index")
273}
274
275/// Move HEAD to `sha`, returning any commits after it to the index.
276pub fn reset_soft(sha: &str) -> Result<()> {
277    status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
278}
279
280/// Stash tracked worktree changes; pair with [`stash_pop`].
281pub fn stash_push() -> Result<()> {
282    status(&["stash", "push", "--quiet"]).context("failed to stash changes")
283}
284
285/// Restore the most recently stashed changes.
286pub fn stash_pop() -> Result<()> {
287    status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
288}
289
290/// Rebase `base..HEAD`, folding `fixup!` commits into their targets. The
291/// generated todo is accepted unedited, so it needs no terminal.
292pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
293    let mut args = vec!["rebase", "--interactive", "--autosquash"];
294    if update_refs {
295        args.push("--update-refs");
296    }
297    args.push(base);
298
299    let output = Command::new("git")
300        .args(&args)
301        .env("GIT_SEQUENCE_EDITOR", "true")
302        .env("GIT_EDITOR", "true")
303        .output()
304        .context("failed to run git rebase")?;
305    if output.status.success() {
306        Ok(())
307    } else {
308        Err(command_error("git rebase --autosquash", &output.stderr))
309    }
310}
311
312pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
313    let output = Command::new("git")
314        .args(["merge-base", "--is-ancestor", ancestor, descendant])
315        .stdout(Stdio::piped())
316        .stderr(Stdio::piped())
317        .output()
318        .context("failed to run git merge-base --is-ancestor")?;
319
320    match output.status.code() {
321        Some(0) => Ok(true),
322        Some(1) => Ok(false),
323        _ => Err(command_error(
324            "git merge-base --is-ancestor",
325            &output.stderr,
326        )),
327    }
328}
329
330pub fn supports_rebase_update_refs() -> Result<bool> {
331    let output = Command::new("git")
332        .args(["rebase", "-h"])
333        .stdout(Stdio::piped())
334        .stderr(Stdio::piped())
335        .output()
336        .context("failed to inspect git rebase help")?;
337
338    let help = format!(
339        "{}{}",
340        String::from_utf8_lossy(&output.stdout),
341        String::from_utf8_lossy(&output.stderr)
342    );
343    Ok(help_mentions_update_refs(&help))
344}
345
346/// Whether the short help advertises --update-refs. Match the option name:
347/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
348fn help_mentions_update_refs(help: &str) -> bool {
349    help.contains("update-refs")
350}
351
352pub fn rebase_continue() -> Result<()> {
353    // Passthrough: continuing a rebase can open the user's editor.
354    status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
355}
356
357pub fn rebase_abort() -> Result<()> {
358    status(&["rebase", "--abort"]).context("failed to abort rebase")
359}
360
361pub fn config_get(key: &str) -> Result<Option<String>> {
362    let output = Command::new("git")
363        .args(["config", "--get", key])
364        .stdout(Stdio::piped())
365        .stderr(Stdio::piped())
366        .output()
367        .with_context(|| format!("failed to read git config {key}"))?;
368
369    match output.status.code() {
370        Some(0) => Ok(Some(
371            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
372        )),
373        Some(1) => Ok(None),
374        _ => Err(command_error("git config --get", &output.stderr)),
375    }
376}
377
378pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
379    let output = Command::new("git")
380        .args(["config", "--type=bool", "--get", key])
381        .stdout(Stdio::piped())
382        .stderr(Stdio::piped())
383        .output()
384        .with_context(|| format!("failed to read git config {key}"))?;
385
386    match output.status.code() {
387        Some(0) => {
388            let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
389            match value.as_str() {
390                "true" => Ok(Some(true)),
391                "false" => Ok(Some(false)),
392                _ => bail!("git config {key} is not a boolean: {value}"),
393            }
394        }
395        Some(1) => Ok(None),
396        _ => Err(command_error(
397            "git config --type=bool --get",
398            &output.stderr,
399        )),
400    }
401}
402
403pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
404    let output = Command::new("git")
405        .args(["config", "--get-regexp", pattern])
406        .stdout(Stdio::piped())
407        .stderr(Stdio::piped())
408        .output()
409        .with_context(|| format!("failed to read git config matching {pattern}"))?;
410
411    match output.status.code() {
412        Some(0) => Ok(String::from_utf8_lossy(&output.stdout)
413            .lines()
414            .filter_map(|line| {
415                line.split_once(' ')
416                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
417            })
418            .collect()),
419        Some(1) => Ok(Vec::new()),
420        _ => Err(command_error("git config --get-regexp", &output.stderr)),
421    }
422}
423
424pub fn config_set(key: &str, value: &str) -> Result<()> {
425    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
426}
427
428pub fn config_unset(key: &str) -> Result<()> {
429    let output = Command::new("git")
430        .args(["config", "--unset", key])
431        .stdout(Stdio::piped())
432        .stderr(Stdio::piped())
433        .output()
434        .with_context(|| format!("failed to unset git config {key}"))?;
435
436    match output.status.code() {
437        Some(0) | Some(5) => Ok(()),
438        _ => Err(command_error("git config --unset", &output.stderr)),
439    }
440}
441
442fn output(args: &[&str]) -> Result<String> {
443    let output = Command::new("git")
444        .args(args)
445        .stdout(Stdio::piped())
446        .stderr(Stdio::piped())
447        .output()
448        .context("failed to run git")?;
449
450    if output.status.success() {
451        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
452    } else {
453        Err(command_error("git", &output.stderr))
454    }
455}
456
457/// Run git quietly: progress and advice only matter when something goes
458/// wrong, so capture them and replay on failure. `--verbose` passes
459/// everything through.
460fn status(args: &[&str]) -> Result<()> {
461    if verbose() {
462        return status_passthrough(args);
463    }
464
465    let output = Command::new("git")
466        .args(args)
467        .output()
468        .context("failed to run git")?;
469
470    if output.status.success() {
471        Ok(())
472    } else {
473        let _ = std::io::stdout().write_all(&output.stdout);
474        let _ = std::io::stderr().write_all(&output.stderr);
475        bail!("git exited with status {}", output.status)
476    }
477}
478
479/// Inherit stdio unconditionally, for git commands that may need the
480/// terminal (e.g. `rebase --continue` opening the editor).
481fn status_passthrough(args: &[&str]) -> Result<()> {
482    let status = Command::new("git")
483        .args(args)
484        .status()
485        .context("failed to run git")?;
486
487    if status.success() {
488        Ok(())
489    } else {
490        bail!("git exited with status {status}")
491    }
492}
493
494fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
495    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
496    if stderr.is_empty() {
497        anyhow!("{command} failed")
498    } else {
499        anyhow!("{command} failed: {stderr}")
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
509        assert!(help_mentions_update_refs(
510            "    --update-refs    update branches that point to commits that are being rebased"
511        ));
512    }
513
514    #[test]
515    fn help_mentions_update_refs_matches_negatable_spelling() {
516        assert!(help_mentions_update_refs(
517            "    --[no-]update-refs    update branches that point to commits that are being rebased"
518        ));
519    }
520
521    #[test]
522    fn help_mentions_update_refs_rejects_help_without_the_option() {
523        assert!(!help_mentions_update_refs(
524            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
525        ));
526    }
527
528    #[test]
529    fn detection_agrees_with_the_real_git_on_this_machine() {
530        // Ground truth: `--update-refs -h` fails with "unknown option" on a
531        // git without the flag and prints help on one that has it.
532        let probe = Command::new("git")
533            .args(["rebase", "--update-refs", "-h"])
534            .stdout(Stdio::piped())
535            .stderr(Stdio::piped())
536            .output()
537            .expect("run git rebase probe");
538        let probe_text = format!(
539            "{}{}",
540            String::from_utf8_lossy(&probe.stdout),
541            String::from_utf8_lossy(&probe.stderr)
542        );
543        let real_support = !probe_text.contains("unknown option");
544
545        assert_eq!(
546            supports_rebase_update_refs().expect("detect support"),
547            real_support
548        );
549    }
550}