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
13#[macro_use]
14extern crate log;
15
16use std::fs::{self, File};
17use std::path::{Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19use std::{env, io};
20
21use args::{BumpOption, Opt, Sort, Strip};
22use clap::ValueEnum;
23use git_cliff_core::changelog::Changelog;
24use git_cliff_core::commit::{Commit, 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")]
35fn 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 if new_version.semver().pre.is_empty() {
42 log::info!("A new version of {pkg_name} is available: v{pkg_version} -> {new_version}",);
43 }
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 }
111
112 Ok(commit_range)
113}
114
115fn process_submodules(
117 repository: &'static Repository,
118 release: &mut Release,
119 topo_order_commits: bool,
120) -> Result<()> {
121 let first_commit = release
123 .previous
124 .as_ref()
125 .and_then(|previous_release| previous_release.commit_id.clone())
126 .and_then(|commit_id| repository.find_commit(&commit_id));
127 let last_commit = release
128 .commit_id
129 .clone()
130 .and_then(|commit_id| repository.find_commit(&commit_id));
131
132 trace!("Processing submodule commits in {first_commit:?}..{last_commit:?}");
133
134 if let Some(last_commit) = last_commit {
138 let submodule_ranges = repository.submodules_range(first_commit, last_commit)?;
139 let submodule_commits = submodule_ranges.iter().filter_map(|submodule_range| {
140 let SubmoduleRange {
143 repository: sub_repo,
144 range: range_str,
145 } = submodule_range;
146 let commits = sub_repo
147 .commits(Some(range_str), None, None, topo_order_commits)
148 .ok()
149 .map(|commits| commits.iter().map(Commit::from).collect());
150
151 let submodule_path = sub_repo.path().to_string_lossy().into_owned();
152 Some(submodule_path).zip(commits)
153 });
154 for (submodule_path, commits) in submodule_commits {
156 release.submodule_commits.insert(submodule_path, commits);
157 }
158 }
159 Ok(())
160}
161
162fn process_repository<'a>(
168 repository: &'static Repository,
169 config: &mut Config,
170 args: &Opt,
171) -> Result<Vec<Release<'a>>> {
172 let mut tags = repository.tags(
173 &config.git.tag_pattern,
174 args.topo_order,
175 args.use_branch_tags,
176 )?;
177 let skip_regex = config.git.skip_tags.as_ref();
178 let ignore_regex = config.git.ignore_tags.as_ref();
179 let count_tags = config.git.count_tags.as_ref();
180 let recurse_submodules = config.git.recurse_submodules.unwrap_or(false);
181 tags.retain(|_, tag| {
182 let name = &tag.name;
183
184 let skip = skip_regex.is_some_and(|r| r.is_match(name));
186 if skip {
187 return true;
188 }
189
190 let count = count_tags.is_none_or(|r| {
191 let count_tag = r.is_match(name);
192 if count_tag {
193 trace!("Counting release: {}", name);
194 }
195 count_tag
196 });
197
198 let ignore = ignore_regex.is_some_and(|r| {
199 if r.as_str().trim().is_empty() {
200 return false;
201 }
202
203 let ignore_tag = r.is_match(name);
204 if ignore_tag {
205 trace!("Ignoring release: {}", name);
206 }
207 ignore_tag
208 });
209
210 count && !ignore
211 });
212
213 if !config.remote.is_any_set() {
214 match repository.upstream_remote() {
215 Ok(remote) => {
216 if !config.remote.github.is_set() {
217 debug!("No GitHub remote is set, using remote: {}", remote);
218 config.remote.github.owner = remote.owner;
219 config.remote.github.repo = remote.repo;
220 config.remote.github.is_custom = remote.is_custom;
221 } else if !config.remote.gitlab.is_set() {
222 debug!("No GitLab remote is set, using remote: {}", remote);
223 config.remote.gitlab.owner = remote.owner;
224 config.remote.gitlab.repo = remote.repo;
225 config.remote.gitlab.is_custom = remote.is_custom;
226 } else if !config.remote.gitea.is_set() {
227 debug!("No Gitea remote is set, using remote: {}", remote);
228 config.remote.gitea.owner = remote.owner;
229 config.remote.gitea.repo = remote.repo;
230 config.remote.gitea.is_custom = remote.is_custom;
231 } else if !config.remote.bitbucket.is_set() {
232 debug!("No Bitbucket remote is set, using remote: {}", remote);
233 config.remote.bitbucket.owner = remote.owner;
234 config.remote.bitbucket.repo = remote.repo;
235 config.remote.bitbucket.is_custom = remote.is_custom;
236 }
237 }
238 Err(e) => {
239 debug!("Failed to get remote from repository: {:?}", e);
240 }
241 }
242 }
243 if args.use_native_tls {
244 config.remote.enable_native_tls();
245 }
246
247 log::trace!("Arguments: {:#?}", args);
249 log::trace!("Config: {:#?}", config);
250
251 let commit_range = determine_commit_range(args, config, repository)?;
253
254 let mut include_path = config.git.include_paths.clone();
256 if let Some(mut path_diff) = pathdiff::diff_paths(env::current_dir()?, repository.root_path()?)
257 {
258 if args.workdir.is_none() && include_path.is_empty() && path_diff != Path::new("") {
259 info!(
260 "Including changes from the current directory: {:?}",
261 path_diff.display()
262 );
263 path_diff.extend(["**", "*"]);
264 include_path = vec![Pattern::new(path_diff.to_string_lossy().as_ref())?];
265 }
266 }
267
268 let include_path = (!include_path.is_empty()).then_some(include_path);
269 let exclude_path =
270 (!config.git.exclude_paths.is_empty()).then_some(config.git.exclude_paths.clone());
271 let mut commits = repository.commits(
272 commit_range.as_deref(),
273 include_path,
274 exclude_path,
275 config.git.topo_order_commits,
276 )?;
277 if let Some(commit_limit_value) = config.git.limit_commits {
278 commits.truncate(commit_limit_value);
279 }
280
281 let mut releases = vec![Release::default()];
283 let mut tag_timestamp = None;
284 if let Some(ref tag) = args.tag {
285 if let Some(commit_id) = commits.first().map(|c| c.id().to_string()) {
286 match tags.get(&commit_id) {
287 Some(tag) => {
288 warn!("There is already a tag ({}) for {}", tag.name, commit_id);
289 tag_timestamp = Some(commits[0].time().seconds());
290 }
291 None => {
292 tags.insert(commit_id, repository.resolve_tag(tag));
293 }
294 }
295 } else {
296 releases[0].version = Some(tag.to_string());
297 releases[0].timestamp = Some(
298 SystemTime::now()
299 .duration_since(UNIX_EPOCH)?
300 .as_secs()
301 .try_into()?,
302 );
303 }
304 }
305
306 let mut previous_release = Release::default();
308 let mut first_processed_tag = None;
309 let repository_path = repository.root_path()?.to_string_lossy().into_owned();
310 for git_commit in commits.iter().rev() {
311 let release = releases.last_mut().unwrap();
312 let commit = Commit::from(git_commit);
313 let commit_id = commit.id.to_string();
314 release.commits.push(commit);
315 release.repository = Some(repository_path.clone());
316 release.commit_id = Some(commit_id);
317 if let Some(tag) = tags.get(release.commit_id.as_ref().unwrap()) {
318 release.version = Some(tag.name.to_string());
319 release.message.clone_from(&tag.message);
320 release.timestamp = if args.tag.as_deref() == Some(tag.name.as_str()) {
321 match tag_timestamp {
322 Some(timestamp) => Some(timestamp),
323 None => Some(
324 SystemTime::now()
325 .duration_since(UNIX_EPOCH)?
326 .as_secs()
327 .try_into()?,
328 ),
329 }
330 } else {
331 Some(git_commit.time().seconds())
332 };
333 if first_processed_tag.is_none() {
334 first_processed_tag = Some(tag);
335 }
336 previous_release.previous = None;
337 release.previous = Some(Box::new(previous_release));
338 previous_release = release.clone();
339 releases.push(Release::default());
340 }
341 }
342
343 debug_assert!(!releases.is_empty());
344
345 if releases.len() > 1 {
346 previous_release.previous = None;
347 releases.last_mut().unwrap().previous = Some(Box::new(previous_release));
348 }
349
350 if args.sort == Sort::Newest {
351 for release in &mut releases {
352 release.commits.reverse();
353 }
354 }
355
356 if let Some(custom_commits) = &args.with_commit {
358 releases
359 .last_mut()
360 .unwrap()
361 .commits
362 .extend(custom_commits.iter().cloned().map(Commit::from));
363 }
364
365 if releases[0]
367 .previous
368 .as_ref()
369 .and_then(|p| p.version.as_ref())
370 .is_none()
371 {
372 let first_tag = first_processed_tag
374 .map(|tag| {
375 tags.iter()
376 .enumerate()
377 .find(|(_, (_, v))| v.name == tag.name)
378 .and_then(|(i, _)| i.checked_sub(1))
379 .and_then(|i| tags.get_index(i))
380 })
381 .or_else(|| Some(tags.last()))
382 .flatten();
383
384 if let Some((commit_id, tag)) = first_tag {
386 let previous_release = Release {
387 commit_id: Some(commit_id.to_string()),
388 version: Some(tag.name.clone()),
389 timestamp: Some(
390 repository
391 .find_commit(commit_id)
392 .map(|v| v.time().seconds())
393 .unwrap_or_default(),
394 ),
395 ..Default::default()
396 };
397 releases[0].previous = Some(Box::new(previous_release));
398 }
399 }
400
401 for release in &mut releases {
402 if !release.commits.is_empty() {
404 release.commit_range = Some(match args.sort {
405 Sort::Oldest => Range::new(
406 release.commits.first().unwrap(),
407 release.commits.last().unwrap(),
408 ),
409 Sort::Newest => Range::new(
410 release.commits.last().unwrap(),
411 release.commits.first().unwrap(),
412 ),
413 })
414 }
415 if recurse_submodules {
416 process_submodules(repository, release, config.git.topo_order_commits)?;
417 }
418 }
419
420 if let Some(message) = &args.with_tag_message {
422 if let Some(latest_release) = releases
423 .iter_mut()
424 .filter(|release| !release.commits.is_empty())
425 .next_back()
426 {
427 latest_release.message = Some(message.to_owned());
428 }
429 }
430
431 Ok(releases)
432}
433
434pub fn run(args: Opt) -> Result<()> {
450 run_with_changelog_modifier(args, |_| Ok(()))
451}
452
453pub fn run_with_changelog_modifier(
477 mut args: Opt,
478 changelog_modifier: impl FnOnce(&mut Changelog) -> Result<()>,
479) -> Result<()> {
480 #[cfg(feature = "update-informer")]
482 check_new_version();
483
484 if let Some(init_config) = args.init {
486 let contents = match init_config {
487 Some(ref name) => BuiltinConfig::get_config(name.to_string())?,
488 None => EmbeddedConfig::get_config()?,
489 };
490
491 let config_path = if args.config == PathBuf::from(DEFAULT_CONFIG) {
492 PathBuf::from(DEFAULT_CONFIG)
493 } else {
494 args.config.clone()
495 };
496
497 info!(
498 "Saving the configuration file{} to {:?}",
499 init_config.map(|v| format!(" ({v})")).unwrap_or_default(),
500 config_path
501 );
502 fs::write(config_path, contents)?;
503 return Ok(());
504 }
505
506 let builtin_config = BuiltinConfig::parse(args.config.to_string_lossy().to_string());
508
509 if let Some(ref workdir) = args.workdir {
511 args.config = workdir.join(args.config);
512 match args.repository.as_mut() {
513 Some(repository) => {
514 repository
515 .iter_mut()
516 .for_each(|r| *r = workdir.join(r.clone()));
517 }
518 None => args.repository = Some(vec![workdir.clone()]),
519 }
520 if let Some(changelog) = args.prepend {
521 args.prepend = Some(workdir.join(changelog));
522 }
523 }
524
525 let mut path = args.config.clone();
527 if !path.exists() {
528 if let Some(config_path) = Config::retrieve_config_path() {
529 path = config_path;
530 }
531 }
532
533 let mut config = if let Some(url) = &args.config_url {
536 debug!("Using configuration file from: {url}");
537 #[cfg(feature = "remote")]
538 {
539 reqwest::blocking::get(url.clone())?
540 .error_for_status()?
541 .text()?
542 .parse()?
543 }
544 #[cfg(not(feature = "remote"))]
545 unreachable!("This option is not available without the 'remote' build-time feature");
546 } else if let Ok((config, name)) = builtin_config {
547 info!("Using built-in configuration file: {name}");
548 config
549 } else if path.exists() {
550 Config::load(&path)?
551 } else if let Some(contents) = Config::read_from_manifest()? {
552 contents.parse()?
553 } else if let Some(discovered_path) = env::current_dir()?.ancestors().find_map(|dir| {
554 let path = dir.join(DEFAULT_CONFIG);
555 if path.is_file() { Some(path) } else { None }
556 }) {
557 info!(
558 "Using configuration from parent directory: {}",
559 discovered_path.display()
560 );
561 Config::load(&discovered_path)?
562 } else {
563 if !args.context {
564 warn!(
565 "{:?} is not found, using the default configuration.",
566 args.config
567 );
568 }
569 EmbeddedConfig::parse()?
570 };
571
572 let output = args.output.clone().or(config.changelog.output.clone());
574 match args.strip {
575 Some(Strip::Header) => {
576 config.changelog.header = None;
577 }
578 Some(Strip::Footer) => {
579 config.changelog.footer = None;
580 }
581 Some(Strip::All) => {
582 config.changelog.header = None;
583 config.changelog.footer = None;
584 }
585 None => {}
586 }
587 if args.prepend.is_some() {
588 config.changelog.footer = None;
589 if !(args.unreleased || args.latest || args.range.is_some()) {
590 return Err(Error::ArgumentError(String::from(
591 "'-u' or '-l' is not specified",
592 )));
593 }
594 }
595 if output.is_some() && args.prepend.is_some() && output.as_ref() == args.prepend.as_ref() {
596 return Err(Error::ArgumentError(String::from(
597 "'-o' and '-p' can only be used together if they point to different files",
598 )));
599 }
600 if let Some(body) = args.body.clone() {
601 config.changelog.body = body;
602 }
603 if args.sort == Sort::Oldest {
604 args.sort = Sort::from_str(&config.git.sort_commits, true)
605 .expect("Incorrect config value for 'sort_commits'");
606 }
607 if !args.topo_order {
608 args.topo_order = config.git.topo_order;
609 }
610
611 if !args.use_branch_tags {
612 args.use_branch_tags = config.git.use_branch_tags;
613 }
614
615 if args.github_token.is_some() {
616 config.remote.github.token.clone_from(&args.github_token);
617 }
618 if args.gitlab_token.is_some() {
619 config.remote.gitlab.token.clone_from(&args.gitlab_token);
620 }
621 if args.gitea_token.is_some() {
622 config.remote.gitea.token.clone_from(&args.gitea_token);
623 }
624 if args.bitbucket_token.is_some() {
625 config
626 .remote
627 .bitbucket
628 .token
629 .clone_from(&args.bitbucket_token);
630 }
631 if let Some(ref remote) = args.github_repo {
632 config.remote.github.owner = remote.0.owner.to_string();
633 config.remote.github.repo = remote.0.repo.to_string();
634 config.remote.github.is_custom = true;
635 }
636 if let Some(ref remote) = args.gitlab_repo {
637 config.remote.gitlab.owner = remote.0.owner.to_string();
638 config.remote.gitlab.repo = remote.0.repo.to_string();
639 config.remote.gitlab.is_custom = true;
640 }
641 if let Some(ref remote) = args.bitbucket_repo {
642 config.remote.bitbucket.owner = remote.0.owner.to_string();
643 config.remote.bitbucket.repo = remote.0.repo.to_string();
644 config.remote.bitbucket.is_custom = true;
645 }
646 if let Some(ref remote) = args.gitea_repo {
647 config.remote.gitea.owner = remote.0.owner.to_string();
648 config.remote.gitea.repo = remote.0.repo.to_string();
649 config.remote.gitea.is_custom = true;
650 }
651 if args.no_exec {
652 config
653 .git
654 .commit_preprocessors
655 .iter_mut()
656 .for_each(|v| v.replace_command = None);
657 config
658 .changelog
659 .postprocessors
660 .iter_mut()
661 .for_each(|v| v.replace_command = None);
662 }
663 config.git.skip_tags = config.git.skip_tags.filter(|r| !r.as_str().is_empty());
664 if args.tag_pattern.is_some() {
665 config.git.tag_pattern.clone_from(&args.tag_pattern);
666 }
667 if args.tag.is_some() {
668 config.bump.initial_tag.clone_from(&args.tag);
669 }
670 if args.ignore_tags.is_some() {
671 config.git.ignore_tags.clone_from(&args.ignore_tags);
672 }
673 if args.count_tags.is_some() {
674 config.git.count_tags.clone_from(&args.count_tags);
675 }
676 if let Some(include_path) = &args.include_path {
677 config
678 .git
679 .include_paths
680 .extend(include_path.iter().cloned());
681 }
682 if let Some(exclude_path) = &args.exclude_path {
683 config
684 .git
685 .exclude_paths
686 .extend(exclude_path.iter().cloned());
687 }
688
689 if let Some(BumpOption::Specific(bump_type)) = args.bump {
691 config.bump.bump_type = Some(bump_type);
692 }
693
694 let mut changelog: Changelog = if let Some(context_path) = args.from_context {
696 let mut input: Box<dyn io::Read> = if context_path == Path::new("-") {
697 Box::new(io::stdin())
698 } else {
699 Box::new(File::open(context_path)?)
700 };
701 let mut changelog = Changelog::from_context(&mut input, &config)?;
702 changelog.add_remote_context()?;
703 changelog
704 } else {
705 let repositories = args.repository.clone().unwrap_or(vec![env::current_dir()?]);
707 let mut releases = Vec::<Release>::new();
708 let mut commit_range = None;
709 for repository in repositories {
710 let mut skip_list = Vec::new();
712 let ignore_file = repository.join(IGNORE_FILE);
713 if ignore_file.exists() {
714 let contents = fs::read_to_string(ignore_file)?;
715 let commits = contents
716 .lines()
717 .filter(|v| !(v.starts_with('#') || v.trim().is_empty()))
718 .map(|v| String::from(v.trim()))
719 .collect::<Vec<String>>();
720 skip_list.extend(commits);
721 }
722 if let Some(ref skip_commit) = args.skip_commit {
723 skip_list.extend(skip_commit.clone());
724 }
725 for sha1 in skip_list {
726 config.git.commit_parsers.insert(0, CommitParser {
727 sha: Some(sha1.to_string()),
728 skip: Some(true),
729 ..Default::default()
730 });
731 }
732
733 let repository = Repository::init(repository)?;
735
736 commit_range = determine_commit_range(&args, &config, &repository)?;
741
742 releases.extend(process_repository(
743 Box::leak(Box::new(repository)),
744 &mut config,
745 &args,
746 )?);
747 }
748 Changelog::new(releases, &config, commit_range.as_deref())?
749 };
750 changelog_modifier(&mut changelog)?;
751
752 let mut out: Box<dyn io::Write> = if let Some(path) = &output {
754 if path == Path::new("-") {
755 Box::new(io::stdout())
756 } else {
757 Box::new(io::BufWriter::new(File::create(path)?))
758 }
759 } else {
760 Box::new(io::stdout())
761 };
762 if args.bump.is_some() || args.bumped_version {
763 let next_version = if let Some(next_version) = changelog.bump_version()? {
764 next_version
765 } else if let Some(last_version) =
766 changelog.releases.first().cloned().and_then(|v| v.version)
767 {
768 warn!("There is nothing to bump.");
769 last_version
770 } else if changelog.releases.is_empty() {
771 config.bump.get_initial_tag()
772 } else {
773 return Ok(());
774 };
775 if let Some(tag_pattern) = &config.git.tag_pattern {
776 if !tag_pattern.is_match(&next_version) {
777 return Err(Error::ChangelogError(format!(
778 "Next version ({}) does not match the tag pattern: {}",
779 next_version, tag_pattern
780 )));
781 }
782 }
783 if args.bumped_version {
784 writeln!(out, "{next_version}")?;
785 return Ok(());
786 }
787 }
788 if args.context {
789 changelog.write_context(&mut out)?;
790 return Ok(());
791 }
792 if let Some(path) = &args.prepend {
793 let changelog_before = fs::read_to_string(path)?;
794 let mut out = io::BufWriter::new(File::create(path)?);
795 changelog.prepend(changelog_before, &mut out)?;
796 }
797 if output.is_some() || args.prepend.is_none() {
798 changelog.generate(&mut out)?;
799 }
800
801 Ok(())
802}