Skip to main content

git_stub_vcs/
vcs.rs

1// Copyright 2026 Oxide Computer Company
2
3//! Version control system abstraction for reading file contents from history.
4
5use 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
13/// Reads a VCS binary path from an environment variable, falling back
14/// to `default` if the variable is unset or empty.
15///
16/// The value is trimmed of leading and trailing whitespace.
17///
18/// Returns an error if the variable is set but is not valid UTF-8.
19fn 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/// The name of a version control system.
40///
41/// Used in error messages and for identifying which VCS is in use.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum VcsName {
45    /// Git version control.
46    Git,
47    /// Jujutsu (jj) version control.
48    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/// The version control system used to read file contents from history.
61///
62/// Supports Git and Jujutsu (jj). Use [`Vcs::git()`], [`Vcs::jj()`], or
63/// [`Vcs::detect()`].
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Vcs(VcsKind);
66
67/// The internal representation of a VCS.
68#[derive(Debug, Clone, PartialEq, Eq)]
69enum VcsKind {
70    /// Git version control.
71    Git {
72        /// Path to the git binary.
73        binary: String,
74    },
75    /// Jujutsu (jj) version control.
76    Jj {
77        /// Path to the jj binary.
78        binary: String,
79    },
80}
81
82impl Vcs {
83    /// Creates a Git VCS using the `$GIT` environment variable or
84    /// `"git"`.
85    ///
86    /// Returns an error if the `$GIT` environment variable is set
87    /// but is not valid UTF-8.
88    pub fn git() -> Result<Self, VcsEnvError> {
89        let binary = read_vcs_env("GIT", "git")?;
90        Ok(Vcs(VcsKind::Git { binary }))
91    }
92
93    /// Creates a Jujutsu VCS using the `$JJ` environment variable
94    /// or `"jj"`.
95    ///
96    /// Returns an error if the `$JJ` environment variable is set
97    /// but is not valid UTF-8.
98    pub fn jj() -> Result<Self, VcsEnvError> {
99        let binary = read_vcs_env("JJ", "jj")?;
100        Ok(Vcs(VcsKind::Jj { binary }))
101    }
102
103    /// Detects the appropriate VCS for a repository.
104    ///
105    /// `repo_root` must be the repository root.
106    ///
107    /// Detection order:
108    /// 1. If a `.jj` path exists, returns jj (including colocated
109    ///    mode where both `.jj` and `.git` exist).
110    /// 2. If a `.git` path exists, returns git. (`.git` may be a
111    ///    directory or a file, as in worktrees and submodules.)
112    /// 3. Otherwise, returns an error.
113    pub fn detect(repo_root: &Utf8Path) -> Result<Self, VcsDetectError> {
114        // Use metadata() to distinguish "not a directory" from I/O
115        // errors (e.g., permission denied).
116        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    /// Returns the path to the VCS binary.
158    pub fn binary(&self) -> &str {
159        match &self.0 {
160            VcsKind::Git { binary } | VcsKind::Jj { binary } => binary,
161        }
162    }
163
164    /// Returns the name of the VCS.
165    pub fn name(&self) -> VcsName {
166        match &self.0 {
167            VcsKind::Git { .. } => VcsName::Git,
168            VcsKind::Jj { .. } => VcsName::Jj,
169        }
170    }
171
172    /// Checks if the repository at `repo_root` is a shallow clone.
173    ///
174    /// For Git, runs `git rev-parse --is-shallow-repository`.
175    /// For Jujutsu, resolves the underlying Git store using
176    /// `jj git root --ignore-working-copy` and checks for a `shallow`
177    /// marker file there.
178    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    /// Reads the contents of the file referenced by a git stub.
254    ///
255    /// For Git, runs `git cat-file blob <commit>:<path>`.
256    /// For Jujutsu, runs `jj file show --revision <commit> <path>`.
257    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                // git cat-file blob <commit>:<path>
271                cmd.args(["cat-file", "blob"]).arg(stub.to_string());
272            }
273            VcsKind::Jj { .. } => {
274                // Skip the working-copy snapshot: this is a read-only
275                // operation and snapshotting can modify repo state or
276                // slow things down in build scripts.
277                cmd.args([
278                    "file",
279                    "show",
280                    "--ignore-working-copy",
281                    "--revision",
282                    &stub.commit().to_string(),
283                ]);
284                // `--` is required so filenames beginning with `-` are
285                // treated as paths rather than options.
286                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        // SAFETY:
322        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
323        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        // SAFETY:
334        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
335        unsafe {
336            std::env::set_var("GIT", "/custom/git");
337        }
338        let vcs = Vcs::git().unwrap();
339        // SAFETY:
340        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
341        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        // SAFETY:
351        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
352        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        // SAFETY:
363        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
364        unsafe {
365            std::env::set_var("JJ", "/custom/jj");
366        }
367        let vcs = Vcs::jj().unwrap();
368        // SAFETY:
369        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
370        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        // SAFETY: nextest runs each test in a separate process, so
380        // no other threads are reading the environment concurrently.
381        // See https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
382        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        // SAFETY: nextest runs each test in a separate process, so
398        // no other threads are reading the environment concurrently.
399        // See https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
400        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        // No .git or .jj directory.
445
446        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        // Binary is "git" by default (unless $GIT is set).
483
484        let jj = Vcs::jj().unwrap();
485        assert_eq!(jj.name(), VcsName::Jj);
486        // Binary is "jj" by default (unless $JJ is set).
487    }
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}