tytanic_core/project/
vcs.rs

1//! Version control support.
2//!
3//! This is used in a project to ensure that ephemeral storage directories are
4//! not managed by the VCS of the user. Currently supports `.gitignore` and
5//! `.hgignore` based VCS' as well as auto discovery  of Git, Mercurial and
6//! Jujutsu through their hidden repository directories.
7
8use std::fmt::{self, Debug, Display};
9use std::path::{Path, PathBuf};
10use std::{fs, io};
11
12use crate::test::Test;
13
14use super::Project;
15
16/// The name of the git ignore file.
17const GITIGNORE_NAME: &str = ".gitignore";
18
19/// The name of the mercurial ignore file.
20const HGIGNORE_NAME: &str = ".hgignore";
21
22/// The content of the generated git ignore file.
23const IGNORE_HEADER: &str = "# generated by tytanic, do not edit";
24
25/// The kind of [`Vcs`] in use.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Kind {
28    /// Uses `.gitignore` files to ignore temporary files and directories.
29    ///
30    /// This means it can also be used by Vcs' which support `.gitignore` files,
31    /// like Jujutsu.
32    Git,
33
34    /// Uses `.hgignore` files to ignore temporary files and directories.
35    ///
36    /// This means it can also be used by Vcs' which support `.hgignore` files.
37    Mercurial,
38}
39
40/// A version control system, this is used to handle persistent storage of
41/// reference images and ignoring of non-persistent directories like the `out`
42/// and `diff` directories.
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct Vcs {
45    root: PathBuf,
46    kind: Kind,
47}
48
49impl Vcs {
50    /// Creates a new Vcs with the given root directory and kind.
51    pub fn new<I>(root: I, kind: Kind) -> Self
52    where
53        I: Into<PathBuf>,
54    {
55        Self {
56            root: root.into(),
57            kind,
58        }
59    }
60
61    /// Checks the given directory for a VCS root, returning which kind was
62    /// found.
63    pub fn exists_at(dir: &Path) -> io::Result<Option<Kind>> {
64        if dir.join(".git").try_exists()? || dir.join(".jj").try_exists()? {
65            return Ok(Some(Kind::Git));
66        }
67
68        if dir.join(".hg").try_exists()? {
69            return Ok(Some(Kind::Mercurial));
70        }
71
72        Ok(None)
73    }
74}
75
76impl Vcs {
77    /// The root of this Vcs' repository.
78    pub fn root(&self) -> &Path {
79        &self.root
80    }
81
82    /// The kind of this repository.
83    pub fn kind(&self) -> Kind {
84        self.kind
85    }
86
87    /// Ignore all ephemeral files and directories of a test.
88    pub fn ignore(&self, project: &Project, test: &Test) -> io::Result<()> {
89        let mut content = format!("{IGNORE_HEADER}\n\n");
90
91        let file = project.unit_test_dir(test.id()).join(match self.kind {
92            Kind::Git => GITIGNORE_NAME,
93            Kind::Mercurial => {
94                content.push_str("syntax: glob\n");
95                HGIGNORE_NAME
96            }
97        });
98
99        for always in ["diff/**\n", "out/**\n"] {
100            content.push_str(always);
101        }
102
103        if !test.kind().is_persistent() {
104            content.push_str("ref/**\n");
105        }
106
107        fs::write(file, content)?;
108
109        Ok(())
110    }
111
112    pub fn unignore(&self, project: &Project, test: &Test) -> io::Result<()> {
113        let file = project.unit_test_dir(test.id()).join(match self.kind {
114            Kind::Git => GITIGNORE_NAME,
115            Kind::Mercurial => HGIGNORE_NAME,
116        });
117
118        fs::remove_file(file)
119    }
120}
121
122impl Display for Vcs {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.pad(match self.kind {
125            Kind::Git => "Git",
126            Kind::Mercurial => "Mercurial",
127        })
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use tytanic_utils::fs::TempTestEnv;
134
135    use super::*;
136    use crate::test::{Id, Kind as TestKind};
137
138    fn test(kind: TestKind) -> Test {
139        Test::new_test(Id::new("fancy").unwrap(), kind)
140    }
141
142    #[test]
143    fn test_git_ignore_create() {
144        TempTestEnv::run(
145            |root| root.setup_dir("tests/fancy"),
146            |root| {
147                let project = Project::new(root);
148                let vcs = Vcs::new(root, Kind::Git);
149                let test = test(TestKind::CompileOnly);
150                vcs.ignore(&project, &test).unwrap();
151            },
152            |root| {
153                root.expect_dir("tests/fancy").expect_file_content(
154                    "tests/fancy/.gitignore",
155                    format!("{IGNORE_HEADER}\n\ndiff/**\nout/**\nref/**\n"),
156                )
157            },
158        );
159    }
160
161    #[test]
162    fn test_git_ignore_truncate() {
163        TempTestEnv::run(
164            |root| root.setup_file("tests/fancy/.gitignore", "blah blah"),
165            |root| {
166                let project = Project::new(root);
167                let vcs = Vcs::new(root, Kind::Git);
168                let test = test(TestKind::CompileOnly);
169                vcs.ignore(&project, &test).unwrap();
170            },
171            |root| {
172                root.expect_dir("tests/fancy").expect_file_content(
173                    "tests/fancy/.gitignore",
174                    format!("{IGNORE_HEADER}\n\ndiff/**\nout/**\nref/**\n"),
175                )
176            },
177        );
178    }
179
180    #[test]
181    fn test_git_unignore() {
182        TempTestEnv::run(
183            |root| root.setup_file("tests/fancy/.gitignore", "blah blah"),
184            |root| {
185                let project = Project::new(root);
186                let vcs = Vcs::new(root, Kind::Git);
187                let test = test(TestKind::CompileOnly);
188                vcs.unignore(&project, &test).unwrap();
189            },
190            |root| root.expect_dir("tests/fancy"),
191        );
192    }
193}