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(repository.root_path()?, env::current_dir()?)
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) =
529 dirs::config_dir().map(|dir| dir.join(env!("CARGO_PKG_NAME")).join(DEFAULT_CONFIG))
530 {
531 path = config_path;
532 }
533 }
534
535 let mut config = if let Some(url) = &args.config_url {
538 debug!("Using configuration file from: {url}");
539 #[cfg(feature = "remote")]
540 {
541 reqwest::blocking::get(url.clone())?
542 .error_for_status()?
543 .text()?
544 .parse()?
545 }
546 #[cfg(not(feature = "remote"))]
547 unreachable!("This option is not available without the 'remote' build-time feature");
548 } else if let Ok((config, name)) = builtin_config {
549 info!("Using built-in configuration file: {name}");
550 config
551 } else if path.exists() {
552 Config::load(&path)?
553 } else if let Some(contents) = Config::read_from_manifest()? {
554 contents.parse()?
555 } else if let Some(discovered_path) = env::current_dir()?.ancestors().find_map(|dir| {
556 let path = dir.join(DEFAULT_CONFIG);
557 if path.is_file() { Some(path) } else { None }
558 }) {
559 info!(
560 "Using configuration from parent directory: {}",
561 discovered_path.display()
562 );
563 Config::load(&discovered_path)?
564 } else {
565 if !args.context {
566 warn!(
567 "{:?} is not found, using the default configuration.",
568 args.config
569 );
570 }
571 EmbeddedConfig::parse()?
572 };
573
574 let output = args.output.clone().or(config.changelog.output.clone());
576 match args.strip {
577 Some(Strip::Header) => {
578 config.changelog.header = None;
579 }
580 Some(Strip::Footer) => {
581 config.changelog.footer = None;
582 }
583 Some(Strip::All) => {
584 config.changelog.header = None;
585 config.changelog.footer = None;
586 }
587 None => {}
588 }
589 if args.prepend.is_some() {
590 config.changelog.footer = None;
591 if !(args.unreleased || args.latest || args.range.is_some()) {
592 return Err(Error::ArgumentError(String::from(
593 "'-u' or '-l' is not specified",
594 )));
595 }
596 }
597 if output.is_some() && args.prepend.is_some() && output.as_ref() == args.prepend.as_ref() {
598 return Err(Error::ArgumentError(String::from(
599 "'-o' and '-p' can only be used together if they point to different files",
600 )));
601 }
602 if let Some(body) = args.body.clone() {
603 config.changelog.body = body;
604 }
605 if args.sort == Sort::Oldest {
606 args.sort = Sort::from_str(&config.git.sort_commits, true)
607 .expect("Incorrect config value for 'sort_commits'");
608 }
609 if !args.topo_order {
610 args.topo_order = config.git.topo_order;
611 }
612
613 if !args.use_branch_tags {
614 args.use_branch_tags = config.git.use_branch_tags;
615 }
616
617 if args.github_token.is_some() {
618 config.remote.github.token.clone_from(&args.github_token);
619 }
620 if args.gitlab_token.is_some() {
621 config.remote.gitlab.token.clone_from(&args.gitlab_token);
622 }
623 if args.gitea_token.is_some() {
624 config.remote.gitea.token.clone_from(&args.gitea_token);
625 }
626 if args.bitbucket_token.is_some() {
627 config
628 .remote
629 .bitbucket
630 .token
631 .clone_from(&args.bitbucket_token);
632 }
633 if let Some(ref remote) = args.github_repo {
634 config.remote.github.owner = remote.0.owner.to_string();
635 config.remote.github.repo = remote.0.repo.to_string();
636 config.remote.github.is_custom = true;
637 }
638 if let Some(ref remote) = args.gitlab_repo {
639 config.remote.gitlab.owner = remote.0.owner.to_string();
640 config.remote.gitlab.repo = remote.0.repo.to_string();
641 config.remote.gitlab.is_custom = true;
642 }
643 if let Some(ref remote) = args.bitbucket_repo {
644 config.remote.bitbucket.owner = remote.0.owner.to_string();
645 config.remote.bitbucket.repo = remote.0.repo.to_string();
646 config.remote.bitbucket.is_custom = true;
647 }
648 if let Some(ref remote) = args.gitea_repo {
649 config.remote.gitea.owner = remote.0.owner.to_string();
650 config.remote.gitea.repo = remote.0.repo.to_string();
651 config.remote.gitea.is_custom = true;
652 }
653 if args.no_exec {
654 config
655 .git
656 .commit_preprocessors
657 .iter_mut()
658 .for_each(|v| v.replace_command = None);
659 config
660 .changelog
661 .postprocessors
662 .iter_mut()
663 .for_each(|v| v.replace_command = None);
664 }
665 config.git.skip_tags = config.git.skip_tags.filter(|r| !r.as_str().is_empty());
666 if args.tag_pattern.is_some() {
667 config.git.tag_pattern.clone_from(&args.tag_pattern);
668 }
669 if args.tag.is_some() {
670 config.bump.initial_tag.clone_from(&args.tag);
671 }
672 if args.ignore_tags.is_some() {
673 config.git.ignore_tags.clone_from(&args.ignore_tags);
674 }
675 if args.count_tags.is_some() {
676 config.git.count_tags.clone_from(&args.count_tags);
677 }
678 if let Some(include_path) = &args.include_path {
679 config
680 .git
681 .include_paths
682 .extend(include_path.iter().cloned());
683 }
684 if let Some(exclude_path) = &args.exclude_path {
685 config
686 .git
687 .exclude_paths
688 .extend(exclude_path.iter().cloned());
689 }
690
691 if let Some(BumpOption::Specific(bump_type)) = args.bump {
693 config.bump.bump_type = Some(bump_type);
694 }
695
696 let mut changelog: Changelog = if let Some(context_path) = args.from_context {
698 let mut input: Box<dyn io::Read> = if context_path == Path::new("-") {
699 Box::new(io::stdin())
700 } else {
701 Box::new(File::open(context_path)?)
702 };
703 let mut changelog = Changelog::from_context(&mut input, &config)?;
704 changelog.add_remote_context()?;
705 changelog
706 } else {
707 let repositories = args.repository.clone().unwrap_or(vec![env::current_dir()?]);
709 let mut releases = Vec::<Release>::new();
710 let mut commit_range = None;
711 for repository in repositories {
712 let mut skip_list = Vec::new();
714 let ignore_file = repository.join(IGNORE_FILE);
715 if ignore_file.exists() {
716 let contents = fs::read_to_string(ignore_file)?;
717 let commits = contents
718 .lines()
719 .filter(|v| !(v.starts_with('#') || v.trim().is_empty()))
720 .map(|v| String::from(v.trim()))
721 .collect::<Vec<String>>();
722 skip_list.extend(commits);
723 }
724 if let Some(ref skip_commit) = args.skip_commit {
725 skip_list.extend(skip_commit.clone());
726 }
727 for sha1 in skip_list {
728 config.git.commit_parsers.insert(0, CommitParser {
729 sha: Some(sha1.to_string()),
730 skip: Some(true),
731 ..Default::default()
732 });
733 }
734
735 let repository = Repository::init(repository)?;
737
738 commit_range = determine_commit_range(&args, &config, &repository)?;
743
744 releases.extend(process_repository(
745 Box::leak(Box::new(repository)),
746 &mut config,
747 &args,
748 )?);
749 }
750 Changelog::new(releases, &config, commit_range.as_deref())?
751 };
752 changelog_modifier(&mut changelog)?;
753
754 let mut out: Box<dyn io::Write> = if let Some(path) = &output {
756 if path == Path::new("-") {
757 Box::new(io::stdout())
758 } else {
759 Box::new(io::BufWriter::new(File::create(path)?))
760 }
761 } else {
762 Box::new(io::stdout())
763 };
764 if args.bump.is_some() || args.bumped_version {
765 let next_version = if let Some(next_version) = changelog.bump_version()? {
766 next_version
767 } else if let Some(last_version) =
768 changelog.releases.first().cloned().and_then(|v| v.version)
769 {
770 warn!("There is nothing to bump.");
771 last_version
772 } else if changelog.releases.is_empty() {
773 config.bump.get_initial_tag()
774 } else {
775 return Ok(());
776 };
777 if let Some(tag_pattern) = &config.git.tag_pattern {
778 if !tag_pattern.is_match(&next_version) {
779 return Err(Error::ChangelogError(format!(
780 "Next version ({}) does not match the tag pattern: {}",
781 next_version, tag_pattern
782 )));
783 }
784 }
785 if args.bumped_version {
786 writeln!(out, "{next_version}")?;
787 return Ok(());
788 }
789 }
790 if args.context {
791 changelog.write_context(&mut out)?;
792 return Ok(());
793 }
794 if let Some(path) = &args.prepend {
795 let changelog_before = fs::read_to_string(path)?;
796 let mut out = io::BufWriter::new(File::create(path)?);
797 changelog.prepend(changelog_before, &mut out)?;
798 }
799 if output.is_some() || args.prepend.is_none() {
800 changelog.generate(&mut out)?;
801 }
802
803 Ok(())
804}