Skip to main content

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 ContentCheck for SubmoduleAvailable {
76    fn name(&self) -> &str {
77        "submodule-available"
78    }
79
80    fn check(
81        &self,
82        ctx: &CheckGitContext,
83        content: &dyn Content,
84    ) -> Result<CheckResult, Box<dyn Error>> {
85        let mut result = CheckResult::new();
86
87        for diff in content.diffs() {
88            // Ignore deleted submodules.
89            if let StatusChange::Deleted = diff.status {
90                continue;
91            }
92
93            // Ignore diffs which are not submodules on the new side.
94            if diff.new_mode != "160000" {
95                continue;
96            }
97
98            let submodule_ctx = if let Some(ctx) = SubmoduleContext::new(ctx, diff.name.as_ref()) {
99                ctx
100            } else {
101                result.add_alert(
102                    format!("submodule at `{}` is not configured.", diff.name),
103                    false,
104                );
105
106                continue;
107            };
108
109            let submodule_commit = &diff.new_blob;
110
111            let cat_file = submodule_ctx
112                .context
113                .git()
114                .arg("cat-file")
115                .arg("-t")
116                .arg(submodule_commit.as_str())
117                .output()
118                .map_err(|err| GitError::subcommand("cat-file -t", err))?;
119            let object_type = String::from_utf8_lossy(&cat_file.stdout);
120            if !cat_file.status.success() || object_type.trim() != "commit" {
121                result
122                    .add_error(format!(
123                        "{}references an unreachable commit {submodule_commit} at `{}`; \
124                         please make the commit available in the {} repository on the `{}` branch \
125                         first.",
126                        commit_prefix(content),
127                        submodule_ctx.path,
128                        submodule_ctx.url,
129                        submodule_ctx.branch,
130                    ))
131                    .make_temporary();
132                continue;
133            }
134
135            let merge_base = submodule_ctx
136                .context
137                .git()
138                .arg("merge-base")
139                .arg(submodule_commit.as_str())
140                .arg(submodule_ctx.branch.as_ref())
141                .output()
142                .map_err(|err| GitError::subcommand("merge-base", err))?;
143            if !merge_base.status.success() {
144                return Err(SubmoduleAvailableError::merge_base(
145                    &diff.name,
146                    submodule_commit.clone(),
147                    submodule_ctx.branch.into(),
148                    &merge_base.stderr,
149                )
150                .into());
151            }
152            let base = String::from_utf8_lossy(&merge_base.stdout);
153
154            if base.trim() != submodule_commit.as_str() {
155                result
156                    .add_error(format!(
157                        "{}references the commit {submodule_commit} at `{}`, but it is \
158                         not available on the tracked branch `{}`; please make the commit \
159                         available from the `{}` branch first.",
160                        commit_prefix(content),
161                        submodule_ctx.path,
162                        submodule_ctx.branch,
163                        submodule_ctx.branch,
164                    ))
165                    .make_temporary();
166                continue;
167            }
168
169            if self.require_first_parent {
170                let refs = submodule_ctx
171                    .context
172                    .git()
173                    .arg("rev-list")
174                    .arg("--first-parent") // only look at first-parent history
175                    .arg("--reverse") // start with oldest commits
176                    .arg(submodule_ctx.branch.as_ref())
177                    .arg(format!("^{submodule_commit}~"))
178                    .output()
179                    .map_err(|err| GitError::subcommand("rev-list", err))?;
180                if !refs.status.success() {
181                    return Err(SubmoduleAvailableError::rev_list(
182                        &diff.name,
183                        submodule_commit.clone(),
184                        submodule_ctx.branch.into(),
185                        &refs.stderr,
186                    )
187                    .into());
188                }
189                let refs = String::from_utf8_lossy(&refs.stdout);
190
191                if !refs.lines().any(|rev| rev == submodule_commit.as_str()) {
192                    // This is not temporary because we've already determined above that it is in
193                    // the history of the target branch in the first place; it not being in the
194                    // first-parent isn't going to change.
195                    result.add_error(format!(
196                        "{}references the commit {submodule_commit} at `{}`, but it is \
197                         not available as a first-parent of the tracked branch `{}`; please \
198                         choose the commit where it was merged into the `{}` branch.",
199                        commit_prefix(content),
200                        submodule_ctx.path,
201                        submodule_ctx.branch,
202                        submodule_ctx.branch,
203                    ));
204                    continue;
205                }
206            }
207        }
208
209        Ok(result)
210    }
211}
212
213#[cfg(feature = "config")]
214pub(crate) mod config {
215    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
216    use serde::Deserialize;
217    #[cfg(test)]
218    use serde_json::json;
219
220    use crate::SubmoduleAvailable;
221
222    /// Configuration for the `SubmoduleAvailable` check.
223    ///
224    /// The `require_first_parent` key is a boolean which defaults to `false`.
225    ///
226    /// This check is registered as a commit check with the name `"submodule_available"`.
227    ///
228    /// # Example
229    ///
230    /// ```json
231    /// {
232    ///     "require_first_parent": false
233    /// }
234    /// ```
235    #[derive(Deserialize, Debug)]
236    pub struct SubmoduleAvailableConfig {
237        #[serde(default)]
238        require_first_parent: Option<bool>,
239    }
240
241    impl IntoCheck for SubmoduleAvailableConfig {
242        type Check = SubmoduleAvailable;
243
244        fn into_check(self) -> Self::Check {
245            let mut builder = SubmoduleAvailable::builder();
246
247            if let Some(require_first_parent) = self.require_first_parent {
248                builder.require_first_parent(require_first_parent);
249            }
250
251            builder
252                .build()
253                .expect("configuration mismatch for `SubmoduleAvailable`")
254        }
255    }
256
257    register_checks! {
258        SubmoduleAvailableConfig {
259            "submodule_available" => CommitCheckConfig,
260            "submodule_available/topic" => TopicCheckConfig,
261        },
262    }
263
264    #[test]
265    fn test_submodule_available_config_empty() {
266        let json = json!({});
267        let check: SubmoduleAvailableConfig = serde_json::from_value(json).unwrap();
268
269        assert_eq!(check.require_first_parent, None);
270
271        let check = check.into_check();
272
273        assert!(!check.require_first_parent);
274    }
275
276    #[test]
277    fn test_submodule_available_config_all_fields() {
278        let json = json!({
279            "require_first_parent": true,
280        });
281        let check: SubmoduleAvailableConfig = serde_json::from_value(json).unwrap();
282
283        assert_eq!(check.require_first_parent, Some(true));
284
285        let check = check.into_check();
286
287        assert!(check.require_first_parent);
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use git_checks_core::{Check, TopicCheck};
294
295    use crate::test::*;
296    use crate::SubmoduleAvailable;
297
298    const BASE_COMMIT: &str = "fe90ee22ae3ce4b4dc41f8d0876e59355ff1e21c";
299    const MOVE_TOPIC: &str = "2088079e35503be3be41dbdca55080ced95614e1";
300    const MOVE_NOT_FIRST_PARENT_TOPIC: &str = "eb4df16a8a38f6ca30b6e67cfbca0672156b54d2";
301    const MOVE_NOT_FIRST_PARENT_TOPIC_FIXED: &str = "8df81fb9d2319d04297e1a077964a19e45a4eb99";
302    const UNAVAILABLE_TOPIC: &str = "1b9275caca1557611df19d1dfea687c3ef302eef";
303    const UNAVAILABLE_TOPIC_FIXED: &str = "df69b0e3e49506f4dcb407efd5cd4f4721251926";
304    const NOT_ANCESTOR_TOPIC: &str = "07fb2ca9c1c8c0ddfcf921e762688ffcd476bc09";
305    const NOT_ANCESTOR_TOPIC_FIXED: &str = "be98bfb31946b6ccd237c60cd44d87e4afad5770";
306    const DELETE_SUBMODULE: &str = "25a69298548584f82efccd8922a1afc0a0d4182d";
307
308    #[test]
309    fn test_submodule_available_builder_default() {
310        assert!(SubmoduleAvailable::builder().build().is_ok());
311    }
312
313    #[test]
314    fn test_submodule_available_name_commit() {
315        let check = SubmoduleAvailable::default();
316        assert_eq!(Check::name(&check), "submodule-available");
317    }
318
319    #[test]
320    fn test_submodule_available_name_topic() {
321        let check = SubmoduleAvailable::default();
322        assert_eq!(TopicCheck::name(&check), "submodule-available");
323    }
324
325    #[test]
326    fn test_submodule_unconfigured() {
327        let check = SubmoduleAvailable::default();
328        let result = run_check("test_submodule_unconfigured", BASE_COMMIT, check);
329
330        assert_eq!(result.warnings().len(), 0);
331        assert_eq!(result.alerts().len(), 1);
332        assert_eq!(
333            result.alerts()[0],
334            "submodule at `submodule` is not configured.",
335        );
336        assert_eq!(result.errors().len(), 0);
337        assert!(!result.temporary());
338        assert!(!result.allowed());
339        assert!(result.pass());
340    }
341
342    #[test]
343    fn test_submodule_move() {
344        let check = SubmoduleAvailable::default();
345        let conf = make_check_conf(&check);
346
347        let result = test_check_submodule("test_submodule_move", MOVE_TOPIC, &conf);
348        test_result_ok(result);
349    }
350
351    #[test]
352    fn test_submodule_move_topic() {
353        let check = SubmoduleAvailable::default();
354        let conf = make_topic_check_conf(&check);
355
356        let result = test_check_submodule("test_submodule_move_topic", MOVE_TOPIC, &conf);
357        test_result_ok(result);
358    }
359
360    #[test]
361    fn test_submodule_move_not_first_parent() {
362        let check = SubmoduleAvailable::default();
363        let conf = make_check_conf(&check);
364
365        let result = test_check_submodule(
366            "test_submodule_move_not_first_parent",
367            MOVE_NOT_FIRST_PARENT_TOPIC,
368            &conf,
369        );
370        test_result_ok(result);
371    }
372
373    #[test]
374    fn test_submodule_move_not_first_parent_topic() {
375        let check = SubmoduleAvailable::default();
376        let conf = make_topic_check_conf(&check);
377
378        let result = test_check_submodule(
379            "test_submodule_move_not_first_parent_topic",
380            MOVE_NOT_FIRST_PARENT_TOPIC,
381            &conf,
382        );
383        test_result_ok(result);
384    }
385
386    #[test]
387    fn test_submodule_move_not_first_parent_reject() {
388        let check = SubmoduleAvailable::builder()
389            .require_first_parent(true)
390            .build()
391            .unwrap();
392        let conf = make_check_conf(&check);
393
394        let result = test_check_submodule(
395            "test_submodule_move_not_first_parent_reject",
396            MOVE_NOT_FIRST_PARENT_TOPIC,
397            &conf,
398        );
399        test_result_errors(result, &[
400            "commit eb4df16a8a38f6ca30b6e67cfbca0672156b54d2 references the commit \
401             c2bd427807b40b1715b8d1441fe92f50e8ad1769 at `submodule`, but it is not available as a \
402             first-parent of the tracked branch `master`; please choose the commit where it was \
403             merged into the `master` branch.",
404        ]);
405    }
406
407    #[test]
408    fn test_submodule_move_not_first_parent_reject_topic() {
409        let check = SubmoduleAvailable::builder()
410            .require_first_parent(true)
411            .build()
412            .unwrap();
413        let conf = make_topic_check_conf(&check);
414
415        let result = test_check_submodule(
416            "test_submodule_move_not_first_parent_reject_topic",
417            MOVE_NOT_FIRST_PARENT_TOPIC,
418            &conf,
419        );
420        test_result_errors(result, &[
421            "references the commit c2bd427807b40b1715b8d1441fe92f50e8ad1769 at `submodule`, but \
422             it is not available as a first-parent of the tracked branch `master`; please choose \
423             the commit where it was merged into the `master` branch.",
424        ]);
425    }
426
427    #[test]
428    fn test_submodule_move_not_first_parent_reject_topic_fixed() {
429        let check = SubmoduleAvailable::builder()
430            .require_first_parent(true)
431            .build()
432            .unwrap();
433        let conf = make_topic_check_conf(&check);
434
435        let result = test_check_submodule(
436            "test_submodule_move_not_first_parent_reject_topic_fixed",
437            MOVE_NOT_FIRST_PARENT_TOPIC_FIXED,
438            &conf,
439        );
440        test_result_ok(result);
441    }
442
443    #[test]
444    fn test_submodule_unavailable() {
445        let check = SubmoduleAvailable::default();
446        let conf = make_check_conf(&check);
447
448        let result = test_check_submodule("test_submodule_unavailable", UNAVAILABLE_TOPIC, &conf);
449
450        assert_eq!(result.warnings().len(), 0);
451        assert_eq!(result.alerts().len(), 0);
452        assert_eq!(result.errors().len(), 1);
453        assert_eq!(
454            result.errors()[0],
455            "commit 1b9275caca1557611df19d1dfea687c3ef302eef references an unreachable commit \
456             4b029c2e0f186d681caa071fa4dd7eb1f0f033f6 at `submodule`; please make the commit \
457             available in the https://gitlab.kitware.com/utils/test-repo.git repository on the \
458             `master` branch first.",
459        );
460        assert!(result.temporary());
461        assert!(!result.allowed());
462        assert!(!result.pass());
463    }
464
465    #[test]
466    fn test_submodule_unavailable_topic() {
467        let check = SubmoduleAvailable::default();
468        let conf = make_topic_check_conf(&check);
469
470        let result =
471            test_check_submodule("test_submodule_unavailable_topic", UNAVAILABLE_TOPIC, &conf);
472
473        assert_eq!(result.warnings().len(), 0);
474        assert_eq!(result.alerts().len(), 0);
475        assert_eq!(result.errors().len(), 1);
476        assert_eq!(
477            result.errors()[0],
478            "references an unreachable commit 4b029c2e0f186d681caa071fa4dd7eb1f0f033f6 at \
479             `submodule`; please make the commit available in the \
480             https://gitlab.kitware.com/utils/test-repo.git repository on the `master` branch \
481             first.",
482        );
483        assert!(result.temporary());
484        assert!(!result.allowed());
485        assert!(!result.pass());
486    }
487
488    #[test]
489    fn test_submodule_unavailable_topic_fixed() {
490        let check = SubmoduleAvailable::default();
491        let conf = make_topic_check_conf(&check);
492
493        let result = test_check_submodule(
494            "test_submodule_unavailable_topic_fixed",
495            UNAVAILABLE_TOPIC_FIXED,
496            &conf,
497        );
498        test_result_ok(result);
499    }
500
501    #[test]
502    fn test_submodule_not_ancestor() {
503        let check = SubmoduleAvailable::default();
504        let conf = make_check_conf(&check);
505
506        let result = test_check_submodule("test_submodule_not_ancestor", NOT_ANCESTOR_TOPIC, &conf);
507
508        assert_eq!(result.warnings().len(), 0);
509        assert_eq!(result.alerts().len(), 0);
510        assert_eq!(result.errors().len(), 1);
511        assert_eq!(
512            result.errors()[0],
513            "commit 07fb2ca9c1c8c0ddfcf921e762688ffcd476bc09 references the commit \
514             bd89a556b6ab6f378a776713439abbc1c1f15b6d at `submodule`, but it is not available on \
515             the tracked branch `master`; please make the commit available from the `master` \
516             branch first."
517        );
518        assert!(result.temporary());
519        assert!(!result.allowed());
520        assert!(!result.pass());
521    }
522
523    #[test]
524    fn test_submodule_not_ancestor_topic() {
525        let check = SubmoduleAvailable::default();
526        let conf = make_topic_check_conf(&check);
527
528        let result = test_check_submodule(
529            "test_submodule_not_ancestor_topic",
530            NOT_ANCESTOR_TOPIC,
531            &conf,
532        );
533
534        assert_eq!(result.warnings().len(), 0);
535        assert_eq!(result.alerts().len(), 0);
536        assert_eq!(result.errors().len(), 1);
537        assert_eq!(
538            result.errors()[0],
539            "references the commit bd89a556b6ab6f378a776713439abbc1c1f15b6d at `submodule`, but \
540             it is not available on the tracked branch `master`; please make the commit available \
541             from the `master` branch first.",
542        );
543        assert!(result.temporary());
544        assert!(!result.allowed());
545        assert!(!result.pass());
546    }
547
548    #[test]
549    fn test_submodule_not_ancestor_topic_fixed() {
550        let check = SubmoduleAvailable::default();
551        let conf = make_topic_check_conf(&check);
552
553        let result = test_check_submodule(
554            "test_submodule_not_ancestor_topic_fixed",
555            NOT_ANCESTOR_TOPIC_FIXED,
556            &conf,
557        );
558        test_result_ok(result);
559    }
560
561    #[test]
562    fn test_submodule_delete() {
563        let check = SubmoduleAvailable::default();
564        let conf = make_check_conf(&check);
565
566        let result = test_check_base(
567            "test_submodule_delete",
568            DELETE_SUBMODULE,
569            UNAVAILABLE_TOPIC,
570            &conf,
571        );
572        test_result_ok(result);
573    }
574
575    #[test]
576    fn test_submodule_delete_topic() {
577        let check = SubmoduleAvailable::default();
578        let conf = make_topic_check_conf(&check);
579
580        let result = test_check_base(
581            "test_submodule_delete_topic",
582            DELETE_SUBMODULE,
583            UNAVAILABLE_TOPIC,
584            &conf,
585        );
586        test_result_ok(result);
587    }
588}