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::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#[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
80fn 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 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 log::trace!("Arguments: {:#?}", args);
166 log::trace!("Config: {:#?}", config);
167
168 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 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 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 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 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 if releases[0]
330 .previous
331 .as_ref()
332 .and_then(|p| p.version.as_ref())
333 .is_none()
334 {
335 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 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 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
376pub fn run(args: Opt) -> Result<()> {
392 run_with_changelog_modifier(args, |_| Ok(()))
393}
394
395pub fn run_with_changelog_modifier(
419 mut args: Opt,
420 changelog_modifier: impl FnOnce(&mut Changelog) -> Result<()>,
421) -> Result<()> {
422 #[cfg(feature = "update-informer")]
424 check_new_version();
425
426 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 let builtin_config =
450 BuiltinConfig::parse(args.config.to_string_lossy().to_string());
451
452 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 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 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 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 if let Some(BumpOption::Specific(bump_type)) = args.bump {
632 config.bump.bump_type = Some(bump_type);
633 }
634
635 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 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 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 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 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}