1use 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#[derive(Builder, Debug, Default, Clone, Copy)]
55#[builder(field(private))]
56pub struct SubmoduleAvailable {
57 #[builder(default = "false")]
65 require_first_parent: bool,
66}
67
68impl SubmoduleAvailable {
69 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 if let StatusChange::Deleted = diff.status {
90 continue;
91 }
92
93 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") .arg("--reverse") .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 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 #[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}