Skip to main content

git_stk/
git.rs

1use std::process::{Command, Stdio};
2
3use anyhow::{Context, Result, anyhow, bail};
4
5pub fn current_branch() -> Result<String> {
6    output(&["symbolic-ref", "--quiet", "--short", "HEAD"])
7        .context("failed to determine current branch")
8}
9
10pub fn local_branches() -> Result<Vec<String>> {
11    let output = output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
12    Ok(output.lines().map(str::to_owned).collect())
13}
14
15pub fn git_path(path: &str) -> Result<String> {
16    output(&["rev-parse", "--git-path", path])
17}
18
19pub fn remote_url(remote: &str) -> Result<Option<String>> {
20    let output = Command::new("git")
21        .args(["remote", "get-url", remote])
22        .stdout(Stdio::piped())
23        .stderr(Stdio::piped())
24        .output()
25        .with_context(|| format!("failed to read git remote {remote}"))?;
26
27    match output.status.code() {
28        Some(0) => Ok(Some(
29            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
30        )),
31        Some(2) => Ok(None),
32        _ => Err(command_error("git remote get-url", &output.stderr)),
33    }
34}
35
36pub fn checkout(branch: &str) -> Result<()> {
37    status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))
38}
39
40pub fn create_branch(branch: &str) -> Result<()> {
41    status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
42}
43
44/// Force-delete a branch. Callers are expected to have verified the branch
45/// landed through review state: after a squash merge its commits are not
46/// ancestry-merged, so `git branch -d` can refuse even though the work is in.
47pub fn delete_branch(branch: &str) -> Result<()> {
48    status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
49}
50
51/// Fast-forward a local branch from its remote without checking it out.
52pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
53    let refspec = format!("{branch}:{branch}");
54    status(&["fetch", remote, &refspec])
55        .with_context(|| format!("failed to fetch {branch} from {remote}"))
56}
57
58pub fn pull_ff_only() -> Result<()> {
59    status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
60}
61
62pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
63    let mut args = vec!["push", "--force-with-lease", remote];
64    args.extend(branches.iter().map(String::as_str));
65
66    status(&args).with_context(|| format!("failed to push branches to {remote}"))
67}
68
69/// Push branches and set upstream tracking; used before submitting so new
70/// branches exist remotely and rebased ones are safely updated.
71pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
72    let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
73    args.extend(branches.iter().map(String::as_str));
74
75    status(&args).with_context(|| format!("failed to push branches to {remote}"))
76}
77
78pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
79    let mut args = vec!["rebase"];
80    if update_refs {
81        args.push("--update-refs");
82    }
83    args.extend([parent, branch]);
84
85    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
86}
87
88/// Rebase only the commits after `base`, replaying `base..branch` onto
89/// `parent`. Used when the recorded fork point is known so commits that
90/// landed upstream by squash or rebase are not replayed.
91pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
92    let mut args = vec!["rebase"];
93    if update_refs {
94        args.push("--update-refs");
95    }
96    args.extend(["--onto", parent, base, branch]);
97
98    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
99}
100
101pub fn rev_parse(rev: &str) -> Result<String> {
102    let spec = format!("{rev}^{{commit}}");
103    output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
104}
105
106/// Default branch of `remote` (from its locally-known HEAD symref), if any.
107pub fn remote_default_branch(remote: &str) -> Option<String> {
108    let reference = format!("refs/remotes/{remote}/HEAD");
109    let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
110    full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
111}
112
113pub fn merge_base(a: &str, b: &str) -> Result<String> {
114    output(&["merge-base", a, b])
115        .with_context(|| format!("failed to find merge base of {a} and {b}"))
116}
117
118pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
119    let output = Command::new("git")
120        .args(["merge-base", "--is-ancestor", ancestor, descendant])
121        .stdout(Stdio::piped())
122        .stderr(Stdio::piped())
123        .output()
124        .context("failed to run git merge-base --is-ancestor")?;
125
126    match output.status.code() {
127        Some(0) => Ok(true),
128        Some(1) => Ok(false),
129        _ => Err(command_error(
130            "git merge-base --is-ancestor",
131            &output.stderr,
132        )),
133    }
134}
135
136pub fn supports_rebase_update_refs() -> Result<bool> {
137    let output = Command::new("git")
138        .args(["rebase", "-h"])
139        .stdout(Stdio::piped())
140        .stderr(Stdio::piped())
141        .output()
142        .context("failed to inspect git rebase help")?;
143
144    let help = format!(
145        "{}{}",
146        String::from_utf8_lossy(&output.stdout),
147        String::from_utf8_lossy(&output.stderr)
148    );
149    Ok(help_mentions_update_refs(&help))
150}
151
152/// Whether the short help advertises --update-refs. Match the option name:
153/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
154fn help_mentions_update_refs(help: &str) -> bool {
155    help.contains("update-refs")
156}
157
158pub fn rebase_continue() -> Result<()> {
159    status(&["rebase", "--continue"]).context("failed to continue rebase")
160}
161
162pub fn rebase_abort() -> Result<()> {
163    status(&["rebase", "--abort"]).context("failed to abort rebase")
164}
165
166pub fn config_get(key: &str) -> Result<Option<String>> {
167    let output = Command::new("git")
168        .args(["config", "--get", key])
169        .stdout(Stdio::piped())
170        .stderr(Stdio::piped())
171        .output()
172        .with_context(|| format!("failed to read git config {key}"))?;
173
174    match output.status.code() {
175        Some(0) => Ok(Some(
176            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
177        )),
178        Some(1) => Ok(None),
179        _ => Err(command_error("git config --get", &output.stderr)),
180    }
181}
182
183pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
184    let output = Command::new("git")
185        .args(["config", "--type=bool", "--get", key])
186        .stdout(Stdio::piped())
187        .stderr(Stdio::piped())
188        .output()
189        .with_context(|| format!("failed to read git config {key}"))?;
190
191    match output.status.code() {
192        Some(0) => {
193            let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
194            match value.as_str() {
195                "true" => Ok(Some(true)),
196                "false" => Ok(Some(false)),
197                _ => bail!("git config {key} is not a boolean: {value}"),
198            }
199        }
200        Some(1) => Ok(None),
201        _ => Err(command_error(
202            "git config --type=bool --get",
203            &output.stderr,
204        )),
205    }
206}
207
208pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
209    let output = Command::new("git")
210        .args(["config", "--get-regexp", pattern])
211        .stdout(Stdio::piped())
212        .stderr(Stdio::piped())
213        .output()
214        .with_context(|| format!("failed to read git config matching {pattern}"))?;
215
216    match output.status.code() {
217        Some(0) => Ok(String::from_utf8_lossy(&output.stdout)
218            .lines()
219            .filter_map(|line| {
220                line.split_once(' ')
221                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
222            })
223            .collect()),
224        Some(1) => Ok(Vec::new()),
225        _ => Err(command_error("git config --get-regexp", &output.stderr)),
226    }
227}
228
229pub fn config_set(key: &str, value: &str) -> Result<()> {
230    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
231}
232
233pub fn config_unset(key: &str) -> Result<()> {
234    let output = Command::new("git")
235        .args(["config", "--unset", key])
236        .stdout(Stdio::piped())
237        .stderr(Stdio::piped())
238        .output()
239        .with_context(|| format!("failed to unset git config {key}"))?;
240
241    match output.status.code() {
242        Some(0) | Some(5) => Ok(()),
243        _ => Err(command_error("git config --unset", &output.stderr)),
244    }
245}
246
247fn output(args: &[&str]) -> Result<String> {
248    let output = Command::new("git")
249        .args(args)
250        .stdout(Stdio::piped())
251        .stderr(Stdio::piped())
252        .output()
253        .context("failed to run git")?;
254
255    if output.status.success() {
256        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
257    } else {
258        Err(command_error("git", &output.stderr))
259    }
260}
261
262fn status(args: &[&str]) -> Result<()> {
263    let status = Command::new("git")
264        .args(args)
265        .status()
266        .context("failed to run git")?;
267
268    if status.success() {
269        Ok(())
270    } else {
271        bail!("git exited with status {status}")
272    }
273}
274
275fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
276    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
277    if stderr.is_empty() {
278        anyhow!("{command} failed")
279    } else {
280        anyhow!("{command} failed: {stderr}")
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
290        assert!(help_mentions_update_refs(
291            "    --update-refs    update branches that point to commits that are being rebased"
292        ));
293    }
294
295    #[test]
296    fn help_mentions_update_refs_matches_negatable_spelling() {
297        assert!(help_mentions_update_refs(
298            "    --[no-]update-refs    update branches that point to commits that are being rebased"
299        ));
300    }
301
302    #[test]
303    fn help_mentions_update_refs_rejects_help_without_the_option() {
304        assert!(!help_mentions_update_refs(
305            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
306        ));
307    }
308
309    #[test]
310    fn detection_agrees_with_the_real_git_on_this_machine() {
311        // Ground truth: `--update-refs -h` fails with "unknown option" on a
312        // git without the flag and prints help on one that has it.
313        let probe = Command::new("git")
314            .args(["rebase", "--update-refs", "-h"])
315            .stdout(Stdio::piped())
316            .stderr(Stdio::piped())
317            .output()
318            .expect("run git rebase probe");
319        let probe_text = format!(
320            "{}{}",
321            String::from_utf8_lossy(&probe.stdout),
322            String::from_utf8_lossy(&probe.stderr)
323        );
324        let real_support = !probe_text.contains("unknown option");
325
326        assert_eq!(
327            supports_rebase_update_refs().expect("detect support"),
328            real_support
329        );
330    }
331}