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