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 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#[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
86fn 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
161fn process_submodules(
163 repository: &'static Repository,
164 release: &mut Release,
165 topo_order_commits: bool,
166) -> Result<()> {
167 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 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 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 for (submodule_path, commits) in submodule_commits {
204 release.submodule_commits.insert(submodule_path, commits);
205 }
206 }
207 Ok(())
208}
209
210fn 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 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 log::trace!("Arguments: {:#?}", args);
297 log::trace!("Config: {:#?}", config);
298
299 let commit_range = determine_commit_range(args, config, repository)?;
301
302 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 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 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 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 if releases[0]
413 .previous
414 .as_ref()
415 .and_then(|p| p.version.as_ref())
416 .is_none()
417 {
418 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 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 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 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
478pub fn run(args: Opt) -> Result<()> {
494 run_with_changelog_modifier(args, |_| Ok(()))
495}
496
497pub fn run_with_changelog_modifier(
521 mut args: Opt,
522 changelog_modifier: impl FnOnce(&mut Changelog) -> Result<()>,
523) -> Result<()> {
524 #[cfg(feature = "update-informer")]
526 check_new_version();
527
528 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 let builtin_config =
552 BuiltinConfig::parse(args.config.to_string_lossy().to_string());
553
554 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 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 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 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 if let Some(BumpOption::Specific(bump_type)) = args.bump {
733 config.bump.bump_type = Some(bump_type);
734 }
735
736 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 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 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 let repository = Repository::init(repository)?;
778
779 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 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}