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 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 if let StatusChange::Deleted = diff.status {
86 continue;
87 }
88
89 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") .arg("--reverse") .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 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 #[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}