git_cliff/
lib.rs

1//! A highly customizable changelog generator ⛰️
2#![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
7/// Command-line argument parser.
8pub mod args;
9
10/// Custom logger implementation.
11pub mod logger;
12
13#[macro_use]
14extern crate log;
15
16use args::{
17	BumpOption,
18	Opt,
19	Sort,
20	Strip,
21};
22use clap::ValueEnum;
23use git_cliff_core::changelog::Changelog;
24use git_cliff_core::commit::{
25	Commit,
26	Range,
27};
28use git_cliff_core::config::{
29	CommitParser,
30	Config,
31};
32use git_cliff_core::embed::{
33	BuiltinConfig,
34	EmbeddedConfig,
35};
36use git_cliff_core::error::{
37	Error,
38	Result,
39};
40use git_cliff_core::release::Release;
41use git_cliff_core::repo::{
42	Repository,
43	SubmoduleRange,
44};
45use git_cliff_core::{
46	DEFAULT_CONFIG,
47	IGNORE_FILE,
48};
49use glob::Pattern;
50use std::env;
51use std::fs::{
52	self,
53	File,
54};
55use std::io;
56use std::path::{
57	Path,
58	PathBuf,
59};
60use std::time::{
61	SystemTime,
62	UNIX_EPOCH,
63};
64
65/// Checks for a new version on crates.io
66#[cfg(feature = "update-informer")]
67fn check_new_version() {
68	use update_informer::Check;
69	let pkg_name = env!("CARGO_PKG_NAME");
70	let pkg_version = env!("CARGO_PKG_VERSION");
71	let informer = update_informer::new(
72		update_informer::registry::Crates,
73		pkg_name,
74		pkg_version,
75	);
76	if let Some(new_version) = informer.check_version().ok().flatten() {
77		if new_version.semver().pre.is_empty() {
78			log::info!(
79				"A new version of {pkg_name} is available: v{pkg_version} -> \
80				 {new_version}",
81			);
82		}
83	}
84}
85
86/// Produces a commit range on the format `BASE..HEAD`, derived from the
87/// command line arguments and repository tags.
88///
89/// If no commit range could be determined, `None` is returned.
90fn determine_commit_range(
91	args: &Opt,
92	config: &Config,
93	repository: &Repository,
94) -> Result<Option<String>> {
95	let tags = repository.tags(
96		&config.git.tag_pattern,
97		args.topo_order,
98		args.use_branch_tags,
99	)?;
100
101	let mut commit_range = args.range.clone();
102	if args.unreleased {
103		if let Some(last_tag) = tags.last().map(|(k, _)| k) {
104			commit_range = Some(format!("{last_tag}..HEAD"));
105		}
106	} else if args.latest || args.current {
107		if tags.len() < 2 {
108			let commits = repository.commits(
109				None,
110				None,
111				None,
112				config.git.topo_order_commits,
113			)?;
114			if let (Some(tag1), Some(tag2)) = (
115				commits.last().map(|c| c.id().to_string()),
116				tags.get_index(0).map(|(k, _)| k),
117			) {
118				if tags.len() == 1 {
119					commit_range = Some(tag2.to_owned());
120				} else {
121					commit_range = Some(format!("{tag1}..{tag2}"));
122				}
123			}
124		} else {
125			let mut tag_index = tags.len() - 2;
126			if args.current {
127				if let Some(current_tag_index) =
128					repository.current_tag().as_ref().and_then(|tag| {
129						tags.iter()
130							.enumerate()
131							.find(|(_, (_, v))| v.name == tag.name)
132							.map(|(i, _)| i)
133					}) {
134					match current_tag_index.checked_sub(1) {
135						Some(i) => tag_index = i,
136						None => {
137							return Err(Error::ChangelogError(String::from(
138								"No suitable tags found. Maybe run with \
139								 '--topo-order'?",
140							)));
141						}
142					}
143				} else {
144					return Err(Error::ChangelogError(String::from(
145						"No tag exists for the current commit",
146					)));
147				}
148			}
149			if let (Some(tag1), Some(tag2)) = (
150				tags.get_index(tag_index).map(|(k, _)| k),
151				tags.get_index(tag_index + 1).map(|(k, _)| k),
152			) {
153				commit_range = Some(format!("{tag1}..{tag2}"));
154			}
155		}
156	}
157
158	Ok(commit_range)
159}
160
161/// Process submodules and add commits to release.
162fn process_submodules(
163	repository: &'static Repository,
164	release: &mut Release,
165	topo_order_commits: bool,
166) -> Result<()> {
167	// Retrieve first and last commit of a release to create a commit range.
168	let first_commit = release
169		.previous
170		.as_ref()
171		.and_then(|previous_release| previous_release.commit_id.clone())
172		.and_then(|commit_id| repository.find_commit(&commit_id));
173	let last_commit = release
174		.commit_id
175		.clone()
176		.and_then(|commit_id| repository.find_commit(&commit_id));
177
178	trace!("Processing submodule commits in {first_commit:?}..{last_commit:?}");
179
180	// Query repository for submodule changes. For each submodule a
181	// SubmoduleRange is created, describing the range of commits in the context
182	// of that submodule.
183	if let Some(last_commit) = last_commit {
184		let submodule_ranges =
185			repository.submodules_range(first_commit, last_commit)?;
186		let submodule_commits =
187			submodule_ranges.iter().filter_map(|submodule_range| {
188				// For each submodule, the commit range is exploded into a list of
189				// commits.
190				let SubmoduleRange {
191					repository: sub_repo,
192					range: range_str,
193				} = submodule_range;
194				let commits = sub_repo
195					.commits(Some(range_str), None, None, topo_order_commits)
196					.ok()
197					.map(|commits| commits.iter().map(Commit::from).collect());
198
199				let submodule_path = sub_repo.path().to_string_lossy().into_owned();
200				Some(submodule_path).zip(commits)
201			});
202		// Insert submodule commits into map.
203		for (submodule_path, commits) in submodule_commits {
204			release.submodule_commits.insert(submodule_path, commits);
205		}
206	}
207	Ok(())
208}
209
210/// Processes the tags and commits for creating release entries for the
211/// changelog.
212///
213/// This function uses the configuration and arguments to process the given
214/// repository individually.
215fn process_repository<'a>(
216	repository: &'static Repository,
217	config: &mut Config,
218	args: &Opt,
219) -> Result<Vec<Release<'a>>> {
220	let mut tags = repository.tags(
221		&config.git.tag_pattern,
222		args.topo_order,
223		args.use_branch_tags,
224	)?;
225	let skip_regex = config.git.skip_tags.as_ref();
226	let ignore_regex = config.git.ignore_tags.as_ref();
227	let count_tags = config.git.count_tags.as_ref();
228	let recurse_submodules = config.git.recurse_submodules.unwrap_or(false);
229	tags.retain(|_, tag| {
230		let name = &tag.name;
231
232		// Keep skip tags to drop commits in the later stage.
233		let skip = skip_regex.is_some_and(|r| r.is_match(name));
234		if skip {
235			return true;
236		}
237
238		let count = count_tags.is_none_or(|r| {
239			let count_tag = r.is_match(name);
240			if count_tag {
241				trace!("Counting release: {}", name);
242			}
243			count_tag
244		});
245
246		let ignore = ignore_regex.is_some_and(|r| {
247			if r.as_str().trim().is_empty() {
248				return false;
249			}
250
251			let ignore_tag = r.is_match(name);
252			if ignore_tag {
253				trace!("Ignoring release: {}", name);
254			}
255			ignore_tag
256		});
257
258		count && !ignore
259	});
260
261	if !config.remote.is_any_set() {
262		match repository.upstream_remote() {
263			Ok(remote) => {
264				if !config.remote.github.is_set() {
265					debug!("No GitHub remote is set, using remote: {}", remote);
266					config.remote.github.owner = remote.owner;
267					config.remote.github.repo = remote.repo;
268					config.remote.github.is_custom = remote.is_custom;
269				} else if !config.remote.gitlab.is_set() {
270					debug!("No GitLab remote is set, using remote: {}", remote);
271					config.remote.gitlab.owner = remote.owner;
272					config.remote.gitlab.repo = remote.repo;
273					config.remote.gitlab.is_custom = remote.is_custom;
274				} else if !config.remote.gitea.is_set() {
275					debug!("No Gitea remote is set, using remote: {}", remote);
276					config.remote.gitea.owner = remote.owner;
277					config.remote.gitea.repo = remote.repo;
278					config.remote.gitea.is_custom = remote.is_custom;
279				} else if !config.remote.bitbucket.is_set() {
280					debug!("No Bitbucket remote is set, using remote: {}", remote);
281					config.remote.bitbucket.owner = remote.owner;
282					config.remote.bitbucket.repo = remote.repo;
283					config.remote.bitbucket.is_custom = remote.is_custom;
284				}
285			}
286			Err(e) => {
287				debug!("Failed to get remote from repository: {:?}", e);
288			}
289		}
290	}
291	if args.use_native_tls {
292		config.remote.enable_native_tls();
293	}
294
295	// Print debug information about configuration and arguments.
296	log::trace!("Arguments: {:#?}", args);
297	log::trace!("Config: {:#?}", config);
298
299	// Parse commits.
300	let commit_range = determine_commit_range(args, config, repository)?;
301
302	// Include only the current directory if not running from the root repository
303	let mut include_path = args.include_path.clone();
304	if let Some(mut path_diff) =
305		pathdiff::diff_paths(env::current_dir()?, repository.root_path()?)
306	{
307		if args.workdir.is_none() &&
308			include_path.is_none() &&
309			path_diff != Path::new("")
310		{
311			info!(
312				"Including changes from the current directory: {:?}",
313				path_diff.display()
314			);
315			path_diff.extend(["**", "*"]);
316			include_path =
317				Some(vec![Pattern::new(path_diff.to_string_lossy().as_ref())?]);
318		}
319	}
320
321	let mut commits = repository.commits(
322		commit_range.as_deref(),
323		include_path,
324		args.exclude_path.clone(),
325		config.git.topo_order_commits,
326	)?;
327	if let Some(commit_limit_value) = config.git.limit_commits {
328		commits.truncate(commit_limit_value);
329	}
330
331	// Update tags.
332	let mut releases = vec![Release::default()];
333	let mut tag_timestamp = None;
334	if let Some(ref tag) = args.tag {
335		if let Some(commit_id) = commits.first().map(|c| c.id().to_string()) {
336			match tags.get(&commit_id) {
337				Some(tag) => {
338					warn!("There is already a tag ({}) for {}", tag.name, commit_id);
339					tag_timestamp = Some(commits[0].time().seconds());
340				}
341				None => {
342					tags.insert(commit_id, repository.resolve_tag(tag));
343				}
344			}
345		} else {
346			releases[0].version = Some(tag.to_string());
347			releases[0].timestamp = SystemTime::now()
348				.duration_since(UNIX_EPOCH)?
349				.as_secs()
350				.try_into()?;
351		}
352	}
353
354	// Process releases.
355	let mut previous_release = Release::default();
356	let mut first_processed_tag = None;
357	let repository_path = repository.root_path()?.to_string_lossy().into_owned();
358	for git_commit in commits.iter().rev() {
359		let release = releases.last_mut().unwrap();
360		let commit = Commit::from(git_commit);
361		let commit_id = commit.id.to_string();
362		release.commits.push(commit);
363		release.repository = Some(repository_path.clone());
364		release.commit_id = Some(commit_id);
365		if let Some(tag) = tags.get(release.commit_id.as_ref().unwrap()) {
366			release.version = Some(tag.name.to_string());
367			release.message.clone_from(&tag.message);
368			release.timestamp = if args.tag.as_deref() == Some(tag.name.as_str()) {
369				match tag_timestamp {
370					Some(timestamp) => timestamp,
371					None => SystemTime::now()
372						.duration_since(UNIX_EPOCH)?
373						.as_secs()
374						.try_into()?,
375				}
376			} else {
377				git_commit.time().seconds()
378			};
379			if first_processed_tag.is_none() {
380				first_processed_tag = Some(tag);
381			}
382			previous_release.previous = None;
383			release.previous = Some(Box::new(previous_release));
384			previous_release = release.clone();
385			releases.push(Release::default());
386		}
387	}
388
389	debug_assert!(!releases.is_empty());
390
391	if releases.len() > 1 {
392		previous_release.previous = None;
393		releases.last_mut().unwrap().previous = Some(Box::new(previous_release));
394	}
395
396	if args.sort == Sort::Newest {
397		for release in &mut releases {
398			release.commits.reverse();
399		}
400	}
401
402	// Add custom commit messages to the latest release.
403	if let Some(custom_commits) = &args.with_commit {
404		releases
405			.last_mut()
406			.unwrap()
407			.commits
408			.extend(custom_commits.iter().cloned().map(Commit::from));
409	}
410
411	// Set the previous release if the first release does not have one set.
412	if releases[0]
413		.previous
414		.as_ref()
415		.and_then(|p| p.version.as_ref())
416		.is_none()
417	{
418		// Get the previous tag of the first processed tag in the release loop.
419		let first_tag = first_processed_tag
420			.map(|tag| {
421				tags.iter()
422					.enumerate()
423					.find(|(_, (_, v))| v.name == tag.name)
424					.and_then(|(i, _)| i.checked_sub(1))
425					.and_then(|i| tags.get_index(i))
426			})
427			.or_else(|| Some(tags.last()))
428			.flatten();
429
430		// Set the previous release if the first tag is found.
431		if let Some((commit_id, tag)) = first_tag {
432			let previous_release = Release {
433				commit_id: Some(commit_id.to_string()),
434				version: Some(tag.name.clone()),
435				timestamp: repository
436					.find_commit(commit_id)
437					.map(|v| v.time().seconds())
438					.unwrap_or_default(),
439				..Default::default()
440			};
441			releases[0].previous = Some(Box::new(previous_release));
442		}
443	}
444
445	for release in &mut releases {
446		// Set the commit ranges for all releases
447		if !release.commits.is_empty() {
448			release.commit_range = Some(match args.sort {
449				Sort::Oldest => Range::new(
450					release.commits.first().unwrap(),
451					release.commits.last().unwrap(),
452				),
453				Sort::Newest => Range::new(
454					release.commits.last().unwrap(),
455					release.commits.first().unwrap(),
456				),
457			})
458		}
459		if recurse_submodules {
460			process_submodules(repository, release, config.git.topo_order_commits)?;
461		}
462	}
463
464	// Set custom message for the latest release.
465	if let Some(message) = &args.with_tag_message {
466		if let Some(latest_release) = releases
467			.iter_mut()
468			.filter(|release| !release.commits.is_empty())
469			.next_back()
470		{
471			latest_release.message = Some(message.to_owned());
472		}
473	}
474
475	Ok(releases)
476}
477
478/// Runs `git-cliff`.
479///
480/// # Example
481///
482/// ```no_run
483/// use clap::Parser;
484/// use git_cliff::args::Opt;
485/// use git_cliff_core::error::Result;
486///
487/// fn main() -> Result<()> {
488/// 	let args = Opt::parse();
489/// 	git_cliff::run(args)?;
490/// 	Ok(())
491/// }
492/// ```
493pub fn run(args: Opt) -> Result<()> {
494	run_with_changelog_modifier(args, |_| Ok(()))
495}
496
497/// Runs `git-cliff` with a changelog modifier.
498///
499/// This is useful if you want to modify the [`Changelog`] before
500/// it's written or the context is printed (depending how git-cliff is started).
501///
502/// # Example
503///
504/// ```no_run
505/// use clap::Parser;
506/// use git_cliff::args::Opt;
507/// use git_cliff_core::error::Result;
508///
509/// fn main() -> Result<()> {
510/// 	let args = Opt::parse();
511///
512/// 	git_cliff::run_with_changelog_modifier(args, |changelog| {
513/// 		println!("Releases: {:?}", changelog.releases);
514/// 		Ok(())
515/// 	})?;
516///
517/// 	Ok(())
518/// }
519/// ```
520pub fn run_with_changelog_modifier(
521	mut args: Opt,
522	changelog_modifier: impl FnOnce(&mut Changelog) -> Result<()>,
523) -> Result<()> {
524	// Check if there is a new version available.
525	#[cfg(feature = "update-informer")]
526	check_new_version();
527
528	// Create the configuration file if init flag is given.
529	if let Some(init_config) = args.init {
530		let contents = match init_config {
531			Some(ref name) => BuiltinConfig::get_config(name.to_string())?,
532			None => EmbeddedConfig::get_config()?,
533		};
534
535		let config_path = if args.config == PathBuf::from(DEFAULT_CONFIG) {
536			PathBuf::from(DEFAULT_CONFIG)
537		} else {
538			args.config.clone()
539		};
540
541		info!(
542			"Saving the configuration file{} to {:?}",
543			init_config.map(|v| format!(" ({v})")).unwrap_or_default(),
544			config_path
545		);
546		fs::write(config_path, contents)?;
547		return Ok(());
548	}
549
550	// Retrieve the built-in configuration.
551	let builtin_config =
552		BuiltinConfig::parse(args.config.to_string_lossy().to_string());
553
554	// Set the working directory.
555	if let Some(ref workdir) = args.workdir {
556		args.config = workdir.join(args.config);
557		match args.repository.as_mut() {
558			Some(repository) => {
559				repository
560					.iter_mut()
561					.for_each(|r| *r = workdir.join(r.clone()));
562			}
563			None => args.repository = Some(vec![workdir.clone()]),
564		}
565		if let Some(changelog) = args.prepend {
566			args.prepend = Some(workdir.join(changelog));
567		}
568	}
569
570	// Set path for the configuration file.
571	let mut path = args.config.clone();
572	if !path.exists() {
573		if let Some(config_path) = dirs::config_dir()
574			.map(|dir| dir.join(env!("CARGO_PKG_NAME")).join(DEFAULT_CONFIG))
575		{
576			path = config_path;
577		}
578	}
579
580	// Parse the configuration file.
581	// Load the default configuration if necessary.
582	let mut config = if let Some(url) = &args.config_url {
583		debug!("Using configuration file from: {url}");
584		#[cfg(feature = "remote")]
585		{
586			let contents = reqwest::blocking::get(url.clone())?
587				.error_for_status()?
588				.text()?;
589			Config::parse_from_str(&contents)?
590		}
591		#[cfg(not(feature = "remote"))]
592		unreachable!(
593			"This option is not available without the 'remote' build-time feature"
594		);
595	} else if let Ok((config, name)) = builtin_config {
596		info!("Using built-in configuration file: {name}");
597		config
598	} else if path.exists() {
599		Config::parse(&path)?
600	} else if let Some(contents) = Config::read_from_manifest()? {
601		Config::parse_from_str(&contents)?
602	} else if let Some(discovered_path) =
603		env::current_dir()?.ancestors().find_map(|dir| {
604			let path = dir.join(DEFAULT_CONFIG);
605			if path.is_file() { Some(path) } else { None }
606		}) {
607		info!(
608			"Using configuration from parent directory: {}",
609			discovered_path.display()
610		);
611		Config::parse(&discovered_path)?
612	} else {
613		if !args.context {
614			warn!(
615				"{:?} is not found, using the default configuration.",
616				args.config
617			);
618		}
619		EmbeddedConfig::parse()?
620	};
621
622	// Update the configuration based on command line arguments and vice versa.
623	let output = args.output.clone().or(config.changelog.output.clone());
624	match args.strip {
625		Some(Strip::Header) => {
626			config.changelog.header = None;
627		}
628		Some(Strip::Footer) => {
629			config.changelog.footer = None;
630		}
631		Some(Strip::All) => {
632			config.changelog.header = None;
633			config.changelog.footer = None;
634		}
635		None => {}
636	}
637	if args.prepend.is_some() {
638		config.changelog.footer = None;
639		if !(args.unreleased || args.latest || args.range.is_some()) {
640			return Err(Error::ArgumentError(String::from(
641				"'-u' or '-l' is not specified",
642			)));
643		}
644	}
645	if output.is_some() &&
646		args.prepend.is_some() &&
647		output.as_ref() == args.prepend.as_ref()
648	{
649		return Err(Error::ArgumentError(String::from(
650			"'-o' and '-p' can only be used together if they point to different \
651			 files",
652		)));
653	}
654	if let Some(body) = args.body.clone() {
655		config.changelog.body = body;
656	}
657	if args.sort == Sort::Oldest {
658		args.sort = Sort::from_str(&config.git.sort_commits, true)
659			.expect("Incorrect config value for 'sort_commits'");
660	}
661	if !args.topo_order {
662		args.topo_order = config.git.topo_order;
663	}
664
665	if !args.use_branch_tags {
666		args.use_branch_tags = config.git.use_branch_tags;
667	}
668
669	if args.github_token.is_some() {
670		config.remote.github.token.clone_from(&args.github_token);
671	}
672	if args.gitlab_token.is_some() {
673		config.remote.gitlab.token.clone_from(&args.gitlab_token);
674	}
675	if args.gitea_token.is_some() {
676		config.remote.gitea.token.clone_from(&args.gitea_token);
677	}
678	if args.bitbucket_token.is_some() {
679		config
680			.remote
681			.bitbucket
682			.token
683			.clone_from(&args.bitbucket_token);
684	}
685	if let Some(ref remote) = args.github_repo {
686		config.remote.github.owner = remote.0.owner.to_string();
687		config.remote.github.repo = remote.0.repo.to_string();
688		config.remote.github.is_custom = true;
689	}
690	if let Some(ref remote) = args.gitlab_repo {
691		config.remote.gitlab.owner = remote.0.owner.to_string();
692		config.remote.gitlab.repo = remote.0.repo.to_string();
693		config.remote.gitlab.is_custom = true;
694	}
695	if let Some(ref remote) = args.bitbucket_repo {
696		config.remote.bitbucket.owner = remote.0.owner.to_string();
697		config.remote.bitbucket.repo = remote.0.repo.to_string();
698		config.remote.bitbucket.is_custom = true;
699	}
700	if let Some(ref remote) = args.gitea_repo {
701		config.remote.gitea.owner = remote.0.owner.to_string();
702		config.remote.gitea.repo = remote.0.repo.to_string();
703		config.remote.gitea.is_custom = true;
704	}
705	if args.no_exec {
706		config
707			.git
708			.commit_preprocessors
709			.iter_mut()
710			.for_each(|v| v.replace_command = None);
711		config
712			.changelog
713			.postprocessors
714			.iter_mut()
715			.for_each(|v| v.replace_command = None);
716	}
717	config.git.skip_tags = config.git.skip_tags.filter(|r| !r.as_str().is_empty());
718	if args.tag_pattern.is_some() {
719		config.git.tag_pattern.clone_from(&args.tag_pattern);
720	}
721	if args.tag.is_some() {
722		config.bump.initial_tag.clone_from(&args.tag);
723	}
724	if args.ignore_tags.is_some() {
725		config.git.ignore_tags.clone_from(&args.ignore_tags);
726	}
727	if args.count_tags.is_some() {
728		config.git.count_tags.clone_from(&args.count_tags);
729	}
730
731	// Process commits and releases for the changelog.
732	if let Some(BumpOption::Specific(bump_type)) = args.bump {
733		config.bump.bump_type = Some(bump_type);
734	}
735
736	// Generate changelog from context.
737	let mut changelog: Changelog = if let Some(context_path) = args.from_context {
738		let mut input: Box<dyn io::Read> = if context_path == Path::new("-") {
739			Box::new(io::stdin())
740		} else {
741			Box::new(File::open(context_path)?)
742		};
743		let mut changelog = Changelog::from_context(&mut input, &config)?;
744		changelog.add_remote_context()?;
745		changelog
746	} else {
747		// Process the repositories.
748		let repositories =
749			args.repository.clone().unwrap_or(vec![env::current_dir()?]);
750		let mut releases = Vec::<Release>::new();
751		let mut commit_range = None;
752		for repository in repositories {
753			// Skip commits
754			let mut skip_list = Vec::new();
755			let ignore_file = repository.join(IGNORE_FILE);
756			if ignore_file.exists() {
757				let contents = fs::read_to_string(ignore_file)?;
758				let commits = contents
759					.lines()
760					.filter(|v| !(v.starts_with('#') || v.trim().is_empty()))
761					.map(|v| String::from(v.trim()))
762					.collect::<Vec<String>>();
763				skip_list.extend(commits);
764			}
765			if let Some(ref skip_commit) = args.skip_commit {
766				skip_list.extend(skip_commit.clone());
767			}
768			for sha1 in skip_list {
769				config.git.commit_parsers.insert(0, CommitParser {
770					sha: Some(sha1.to_string()),
771					skip: Some(true),
772					..Default::default()
773				});
774			}
775
776			// Process the repository.
777			let repository = Repository::init(repository)?;
778
779			// The commit range, used for determining the remote commits to include
780			// in the changelog, doesn't make sense if multiple repositories are
781			// specified. As such, pick the commit range from the last given
782			// repository.
783			commit_range = determine_commit_range(&args, &config, &repository)?;
784
785			releases.extend(process_repository(
786				Box::leak(Box::new(repository)),
787				&mut config,
788				&args,
789			)?);
790		}
791		Changelog::new(releases, &config, commit_range.as_deref())?
792	};
793	changelog_modifier(&mut changelog)?;
794
795	// Print the result.
796	let mut out: Box<dyn io::Write> = if let Some(path) = &output {
797		if path == Path::new("-") {
798			Box::new(io::stdout())
799		} else {
800			Box::new(io::BufWriter::new(File::create(path)?))
801		}
802	} else {
803		Box::new(io::stdout())
804	};
805	if args.bump.is_some() || args.bumped_version {
806		let next_version = if let Some(next_version) = changelog.bump_version()? {
807			next_version
808		} else if let Some(last_version) =
809			changelog.releases.first().cloned().and_then(|v| v.version)
810		{
811			warn!("There is nothing to bump.");
812			last_version
813		} else if changelog.releases.is_empty() {
814			config.bump.get_initial_tag()
815		} else {
816			return Ok(());
817		};
818		if let Some(tag_pattern) = &config.git.tag_pattern {
819			if !tag_pattern.is_match(&next_version) {
820				return Err(Error::ChangelogError(format!(
821					"Next version ({}) does not match the tag pattern: {}",
822					next_version, tag_pattern
823				)));
824			}
825		}
826		if args.bumped_version {
827			writeln!(out, "{next_version}")?;
828			return Ok(());
829		}
830	}
831	if args.context {
832		changelog.write_context(&mut out)?;
833		return Ok(());
834	}
835	if let Some(path) = &args.prepend {
836		let changelog_before = fs::read_to_string(path)?;
837		let mut out = io::BufWriter::new(File::create(path)?);
838		changelog.prepend(changelog_before, &mut out)?;
839	}
840	if output.is_some() || args.prepend.is_none() {
841		changelog.generate(&mut out)?;
842	}
843
844	Ok(())
845}