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/// Default branch of `remote` (from its locally-known HEAD symref), if any.
130pub fn remote_default_branch(remote: &str) -> Option<String> {
131    let reference = format!("refs/remotes/{remote}/HEAD");
132    let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
133    full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
134}
135
136/// How many commits `parent` has that `branch` does not: nonzero means the
137/// branch needs a restack.
138pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
139    let range = format!("{branch}..{parent}");
140    let count = output(&["rev-list", "--count", &range])
141        .with_context(|| format!("failed to count commits in {range}"))?;
142    count
143        .trim()
144        .parse()
145        .context("failed to parse rev-list count")
146}
147
148pub fn merge_base(a: &str, b: &str) -> Result<String> {
149    output(&["merge-base", a, b])
150        .with_context(|| format!("failed to find merge base of {a} and {b}"))
151}
152
153pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
154    let output = Command::new("git")
155        .args(["merge-base", "--is-ancestor", ancestor, descendant])
156        .stdout(Stdio::piped())
157        .stderr(Stdio::piped())
158        .output()
159        .context("failed to run git merge-base --is-ancestor")?;
160
161    match output.status.code() {
162        Some(0) => Ok(true),
163        Some(1) => Ok(false),
164        _ => Err(command_error(
165            "git merge-base --is-ancestor",
166            &output.stderr,
167        )),
168    }
169}
170
171pub fn supports_rebase_update_refs() -> Result<bool> {
172    let output = Command::new("git")
173        .args(["rebase", "-h"])
174        .stdout(Stdio::piped())
175        .stderr(Stdio::piped())
176        .output()
177        .context("failed to inspect git rebase help")?;
178
179    let help = format!(
180        "{}{}",
181        String::from_utf8_lossy(&output.stdout),
182        String::from_utf8_lossy(&output.stderr)
183    );
184    Ok(help_mentions_update_refs(&help))
185}
186
187/// Whether the short help advertises --update-refs. Match the option name:
188/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
189fn help_mentions_update_refs(help: &str) -> bool {
190    help.contains("update-refs")
191}
192
193pub fn rebase_continue() -> Result<()> {
194    // Passthrough: continuing a rebase can open the user's editor.
195    status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
196}
197
198pub fn rebase_abort() -> Result<()> {
199    status(&["rebase", "--abort"]).context("failed to abort rebase")
200}
201
202pub fn config_get(key: &str) -> Result<Option<String>> {
203    let output = Command::new("git")
204        .args(["config", "--get", key])
205        .stdout(Stdio::piped())
206        .stderr(Stdio::piped())
207        .output()
208        .with_context(|| format!("failed to read git config {key}"))?;
209
210    match output.status.code() {
211        Some(0) => Ok(Some(
212            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
213        )),
214        Some(1) => Ok(None),
215        _ => Err(command_error("git config --get", &output.stderr)),
216    }
217}
218
219pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
220    let output = Command::new("git")
221        .args(["config", "--type=bool", "--get", key])
222        .stdout(Stdio::piped())
223        .stderr(Stdio::piped())
224        .output()
225        .with_context(|| format!("failed to read git config {key}"))?;
226
227    match output.status.code() {
228        Some(0) => {
229            let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
230            match value.as_str() {
231                "true" => Ok(Some(true)),
232                "false" => Ok(Some(false)),
233                _ => bail!("git config {key} is not a boolean: {value}"),
234            }
235        }
236        Some(1) => Ok(None),
237        _ => Err(command_error(
238            "git config --type=bool --get",
239            &output.stderr,
240        )),
241    }
242}
243
244pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
245    let output = Command::new("git")
246        .args(["config", "--get-regexp", pattern])
247        .stdout(Stdio::piped())
248        .stderr(Stdio::piped())
249        .output()
250        .with_context(|| format!("failed to read git config matching {pattern}"))?;
251
252    match output.status.code() {
253        Some(0) => Ok(String::from_utf8_lossy(&output.stdout)
254            .lines()
255            .filter_map(|line| {
256                line.split_once(' ')
257                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
258            })
259            .collect()),
260        Some(1) => Ok(Vec::new()),
261        _ => Err(command_error("git config --get-regexp", &output.stderr)),
262    }
263}
264
265pub fn config_set(key: &str, value: &str) -> Result<()> {
266    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
267}
268
269pub fn config_unset(key: &str) -> Result<()> {
270    let output = Command::new("git")
271        .args(["config", "--unset", key])
272        .stdout(Stdio::piped())
273        .stderr(Stdio::piped())
274        .output()
275        .with_context(|| format!("failed to unset git config {key}"))?;
276
277    match output.status.code() {
278        Some(0) | Some(5) => Ok(()),
279        _ => Err(command_error("git config --unset", &output.stderr)),
280    }
281}
282
283fn output(args: &[&str]) -> Result<String> {
284    let output = Command::new("git")
285        .args(args)
286        .stdout(Stdio::piped())
287        .stderr(Stdio::piped())
288        .output()
289        .context("failed to run git")?;
290
291    if output.status.success() {
292        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
293    } else {
294        Err(command_error("git", &output.stderr))
295    }
296}
297
298/// Run git quietly: progress and advice only matter when something goes
299/// wrong, so capture them and replay on failure. `--verbose` passes
300/// everything through.
301fn status(args: &[&str]) -> Result<()> {
302    if verbose() {
303        return status_passthrough(args);
304    }
305
306    let output = Command::new("git")
307        .args(args)
308        .output()
309        .context("failed to run git")?;
310
311    if output.status.success() {
312        Ok(())
313    } else {
314        let _ = std::io::stdout().write_all(&output.stdout);
315        let _ = std::io::stderr().write_all(&output.stderr);
316        bail!("git exited with status {}", output.status)
317    }
318}
319
320/// Inherit stdio unconditionally, for git commands that may need the
321/// terminal (e.g. `rebase --continue` opening the editor).
322fn status_passthrough(args: &[&str]) -> Result<()> {
323    let status = Command::new("git")
324        .args(args)
325        .status()
326        .context("failed to run git")?;
327
328    if status.success() {
329        Ok(())
330    } else {
331        bail!("git exited with status {status}")
332    }
333}
334
335fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
336    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
337    if stderr.is_empty() {
338        anyhow!("{command} failed")
339    } else {
340        anyhow!("{command} failed: {stderr}")
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
350        assert!(help_mentions_update_refs(
351            "    --update-refs    update branches that point to commits that are being rebased"
352        ));
353    }
354
355    #[test]
356    fn help_mentions_update_refs_matches_negatable_spelling() {
357        assert!(help_mentions_update_refs(
358            "    --[no-]update-refs    update branches that point to commits that are being rebased"
359        ));
360    }
361
362    #[test]
363    fn help_mentions_update_refs_rejects_help_without_the_option() {
364        assert!(!help_mentions_update_refs(
365            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
366        ));
367    }
368
369    #[test]
370    fn detection_agrees_with_the_real_git_on_this_machine() {
371        // Ground truth: `--update-refs -h` fails with "unknown option" on a
372        // git without the flag and prints help on one that has it.
373        let probe = Command::new("git")
374            .args(["rebase", "--update-refs", "-h"])
375            .stdout(Stdio::piped())
376            .stderr(Stdio::piped())
377            .output()
378            .expect("run git rebase probe");
379        let probe_text = format!(
380            "{}{}",
381            String::from_utf8_lossy(&probe.stdout),
382            String::from_utf8_lossy(&probe.stderr)
383        );
384        let real_support = !probe_text.contains("unknown option");
385
386        assert_eq!(
387            supports_rebase_update_refs().expect("detect support"),
388            real_support
389        );
390    }
391}