1use std::path::{Path, PathBuf};
9use std::process::Command;
10
11#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14#[error("{0}")]
15pub struct GitError(pub String);
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct GitHead {
20 pub branch: String,
22 pub sha: String,
23 pub short_sha: String,
24 pub dirty: bool,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct KnownHook {
33 pub name: &'static str,
34 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
111fn 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
131fn 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
146fn 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
154fn 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
166pub 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
187pub 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
201pub fn hooks_path(cwd: &Path) -> Result<Option<String>, GitError> {
203 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}