1use std::io;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Output, Stdio};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum GitContextError {
7 GitNotFound,
8 NotRepository,
9}
10
11pub fn run_output(args: &[&str]) -> io::Result<Output> {
12 run_output_inner(None, args)
13}
14
15pub fn run_output_in(cwd: &Path, args: &[&str]) -> io::Result<Output> {
16 run_output_inner(Some(cwd), args)
17}
18
19pub fn run_status_quiet(args: &[&str]) -> io::Result<ExitStatus> {
20 run_status_quiet_inner(None, args)
21}
22
23pub fn run_status_quiet_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
24 run_status_quiet_inner(Some(cwd), args)
25}
26
27pub fn run_status_inherit(args: &[&str]) -> io::Result<ExitStatus> {
28 run_status_inherit_inner(None, args)
29}
30
31pub fn run_status_inherit_in(cwd: &Path, args: &[&str]) -> io::Result<ExitStatus> {
32 run_status_inherit_inner(Some(cwd), args)
33}
34
35pub fn is_git_available() -> bool {
36 run_status_quiet(&["--version"])
37 .map(|status| status.success())
38 .unwrap_or(false)
39}
40
41pub fn require_repo() -> Result<(), GitContextError> {
42 require_context(None, &["rev-parse", "--git-dir"])
43}
44
45pub fn require_repo_in(cwd: &Path) -> Result<(), GitContextError> {
46 require_context(Some(cwd), &["rev-parse", "--git-dir"])
47}
48
49pub fn require_work_tree() -> Result<(), GitContextError> {
50 require_context(None, &["rev-parse", "--is-inside-work-tree"])
51}
52
53pub fn require_work_tree_in(cwd: &Path) -> Result<(), GitContextError> {
54 require_context(Some(cwd), &["rev-parse", "--is-inside-work-tree"])
55}
56
57pub fn is_inside_work_tree() -> io::Result<bool> {
58 Ok(run_status_quiet(&["rev-parse", "--is-inside-work-tree"])?.success())
59}
60
61pub fn is_inside_work_tree_in(cwd: &Path) -> io::Result<bool> {
62 Ok(run_status_quiet_in(cwd, &["rev-parse", "--is-inside-work-tree"])?.success())
63}
64
65pub fn is_git_repo() -> io::Result<bool> {
66 Ok(run_status_quiet(&["rev-parse", "--git-dir"])?.success())
67}
68
69pub fn is_git_repo_in(cwd: &Path) -> io::Result<bool> {
70 Ok(run_status_quiet_in(cwd, &["rev-parse", "--git-dir"])?.success())
71}
72
73pub fn repo_root() -> io::Result<Option<PathBuf>> {
74 let output = run_output(&["rev-parse", "--show-toplevel"])?;
75 Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
76}
77
78pub fn repo_root_in(cwd: &Path) -> io::Result<Option<PathBuf>> {
79 let output = run_output_in(cwd, &["rev-parse", "--show-toplevel"])?;
80 Ok(trimmed_stdout_if_success(&output).map(PathBuf::from))
81}
82
83pub fn rev_parse(args: &[&str]) -> io::Result<Option<String>> {
84 let output = run_output(&rev_parse_args(args))?;
85 Ok(trimmed_stdout_if_success(&output))
86}
87
88pub fn rev_parse_in(cwd: &Path, args: &[&str]) -> io::Result<Option<String>> {
89 let output = run_output_in(cwd, &rev_parse_args(args))?;
90 Ok(trimmed_stdout_if_success(&output))
91}
92
93fn run_output_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<Output> {
94 let mut cmd = Command::new("git");
95 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
96 if let Some(cwd) = cwd {
97 cmd.current_dir(cwd);
98 }
99 cmd.output()
100}
101
102fn run_status_quiet_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
103 let mut cmd = Command::new("git");
104 cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
105 if let Some(cwd) = cwd {
106 cmd.current_dir(cwd);
107 }
108 cmd.status()
109}
110
111fn run_status_inherit_inner(cwd: Option<&Path>, args: &[&str]) -> io::Result<ExitStatus> {
112 let mut cmd = Command::new("git");
113 cmd.args(args)
114 .stdout(Stdio::inherit())
115 .stderr(Stdio::inherit());
116 if let Some(cwd) = cwd {
117 cmd.current_dir(cwd);
118 }
119 cmd.status()
120}
121
122fn require_context(cwd: Option<&Path>, probe_args: &[&str]) -> Result<(), GitContextError> {
123 if !is_git_available() {
124 return Err(GitContextError::GitNotFound);
125 }
126
127 let in_context = match cwd {
128 Some(cwd) => run_status_quiet_in(cwd, probe_args),
129 None => run_status_quiet(probe_args),
130 }
131 .map(|status| status.success())
132 .unwrap_or(false);
133
134 if in_context {
135 Ok(())
136 } else {
137 Err(GitContextError::NotRepository)
138 }
139}
140
141fn rev_parse_args<'a>(args: &'a [&'a str]) -> Vec<&'a str> {
142 let mut full = Vec::with_capacity(args.len() + 1);
143 full.push("rev-parse");
144 full.extend_from_slice(args);
145 full
146}
147
148fn trimmed_stdout_if_success(output: &Output) -> Option<String> {
149 if !output.status.success() {
150 return None;
151 }
152
153 let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
154 if trimmed.is_empty() {
155 None
156 } else {
157 Some(trimmed)
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use nils_test_support::git::{InitRepoOptions, git as run_git, init_repo_with};
165 use nils_test_support::{CwdGuard, EnvGuard, GlobalStateLock};
166 use pretty_assertions::assert_eq;
167 use tempfile::TempDir;
168
169 #[test]
170 fn run_output_in_preserves_nonzero_status() {
171 let repo = init_repo_with(InitRepoOptions::new());
172
173 let output = run_output_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
174 .expect("run output in repo");
175
176 assert!(!output.status.success());
177 assert!(!output.stderr.is_empty());
178 }
179
180 #[test]
181 fn run_status_quiet_in_returns_success_and_failure_statuses() {
182 let repo = init_repo_with(InitRepoOptions::new());
183
184 let ok =
185 run_status_quiet_in(repo.path(), &["rev-parse", "--git-dir"]).expect("status success");
186 let bad = run_status_quiet_in(repo.path(), &["rev-parse", "--verify", "HEAD"])
187 .expect("status failure");
188
189 assert!(ok.success());
190 assert!(!bad.success());
191 }
192
193 #[test]
194 fn is_git_repo_in_and_is_inside_work_tree_in_match_repo_context() {
195 let repo = init_repo_with(InitRepoOptions::new());
196 let outside = TempDir::new().expect("tempdir");
197
198 assert!(is_git_repo_in(repo.path()).expect("is_git_repo in repo"));
199 assert!(is_inside_work_tree_in(repo.path()).expect("is_inside_work_tree in repo"));
200 assert!(!is_git_repo_in(outside.path()).expect("is_git_repo outside repo"));
201 assert!(!is_inside_work_tree_in(outside.path()).expect("is_inside_work_tree outside repo"));
202 }
203
204 #[test]
205 fn repo_root_in_returns_root_or_none() {
206 let repo = init_repo_with(InitRepoOptions::new());
207 let outside = TempDir::new().expect("tempdir");
208 let expected_root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
209 .trim()
210 .to_string();
211
212 assert_eq!(
213 repo_root_in(repo.path()).expect("repo_root_in repo"),
214 Some(expected_root.into())
215 );
216 assert_eq!(
217 repo_root_in(outside.path()).expect("repo_root_in outside"),
218 None
219 );
220 }
221
222 #[test]
223 fn rev_parse_in_returns_value_or_none() {
224 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
225 let head = run_git(repo.path(), &["rev-parse", "HEAD"])
226 .trim()
227 .to_string();
228
229 assert_eq!(
230 rev_parse_in(repo.path(), &["HEAD"]).expect("rev_parse head"),
231 Some(head)
232 );
233 assert_eq!(
234 rev_parse_in(repo.path(), &["--verify", "refs/heads/does-not-exist"])
235 .expect("rev_parse missing ref"),
236 None
237 );
238 }
239
240 #[test]
241 fn cwd_wrappers_delegate_to_in_variants() {
242 let lock = GlobalStateLock::new();
243 let repo = init_repo_with(InitRepoOptions::new().with_initial_commit());
244 let _cwd = CwdGuard::set(&lock, repo.path()).expect("set cwd");
245 let head = run_git(repo.path(), &["rev-parse", "HEAD"])
246 .trim()
247 .to_string();
248 let root = run_git(repo.path(), &["rev-parse", "--show-toplevel"])
249 .trim()
250 .to_string();
251
252 assert!(is_git_repo().expect("is_git_repo"));
253 assert!(is_inside_work_tree().expect("is_inside_work_tree"));
254 assert_eq!(require_repo(), Ok(()));
255 assert_eq!(require_work_tree(), Ok(()));
256 assert_eq!(repo_root().expect("repo_root"), Some(root.into()));
257 assert_eq!(rev_parse(&["HEAD"]).expect("rev_parse"), Some(head));
258 }
259
260 #[test]
261 fn require_work_tree_in_reports_missing_git_or_repo_state() {
262 let lock = GlobalStateLock::new();
263 let outside = TempDir::new().expect("tempdir");
264 let empty = TempDir::new().expect("tempdir");
265 let _path = EnvGuard::set(&lock, "PATH", &empty.path().to_string_lossy());
266
267 assert_eq!(
268 require_work_tree_in(outside.path()),
269 Err(GitContextError::GitNotFound)
270 );
271 }
272
273 #[test]
274 fn require_repo_and_work_tree_in_report_context_readiness() {
275 let repo = init_repo_with(InitRepoOptions::new());
276 let outside = TempDir::new().expect("tempdir");
277
278 assert_eq!(require_repo_in(repo.path()), Ok(()));
279 assert_eq!(require_work_tree_in(repo.path()), Ok(()));
280 assert_eq!(
281 require_repo_in(outside.path()),
282 Err(GitContextError::NotRepository)
283 );
284 assert_eq!(
285 require_work_tree_in(outside.path()),
286 Err(GitContextError::NotRepository)
287 );
288 }
289}