Skip to main content

alba_core/
git.rs

1//! The one place Alba talks to git: a thin wrapper over the `git` CLI.
2//!
3//! Shelling out rather than linking a library is the same call the docker
4//! executor made: no native dependency, and git is present on any machine
5//! that has hooks to install. Every function takes the directory to run
6//! from; paths come back relative to it.
7
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11/// A git invocation that could not answer: git missing from the PATH, no
12/// repository, an unknown reference. Carries git's own words.
13#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14#[error("{0}")]
15pub struct GitError(pub String);
16
17/// What `HEAD` looks like right now.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct GitHead {
20    /// The branch name, or `HEAD` when detached.
21    pub branch: String,
22    pub sha: String,
23    pub short_sha: String,
24    /// Whether the index or the working tree differs from `HEAD`,
25    /// untracked files included.
26    pub dirty: bool,
27}
28
29/// A hook git knows, with the number of arguments it passes. Server-side
30/// hooks are left out: a developer's clone never fires them.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct KnownHook {
33    pub name: &'static str,
34    /// The guaranteed argument count. `prepare-commit-msg` and
35    /// `pre-rebase` receive more in some situations; the minimum is what
36    /// a beam may rely on.
37    pub arity: usize,
38}
39
40pub const KNOWN_HOOKS: &[KnownHook] = &[
41    KnownHook {
42        name: "applypatch-msg",
43        arity: 1,
44    },
45    KnownHook {
46        name: "pre-applypatch",
47        arity: 0,
48    },
49    KnownHook {
50        name: "post-applypatch",
51        arity: 0,
52    },
53    KnownHook {
54        name: "pre-commit",
55        arity: 0,
56    },
57    KnownHook {
58        name: "pre-merge-commit",
59        arity: 0,
60    },
61    KnownHook {
62        name: "prepare-commit-msg",
63        arity: 1,
64    },
65    KnownHook {
66        name: "commit-msg",
67        arity: 1,
68    },
69    KnownHook {
70        name: "post-commit",
71        arity: 0,
72    },
73    KnownHook {
74        name: "pre-rebase",
75        arity: 1,
76    },
77    KnownHook {
78        name: "post-checkout",
79        arity: 3,
80    },
81    KnownHook {
82        name: "post-merge",
83        arity: 1,
84    },
85    KnownHook {
86        name: "pre-push",
87        arity: 2,
88    },
89    KnownHook {
90        name: "pre-auto-gc",
91        arity: 0,
92    },
93    KnownHook {
94        name: "post-rewrite",
95        arity: 1,
96    },
97    KnownHook {
98        name: "sendemail-validate",
99        arity: 1,
100    },
101    KnownHook {
102        name: "post-index-change",
103        arity: 2,
104    },
105];
106
107pub fn known_hook(name: &str) -> Option<&'static KnownHook> {
108    KNOWN_HOOKS.iter().find(|hook| hook.name == name)
109}
110
111/// Spawns `git <args>` in `cwd` and returns its raw output. The only
112/// concern here is whether the process could run at all: a missing `git`
113/// binary is the fixed [`GitError`] every caller reports the same way.
114/// Interpreting the exit status (success, or a status worth treating as
115/// "no answer" rather than failure, as [`hooks_path`] does for `1`) is
116/// left to the caller.
117fn spawn(cwd: &Path, args: &[&str]) -> Result<std::process::Output, GitError> {
118    Command::new("git")
119        .current_dir(cwd)
120        .args(args)
121        .output()
122        .map_err(|error| {
123            if error.kind() == std::io::ErrorKind::NotFound {
124                GitError("git not found on PATH".to_string())
125            } else {
126                GitError(format!("cannot run git: {error}"))
127            }
128        })
129}
130
131/// Runs `git <args>` in `cwd` and returns its raw stdout. A non-zero exit
132/// is a [`GitError`] carrying the command and stderr.
133fn git_output(cwd: &Path, args: &[&str]) -> Result<Vec<u8>, GitError> {
134    let output = spawn(cwd, args)?;
135    if !output.status.success() {
136        let stderr = String::from_utf8_lossy(&output.stderr);
137        return Err(GitError(format!(
138            "git {} failed: {}",
139            args.join(" "),
140            stderr.trim()
141        )));
142    }
143    Ok(output.stdout)
144}
145
146/// Runs `git <args>` in `cwd` and returns its stdout as text, trimmed of
147/// the trailing newline.
148fn git(cwd: &Path, args: &[&str]) -> Result<String, GitError> {
149    let stdout = git_output(cwd, args)?;
150    let stdout = String::from_utf8_lossy(&stdout);
151    Ok(stdout.trim_end_matches(['\n', '\r']).to_string())
152}
153
154/// Splits `-z`-terminated `git` output (paths separated by `NUL`, not
155/// newline) into its entries. `-z` also turns off `core.quotepath`, so a
156/// non-ASCII or special-character filename comes back verbatim instead of
157/// C-quoted (`"caf\303\251.txt"`).
158fn split_nul_terminated(bytes: &[u8]) -> Vec<PathBuf> {
159    String::from_utf8_lossy(bytes)
160        .split('\0')
161        .filter(|entry| !entry.is_empty())
162        .map(PathBuf::from)
163        .collect()
164}
165
166/// The repository's top-level directory, canonical.
167pub fn repository_root(cwd: &Path) -> Result<PathBuf, GitError> {
168    let root = git(cwd, &["rev-parse", "--show-toplevel"])?;
169    PathBuf::from(root)
170        .canonicalize()
171        .map_err(|error| GitError(format!("cannot canonicalize repository root: {error}")))
172}
173
174pub fn head(cwd: &Path) -> Result<GitHead, GitError> {
175    let branch = git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
176    let sha = git(cwd, &["rev-parse", "HEAD"])?;
177    let short_sha = git(cwd, &["rev-parse", "--short", "HEAD"])?;
178    let status = git(cwd, &["status", "--porcelain", "--untracked-files=all"])?;
179    Ok(GitHead {
180        branch,
181        sha,
182        short_sha,
183        dirty: !status.is_empty(),
184    })
185}
186
187/// Every path that differs between `reference` and the working tree
188/// (committed since, staged, modified, deleted, renamed) plus every
189/// untracked file `.gitignore` does not exclude; relative to `cwd`,
190/// sorted, without duplicates. Paths outside `cwd` are not reported.
191pub fn changed_files(cwd: &Path, reference: &str) -> Result<Vec<PathBuf>, GitError> {
192    let diff = git_output(cwd, &["diff", "--name-only", "--relative", "-z", reference])?;
193    let untracked = git_output(cwd, &["ls-files", "-z", "--others", "--exclude-standard"])?;
194    let mut paths = split_nul_terminated(&diff);
195    paths.extend(split_nul_terminated(&untracked));
196    paths.sort();
197    paths.dedup();
198    Ok(paths)
199}
200
201/// The repository-local `core.hooksPath`, if set.
202pub fn hooks_path(cwd: &Path) -> Result<Option<String>, GitError> {
203    // `git config --get` exits 1 when the key is unset: not an error here.
204    let args = ["config", "--local", "--get", "core.hooksPath"];
205    let output = spawn(cwd, &args)?;
206    match output.status.code() {
207        Some(0) => Ok(Some(
208            String::from_utf8_lossy(&output.stdout).trim().to_string(),
209        )),
210        Some(1) => Ok(None),
211        _ => Err(GitError(format!(
212            "git {} failed: {}",
213            args.join(" "),
214            String::from_utf8_lossy(&output.stderr).trim()
215        ))),
216    }
217}
218
219pub fn set_hooks_path(cwd: &Path, value: &str) -> Result<(), GitError> {
220    git(cwd, &["config", "--local", "core.hooksPath", value]).map(|_| ())
221}
222
223pub fn unset_hooks_path(cwd: &Path) -> Result<(), GitError> {
224    git(cwd, &["config", "--local", "--unset", "core.hooksPath"]).map(|_| ())
225}