1use breezyshim::error::Error;
3use breezyshim::graph::Graph;
4use breezyshim::prelude::*;
5use breezyshim::revisionid::RevisionId;
6use debian_changelog::{ChangeLog, Entry as ChangeLogEntry};
7use lazy_regex::regex;
8
9#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
10pub struct ChangelogBehaviour {
12 #[serde(rename = "update")]
13 pub update_changelog: bool,
15
16 pub explanation: String,
18}
19
20#[cfg(feature = "svp")]
21impl From<ChangelogBehaviour> for svp_client::ChangelogBehaviour {
22 fn from(b: ChangelogBehaviour) -> Self {
23 svp_client::ChangelogBehaviour {
24 update_changelog: b.update_changelog,
25 explanation: b.explanation,
26 }
27 }
28}
29
30impl From<ChangelogBehaviour> for (bool, String) {
31 fn from(b: ChangelogBehaviour) -> Self {
32 (b.update_changelog, b.explanation)
33 }
34}
35
36impl From<&ChangelogBehaviour> for (bool, String) {
37 fn from(b: &ChangelogBehaviour) -> Self {
38 (b.update_changelog, b.explanation.clone())
39 }
40}
41
42const DEFAULT_BACKLOG: usize = 50;
44
45fn gbp_conf_has_dch_section(tree: &dyn Tree, debian_path: &std::path::Path) -> bool {
49 let gbp_conf_path = debian_path.join("gbp.conf");
50 let gbp_conf_text = match tree.get_file_text(gbp_conf_path.as_path()) {
51 Ok(text) => text,
52 Err(Error::NoSuchFile(_)) => return false,
53 Err(e) => panic!("Unexpected error reading gbp.conf: {:?}", e),
54 };
55
56 let mut parser = configparser::ini::Ini::new();
57 parser
58 .read(String::from_utf8_lossy(gbp_conf_text.as_slice()).to_string())
59 .unwrap();
60 parser.sections().contains(&"dch".to_string())
61}
62
63pub fn guess_update_changelog(
74 tree: &dyn WorkingTree,
75 debian_path: &std::path::Path,
76 mut cl: Option<ChangeLog>,
77) -> Option<ChangelogBehaviour> {
78 if debian_path != std::path::Path::new("debian") {
79 return Some(ChangelogBehaviour{
80 update_changelog: true,
81 explanation: "assuming changelog needs to be updated since gbp dch only supports a debian directory in the root of the repository".to_string(),
82 });
83 }
84 let changelog_path = debian_path.join("changelog");
85 if cl.is_none() {
86 match tree.get_file(changelog_path.as_path()) {
87 Ok(f) => {
88 cl = Some(ChangeLog::read(f).unwrap());
89 }
90 Err(Error::NoSuchFile(_)) => {
91 log::debug!("No changelog found");
92 }
93 Err(e) => {
94 panic!("Unexpected error reading changelog: {:?}", e);
95 }
96 }
97 }
98 if let Some(ref cl) = cl {
99 if debian_changelog::is_unreleased_inaugural(cl) {
100 return Some(ChangelogBehaviour {
101 update_changelog: false,
102 explanation: "assuming changelog does not need to be updated since it is the inaugural unreleased entry".to_string()
103 });
104 }
105 if let Some(first_entry) = cl.iter().next() {
106 for line in first_entry.change_lines() {
107 if line.contains("generated at release time") {
108 return Some(ChangelogBehaviour {
109 update_changelog: false,
110 explanation:
111 "last changelog entry warns changelog is generated at release time"
112 .to_string(),
113 });
114 }
115 }
116 }
117 }
118 if let Some(ret) = guess_update_changelog_from_tree(tree, debian_path, cl) {
119 Some(ret)
120 } else {
121 guess_update_changelog_from_branch(&tree.branch(), debian_path, None)
122 }
123}
124
125pub fn guess_update_changelog_from_tree(
127 tree: &dyn Tree,
128 debian_path: &std::path::Path,
129 cl: Option<ChangeLog>,
130) -> Option<ChangelogBehaviour> {
131 if gbp_conf_has_dch_section(tree, debian_path) {
132 return Some(ChangelogBehaviour {
133 update_changelog: false,
134 explanation: "Assuming changelog does not need to be updated, since there is a [dch] section in gbp.conf.".to_string()
135 });
136 }
137
138 if let Some(cl) = cl {
140 if let Some(entry) = cl.iter().next() {
141 if all_sha_prefixed(&entry) {
142 return Some(ChangelogBehaviour {
143 update_changelog: false,
144 explanation: "Assuming changelog does not need to be updated, since all entries in last changelog entry are prefixed by git shas.".to_string()
145 });
146 }
147 }
148 }
149
150 None
151}
152
153fn greedy_revisions(graph: &Graph, revid: &RevisionId, length: usize) -> (Vec<RevisionId>, bool) {
154 let mut ret = vec![];
155 let mut it = match graph.iter_lefthand_ancestry(revid, None) {
156 Ok(iter) => iter,
157 Err(_) => return (ret, true),
158 };
159 while ret.len() < length {
160 ret.push(match it.next() {
161 None => break,
162 Some(Ok(rev)) => rev,
163 Some(Err(Error::RevisionNotPresent(_))) => {
164 if !ret.is_empty() {
165 ret.pop();
166 }
167 return (ret, true);
169 }
170 Some(Err(e)) => {
171 panic!("Error iterating through ancestry: {:?}", e);
173 }
174 });
175 }
176 (ret, false)
177}
178
179#[derive(Debug, Default)]
180struct ChangelogStats {
181 mixed: usize,
182 changelog_only: usize,
183 other_only: usize,
184 dch_references: usize,
185 unreleased_references: usize,
186}
187
188fn changelog_stats(
189 branch: &dyn Branch,
190 history: usize,
191 debian_path: &std::path::Path,
192) -> ChangelogStats {
193 let mut ret = ChangelogStats::default();
194 let branch_lock = branch.lock_read();
195 let graph = branch.repository().get_graph();
196 let (revids, _truncated) = greedy_revisions(&graph, &branch.last_revision(), history);
197 let mut revs = vec![];
198 for (_revid, rev) in branch.repository().iter_revisions(revids) {
199 if rev.is_none() {
200 continue;
202 }
203 let rev = rev.unwrap();
204 if rev.message.contains("Git-Dch: ") || rev.message.contains("Gbp-Dch: ") {
205 ret.dch_references += 1;
206 }
207 revs.push(rev);
208 }
209 for (rev, delta) in revs.iter().zip(
210 branch
211 .repository()
212 .get_revision_deltas(revs.as_slice(), None),
213 ) {
214 let filenames: Vec<_> = delta
215 .added
216 .iter()
217 .filter_map(|a| a.path.1.as_ref())
218 .chain(delta.removed.iter().filter_map(|r| r.path.0.as_ref()))
219 .chain(delta.renamed.iter().filter_map(|r| r.path.0.as_ref()))
220 .chain(delta.renamed.iter().filter_map(|r| r.path.1.as_ref()))
221 .chain(delta.modified.iter().filter_map(|m| m.path.0.as_ref()))
222 .cloned()
223 .collect();
224 if !filenames.iter().any(|f| f.starts_with(debian_path)) {
225 continue;
226 }
227 let cl_path = debian_path.join("changelog");
228 if filenames.contains(&cl_path) {
229 let revtree = branch.repository().revision_tree(&rev.revision_id).unwrap();
230 match revtree.get_file_lines(cl_path.as_path()) {
231 Err(Error::NoSuchFile(_p)) => {}
232 Err(e) => {
233 panic!("Error reading changelog: {}", e);
234 }
235 Ok(cl_lines) => {
236 if String::from_utf8_lossy(cl_lines[0].as_slice()).contains("UNRELEASED") {
237 ret.unreleased_references += 1;
238 }
239 }
240 }
241 if filenames.len() > 1 {
242 ret.mixed += 1;
243 } else {
244 ret.changelog_only += 1;
245 }
246 } else {
247 ret.other_only += 1;
248 }
249 }
250 std::mem::drop(branch_lock);
251 ret
252}
253
254pub fn guess_update_changelog_from_branch(
266 branch: &dyn Branch,
267 debian_path: &std::path::Path,
268 history: Option<usize>,
269) -> Option<ChangelogBehaviour> {
270 let history = history.unwrap_or(DEFAULT_BACKLOG);
271 let stats = changelog_stats(branch, history, debian_path);
277 log::debug!("Branch history analysis: changelog_only: {}, other_only: {}, mixed: {}, dch_references: {}, unreleased_references: {}",
278 stats.changelog_only, stats.other_only, stats.mixed, stats.dch_references,
279 stats.unreleased_references);
280 if stats.dch_references > 0 {
281 return Some(ChangelogBehaviour {
282 update_changelog: false,
283 explanation: "Assuming changelog does not need to be updated, since there are Gbp-Dch stanzas in commit messages".to_string()
284 });
285 }
286 if stats.changelog_only == 0 {
287 return Some(ChangelogBehaviour {
288 update_changelog: true,
289 explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string()
290 });
291 }
292 if stats.unreleased_references == 0 {
293 return Some(ChangelogBehaviour {
294 update_changelog: false,
295 explanation: "Assuming changelog does not need to be updated, since it never uses UNRELEASED entries".to_string()
296 });
297 }
298 if stats.mixed == 0 && stats.changelog_only > 0 && stats.other_only > 0 {
299 return Some(ChangelogBehaviour {
301 update_changelog: false,
302 explanation: "Assuming changelog does not need to be updated, since changelog entries are always updated in separate commits.".to_string()
303 });
304 }
305 if stats.changelog_only > stats.mixed && stats.other_only > stats.mixed {
307 return Some(ChangelogBehaviour{
308 update_changelog: false,
309 explanation: "Assuming changelog does not need to be updated, since changelog entries are usually updated in separate commits.".to_string()
310 });
311 }
312 None
313}
314
315pub fn all_sha_prefixed(cb: &ChangeLogEntry) -> bool {
321 let mut sha_prefixed = 0;
322 for change in cb.change_lines() {
323 if !change.starts_with("* ") {
324 continue;
325 }
326 if regex!(r"\* \[[0-9a-f]{7}\] ").is_match(change.as_str()) {
327 sha_prefixed += 1;
328 } else {
329 return false;
330 }
331 }
332
333 sha_prefixed > 0
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use breezyshim::controldir::{create_standalone_workingtree, ControlDirFormat};
340 use std::path::Path;
341 pub const COMMITTER: &str = "Test User <test@example.com>";
342 fn make_changelog(entries: Vec<String>) -> String {
343 format!(
344 r###"lintian-brush (0.1) UNRELEASED; urgency=medium
345
346{}
347 -- Jelmer Vernooij <jelmer@debian.org> Sat, 13 Oct 2018 11:21:39 +0100
348"###,
349 entries
350 .iter()
351 .map(|x| format!(" * {}\n", x))
352 .collect::<Vec<_>>()
353 .concat()
354 )
355 }
356
357 #[test]
358 fn test_no_gbp_conf() {
359 let td = tempfile::tempdir().unwrap();
360 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
361 assert_eq!(
362 Some(ChangelogBehaviour{
363 update_changelog: true,
364 explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string(),
365 }),
366 guess_update_changelog(&tree, Path::new("debian"), None),
367 );
368 }
369
370 #[test]
371 fn test_custom_path() {
372 let td = tempfile::tempdir().unwrap();
373 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
374 assert_eq!(
375 Some(ChangelogBehaviour{
376 update_changelog: true,
377 explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string(),
378 }),
379 guess_update_changelog(&tree, Path::new("debian"), None),
380 );
381 assert_eq!(
382 Some(ChangelogBehaviour{
383 update_changelog: true,
384 explanation: "assuming changelog needs to be updated since gbp dch only supports a debian directory in the root of the repository".to_string(),
385 }),
386 guess_update_changelog(&tree, Path::new(""), None),
387 );
388 assert_eq!(
389 Some(ChangelogBehaviour{
390 update_changelog: true,
391 explanation: "assuming changelog needs to be updated since gbp dch only supports a debian directory in the root of the repository".to_string(),
392 }),
393 guess_update_changelog(&tree, Path::new("lala/debian"), None),
394 );
395 }
396
397 #[test]
398 fn test_gbp_conf_dch() {
399 let td = tempfile::tempdir().unwrap();
400 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
401 std::fs::create_dir(td.path().join("debian")).unwrap();
402 std::fs::write(
403 td.path().join("debian/gbp.conf"),
404 r#"[dch]
405pristine-tar = False
406"#,
407 )
408 .unwrap();
409 tree.add(&[Path::new("debian"), Path::new("debian/gbp.conf")])
410 .unwrap();
411 assert_eq!(Some(ChangelogBehaviour{
412 update_changelog: false,
413 explanation: "Assuming changelog does not need to be updated, since there is a [dch] section in gbp.conf.".to_string(),
414 }),
415 guess_update_changelog(&tree, Path::new("debian"), None)
416 );
417 }
418
419 #[test]
420 fn test_changelog_sha_prefixed() {
421 let td = tempfile::tempdir().unwrap();
422 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
423 std::fs::create_dir(td.path().join("debian")).unwrap();
424 std::fs::write(
425 td.path().join("debian/changelog"),
426 r#"blah (0.20.1) unstable; urgency=medium
427
428 [ Somebody ]
429 * [ebb7c31] do a thing
430 * [629746a] do another thing that actually requires us to wrap lines
431 and then
432
433 [ Somebody Else ]
434 * [b02b435] do another thing
435
436 -- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
437"#,
438 )
439 .unwrap();
440 tree.add(&[Path::new("debian"), Path::new("debian/changelog")])
441 .unwrap();
442 assert_eq!(
443 Some(ChangelogBehaviour{
444 update_changelog: false,
445 explanation: "Assuming changelog does not need to be updated, since all entries in last changelog entry are prefixed by git shas.".to_string(),
446 }),
447 guess_update_changelog(&tree, Path::new("debian"), None)
448 );
449 }
450
451 #[test]
452 fn test_empty() {
453 let td = tempfile::tempdir().unwrap();
454 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
455 assert_eq!(
456 Some(ChangelogBehaviour{
457 update_changelog: true,
458 explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string(),
459 }),
460 guess_update_changelog(&tree, Path::new("debian"), None)
461 );
462 }
463
464 #[test]
465 fn test_update_with_change() {
466 let td = tempfile::tempdir().unwrap();
467 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
468 std::fs::write(td.path().join("upstream"), b"upstream").unwrap();
469 std::fs::create_dir(td.path().join("debian")).unwrap();
470 std::fs::write(
471 td.path().join("debian/changelog"),
472 make_changelog(vec!["initial release".to_string()]),
473 )
474 .unwrap();
475 std::fs::write(td.path().join("debian/control"), b"initial").unwrap();
476 tree.add(&[
477 Path::new("upstream"),
478 Path::new("debian"),
479 Path::new("debian/changelog"),
480 Path::new("debian/control"),
481 ])
482 .unwrap();
483 tree.build_commit()
484 .message("initial release")
485 .committer(COMMITTER)
486 .commit()
487 .unwrap();
488 let mut changelog_entries = vec!["initial release".to_string()];
489 for i in 0..20 {
490 std::fs::write(td.path().join("upstream"), format!("upstream {}", i)).unwrap();
491 changelog_entries.push(format!("next entry {}", i));
492 std::fs::write(
493 td.path().join("debian/changelog"),
494 make_changelog(changelog_entries.clone()),
495 )
496 .unwrap();
497 std::fs::write(td.path().join("debian/control"), format!("next {}", i)).unwrap();
498 tree.build_commit()
499 .committer(COMMITTER)
500 .message("Next")
501 .commit()
502 .unwrap();
503 }
504 assert_eq!(Some(ChangelogBehaviour {
505 update_changelog: true,
506 explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string(),
507 }), guess_update_changelog(&tree, Path::new("debian"), None));
508 }
509
510 #[test]
511 fn test_changelog_updated_separately() {
512 let td = tempfile::tempdir().unwrap();
513 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
514 std::fs::create_dir(td.path().join("debian")).unwrap();
515 std::fs::write(
516 td.path().join("debian/changelog"),
517 make_changelog(vec!["initial release".to_string()]),
518 )
519 .unwrap();
520 std::fs::write(td.path().join("debian/control"), b"initial").unwrap();
521 tree.add(&[
522 Path::new("debian"),
523 Path::new("debian/changelog"),
524 Path::new("debian/control"),
525 ])
526 .unwrap();
527 tree.build_commit()
528 .message("initial release")
529 .committer(COMMITTER)
530 .commit()
531 .unwrap();
532 let mut changelog_entries = vec!["initial release".to_string()];
533 for i in 0..20 {
534 changelog_entries.push(format!("next entry {}", i));
535 std::fs::write(
536 td.path().join("debian/control"),
537 format!("next {}", i).as_bytes(),
538 )
539 .unwrap();
540 tree.build_commit()
541 .committer(COMMITTER)
542 .message("Next")
543 .commit()
544 .unwrap();
545 }
546 std::fs::write(
547 td.path().join("debian/changelog"),
548 make_changelog(changelog_entries.clone()),
549 )
550 .unwrap();
551 tree.build_commit()
552 .committer(COMMITTER)
553 .message("Next")
554 .commit()
555 .unwrap();
556 changelog_entries.push("final entry".to_string());
557 std::fs::write(td.path().join("debian/control"), b"more").unwrap();
558 tree.build_commit()
559 .committer(COMMITTER)
560 .message("Next")
561 .commit()
562 .unwrap();
563 std::fs::write(
564 td.path().join("debian/changelog"),
565 make_changelog(changelog_entries),
566 )
567 .unwrap();
568 tree.build_commit()
569 .committer(COMMITTER)
570 .message("Next")
571 .commit()
572 .unwrap();
573 assert_eq!(Some(ChangelogBehaviour{
574 update_changelog: false,
575 explanation: "Assuming changelog does not need to be updated, since changelog entries are usually updated in separate commits.".to_string(),
576 }), guess_update_changelog(&tree, Path::new("debian"), None));
577 }
578
579 #[test]
580 fn test_has_dch_in_messages() {
581 let td = tempfile::tempdir().unwrap();
582 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
583 tree.build_commit()
584 .message("Git-Dch: ignore\n")
585 .allow_pointless(true)
586 .committer(COMMITTER)
587 .commit()
588 .unwrap();
589
590 assert_eq!(Some(ChangelogBehaviour{
591 update_changelog: false,
592 explanation: "Assuming changelog does not need to be updated, since there are Gbp-Dch stanzas in commit messages".to_string(),
593 }), guess_update_changelog(&tree, Path::new("debian"), None));
594 }
595
596 #[test]
597 fn test_inaugural_unreleased() {
598 let td = tempfile::tempdir().unwrap();
599 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
600 std::fs::create_dir(td.path().join("debian")).unwrap();
601 std::fs::write(
602 td.path().join("debian/changelog"),
603 r#"blah (0.20.1) UNRELEASED; urgency=medium
604
605 * Initial release. Closes: #123123
606
607 -- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
608"#,
609 )
610 .unwrap();
611 tree.add(&[Path::new("debian"), Path::new("debian/changelog")])
612 .unwrap();
613 assert_eq!(Some(ChangelogBehaviour{
614 update_changelog: false,
615 explanation: "assuming changelog does not need to be updated since it is the inaugural unreleased entry".to_string(),
616 }), guess_update_changelog(&tree, Path::new("debian"), None));
617 }
618
619 #[test]
620 fn test_last_entry_warns_generated() {
621 let td = tempfile::tempdir().unwrap();
622 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
623 std::fs::create_dir(td.path().join("debian")).unwrap();
624 std::fs::write(
625 td.path().join("debian/changelog"),
626 r#"blah (0.20.1) UNRELEASED; urgency=medium
627
628 * WIP (generated at release time: please do not add entries below).
629
630 -- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
631
632blah (0.20.1) unstable; urgency=medium
633
634 * Initial release. Closes: #123123
635
636 -- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
637"#,
638 )
639 .unwrap();
640 tree.add(&[Path::new("debian"), Path::new("debian/changelog")])
641 .unwrap();
642 assert_eq!(
643 Some(ChangelogBehaviour {
644 update_changelog: false,
645 explanation: "last changelog entry warns changelog is generated at release time"
646 .to_string()
647 }),
648 guess_update_changelog(&tree, Path::new("debian"), None)
649 );
650 }
651
652 #[test]
653 fn test_never_unreleased() {
654 let td = tempfile::tempdir().unwrap();
655 let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
656 std::fs::create_dir(td.path().join("debian")).unwrap();
657 std::fs::write(td.path().join("debian/control"), b"foo").unwrap();
658 std::fs::write(
659 td.path().join("debian/changelog"),
660 r#"blah (0.20.1) unstable; urgency=medium
661
662 * Initial release. Closes: #123123
663
664 -- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
665"#,
666 )
667 .unwrap();
668
669 tree.add(&[
670 (Path::new("debian")),
671 (Path::new("debian/control")),
672 (Path::new("debian/changelog")),
673 ])
674 .unwrap();
675 tree.build_commit()
676 .committer(COMMITTER)
677 .message("rev1")
678 .commit()
679 .unwrap();
680 std::fs::write(td.path().join("debian/control"), b"bar").unwrap();
681 tree.build_commit()
682 .committer(COMMITTER)
683 .message("rev2")
684 .commit()
685 .unwrap();
686 std::fs::write(td.path().join("debian/control"), b"bla").unwrap();
687 tree.build_commit()
688 .committer(COMMITTER)
689 .message("rev2")
690 .commit()
691 .unwrap();
692 std::fs::write(
693 td.path().join("debian/changelog"),
694 r#"blah (0.21.1) unstable; urgency=medium
695
696 * Next release.
697
698 -- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
699
700blah (0.20.1) unstable; urgency=medium
701
702 * Initial release. Closes: #123123
703
704 -- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
705"#,
706 )
707 .unwrap();
708 tree.build_commit()
709 .committer(COMMITTER)
710 .message("rev2")
711 .commit()
712 .unwrap();
713 assert_eq!(Some(ChangelogBehaviour{
714 update_changelog: false,
715 explanation: "Assuming changelog does not need to be updated, since it never uses UNRELEASED entries".to_string()
716 }), guess_update_changelog(&tree, Path::new("debian"), None));
717 }
718}