branchless/git/test.rs
1//! Saving and loading of on-disk information for the `git branchless test`
2//! subcommand. This isn't part of Git itself, but multiple `git-branchless`
3//! subsystems need to know about it, similar to snapshotting.
4//!
5//! Regrettably, this adds `serde` as a new dependency to `git-branchless-lib`,
6//! which will increase build times.
7
8use std::{fmt::Display, path::PathBuf};
9
10use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
11
12use super::{Commit, NonZeroOid, Repo, RepoError};
13
14/// The exit status to use when a test command succeeds.
15pub const TEST_SUCCESS_EXIT_CODE: i32 = 0;
16
17/// The exit status to use when a test command intends to skip the provided commit.
18///
19/// This exit code is used officially by several source control systems:
20///
21/// - Git: "Note that the script (my_script in the above example) should exit
22/// with code 0 if the current source code is good/old, and exit with a code
23/// between 1 and 127 (inclusive), except 125, if the current source code is
24/// bad/new."
25/// - Mercurial: "The exit status of the command will be used to mark revisions
26/// as good or bad: status 0 means good, 125 means to skip the revision, 127
27/// (command not found) will abort the bisection, and any other non-zero exit
28/// status means the revision is bad."
29///
30/// And it's become the de-facto standard for custom bisection scripts for other
31/// source control systems as well.
32pub const TEST_INDETERMINATE_EXIT_CODE: i32 = 125;
33
34/// The exit status used to abort a process.
35///
36/// Similarly to `INDETERMINATE_EXIT_CODE`, this exit code is used officially by
37/// `git-bisect` and others to abort the process. It's also typically raised by
38/// the shell when the command is not found, so it's technically ambiguous
39/// whether the command existed or not. Nonetheless, it's intuitive for a
40/// failure to run a given command to abort the process altogether, so it
41/// shouldn't be too confusing in practice.
42pub const TEST_ABORT_EXIT_CODE: i32 = 127;
43
44/// Convert a command string into a string that's safe to use as a filename.
45pub fn make_test_command_slug(command: String) -> String {
46 command.replace(['/', ' ', '\n'], "__")
47}
48
49/// A version of `NonZeroOid` that can be serialized and deserialized.
50///
51/// This exists in case we want to move this type (back) into a separate module
52/// which has a `serde` dependency in the interest of improving build times.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct SerializedNonZeroOid(pub NonZeroOid);
55
56impl Serialize for SerializedNonZeroOid {
57 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
58 serializer.serialize_str(&self.0.to_string())
59 }
60}
61
62impl<'de> Deserialize<'de> for SerializedNonZeroOid {
63 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
64 let s = String::deserialize(deserializer)?;
65 let oid: NonZeroOid = s.parse().map_err(|_| {
66 de::Error::invalid_value(de::Unexpected::Str(&s), &"a valid non-zero OID")
67 })?;
68 Ok(SerializedNonZeroOid(oid))
69 }
70}
71
72/// A test command to run.
73#[derive(Clone, Debug, Deserialize, Serialize)]
74pub enum TestCommand {
75 /// A full command string (to be passed to a shell for processing).
76 String(String),
77
78 /// A list of arguments. The first argument indicates the program to invoke.
79 Args(Vec<String>),
80}
81
82impl Display for TestCommand {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 TestCommand::String(s) => write!(f, "{s}"),
86 TestCommand::Args(args) => {
87 for (i, arg) in args.iter().enumerate() {
88 if i > 0 {
89 write!(f, " ")?;
90 }
91 write!(f, "{}", shell_words::quote(arg))?;
92 }
93 Ok(())
94 }
95 }
96 }
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100#[allow(missing_docs)]
101pub struct SerializedTestResult {
102 pub command: TestCommand,
103 pub exit_code: i32,
104 pub head_commit_oid: Option<SerializedNonZeroOid>,
105 pub snapshot_tree_oid: Option<SerializedNonZeroOid>,
106 #[serde(default)]
107 pub interactive: bool,
108}
109
110/// Get the directory where the results of running tests are stored.
111fn get_test_dir(repo: &Repo) -> Result<PathBuf, RepoError> {
112 Ok(repo.get_branchless_dir()?.join("test"))
113}
114
115/// Get the directory where the result of tests for a particular commit are
116/// stored.
117///
118/// Tests are keyed by tree OID, not commit OID, so that they can be cached
119/// based on the contents of the commit, rather than its specific commit
120/// hash. This means that we can cache the results of tests for commits that
121/// have been amended or rebased.
122pub fn get_test_tree_dir(repo: &Repo, commit: &Commit) -> Result<PathBuf, RepoError> {
123 Ok(get_test_dir(repo)?.join(commit.get_tree_oid().to_string()))
124}
125
126/// Get the directory where the locks for running tests are stored.
127pub fn get_test_locks_dir(repo: &Repo) -> Result<PathBuf, RepoError> {
128 Ok(get_test_dir(repo)?.join("locks"))
129}
130
131/// Get the directory where the worktrees for running tests are stored.
132pub fn get_test_worktrees_dir(repo: &Repo) -> Result<PathBuf, RepoError> {
133 Ok(get_test_dir(repo)?.join("worktrees"))
134}
135
136/// Get the path to the file where the latest test command is stored.
137pub fn get_latest_test_command_path(repo: &Repo) -> Result<PathBuf, RepoError> {
138 Ok(get_test_dir(repo)?.join("latest-command"))
139}