git_checks/
submodule_rewind.rs

1// Copyright Kitware, Inc.
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use std::path::PathBuf;
10
11use derive_builder::Builder;
12use git_checks_core::impl_prelude::*;
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16enum SubmoduleRewindError {
17    #[error("failed to get the merge-base between {} (old) and {} (new) in {}: {}", old_commit, new_commit, submodule.display(), output)]
18    MergeBase {
19        submodule: PathBuf,
20        old_commit: CommitId,
21        new_commit: CommitId,
22        output: String,
23    },
24}
25
26impl SubmoduleRewindError {
27    fn merge_base(
28        submodule: &FileName,
29        old_commit: CommitId,
30        new_commit: CommitId,
31        output: &[u8],
32    ) -> Self {
33        SubmoduleRewindError::MergeBase {
34            submodule: submodule.as_path().into(),
35            old_commit,
36            new_commit,
37            output: String::from_utf8_lossy(output).into(),
38        }
39    }
40}
41
42/// Check that submodules are not rewound to older revisions.
43#[derive(Builder, Debug, Default, Clone, Copy)]
44#[builder(field(private))]
45pub struct SubmoduleRewind {}
46
47impl SubmoduleRewind {
48    /// Create a new builder.
49    pub fn builder() -> SubmoduleRewindBuilder {
50        Default::default()
51    }
52}
53
54impl Check for SubmoduleRewind {
55    fn name(&self) -> &str {
56        "submodule-rewind"
57    }
58
59    fn check(&self, ctx: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
60        let mut result = CheckResult::new();
61
62        for diff in &commit.diffs {
63            // Ignore diffs which are not submodules on the new side and submodules without a
64            // history.
65            if diff.new_mode != "160000" || diff.status == StatusChange::Added {
66                continue;
67            }
68
69            let submodule_ctx = if let Some(ctx) = SubmoduleContext::new(ctx, diff.name.as_ref()) {
70                ctx
71            } else {
72                continue;
73            };
74
75            let cat_file = submodule_ctx
76                .context
77                .git()
78                .arg("cat-file")
79                .arg("-t")
80                .arg(diff.new_blob.as_str())
81                .output()
82                .map_err(|err| GitError::subcommand("cat-file -t <new>", err))?;
83            let object_type = String::from_utf8_lossy(&cat_file.stdout);
84            if !cat_file.status.success() || object_type.trim() != "commit" {
85                // The commit is updating to a submodule reference which we can't find; we can't do
86                // our work here.
87                continue;
88            }
89
90            let cat_file = submodule_ctx
91                .context
92                .git()
93                .arg("cat-file")
94                .arg("-t")
95                .arg(diff.old_blob.as_str())
96                .output()
97                .map_err(|err| GitError::subcommand("cat-file -t <old>", err))?;
98            let object_type = String::from_utf8_lossy(&cat_file.stdout);
99            if !cat_file.status.success() || object_type.trim() != "commit" {
100                // The commit is updating a submodule reference which we can't find; we can't do
101                // our work here.
102                continue;
103            }
104
105            let merge_base = submodule_ctx
106                .context
107                .git()
108                .arg("merge-base")
109                .arg(diff.old_blob.as_str())
110                .arg(diff.new_blob.as_str())
111                .output()
112                .map_err(|err| GitError::subcommand("merge-base", err))?;
113            if !merge_base.status.success() {
114                return Err(SubmoduleRewindError::merge_base(
115                    &diff.name,
116                    diff.old_blob.clone(),
117                    diff.new_blob.clone(),
118                    &merge_base.stderr,
119                )
120                .into());
121            }
122            let base = String::from_utf8_lossy(&merge_base.stdout);
123
124            if base.trim() == diff.new_blob.as_str() {
125                result.add_error(format!(
126                    "commit {} is not allowed since it moves the submodule `{}` backwards from {} \
127                     to {}.",
128                    commit.sha1, submodule_ctx.path, diff.old_blob, diff.new_blob,
129                ));
130            }
131        }
132
133        Ok(result)
134    }
135}
136
137#[cfg(feature = "config")]
138pub(crate) mod config {
139    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck};
140    use serde::Deserialize;
141    #[cfg(test)]
142    use serde_json::json;
143
144    use crate::SubmoduleRewind;
145
146    /// Configuration for the `SubmoduleRewind` check.
147    ///
148    /// No configuration available.
149    ///
150    /// This check is registered as a commit check with the name `"submodule_rewind"`.
151    #[derive(Deserialize, Debug)]
152    pub struct SubmoduleRewindConfig {}
153
154    impl IntoCheck for SubmoduleRewindConfig {
155        type Check = SubmoduleRewind;
156
157        fn into_check(self) -> Self::Check {
158            Default::default()
159        }
160    }
161
162    register_checks! {
163        SubmoduleRewindConfig {
164            "submodule_rewind" => CommitCheckConfig,
165        },
166    }
167
168    #[test]
169    fn test_submodule_rewind_config_empty() {
170        let json = json!({});
171        let check: SubmoduleRewindConfig = serde_json::from_value(json).unwrap();
172
173        let _ = check.into_check();
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use git_checks_core::Check;
180
181    use crate::test::*;
182    use crate::SubmoduleRewind;
183
184    const MOVE_TOPIC: &str = "2088079e35503be3be41dbdca55080ced95614e1";
185    const REWIND_TOPIC: &str = "39c5d0d9dc7ee6abad72cd42c90d7c1af1be169c";
186    const TO_UNAVAILABLE_TOPIC: &str = "1b9275caca1557611df19d1dfea687c3ef302eef";
187    const FROM_UNAVAILABLE_TOPIC: &str = "4d33c389cedef6fe4003ae05633fd2356bcd2acc";
188    const DELETE_SUBMODULE: &str = "25a69298548584f82efccd8922a1afc0a0d4182d";
189
190    #[test]
191    fn test_submodule_rewind_builder_default() {
192        assert!(SubmoduleRewind::builder().build().is_ok());
193    }
194
195    #[test]
196    fn test_submodule_rewind_name_commit() {
197        let check = SubmoduleRewind::default();
198        assert_eq!(Check::name(&check), "submodule-rewind");
199    }
200
201    #[test]
202    fn test_submodule_rewind_ok() {
203        let check = SubmoduleRewind::default();
204        let conf = make_check_conf(&check);
205
206        let result = test_check_submodule("test_submodule_rewind_ok", MOVE_TOPIC, &conf);
207        test_result_ok(result);
208    }
209
210    #[test]
211    fn test_submodule_rewind_to_unavailable() {
212        let check = SubmoduleRewind::default();
213        let conf = make_check_conf(&check);
214
215        let result = test_check_submodule(
216            "test_submodule_rewind_to_unavailable",
217            TO_UNAVAILABLE_TOPIC,
218            &conf,
219        );
220
221        // No errors because we can't give an answer due to not having the referenced commits
222        // locally..
223        test_result_ok(result);
224    }
225
226    #[test]
227    fn test_submodule_rewind_from_unavailable() {
228        let check = SubmoduleRewind::default();
229        let conf = make_check_conf(&check);
230
231        let result = test_check_submodule(
232            "test_submodule_rewind_from_unavailable",
233            FROM_UNAVAILABLE_TOPIC,
234            &conf,
235        );
236
237        // No errors because we can't give an answer due to not having the referenced commits
238        // locally..
239        test_result_ok(result);
240    }
241
242    #[test]
243    fn test_submodule_rewind_rewind() {
244        let check = SubmoduleRewind::default();
245        let conf = make_check_conf(&check);
246
247        let result = test_check_submodule_base(
248            "test_submodule_rewind_rewind",
249            REWIND_TOPIC,
250            MOVE_TOPIC,
251            &conf,
252        );
253        test_result_errors(result, &[
254            "commit 39c5d0d9dc7ee6abad72cd42c90d7c1af1be169c is not allowed since it moves the \
255             submodule `submodule` backwards from 8a890d8c4b89560c70a059bbdd7bc59b92b5c92b to \
256             2a8baa8e23bb1de5eec202dd4a29adf47feb03b1.",
257        ]);
258    }
259
260    #[test]
261    fn test_submodule_rewind_unwatched() {
262        let check = SubmoduleRewind::default();
263        let conf = make_check_conf(&check);
264
265        let result = test_check_base(
266            "test_submodule_rewind_unwatched",
267            REWIND_TOPIC,
268            MOVE_TOPIC,
269            &conf,
270        );
271        test_result_ok(result);
272    }
273
274    #[test]
275    fn test_submodule_rewind_add() {
276        let check = SubmoduleRewind::default();
277
278        run_check_ok("test_submodule_rewind_add", TO_UNAVAILABLE_TOPIC, check);
279    }
280
281    #[test]
282    fn test_submodule_rewind_delete() {
283        let check = SubmoduleRewind::default();
284        let conf = make_check_conf(&check);
285
286        let result = test_check_base(
287            "test_submodule_rewind_delete",
288            DELETE_SUBMODULE,
289            TO_UNAVAILABLE_TOPIC,
290            &conf,
291        );
292        test_result_ok(result);
293    }
294}