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