Skip to main content

bb_cli/
git.rs

1use crate::error::{BbError, Result};
2use std::path::Path;
3use std::process::Command;
4
5/// Runs `git` with an explicit argument vector. No shell is involved, so no
6/// argument can be interpreted as a command. Runs in `dir` if given,
7/// otherwise in the process's current directory.
8fn git_in(dir: Option<&Path>, args: &[&str]) -> Result<String> {
9    let mut cmd = Command::new("git");
10    cmd.args(args);
11    if let Some(dir) = dir {
12        cmd.current_dir(dir);
13    }
14    let output = cmd
15        .output()
16        .map_err(|e| BbError::Git(format!("cannot run git: {e}")))?;
17
18    if !output.status.success() {
19        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
20        return Err(BbError::Git(if stderr.is_empty() {
21            format!("git {} failed", args.join(" "))
22        } else {
23            stderr
24        }));
25    }
26
27    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
28}
29
30fn git(args: &[&str]) -> Result<String> {
31    git_in(None, args)
32}
33
34pub fn current_branch() -> Result<String> {
35    let branch = git(&["symbolic-ref", "--short", "HEAD"])?;
36    if branch.is_empty() {
37        return Err(BbError::Git(
38            "detached HEAD — cannot infer source branch".into(),
39        ));
40    }
41    Ok(branch)
42}
43
44pub fn remote_url(remote: &str) -> Result<String> {
45    let url = git(&["config", "--get", &format!("remote.{remote}.url")])?;
46    if url.is_empty() {
47        return Err(BbError::Git(format!("remote `{remote}` has no url")));
48    }
49    Ok(url)
50}
51
52/// Remote names as `git remote` lists them, in git's own order.
53pub fn remotes() -> Result<Vec<String>> {
54    remotes_in(None)
55}
56
57fn remotes_in(dir: Option<&Path>) -> Result<Vec<String>> {
58    Ok(git_in(dir, &["remote"])?
59        .lines()
60        .map(str::trim)
61        .filter(|line| !line.is_empty())
62        .map(str::to_string)
63        .collect())
64}
65
66pub fn in_repo() -> bool {
67    git(&["rev-parse", "--git-dir"]).is_ok()
68}
69
70#[cfg(test)]
71#[allow(clippy::unwrap_used)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn remotes_is_empty_for_a_repo_with_no_remotes() {
77        let tmp = tempfile::tempdir().unwrap();
78        let dir = tmp.path();
79
80        git_in(Some(dir), &["init"]).unwrap();
81
82        let names = remotes_in(Some(dir)).unwrap();
83        assert!(names.is_empty(), "expected no remotes, got {names:?}");
84    }
85
86    #[test]
87    fn remotes_lists_configured_remote_names() {
88        let tmp = tempfile::tempdir().unwrap();
89        let dir = tmp.path();
90
91        git_in(Some(dir), &["init"]).unwrap();
92        git_in(
93            Some(dir),
94            &["remote", "add", "origin", "https://example.com/origin.git"],
95        )
96        .unwrap();
97        git_in(
98            Some(dir),
99            &[
100                "remote",
101                "add",
102                "bitbucket",
103                "https://example.com/bitbucket.git",
104            ],
105        )
106        .unwrap();
107
108        let names = remotes_in(Some(dir)).unwrap();
109        assert_eq!(names, vec!["bitbucket".to_string(), "origin".to_string()]);
110    }
111
112    #[test]
113    fn git_failure_is_reported_not_panicked() {
114        // Modern git (2.50+) exits 0 for unknown `rev-parse` flags, treating them
115        // as literal output instead of erroring. Use a ref-verification failure,
116        // which reliably exits non-zero across git versions, to exercise the
117        // BbError::Git error path without panicking.
118        let err = git(&[
119            "rev-parse",
120            "--verify",
121            "refs/heads/definitely-not-a-branch-xyz",
122        ])
123        .unwrap_err();
124        assert!(matches!(err, BbError::Git(_)));
125    }
126}