1#![doc(
3 html_logo_url = "https://raw.githubusercontent.com/orhun/git-cliff/main/website/static/img/git-cliff.png",
4 html_favicon_url = "https://raw.githubusercontent.com/orhun/git-cliff/main/website/static/favicon/favicon.ico"
5)]
6
7pub mod args;
9
10pub mod logger;
12
13use std::collections::{HashMap, HashSet};
14use std::env;
15use std::fs::{self, File};
16use std::io::{self, Write};
17use std::path::{Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use args::{BumpOption, Opt, Sort, Strip};
21use clap::ValueEnum;
22use git_cliff_core::changelog::Changelog;
23use git_cliff_core::commit::{Commit, CommitStatistics, Range};
24use git_cliff_core::config::{CommitParser, Config};
25use git_cliff_core::embed::{BuiltinConfig, EmbeddedConfig};
26use git_cliff_core::error::{Error, Result};
27use git_cliff_core::release::Release;
28use git_cliff_core::repo::{Repository, SubmoduleRange};
29use git_cliff_core::{DEFAULT_CONFIG, IGNORE_FILE};
30use glob::Pattern;
31
32#[cfg(feature = "update-informer")]
34pub fn check_new_version() {
35 use update_informer::Check;
36 let pkg_name = env!("CARGO_PKG_NAME");
37 let pkg_version = env!("CARGO_PKG_VERSION");
38 let informer = update_informer::new(update_informer::registry::Crates, pkg_name, pkg_version);
39 if let Some(new_version) = informer.check_version().ok().flatten() {
40 if new_version.semver().pre.is_empty() {
41 tracing::info!(
42 "A new version of {pkg_name} is available: v{pkg_version} -> {new_version}",
43 );
44 }
45 }
46}
47
48fn determine_commit_range(
53 args: &Opt,
54 config: &Config,
55 repository: &Repository,
56) -> Result<Option<String>> {
57 let tags = repository.tags(
58 &config.git.tag_pattern,
59 args.topo_order,
60 args.use_branch_tags,
61 )?;
62
63 let mut commit_range = args.range.clone();
64 if args.unreleased {
65 if let Some(last_tag) = tags.last().map(|(k, _)| k) {
66 commit_range = Some(format!("{last_tag}..HEAD"));
67 }
68 } else if args.latest || args.current {
69 if tags.len() < 2 {
70 let commits = repository.commits(None, None, None, config.git.topo_order_commits)?;
71 if let (Some(tag1), Some(tag2)) = (
72 commits.last().map(|c| c.id().to_string()),
73 tags.get_index(0).map(|(k, _)| k),
74 ) {
75 if tags.len() == 1 {
76 commit_range = Some(tag2.to_owned());
77 } else {
78 commit_range = Some(format!("{tag1}..{tag2}"));
79 }
80 }
81 } else {
82 let mut tag_index = tags.len() - 2;
83 if args.current {
84 if let Some(current_tag_index) = repository.current_tag().as_ref().and_then(|tag| {
85 tags.iter()
86 .enumerate()
87 .find(|(_, (_, v))| v.name == tag.name)
88 .map(|(i, _)| i)
89 }) {
90 match current_tag_index.checked_sub(1) {
91 Some(i) => tag_index = i,
92 None => {
93 return Err(Error::ChangelogError(String::from(
94 "No suitable tags found. Maybe run with '--topo-order'?",
95 )));
96 }
97 }
98 } else {
99 return Err(Error::ChangelogError(String::from(
100 "No tag exists for the current commit",
101 )));
102 }
103 }
104 if let (Some(tag1), Some(tag2)) = (
105 tags.get_index(tag_index).map(|(k, _)| k),
106 tags.get_index(tag_index + 1).map(|(k, _)| k),
107 ) {
108 commit_range = Some(format!("{tag1}..{tag2}"));
109 }
110 }
111 } else if commit_range.is_none() {
112 if let Some(tag_limit) = config.git.limit_tags.filter(|limit| *limit > 0) {
113 let tag_index = tags.len().saturating_sub(tag_limit);
114 if let (Some((tag1, _)), Some((tag2, _))) = (tags.get_index(tag_index), tags.last()) {
115 if tag1 == tag2 {
116 commit_range = Some(tag2.to_owned());
117 } else {
118 commit_range = Some(format!("{tag1}..{tag2}"));
119 }
120 }
121 }
122 }
123
124 Ok(commit_range)
125}
126
127fn process_submodules(
129 repository: &'static Repository,
130 release: &mut Release,
131 topo_order_commits: bool,
132) -> Result<()> {
133 let first_commit = release
135 .previous
136 .as_ref()
137 .and_then(|previous_release| previous_release.commit_id.clone())
138 .and_then(|commit_id| repository.find_commit(&commit_id));
139 let last_commit = release
140 .commit_id
141 .clone()
142 .and_then(|commit_id| repository.find_commit(&commit_id));
143
144 tracing::debug!("Processing submodule commits in {first_commit:?}..{last_commit:?}");
145
146 if let Some(last_commit) = last_commit {
150 let submodule_ranges = repository.submodules_range(first_commit.as_ref(), &last_commit)?;
151 let submodule_commits = submodule_ranges.iter().filter_map(|submodule_range| {
152 let SubmoduleRange {
155 repository: sub_repo,
156 range: range_str,
157 } = submodule_range;
158 let commits = sub_repo
159 .commits(Some(range_str), None, None, topo_order_commits)
160 .ok()
161 .map(|commits| commits.iter().map(Commit::from).collect());
162
163 let submodule_path = sub_repo.path().to_string_lossy().into_owned();
164 Some(submodule_path).zip(commits)
165 });
166 for (submodule_path, commits) in submodule_commits {
168 release.submodule_commits.insert(submodule_path, commits);
169 }
170 }
171 Ok(())
172}
173
174pub fn init_config(name: Option<&str>, config_path: &Path) -> Result<()> {
176 init_config_from(name, None, config_path)
177}
178
179pub fn init_config_from(
181 name: Option<&str>,
182 templates_dir: Option<&Path>,
183 config_path: &Path,
184) -> Result<()> {
185 let contents = match name {
186 Some(name) => BuiltinConfig::get_config_from(name.to_string(), templates_dir)?,
187 None => {
188 if let Some(dir) = templates_dir {
189 BuiltinConfig::validate_templates_dir(dir)?;
190 }
191 EmbeddedConfig::get_config()?
192 }
193 };
194
195 tracing::info!(
196 "Saving the configuration file{} to {}",
197 name.map(|v| format!(" ({v})")).unwrap_or_default(),
198 config_path.display(),
199 );
200
201 fs::write(config_path, contents)?;
202
203 Ok(())
204}
205
206fn process_repository<'a>(
212 repository: &'static Repository,
213 config: &mut Config,
214 args: &Opt,
215) -> Result<Vec<Release<'a>>> {
216 let mut tags = repository.tags(
217 &config.git.tag_pattern,
218 args.topo_order,
219 args.use_branch_tags,
220 )?;
221 let skip_regex = config.git.skip_tags.as_ref();
222 let ignore_regex = config.git.ignore_tags.as_ref();
223 let count_tags = config.git.count_tags.as_ref();
224 let recurse_submodules = config.git.recurse_submodules.unwrap_or(false);
225 let compute_commit_statistics = args.context || config.uses_commit_statistics()?;
226 tags.retain(|_, tag| {
227 let name = &tag.name;
228
229 let skip = skip_regex.is_some_and(|r| r.is_match(name));
231 if skip {
232 return true;
233 }
234
235 let count = count_tags.is_none_or(|r| {
236 let count_tag = r.is_match(name);
237 if count_tag {
238 tracing::debug!("Counting release: {name}");
239 }
240 count_tag
241 });
242
243 let ignore = ignore_regex.is_some_and(|r| {
244 if r.as_str().trim().is_empty() {
245 return false;
246 }
247
248 let ignore_tag = r.is_match(name);
249 if ignore_tag {
250 tracing::debug!("Ignoring release: {name}");
251 }
252 ignore_tag
253 });
254
255 count && !ignore
256 });
257
258 if !config.remote.is_any_set() {
259 match repository.upstream_remote() {
260 Ok(remote) => {
261 if !config.remote.github.is_set() {
262 tracing::debug!("No GitHub remote is set, using remote: {remote}");
263 config.remote.github.owner = remote.owner;
264 config.remote.github.repo = remote.repo;
265 config.remote.github.is_custom = remote.is_custom;
266 } else if !config.remote.gitlab.is_set() {
267 tracing::debug!("No GitLab remote is set, using remote: {remote}");
268 config.remote.gitlab.owner = remote.owner;
269 config.remote.gitlab.repo = remote.repo;
270 config.remote.gitlab.is_custom = remote.is_custom;
271 } else if !config.remote.gitea.is_set() {
272 tracing::debug!("No Gitea remote is set, using remote: {remote}");
273 config.remote.gitea.owner = remote.owner;
274 config.remote.gitea.repo = remote.repo;
275 config.remote.gitea.is_custom = remote.is_custom;
276 } else if !config.remote.bitbucket.is_set() {
277 tracing::debug!("No Bitbucket remote is set, using remote: {remote}");
278 config.remote.bitbucket.owner = remote.owner;
279 config.remote.bitbucket.repo = remote.repo;
280 config.remote.bitbucket.is_custom = remote.is_custom;
281 }
282 }
283 Err(e) => {
284 tracing::debug!("Failed to get remote from repository: {e:?}");
285 }
286 }
287 }
288 if args.use_native_tls {
289 config.remote.enable_native_tls();
290 }
291
292 tracing::trace!("Arguments: {args:#?}");
294 tracing::trace!("Config: {config:#?}");
295
296 let commit_range = determine_commit_range(args, config, repository)?;
298
299 let cwd = env::current_dir()?;
313 let mut include_path = config.git.include_paths.clone();
314 if let Ok(root) = repository.root_path() {
315 if cwd.starts_with(&root) &&
316 cwd != root &&
317 args.repository.as_ref().is_none_or(Vec::is_empty) &&
318 args.workdir.is_none() &&
319 include_path.is_empty()
320 {
321 let path = cwd.join("**").join("*");
322 if let Ok(stripped) = path.strip_prefix(root) {
323 tracing::info!(
324 "Including changes from the current directory: {}",
325 cwd.display()
326 );
327 include_path = vec![Pattern::new(stripped.to_string_lossy().as_ref())?];
328 }
329 }
330 }
331
332 let include_path = (!include_path.is_empty()).then_some(include_path);
333 let exclude_path =
334 (!config.git.exclude_paths.is_empty()).then_some(config.git.exclude_paths.clone());
335 let mut commits = repository.commits(
336 commit_range.as_deref(),
337 include_path,
338 exclude_path,
339 config.git.topo_order_commits,
340 )?;
341 repository.filter_git_blame_ignore_revs(&mut commits);
342 if let Some(commit_limit_value) = config.git.limit_commits {
343 commits.truncate(commit_limit_value);
344 }
345
346 let mut releases = vec![Release::default()];
348 let mut tag_timestamp = None;
349 if let Some(ref tag) = args.tag {
350 if let Some(commit_id) = commits.first().map(|c| c.id().to_string()) {
351 match tags.get(&commit_id) {
352 Some(tag) => {
353 tracing::warn!("There is already a tag ({}) for {}", tag.name, commit_id);
354 tag_timestamp = Some(commits[0].time().seconds());
355 }
356 None => {
357 tags.insert(commit_id, repository.resolve_tag(tag));
358 }
359 }
360 } else {
361 releases[0].version = Some(tag.clone());
362 releases[0].timestamp = Some(
363 SystemTime::now()
364 .duration_since(UNIX_EPOCH)?
365 .as_secs()
366 .try_into()?,
367 );
368 }
369 }
370
371 let commit_ids: HashSet<_> = commits.iter().map(|commit| commit.id()).collect();
375 let ownership = repository.commit_tag_ownership(&tags, &commit_ids)?;
376
377 let mut groups: HashMap<&str, Vec<_>> = HashMap::new();
380 let mut unreleased = Vec::new();
381 for commit in commits.iter().rev() {
382 match ownership.get(&commit.id()) {
383 Some(tag_id) => groups.entry(tag_id).or_default().push(commit),
384 None => unreleased.push(commit),
385 }
386 }
387
388 let mut ordered_commits = Vec::with_capacity(commits.len());
391 for tag_id in tags.keys() {
392 let Some(mut group) = groups.remove(tag_id.as_str()) else {
393 continue;
394 };
395 if let Some(pos) = group
397 .iter()
398 .position(|commit| commit.id().to_string() == *tag_id)
399 {
400 let tag_commit = group.remove(pos);
401 group.push(tag_commit);
402 }
403 ordered_commits.extend(group);
404 }
405 ordered_commits.extend(unreleased);
406
407 let mut previous_release = Release::default();
409 let mut first_processed_tag = None;
410 let repository_path = repository.root_path()?.to_string_lossy().into_owned();
411 for git_commit in ordered_commits {
412 let release = releases.last_mut().unwrap();
413 let mut commit = Commit::from(git_commit);
414 if compute_commit_statistics {
415 commit.statistics = match repository.commit_statistics(git_commit) {
416 Ok(statistics) => statistics,
417 Err(err)
418 if matches!(
419 &err,
420 Error::GitError(git_err) if git_err.message().contains("object not found")
421 ) =>
422 {
423 tracing::warn!(
424 "Skipping diff statistics for commit {} because a Git object is missing: \
425 {err}",
426 commit.id,
427 );
428 CommitStatistics::default()
429 }
430 Err(err) => return Err(err),
431 }
432 }
433 let commit_id = commit.id.clone();
434 release.commits.push(commit);
435 release.repository = Some(repository_path.clone());
436 release.commit_id = Some(commit_id);
437 if let Some(tag) = tags.get(release.commit_id.as_ref().unwrap()) {
438 release.version = Some(tag.name.clone());
439 release.message.clone_from(&tag.message);
440 release.timestamp = if args.tag.as_deref() == Some(tag.name.as_str()) {
441 match tag_timestamp {
442 Some(timestamp) => Some(timestamp),
443 None => Some(
444 SystemTime::now()
445 .duration_since(UNIX_EPOCH)?
446 .as_secs()
447 .try_into()?,
448 ),
449 }
450 } else {
451 Some(git_commit.time().seconds())
452 };
453 if first_processed_tag.is_none() {
454 first_processed_tag = Some(tag);
455 }
456 previous_release.previous = None;
457 release.previous = Some(Box::new(previous_release));
458 previous_release = release.clone();
459 releases.push(Release::default());
460 }
461 }
462
463 debug_assert!(!releases.is_empty());
464
465 if releases.len() > 1 {
466 previous_release.previous = None;
467 releases.last_mut().unwrap().previous = Some(Box::new(previous_release));
468 }
469
470 if args.sort == Sort::Newest {
471 for release in &mut releases {
472 release.commits.reverse();
473 }
474 }
475
476 if let Some(custom_commits) = &args.with_commit {
478 releases
479 .last_mut()
480 .unwrap()
481 .commits
482 .extend(custom_commits.iter().cloned().map(Commit::from));
483 }
484
485 if releases[0]
487 .previous
488 .as_ref()
489 .and_then(|p| p.version.as_ref())
490 .is_none()
491 {
492 let first_tag = first_processed_tag
494 .map(|tag| {
495 tags.iter()
496 .enumerate()
497 .find(|(_, (_, v))| v.name == tag.name)
498 .and_then(|(i, _)| i.checked_sub(1))
499 .and_then(|i| tags.get_index(i))
500 })
501 .or_else(|| Some(tags.last()))
502 .flatten();
503
504 if let Some((commit_id, tag)) = first_tag {
506 let previous_release = Release {
507 commit_id: Some(commit_id.clone()),
508 version: Some(tag.name.clone()),
509 timestamp: Some(
510 repository
511 .find_commit(commit_id)
512 .map(|v| v.time().seconds())
513 .unwrap_or_default(),
514 ),
515 ..Default::default()
516 };
517 releases[0].previous = Some(Box::new(previous_release));
518 }
519 }
520
521 for release in &mut releases {
522 if !release.commits.is_empty() {
524 release.commit_range = Some(match args.sort {
525 Sort::Oldest => Range::new(
526 release.commits.first().unwrap(),
527 release.commits.last().unwrap(),
528 ),
529 Sort::Newest => Range::new(
530 release.commits.last().unwrap(),
531 release.commits.first().unwrap(),
532 ),
533 });
534 }
535 if recurse_submodules {
536 process_submodules(repository, release, config.git.topo_order_commits)?;
537 }
538 }
539
540 if let Some(message) = &args.with_tag_message {
542 if let Some(latest_release) = releases
543 .iter_mut()
544 .rfind(|release| !release.commits.is_empty())
545 {
546 latest_release.message = Some(message.to_owned());
547 }
548 }
549
550 Ok(releases)
551}
552
553fn resolve_config_path(
564 config: Option<&Path>,
565 workdir: Option<&Path>,
566 current_dir: &Path,
567 user_config: impl FnOnce() -> Option<PathBuf>,
568) -> Option<PathBuf> {
569 match config {
570 Some(path) if path.exists() => Some(path.to_path_buf()),
571 Some(_) => user_config(),
572 None => workdir
573 .unwrap_or(current_dir)
574 .ancestors()
575 .find_map(Config::retrieve_project_config_path)
576 .or_else(user_config),
577 }
578}
579
580pub fn run<'a>(args: Opt) -> Result<Changelog<'a>> {
596 run_with_changelog_modifier(args, |_| Ok(()))
597}
598
599pub fn run_with_changelog_modifier<'a>(
623 mut args: Opt,
624 changelog_modifier: impl FnOnce(&mut Changelog) -> Result<()>,
625) -> Result<Changelog<'a>> {
626 let builtin_config = args
628 .config
629 .as_ref()
630 .map(|config| BuiltinConfig::parse(config.to_string_lossy().to_string()));
631
632 if let Some(ref workdir) = args.workdir {
634 if let Some(config) = &args.config {
635 args.config = Some(workdir.join(config));
636 }
637 match args.repository.as_mut() {
638 Some(repository) => {
639 repository
640 .iter_mut()
641 .for_each(|r| *r = workdir.join(r.clone()));
642 }
643 None => args.repository = Some(vec![workdir.clone()]),
644 }
645 if let Some(changelog) = args.prepend {
646 args.prepend = Some(workdir.join(changelog));
647 }
648 if let Some(body_file) = args.body_file {
649 args.body_file = Some(workdir.join(body_file));
650 }
651 args.include_path = Some(vec![Pattern::new(
654 workdir.join("").to_string_lossy().as_ref(),
655 )?]);
656 }
657
658 let mut config = if let Some(url) = &args.config_url {
663 tracing::debug!("Using configuration file from: {url}");
664 #[cfg(feature = "remote")]
665 {
666 reqwest::blocking::get(url.clone())?
667 .error_for_status()?
668 .text()?
669 .parse()?
670 }
671 #[cfg(not(feature = "remote"))]
672 unreachable!("This option is not available without the 'remote' build-time feature");
673 } else if let Some(Ok((config, name))) = builtin_config {
674 tracing::info!("Using built-in configuration file: {name}");
675 config
676 } else if let Some(config_path) = resolve_config_path(
677 args.config.as_deref(),
678 args.workdir.as_deref(),
679 &env::current_dir()?,
680 Config::retrieve_user_config_path,
681 ) {
682 #[allow(clippy::unnecessary_debug_formatting)]
683 {
684 tracing::info!("Using configuration from: {}", config_path.display());
685 }
686 Config::load(&config_path)?
687 } else if let Some(contents) = Config::read_from_manifest()? {
688 contents.parse()?
689 } else {
690 #[allow(clippy::unnecessary_debug_formatting)]
691 if !args.context {
692 tracing::warn!(
693 "{:?} is not found, using the default configuration",
694 args.config.as_deref().unwrap_or(Path::new(DEFAULT_CONFIG))
695 );
696 }
697 EmbeddedConfig::parse()?
698 };
699
700 let output = args.output.clone().or(config.changelog.output.clone());
702 match args.strip {
703 Some(Strip::Header) => {
704 config.changelog.header = None;
705 }
706 Some(Strip::Footer) => {
707 config.changelog.footer = None;
708 }
709 Some(Strip::All) => {
710 config.changelog.header = None;
711 config.changelog.footer = None;
712 }
713 None => {}
714 }
715 if args.prepend.is_some() {
716 config.changelog.footer = None;
717 if !(args.unreleased || args.latest || args.range.is_some()) {
718 return Err(Error::ArgumentError(String::from(
719 "'-u' or '-l' is not specified",
720 )));
721 }
722 }
723 if output.is_some() && args.prepend.is_some() && output.as_ref() == args.prepend.as_ref() {
724 return Err(Error::ArgumentError(String::from(
725 "'-o' and '-p' can only be used together if they point to different files",
726 )));
727 }
728 if let Some(body) = if let Some(body_file) = &args.body_file {
729 Some(fs::read_to_string(body_file)?)
730 } else {
731 args.body.clone()
732 } {
733 config.changelog.body = body;
734 }
735 if args.sort == Sort::Oldest {
736 args.sort = Sort::from_str(&config.git.sort_commits, true)
737 .expect("Incorrect config value for 'sort_commits'");
738 }
739 if !args.topo_order {
740 args.topo_order = config.git.topo_order;
741 }
742
743 if !args.use_branch_tags {
744 args.use_branch_tags = config.git.use_branch_tags;
745 }
746
747 if args.github_token.is_some() {
748 config.remote.github.token.clone_from(&args.github_token);
749 }
750 if args.gitlab_token.is_some() {
751 config.remote.gitlab.token.clone_from(&args.gitlab_token);
752 }
753 if args.gitea_token.is_some() {
754 config.remote.gitea.token.clone_from(&args.gitea_token);
755 }
756 if args.bitbucket_token.is_some() {
757 config
758 .remote
759 .bitbucket
760 .token
761 .clone_from(&args.bitbucket_token);
762 }
763 if args.azure_devops_token.is_some() {
764 config
765 .remote
766 .azure_devops
767 .token
768 .clone_from(&args.azure_devops_token);
769 }
770 if let Some(http_timeout) = args.http_timeout {
771 let timeout = std::time::Duration::from_secs(http_timeout);
772 config.remote.github.http_timeout = timeout;
773 config.remote.gitlab.http_timeout = timeout;
774 config.remote.gitea.http_timeout = timeout;
775 config.remote.bitbucket.http_timeout = timeout;
776 config.remote.azure_devops.http_timeout = timeout;
777 }
778 if args.offline {
779 config.remote.offline = args.offline;
780 }
781 if let Some(ref remote) = args.github_repo {
782 config.remote.github.owner.clone_from(&remote.0.owner);
783 config.remote.github.repo.clone_from(&remote.0.repo);
784 config.remote.github.is_custom = true;
785 }
786 if let Some(ref remote) = args.gitlab_repo {
787 config.remote.gitlab.owner.clone_from(&remote.0.owner);
788 config.remote.gitlab.repo.clone_from(&remote.0.repo);
789 config.remote.gitlab.is_custom = true;
790 }
791 if let Some(ref remote) = args.bitbucket_repo {
792 config.remote.bitbucket.owner.clone_from(&remote.0.owner);
793 config.remote.bitbucket.repo.clone_from(&remote.0.repo);
794 config.remote.bitbucket.is_custom = true;
795 }
796 if let Some(ref remote) = args.gitea_repo {
797 config.remote.gitea.owner.clone_from(&remote.0.owner);
798 config.remote.gitea.repo.clone_from(&remote.0.repo);
799 config.remote.gitea.is_custom = true;
800 }
801 if let Some(ref remote) = args.azure_devops_repo {
802 config.remote.azure_devops.owner.clone_from(&remote.0.owner);
803 config.remote.azure_devops.repo.clone_from(&remote.0.repo);
804 config.remote.azure_devops.is_custom = true;
805 }
806 if args.no_exec {
807 config
808 .git
809 .commit_preprocessors
810 .iter_mut()
811 .for_each(|v| v.replace_command = None);
812 config
813 .changelog
814 .postprocessors
815 .iter_mut()
816 .for_each(|v| v.replace_command = None);
817 }
818 if args.skip_tags.is_some() {
819 config.git.skip_tags.clone_from(&args.skip_tags);
820 }
821 config.git.skip_tags = config.git.skip_tags.filter(|r| !r.as_str().is_empty());
822 if args.tag_pattern.is_some() {
823 config.git.tag_pattern.clone_from(&args.tag_pattern);
824 }
825 if args.tag.is_some() {
826 config.bump.initial_tag.clone_from(&args.tag);
827 }
828 if args.ignore_tags.is_some() {
829 config.git.ignore_tags.clone_from(&args.ignore_tags);
830 }
831 if args.count_tags.is_some() {
832 config.git.count_tags.clone_from(&args.count_tags);
833 }
834 if args.limit_tags.is_some() {
835 config.git.limit_tags = args.limit_tags;
836 }
837 if let Some(include_path) = &args.include_path {
838 config
839 .git
840 .include_paths
841 .extend(include_path.iter().cloned());
842 }
843 if let Some(exclude_path) = &args.exclude_path {
844 config
845 .git
846 .exclude_paths
847 .extend(exclude_path.iter().cloned());
848 }
849
850 if let Some(BumpOption::Specific(bump_type)) = args.bump {
852 config.bump.bump_type = Some(bump_type);
853 }
854
855 let mut changelog: Changelog = if let Some(context_path) = args.from_context {
857 let mut input: Box<dyn io::Read> = if context_path == Path::new("-") {
858 Box::new(io::stdin())
859 } else {
860 Box::new(File::open(context_path)?)
861 };
862 let mut changelog = Changelog::from_context(&mut input, config)?;
863 changelog.add_remote_context()?;
864 changelog
865 } else {
866 let repositories: Vec<Repository> = if let Some(paths) = &args.repository {
868 paths
869 .iter()
870 .map(|p| {
871 let abs_path = fs::canonicalize(p)?;
872 Repository::discover(abs_path)
873 })
874 .collect::<Result<Vec<_>>>()?
875 } else {
876 let cwd = env::current_dir()?;
877 vec![Repository::discover(cwd)?]
878 };
879 let mut releases = Vec::<Release>::new();
880 let mut commit_range = None;
881 for repository in repositories {
882 let mut skip_list = Vec::new();
884 let ignore_file = repository.root_path()?.join(IGNORE_FILE);
885 if ignore_file.exists() {
886 let contents = fs::read_to_string(ignore_file)?;
887 let commits = contents
888 .lines()
889 .filter(|v| !(v.starts_with('#') || v.trim().is_empty()))
890 .map(|v| String::from(v.trim()))
891 .collect::<Vec<String>>();
892 skip_list.extend(commits);
893 }
894 if let Some(ref skip_commit) = args.skip_commit {
895 skip_list.extend(skip_commit.clone());
896 }
897 for sha1 in skip_list {
898 config.git.commit_parsers.insert(0, CommitParser {
899 sha: Some(sha1.clone()),
900 skip: Some(true),
901 ..Default::default()
902 });
903 }
904
905 commit_range = determine_commit_range(&args, &config, &repository)?;
910
911 releases.extend(process_repository(
912 Box::leak(Box::new(repository)),
913 &mut config,
914 &args,
915 )?);
916 }
917 Changelog::new(releases, config, commit_range.as_deref())?
918 };
919 changelog_modifier(&mut changelog)?;
920
921 Ok(changelog)
922}
923
924pub fn write_changelog<W: io::Write>(
926 args: &Opt,
927 mut changelog: Changelog<'_>,
928 mut out: W,
929) -> Result<()> {
930 let output = args
931 .output
932 .clone()
933 .or(changelog.config.changelog.output.clone());
934 if changelog.config.changelog.format {
939 let is_markdown_path = |path: &PathBuf| {
940 path.extension()
941 .is_none_or(|ext| ext.eq_ignore_ascii_case("md"))
942 };
943 let is_markdown = output.as_ref().is_none_or(&is_markdown_path) &&
944 args.prepend.as_ref().is_none_or(&is_markdown_path);
945 if !is_markdown {
946 tracing::warn!(
947 "`changelog.format` is enabled but the output is not Markdown; skipping formatting"
948 );
949 changelog.config.changelog.format = false;
950 }
951 }
952 if args.bump.is_some() || args.bumped_version {
953 let current_version = changelog.releases.first().and_then(|release| {
954 release.version.clone().or_else(|| {
955 release
956 .previous
957 .as_ref()
958 .and_then(|previous| previous.version.clone())
959 })
960 });
961 let next_version = if let Some(next_version) = changelog.bump_version()? {
962 if current_version.as_ref() == Some(&next_version) {
963 tracing::warn!(
964 "The next version is the same as the current version, there is nothing to bump"
965 );
966 }
967 next_version
968 } else if let Some(last_version) =
969 changelog.releases.first().cloned().and_then(|v| v.version)
970 {
971 tracing::warn!("There is nothing to bump");
972 last_version
973 } else if changelog.releases.is_empty() {
974 changelog.config.bump.get_initial_tag()
975 } else {
976 return Ok(());
977 };
978 if let Some(tag_pattern) = &changelog.config.git.tag_pattern {
979 if !tag_pattern.is_match(&next_version) {
980 return Err(Error::ChangelogError(format!(
981 "Next version ({next_version}) does not match the tag pattern: {tag_pattern}",
982 )));
983 }
984 }
985 if args.bumped_version {
986 if changelog.config.changelog.output.is_none() {
987 writeln!(out, "{next_version}")?;
988 } else {
989 writeln!(io::stdout(), "{next_version}")?;
990 }
991 return Ok(());
992 }
993 }
994 if args.context {
995 changelog.write_context(&mut out)?;
996 return Ok(());
997 }
998 if let Some(path) = &args.prepend {
999 let changelog_before = fs::read_to_string(path)?;
1000 let mut out = io::BufWriter::new(File::create(path)?);
1001 changelog.prepend(changelog_before, &mut out)?;
1002 }
1003 if output.is_some() || args.prepend.is_none() {
1004 changelog.generate(&mut out)?;
1005 }
1006
1007 Ok(())
1008}