git_checks/
submodule_available.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 SubmoduleAvailableError {
17    #[error("failed to get the merge-base for {} against the tracking branch {} in {}: {}", commit, branch, submodule.display(), output)]
18    MergeBase {
19        submodule: PathBuf,
20        commit: CommitId,
21        branch: String,
22        output: String,
23    },
24    #[error("failed to list refs from {} to {} in {}: {}", branch, commit, submodule.display(), output)]
25    RevList {
26        submodule: PathBuf,
27        commit: CommitId,
28        branch: String,
29        output: String,
30    },
31}
32
33impl SubmoduleAvailableError {
34    fn merge_base(submodule: &FileName, commit: CommitId, branch: String, output: &[u8]) -> Self {
35        SubmoduleAvailableError::MergeBase {
36            submodule: submodule.as_path().into(),
37            commit,
38            branch,
39            output: String::from_utf8_lossy(output).into(),
40        }
41    }
42
43    fn rev_list(submodule: &FileName, commit: CommitId, branch: String, output: &[u8]) -> Self {
44        SubmoduleAvailableError::RevList {
45            submodule: submodule.as_path().into(),
46            commit,
47            branch,
48            output: String::from_utf8_lossy(output).into(),
49        }
50    }
51}
52
53/// Check that submodules are reachable from a given branch and available.
54#[derive(Builder, Debug, Default, Clone, Copy)]
55#[builder(field(private))]
56pub struct SubmoduleAvailable {
57    /// Whether the first-parent history is required to contain commits or not.
58    ///
59    /// If the merge commit of the submodule into the tracked branch should be required, set this
60    /// flag.
61    ///
62    /// Configuration: Optional
63    /// Default: `false`
64    #[builder(default = "false")]
65    require_first_parent: bool,
66}
67
68impl SubmoduleAvailable {
69    /// Create a new builder.
70    pub fn builder() -> SubmoduleAvailableBuilder {
71        Default::default()
72    }
73}
74
75impl Check for SubmoduleAvailable {
76    fn name(&self) -> &str {
77        "submodule-available"
78    }
79
80    fn check(&self, ctx: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
81        let mut result = CheckResult::new();
82
83        for diff in &commit.diffs {
84            // Ignore deleted submodules.
85            if let StatusChange::Deleted = diff.status {
86                continue;
87            }
88
89            // Ignore diffs which are not submodules on the new side.
90            if diff.new_mode != "160000" {
91                continue;
92            }
93
94            let submodule_ctx = if let Some(ctx) = SubmoduleContext::new(ctx, diff.name.as_ref()) {
95                ctx
96            } else {
97                result.add_alert(
98                    format!("submodule at `{}` is not configured.", diff.name),
99                    false,
100                );
101
102                continue;
103            };
104
105            let submodule_commit = &diff.new_blob;
106
107            let cat_file = submodule_ctx
108                .context
109                .git()
110                .arg("cat-file")
111                .arg("-t")
112                .arg(submodule_commit.as_str())
113                .output()
114                .map_err(|err| GitError::subcommand("cat-file -t", err))?;
115            let object_type = String::from_utf8_lossy(&cat_file.stdout);
116            if !cat_file.status.success() || object_type.trim() != "commit" {
117                result
118                    .add_error(format!(
119                        "commit {} references an unreachable commit {} at `{}`; please make the \
120                         commit available in the {} repository on the `{}` branch first.",
121                        commit.sha1,
122                        submodule_commit,
123                        submodule_ctx.path,
124                        submodule_ctx.url,
125                        submodule_ctx.branch,
126                    ))
127                    .make_temporary();
128                continue;
129            }
130
131            let merge_base = submodule_ctx
132                .context
133                .git()
134                .arg("merge-base")
135                .arg(submodule_commit.as_str())
136                .arg(submodule_ctx.branch.as_ref())
137                .output()
138                .map_err(|err| GitError::subcommand("merge-base", err))?;
139            if !merge_base.status.success() {
140                return Err(SubmoduleAvailableError::merge_base(
141                    &diff.name,
142                    submodule_commit.clone(),
143                    submodule_ctx.branch.into(),
144                    &merge_base.stderr,
145                )
146                .into());
147            }
148            let base = String::from_utf8_lossy(&merge_base.stdout);
149
150            if base.trim() != submodule_commit.as_str() {
151                result
152                    .add_error(format!(
153                        "commit {} references the commit {} at `{}`, but it is not available on \
154                         the tracked branch `{}`; please make the commit available from the `{}` \
155                         branch first.",
156                        commit.sha1,
157                        submodule_commit,
158                        submodule_ctx.path,
159                        submodule_ctx.branch,
160                        submodule_ctx.branch,
161                    ))
162                    .make_temporary();
163                continue;
164            }
165
166            if self.require_first_parent {
167                let refs = submodule_ctx
168                    .context
169                    .git()
170                    .arg("rev-list")
171                    .arg("--first-parent") // only look at first-parent history
172                    .arg("--reverse") // start with oldest commits
173                    .arg(submodule_ctx.branch.as_ref())
174                    .arg(format!("^{}~", submodule_commit))
175                    .output()
176                    .map_err(|err| GitError::subcommand("rev-list", err))?;
177                if !refs.status.success() {
178                    return Err(SubmoduleAvailableError::rev_list(
179                        &diff.name,
180                        submodule_commit.clone(),
181                        submodule_ctx.branch.into(),
182                        &refs.stderr,
183                    )
184                    .into());
185                }
186                let refs = String::from_utf8_lossy(&refs.stdout);
187
188                if !refs.lines().any(|rev| rev == submodule_commit.as_str()) {
189                    // This is not temporary because we've already determined above that it is in
190                    // the history of the target branch in the first place; it not being in the
191                    // first-parent isn't going to change.
192                    result.add_error(format!(
193                        "commit {} references the commit {} at `{}`, but it is not available as a \
194                         first-parent of the tracked branch `{}`; please choose the commit where \
195                         it was merged into the `{}` branch.",
196                        commit.sha1,
197                        submodule_commit,
198                        submodule_ctx.path,
199                        submodule_ctx.branch,
200                        submodule_ctx.branch,
201                    ));
202                    continue;
203                }
204            }
205        }
206
207        Ok(result)
208    }
209}
210
211#[cfg(feature = "config")]
212pub(crate) mod config {
213    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck};
214    use serde::Deserialize;
215    #[cfg(test)]
216    use serde_json::json;
217
218    use crate::SubmoduleAvailable;
219
220    /// Configuration for the `SubmoduleAvailable` check.
221    ///
222    /// The `require_first_parent` key is a boolean which defaults to `false`.
223    ///
224    /// This check is registered as a commit check with the name `"submodule_available"`.
225    ///
226    /// # Example
227    ///
228    /// ```json
229    /// {
230    ///     "require_first_parent": false
231    /// }
232    /// ```
233    #[derive(Deserialize, Debug)]
234    pub struct SubmoduleAvailableConfig {
235        #[serde(default)]
236        require_first_parent: Option<bool>,
237    }
238
239    impl IntoCheck for SubmoduleAvailableConfig {
240        type Check = SubmoduleAvailable;
241
242        fn into_check(self) -> Self::Check {
243            let mut builder = SubmoduleAvailable::builder();
244
245            if let Some(require_first_parent) = self.require_first_parent {
246                builder.require_first_parent(require_first_parent);
247            }
248
249            builder
250                .build()
251                .expect("configuration mismatch for `SubmoduleAvailable`")
252        }
253    }
254
255    register_checks! {
256        SubmoduleAvailableConfig {
257            "submodule_available" => CommitCheckConfig,
258        },
259    }
260
261    #[test]
262    fn test_submodule_available_config_empty() {
263        let json = json!({});
264        let check: SubmoduleAvailableConfig = serde_json::from_value(json).unwrap();
265
266        assert_eq!(check.require_first_parent, None);
267
268        let check = check.into_check();
269
270        assert!(!check.require_first_parent);
271    }
272
273    #[test]
274    fn test_submodule_available_config_all_fields() {
275        let json = json!({
276            "require_first_parent": true,
277        });
278        let check: SubmoduleAvailableConfig = serde_json::from_value(json).unwrap();
279
280        assert_eq!(check.require_first_parent, Some(true));
281
282        let check = check.into_check();
283
284        assert!(check.require_first_parent);
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use git_checks_core::Check;
291
292    use crate::test::*;
293    use crate::SubmoduleAvailable;
294
295    const BASE_COMMIT: &str = "fe90ee22ae3ce4b4dc41f8d0876e59355ff1e21c";
296    const MOVE_TOPIC: &str = "2088079e35503be3be41dbdca55080ced95614e1";
297    const MOVE_NOT_FIRST_PARENT_TOPIC: &str = "eb4df16a8a38f6ca30b6e67cfbca0672156b54d2";
298    const UNAVAILABLE_TOPIC: &str = "1b9275caca1557611df19d1dfea687c3ef302eef";
299    const NOT_ANCESTOR_TOPIC: &str = "07fb2ca9c1c8c0ddfcf921e762688ffcd476bc09";
300    const DELETE_SUBMODULE: &str = "25a69298548584f82efccd8922a1afc0a0d4182d";
301
302    #[test]
303    fn test_submodule_available_builder_default() {
304        assert!(SubmoduleAvailable::builder().build().is_ok());
305    }
306
307    #[test]
308    fn test_submodule_available_name_commit() {
309        let check = SubmoduleAvailable::default();
310        assert_eq!(Check::name(&check), "submodule-available");
311    }
312
313    #[test]
314    fn test_submodule_unconfigured() {
315        let check = SubmoduleAvailable::default();
316        let result = run_check("test_submodule_unconfigured", BASE_COMMIT, check);
317
318        assert_eq!(result.warnings().len(), 0);
319        assert_eq!(result.alerts().len(), 1);
320        assert_eq!(
321            result.alerts()[0],
322            "submodule at `submodule` is not configured.",
323        );
324        assert_eq!(result.errors().len(), 0);
325        assert!(!result.temporary());
326        assert!(!result.allowed());
327        assert!(result.pass());
328    }
329
330    #[test]
331    fn test_submodule_move() {
332        let check = SubmoduleAvailable::default();
333        let conf = make_check_conf(&check);
334
335        let result = test_check_submodule("test_submodule_move", MOVE_TOPIC, &conf);
336        test_result_ok(result);
337    }
338
339    #[test]
340    fn test_submodule_move_not_first_parent() {
341        let check = SubmoduleAvailable::default();
342        let conf = make_check_conf(&check);
343
344        let result = test_check_submodule(
345            "test_submodule_move_not_first_parent",
346            MOVE_NOT_FIRST_PARENT_TOPIC,
347            &conf,
348        );
349        test_result_ok(result);
350    }
351
352    #[test]
353    fn test_submodule_move_not_first_parent_reject() {
354        let check = SubmoduleAvailable::builder()
355            .require_first_parent(true)
356            .build()
357            .unwrap();
358        let conf = make_check_conf(&check);
359
360        let result = test_check_submodule(
361            "test_submodule_move_not_first_parent_reject",
362            MOVE_NOT_FIRST_PARENT_TOPIC,
363            &conf,
364        );
365        test_result_errors(result, &[
366            "commit eb4df16a8a38f6ca30b6e67cfbca0672156b54d2 references the commit \
367             c2bd427807b40b1715b8d1441fe92f50e8ad1769 at `submodule`, but it is not available as a \
368             first-parent of the tracked branch `master`; please choose the commit where it was \
369             merged into the `master` branch.",
370        ]);
371    }
372
373    #[test]
374    fn test_submodule_unavailable() {
375        let check = SubmoduleAvailable::default();
376        let conf = make_check_conf(&check);
377
378        let result = test_check_submodule("test_submodule_unavailable", UNAVAILABLE_TOPIC, &conf);
379
380        assert_eq!(result.warnings().len(), 0);
381        assert_eq!(result.alerts().len(), 0);
382        assert_eq!(result.errors().len(), 1);
383        assert_eq!(
384            result.errors()[0],
385            "commit 1b9275caca1557611df19d1dfea687c3ef302eef references an unreachable commit \
386             4b029c2e0f186d681caa071fa4dd7eb1f0f033f6 at `submodule`; please make the commit \
387             available in the https://gitlab.kitware.com/utils/test-repo.git repository on the \
388             `master` branch first.",
389        );
390        assert!(result.temporary());
391        assert!(!result.allowed());
392        assert!(!result.pass());
393    }
394
395    #[test]
396    fn test_submodule_not_ancestor() {
397        let check = SubmoduleAvailable::default();
398        let conf = make_check_conf(&check);
399
400        let result = test_check_submodule("test_submodule_not_ancestor", NOT_ANCESTOR_TOPIC, &conf);
401
402        assert_eq!(result.warnings().len(), 0);
403        assert_eq!(result.alerts().len(), 0);
404        assert_eq!(result.errors().len(), 1);
405        assert_eq!(
406            result.errors()[0],
407            "commit 07fb2ca9c1c8c0ddfcf921e762688ffcd476bc09 references the commit \
408             bd89a556b6ab6f378a776713439abbc1c1f15b6d at `submodule`, but it is not available on \
409             the tracked branch `master`; please make the commit available from the `master` \
410             branch first."
411        );
412        assert!(result.temporary());
413        assert!(!result.allowed());
414        assert!(!result.pass());
415    }
416
417    #[test]
418    fn test_submodule_delete() {
419        let check = SubmoduleAvailable::default();
420        let conf = make_check_conf(&check);
421
422        let result = test_check_base(
423            "test_submodule_delete",
424            DELETE_SUBMODULE,
425            UNAVAILABLE_TOPIC,
426            &conf,
427        );
428        test_result_ok(result);
429    }
430}