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