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/// Rename a branch; git moves its `branch.<name>.*` config along with it.
52pub fn rename_branch(old: &str, new: &str) -> Result<()> {
53    status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
54}
55
56/// Fast-forward a local branch from its remote without checking it out.
57pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
58    let refspec = format!("{branch}:{branch}");
59    status(&["fetch", remote, &refspec])
60        .with_context(|| format!("failed to fetch {branch} from {remote}"))
61}
62
63pub fn pull_ff_only() -> Result<()> {
64    status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
65}
66
67pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
68    let mut args = vec!["push", "--force-with-lease", remote];
69    args.extend(branches.iter().map(String::as_str));
70
71    status(&args).with_context(|| format!("failed to push branches to {remote}"))
72}
73
74/// Push branches and set upstream tracking; used before submitting so new
75/// branches exist remotely and rebased ones are safely updated.
76pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
77    let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
78    args.extend(branches.iter().map(String::as_str));
79
80    status(&args).with_context(|| format!("failed to push branches to {remote}"))
81}
82
83pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
84    let mut args = vec!["rebase"];
85    if update_refs {
86        args.push("--update-refs");
87    }
88    args.extend([parent, branch]);
89
90    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
91}
92
93/// Rebase only the commits after `base`, replaying `base..branch` onto
94/// `parent`. Used when the recorded fork point is known so commits that
95/// landed upstream by squash or rebase are not replayed.
96pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
97    let mut args = vec!["rebase"];
98    if update_refs {
99        args.push("--update-refs");
100    }
101    args.extend(["--onto", parent, base, branch]);
102
103    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
104}
105
106pub fn rev_parse(rev: &str) -> Result<String> {
107    let spec = format!("{rev}^{{commit}}");
108    output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
109}
110
111/// Default branch of `remote` (from its locally-known HEAD symref), if any.
112pub fn remote_default_branch(remote: &str) -> Option<String> {
113    let reference = format!("refs/remotes/{remote}/HEAD");
114    let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
115    full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
116}
117
118/// How many commits `parent` has that `branch` does not: nonzero means the
119/// branch needs a restack.
120pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
121    let range = format!("{branch}..{parent}");
122    let count = output(&["rev-list", "--count", &range])
123        .with_context(|| format!("failed to count commits in {range}"))?;
124    count
125        .trim()
126        .parse()
127        .context("failed to parse rev-list count")
128}
129
130pub fn merge_base(a: &str, b: &str) -> Result<String> {
131    output(&["merge-base", a, b])
132        .with_context(|| format!("failed to find merge base of {a} and {b}"))
133}
134
135pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
136    let output = Command::new("git")
137        .args(["merge-base", "--is-ancestor", ancestor, descendant])
138        .stdout(Stdio::piped())
139        .stderr(Stdio::piped())
140        .output()
141        .context("failed to run git merge-base --is-ancestor")?;
142
143    match output.status.code() {
144        Some(0) => Ok(true),
145        Some(1) => Ok(false),
146        _ => Err(command_error(
147            "git merge-base --is-ancestor",
148            &output.stderr,
149        )),
150    }
151}
152
153pub fn supports_rebase_update_refs() -> Result<bool> {
154    let output = Command::new("git")
155        .args(["rebase", "-h"])
156        .stdout(Stdio::piped())
157        .stderr(Stdio::piped())
158        .output()
159        .context("failed to inspect git rebase help")?;
160
161    let help = format!(
162        "{}{}",
163        String::from_utf8_lossy(&output.stdout),
164        String::from_utf8_lossy(&output.stderr)
165    );
166    Ok(help_mentions_update_refs(&help))
167}
168
169/// Whether the short help advertises --update-refs. Match the option name:
170/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
171fn help_mentions_update_refs(help: &str) -> bool {
172    help.contains("update-refs")
173}
174
175pub fn rebase_continue() -> Result<()> {
176    status(&["rebase", "--continue"]).context("failed to continue rebase")
177}
178
179pub fn rebase_abort() -> Result<()> {
180    status(&["rebase", "--abort"]).context("failed to abort rebase")
181}
182
183pub fn config_get(key: &str) -> Result<Option<String>> {
184    let output = Command::new("git")
185        .args(["config", "--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) => Ok(Some(
193            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
194        )),
195        Some(1) => Ok(None),
196        _ => Err(command_error("git config --get", &output.stderr)),
197    }
198}
199
200pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
201    let output = Command::new("git")
202        .args(["config", "--type=bool", "--get", key])
203        .stdout(Stdio::piped())
204        .stderr(Stdio::piped())
205        .output()
206        .with_context(|| format!("failed to read git config {key}"))?;
207
208    match output.status.code() {
209        Some(0) => {
210            let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
211            match value.as_str() {
212                "true" => Ok(Some(true)),
213                "false" => Ok(Some(false)),
214                _ => bail!("git config {key} is not a boolean: {value}"),
215            }
216        }
217        Some(1) => Ok(None),
218        _ => Err(command_error(
219            "git config --type=bool --get",
220            &output.stderr,
221        )),
222    }
223}
224
225pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
226    let output = Command::new("git")
227        .args(["config", "--get-regexp", pattern])
228        .stdout(Stdio::piped())
229        .stderr(Stdio::piped())
230        .output()
231        .with_context(|| format!("failed to read git config matching {pattern}"))?;
232
233    match output.status.code() {
234        Some(0) => Ok(String::from_utf8_lossy(&output.stdout)
235            .lines()
236            .filter_map(|line| {
237                line.split_once(' ')
238                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
239            })
240            .collect()),
241        Some(1) => Ok(Vec::new()),
242        _ => Err(command_error("git config --get-regexp", &output.stderr)),
243    }
244}
245
246pub fn config_set(key: &str, value: &str) -> Result<()> {
247    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
248}
249
250pub fn config_unset(key: &str) -> Result<()> {
251    let output = Command::new("git")
252        .args(["config", "--unset", key])
253        .stdout(Stdio::piped())
254        .stderr(Stdio::piped())
255        .output()
256        .with_context(|| format!("failed to unset git config {key}"))?;
257
258    match output.status.code() {
259        Some(0) | Some(5) => Ok(()),
260        _ => Err(command_error("git config --unset", &output.stderr)),
261    }
262}
263
264fn output(args: &[&str]) -> Result<String> {
265    let output = Command::new("git")
266        .args(args)
267        .stdout(Stdio::piped())
268        .stderr(Stdio::piped())
269        .output()
270        .context("failed to run git")?;
271
272    if output.status.success() {
273        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
274    } else {
275        Err(command_error("git", &output.stderr))
276    }
277}
278
279fn status(args: &[&str]) -> Result<()> {
280    let status = Command::new("git")
281        .args(args)
282        .status()
283        .context("failed to run git")?;
284
285    if status.success() {
286        Ok(())
287    } else {
288        bail!("git exited with status {status}")
289    }
290}
291
292fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
293    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
294    if stderr.is_empty() {
295        anyhow!("{command} failed")
296    } else {
297        anyhow!("{command} failed: {stderr}")
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
307        assert!(help_mentions_update_refs(
308            "    --update-refs    update branches that point to commits that are being rebased"
309        ));
310    }
311
312    #[test]
313    fn help_mentions_update_refs_matches_negatable_spelling() {
314        assert!(help_mentions_update_refs(
315            "    --[no-]update-refs    update branches that point to commits that are being rebased"
316        ));
317    }
318
319    #[test]
320    fn help_mentions_update_refs_rejects_help_without_the_option() {
321        assert!(!help_mentions_update_refs(
322            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
323        ));
324    }
325
326    #[test]
327    fn detection_agrees_with_the_real_git_on_this_machine() {
328        // Ground truth: `--update-refs -h` fails with "unknown option" on a
329        // git without the flag and prints help on one that has it.
330        let probe = Command::new("git")
331            .args(["rebase", "--update-refs", "-h"])
332            .stdout(Stdio::piped())
333            .stderr(Stdio::piped())
334            .output()
335            .expect("run git rebase probe");
336        let probe_text = format!(
337            "{}{}",
338            String::from_utf8_lossy(&probe.stdout),
339            String::from_utf8_lossy(&probe.stderr)
340        );
341        let real_support = !probe_text.contains("unknown option");
342
343        assert_eq!(
344            supports_rebase_update_refs().expect("detect support"),
345            real_support
346        );
347    }
348}