1use crate::{
6 ReadContentsError, ShallowCloneError, VcsDetectError, VcsEnvError,
7};
8use camino::Utf8Path;
9use fs_err as fs;
10use git_stub::GitStub;
11use std::{fmt, io, process::Command};
12
13fn read_vcs_env(
20 var: &'static str,
21 default: &str,
22) -> Result<String, VcsEnvError> {
23 match std::env::var(var) {
24 Ok(s) => {
25 let trimmed = s.trim();
26 if trimmed.is_empty() {
27 Ok(default.to_string())
28 } else {
29 Ok(trimmed.to_string())
30 }
31 }
32 Err(std::env::VarError::NotPresent) => Ok(default.to_string()),
33 Err(std::env::VarError::NotUnicode(value)) => {
34 Err(VcsEnvError::NonUtf8 { var, value })
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum VcsName {
45 Git,
47 Jj,
49}
50
51impl fmt::Display for VcsName {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 match self {
54 VcsName::Git => write!(f, "git"),
55 VcsName::Jj => write!(f, "jj"),
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Vcs(VcsKind);
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69enum VcsKind {
70 Git {
72 binary: String,
74 },
75 Jj {
77 binary: String,
79 },
80}
81
82impl Vcs {
83 pub fn git() -> Result<Self, VcsEnvError> {
89 let binary = read_vcs_env("GIT", "git")?;
90 Ok(Vcs(VcsKind::Git { binary }))
91 }
92
93 pub fn jj() -> Result<Self, VcsEnvError> {
99 let binary = read_vcs_env("JJ", "jj")?;
100 Ok(Vcs(VcsKind::Jj { binary }))
101 }
102
103 pub fn detect(repo_root: &Utf8Path) -> Result<Self, VcsDetectError> {
114 match fs::metadata(repo_root) {
117 Ok(meta) if meta.is_dir() => {}
118 Ok(_) => {
119 return Err(VcsDetectError::NotADirectory {
120 repo_root: repo_root.to_owned(),
121 });
122 }
123 Err(err) if err.kind() == io::ErrorKind::NotFound => {
124 return Err(VcsDetectError::PathNotFound {
125 repo_root: repo_root.to_owned(),
126 });
127 }
128 Err(err) => {
129 return Err(VcsDetectError::Io {
130 path: repo_root.to_owned(),
131 source: err,
132 });
133 }
134 }
135
136 let jj_path = repo_root.join(".jj");
137 match jj_path.try_exists() {
138 Ok(true) => return Ok(Self::jj()?),
139 Ok(false) => {}
140 Err(source) => {
141 return Err(VcsDetectError::Io { path: jj_path, source });
142 }
143 }
144
145 let git_path = repo_root.join(".git");
146 match git_path.try_exists() {
147 Ok(true) => return Ok(Self::git()?),
148 Ok(false) => {}
149 Err(source) => {
150 return Err(VcsDetectError::Io { path: git_path, source });
151 }
152 }
153
154 Err(VcsDetectError::NotFound { repo_root: repo_root.to_owned() })
155 }
156
157 pub fn binary(&self) -> &str {
159 match &self.0 {
160 VcsKind::Git { binary } | VcsKind::Jj { binary } => binary,
161 }
162 }
163
164 pub fn name(&self) -> VcsName {
166 match &self.0 {
167 VcsKind::Git { .. } => VcsName::Git,
168 VcsKind::Jj { .. } => VcsName::Jj,
169 }
170 }
171
172 pub fn is_shallow_clone(
179 &self,
180 repo_root: &Utf8Path,
181 ) -> Result<bool, ShallowCloneError> {
182 match &self.0 {
183 VcsKind::Git { binary } => {
184 let output = Command::new(binary)
185 .current_dir(repo_root)
186 .args(["rev-parse", "--is-shallow-repository"])
187 .output()
188 .map_err(|source| ShallowCloneError::SpawnFailed {
189 vcs_name: VcsName::Git,
190 binary_path: binary.clone(),
191 repo_root: repo_root.to_owned(),
192 source,
193 })?;
194
195 if output.status.success() {
196 let stdout = String::from_utf8_lossy(&output.stdout);
197 match stdout.trim() {
198 "true" => Ok(true),
199 "false" => Ok(false),
200 other => Err(ShallowCloneError::UnexpectedOutput {
201 vcs_name: VcsName::Git,
202 stdout: other.to_owned(),
203 }),
204 }
205 } else {
206 let stderr = String::from_utf8_lossy(&output.stderr);
207 Err(ShallowCloneError::VcsFailed {
208 vcs_name: VcsName::Git,
209 exit_status: output.status.to_string(),
210 stderr: stderr.trim().to_string(),
211 })
212 }
213 }
214 VcsKind::Jj { binary } => {
215 let output = Command::new(binary)
216 .current_dir(repo_root)
217 .args(["git", "root", "--ignore-working-copy"])
218 .output()
219 .map_err(|source| ShallowCloneError::SpawnFailed {
220 vcs_name: VcsName::Jj,
221 binary_path: binary.clone(),
222 repo_root: repo_root.to_owned(),
223 source,
224 })?;
225
226 if !output.status.success() {
227 let stderr = String::from_utf8_lossy(&output.stderr);
228 return Err(ShallowCloneError::VcsFailed {
229 vcs_name: VcsName::Jj,
230 exit_status: output.status.to_string(),
231 stderr: stderr.trim().to_string(),
232 });
233 }
234
235 let git_root = String::from_utf8_lossy(&output.stdout);
236 let git_root = git_root.trim();
237 if git_root.is_empty() {
238 return Err(ShallowCloneError::UnexpectedOutput {
239 vcs_name: VcsName::Jj,
240 stdout: git_root.to_string(),
241 });
242 }
243
244 let shallow_path =
245 camino::Utf8PathBuf::from(git_root).join("shallow");
246 shallow_path.try_exists().map_err(|source| {
247 ShallowCloneError::Io { path: shallow_path.clone(), source }
248 })
249 }
250 }
251 }
252
253 pub fn read_git_stub_contents(
258 &self,
259 stub: &GitStub,
260 repo_root: &Utf8Path,
261 ) -> Result<Vec<u8>, ReadContentsError> {
262 let vcs_name = self.name();
263 let binary_path = self.binary().to_string();
264
265 let mut cmd = Command::new(self.binary());
266 cmd.current_dir(repo_root);
267
268 match &self.0 {
269 VcsKind::Git { .. } => {
270 cmd.args(["cat-file", "blob"]).arg(stub.to_string());
272 }
273 VcsKind::Jj { .. } => {
274 cmd.args([
278 "file",
279 "show",
280 "--ignore-working-copy",
281 "--revision",
282 &stub.commit().to_string(),
283 ]);
284 cmd.arg("--").arg(stub.path().as_str());
287 }
288 }
289
290 let output =
291 cmd.output().map_err(|source| ReadContentsError::SpawnFailed {
292 vcs_name,
293 binary_path,
294 repo_root: repo_root.to_owned(),
295 source,
296 })?;
297
298 if output.status.success() {
299 Ok(output.stdout)
300 } else {
301 let stderr = String::from_utf8_lossy(&output.stderr);
302 Err(ReadContentsError::VcsFailed {
303 vcs_name,
304 stub: stub.clone(),
305 exit_status: output.status.to_string(),
306 stderr: stderr.trim().to_string(),
307 })
308 }
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::{Vcs, VcsName};
315 use crate::VcsDetectError;
316 use camino_tempfile::Utf8TempDir;
317 use std::fs;
318
319 #[test]
320 fn test_vcs_git_default() {
321 unsafe {
324 std::env::remove_var("GIT");
325 }
326 let vcs = Vcs::git().unwrap();
327 assert_eq!(vcs.name(), VcsName::Git);
328 assert_eq!(vcs.binary(), "git");
329 }
330
331 #[test]
332 fn test_vcs_git_from_env() {
333 unsafe {
336 std::env::set_var("GIT", "/custom/git");
337 }
338 let vcs = Vcs::git().unwrap();
339 unsafe {
342 std::env::remove_var("GIT");
343 }
344 assert_eq!(vcs.name(), VcsName::Git);
345 assert_eq!(vcs.binary(), "/custom/git");
346 }
347
348 #[test]
349 fn test_vcs_jj_default() {
350 unsafe {
353 std::env::remove_var("JJ");
354 }
355 let vcs = Vcs::jj().unwrap();
356 assert_eq!(vcs.name(), VcsName::Jj);
357 assert_eq!(vcs.binary(), "jj");
358 }
359
360 #[test]
361 fn test_vcs_jj_from_env() {
362 unsafe {
365 std::env::set_var("JJ", "/custom/jj");
366 }
367 let vcs = Vcs::jj().unwrap();
368 unsafe {
371 std::env::remove_var("JJ");
372 }
373 assert_eq!(vcs.name(), VcsName::Jj);
374 assert_eq!(vcs.binary(), "/custom/jj");
375 }
376
377 #[test]
378 fn test_vcs_git_empty_env_falls_back() {
379 unsafe {
383 std::env::set_var("GIT", "");
384 }
385 assert_eq!(Vcs::git().unwrap().binary(), "git", "empty string");
386 unsafe {
387 std::env::set_var("GIT", " ");
388 }
389 assert_eq!(Vcs::git().unwrap().binary(), "git", "whitespace only");
390 unsafe {
391 std::env::remove_var("GIT");
392 }
393 }
394
395 #[test]
396 fn test_vcs_jj_empty_env_falls_back() {
397 unsafe {
401 std::env::set_var("JJ", "");
402 }
403 assert_eq!(Vcs::jj().unwrap().binary(), "jj", "empty string");
404 unsafe {
405 std::env::set_var("JJ", " ");
406 }
407 assert_eq!(Vcs::jj().unwrap().binary(), "jj", "whitespace only");
408 unsafe {
409 std::env::remove_var("JJ");
410 }
411 }
412
413 #[test]
414 fn test_vcs_detect_git_only() {
415 let temp = Utf8TempDir::with_prefix("git-stub-vcs-").unwrap();
416 fs::create_dir(temp.path().join(".git")).unwrap();
417
418 let vcs = Vcs::detect(temp.path()).unwrap();
419 assert_eq!(vcs.name(), VcsName::Git);
420 }
421
422 #[test]
423 fn test_vcs_detect_jj_only() {
424 let temp = Utf8TempDir::with_prefix("git-stub-vcs-").unwrap();
425 fs::create_dir(temp.path().join(".jj")).unwrap();
426
427 let vcs = Vcs::detect(temp.path()).unwrap();
428 assert_eq!(vcs.name(), VcsName::Jj);
429 }
430
431 #[test]
432 fn test_vcs_detect_colocated_prefers_jj() {
433 let temp = Utf8TempDir::with_prefix("git-stub-vcs-").unwrap();
434 fs::create_dir(temp.path().join(".git")).unwrap();
435 fs::create_dir(temp.path().join(".jj")).unwrap();
436
437 let vcs = Vcs::detect(temp.path()).unwrap();
438 assert_eq!(vcs.name(), VcsName::Jj, "colocated mode should prefer jj");
439 }
440
441 #[test]
442 fn test_vcs_detect_neither_returns_error() {
443 let temp = Utf8TempDir::with_prefix("git-stub-vcs-").unwrap();
444 let err = Vcs::detect(temp.path()).unwrap_err();
447 assert!(
448 matches!(err, VcsDetectError::NotFound { .. }),
449 "should return NotFound when neither .git nor .jj exists"
450 );
451 }
452
453 #[test]
454 fn test_vcs_detect_not_a_directory() {
455 let temp = Utf8TempDir::with_prefix("git-stub-vcs-").unwrap();
456 let file_path = temp.path().join("not-a-dir");
457 fs::write(&file_path, "").unwrap();
458
459 let err = Vcs::detect(&file_path).unwrap_err();
460 assert!(
461 matches!(err, VcsDetectError::NotADirectory { .. }),
462 "should return NotADirectory for a file path"
463 );
464 }
465
466 #[test]
467 fn test_vcs_detect_nonexistent_path() {
468 let temp = Utf8TempDir::with_prefix("git-stub-vcs-").unwrap();
469 let gone = temp.path().join("nonexistent");
470
471 let err = Vcs::detect(&gone).unwrap_err();
472 assert!(
473 matches!(err, VcsDetectError::PathNotFound { .. }),
474 "should return PathNotFound for a nonexistent path"
475 );
476 }
477
478 #[test]
479 fn test_vcs_binary() {
480 let git = Vcs::git().unwrap();
481 assert_eq!(git.name(), VcsName::Git);
482 let jj = Vcs::jj().unwrap();
485 assert_eq!(jj.name(), VcsName::Jj);
486 }
488
489 #[test]
490 fn test_vcs_name() {
491 let git = Vcs::git().unwrap();
492 assert_eq!(git.name(), VcsName::Git);
493 assert_eq!(git.name().to_string(), "git");
494
495 let jj = Vcs::jj().unwrap();
496 assert_eq!(jj.name(), VcsName::Jj);
497 assert_eq!(jj.name().to_string(), "jj");
498 }
499}