Skip to main content

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
13use std::env;
14use std::fs::{self, File};
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use args::{BumpOption, Opt, Sort, Strip};
20use clap::ValueEnum;
21use git_cliff_core::changelog::Changelog;
22use git_cliff_core::commit::{Commit, Range};
23use git_cliff_core::config::{CommitParser, Config};
24use git_cliff_core::embed::{BuiltinConfig, EmbeddedConfig};
25use git_cliff_core::error::{Error, Result};
26use git_cliff_core::release::Release;
27use git_cliff_core::repo::{Repository, SubmoduleRange};
28use git_cliff_core::{DEFAULT_CONFIG, IGNORE_FILE};
29use glob::Pattern;
30
31/// Checks for a new version on crates.io
32#[cfg(feature = "update-informer")]
33pub fn check_new_version() {
34    use update_informer::Check;
35    let pkg_name = env!("CARGO_PKG_NAME");
36    let pkg_version = env!("CARGO_PKG_VERSION");
37    let informer = update_informer::new(update_informer::registry::Crates, pkg_name, pkg_version);
38    if let Some(new_version) = informer.check_version().ok().flatten() {
39        if new_version.semver().pre.is_empty() {
40            tracing::info!(
41                "A new version of {pkg_name} is available: v{pkg_version} -> {new_version}",
42            );
43        }
44    }
45}
46
47/// Produces a commit range on the format `BASE..HEAD`, derived from the
48/// command line arguments and repository tags.
49///
50/// If no commit range could be determined, `None` is returned.
51fn 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
115/// Process submodules and add commits to release.
116fn process_submodules(
117    repository: &'static Repository,
118    release: &mut Release,
119    topo_order_commits: bool,
120) -> Result<()> {
121    // Retrieve first and last commit of a release to create a commit range.
122    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    tracing::debug!("Processing submodule commits in {first_commit:?}..{last_commit:?}");
133
134    // Query repository for submodule changes. For each submodule a
135    // SubmoduleRange is created, describing the range of commits in the context
136    // of that submodule.
137    if let Some(last_commit) = last_commit {
138        let submodule_ranges = repository.submodules_range(first_commit.as_ref(), &last_commit)?;
139        let submodule_commits = submodule_ranges.iter().filter_map(|submodule_range| {
140            // For each submodule, the commit range is exploded into a list of
141            // commits.
142            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        // Insert submodule commits into map.
155        for (submodule_path, commits) in submodule_commits {
156            release.submodule_commits.insert(submodule_path, commits);
157        }
158    }
159    Ok(())
160}
161
162/// Initializes the configuration file.
163pub fn init_config(name: Option<&str>, config_path: &Path) -> Result<()> {
164    let contents = match name {
165        Some(name) => BuiltinConfig::get_config(name.to_string())?,
166        None => EmbeddedConfig::get_config()?,
167    };
168
169    let config_path = if config_path == Path::new(DEFAULT_CONFIG) {
170        PathBuf::from(DEFAULT_CONFIG)
171    } else {
172        config_path.to_path_buf()
173    };
174
175    tracing::info!(
176        "Saving the configuration file{} to {}",
177        name.map(|v| format!(" ({v})")).unwrap_or_default(),
178        config_path.display(),
179    );
180
181    fs::write(config_path, contents)?;
182
183    Ok(())
184}
185
186/// Processes the tags and commits for creating release entries for the
187/// changelog.
188///
189/// This function uses the configuration and arguments to process the given
190/// repository individually.
191fn process_repository<'a>(
192    repository: &'static Repository,
193    config: &mut Config,
194    args: &Opt,
195) -> Result<Vec<Release<'a>>> {
196    let mut tags = repository.tags(
197        &config.git.tag_pattern,
198        args.topo_order,
199        args.use_branch_tags,
200    )?;
201    let skip_regex = config.git.skip_tags.as_ref();
202    let ignore_regex = config.git.ignore_tags.as_ref();
203    let count_tags = config.git.count_tags.as_ref();
204    let recurse_submodules = config.git.recurse_submodules.unwrap_or(false);
205    tags.retain(|_, tag| {
206        let name = &tag.name;
207
208        // Keep skip tags to drop commits in the later stage.
209        let skip = skip_regex.is_some_and(|r| r.is_match(name));
210        if skip {
211            return true;
212        }
213
214        let count = count_tags.is_none_or(|r| {
215            let count_tag = r.is_match(name);
216            if count_tag {
217                tracing::debug!("Counting release: {name}");
218            }
219            count_tag
220        });
221
222        let ignore = ignore_regex.is_some_and(|r| {
223            if r.as_str().trim().is_empty() {
224                return false;
225            }
226
227            let ignore_tag = r.is_match(name);
228            if ignore_tag {
229                tracing::debug!("Ignoring release: {name}");
230            }
231            ignore_tag
232        });
233
234        count && !ignore
235    });
236
237    if !config.remote.is_any_set() {
238        match repository.upstream_remote() {
239            Ok(remote) => {
240                if !config.remote.github.is_set() {
241                    tracing::debug!("No GitHub remote is set, using remote: {remote}");
242                    config.remote.github.owner = remote.owner;
243                    config.remote.github.repo = remote.repo;
244                    config.remote.github.is_custom = remote.is_custom;
245                } else if !config.remote.gitlab.is_set() {
246                    tracing::debug!("No GitLab remote is set, using remote: {remote}");
247                    config.remote.gitlab.owner = remote.owner;
248                    config.remote.gitlab.repo = remote.repo;
249                    config.remote.gitlab.is_custom = remote.is_custom;
250                } else if !config.remote.gitea.is_set() {
251                    tracing::debug!("No Gitea remote is set, using remote: {remote}");
252                    config.remote.gitea.owner = remote.owner;
253                    config.remote.gitea.repo = remote.repo;
254                    config.remote.gitea.is_custom = remote.is_custom;
255                } else if !config.remote.bitbucket.is_set() {
256                    tracing::debug!("No Bitbucket remote is set, using remote: {remote}");
257                    config.remote.bitbucket.owner = remote.owner;
258                    config.remote.bitbucket.repo = remote.repo;
259                    config.remote.bitbucket.is_custom = remote.is_custom;
260                }
261            }
262            Err(e) => {
263                tracing::debug!("Failed to get remote from repository: {e:?}");
264            }
265        }
266    }
267    if args.use_native_tls {
268        config.remote.enable_native_tls();
269    }
270
271    // Print debug information about configuration and arguments.
272    tracing::trace!("Arguments: {args:#?}");
273    tracing::trace!("Config: {config:#?}");
274
275    // Parse commits.
276    let commit_range = determine_commit_range(args, config, repository)?;
277
278    // Include only the current directory if not running from the root repository.
279    //
280    // NOTE:
281    // The conditions for including the current directory when not running from the root repository
282    // have grown quite complex. This may warrant additional documentation to explain the behavior.
283    //
284    // Current logic triggers when all of the following are true:
285    // - `cwd` is a child of the repository root but not the root itself
286    // - `args.repository` is either None or empty
287    // - `args.workdir` is None
288    // - `include_path` is currently empty
289    //
290    // Additionally, if `include_path` is already explicitly set, it might be preferable to append.
291    let cwd = env::current_dir()?;
292    let mut include_path = config.git.include_paths.clone();
293    if let Ok(root) = repository.root_path() {
294        if cwd.starts_with(&root) &&
295            cwd != root &&
296            args.repository.as_ref().is_none_or(Vec::is_empty) &&
297            args.workdir.is_none() &&
298            include_path.is_empty()
299        {
300            let path = cwd.join("**").join("*");
301            if let Ok(stripped) = path.strip_prefix(root) {
302                tracing::info!(
303                    "Including changes from the current directory: {}",
304                    cwd.display()
305                );
306                include_path = vec![Pattern::new(stripped.to_string_lossy().as_ref())?];
307            }
308        }
309    }
310
311    let include_path = (!include_path.is_empty()).then_some(include_path);
312    let exclude_path =
313        (!config.git.exclude_paths.is_empty()).then_some(config.git.exclude_paths.clone());
314    let mut commits = repository.commits(
315        commit_range.as_deref(),
316        include_path,
317        exclude_path,
318        config.git.topo_order_commits,
319    )?;
320    if let Some(commit_limit_value) = config.git.limit_commits {
321        commits.truncate(commit_limit_value);
322    }
323
324    // Update tags.
325    let mut releases = vec![Release::default()];
326    let mut tag_timestamp = None;
327    if let Some(ref tag) = args.tag {
328        if let Some(commit_id) = commits.first().map(|c| c.id().to_string()) {
329            match tags.get(&commit_id) {
330                Some(tag) => {
331                    tracing::warn!("There is already a tag ({}) for {}", tag.name, commit_id);
332                    tag_timestamp = Some(commits[0].time().seconds());
333                }
334                None => {
335                    tags.insert(commit_id, repository.resolve_tag(tag));
336                }
337            }
338        } else {
339            releases[0].version = Some(tag.clone());
340            releases[0].timestamp = Some(
341                SystemTime::now()
342                    .duration_since(UNIX_EPOCH)?
343                    .as_secs()
344                    .try_into()?,
345            );
346        }
347    }
348
349    // Process releases.
350    let mut previous_release = Release::default();
351    let mut first_processed_tag = None;
352    let repository_path = repository.root_path()?.to_string_lossy().into_owned();
353    for git_commit in commits.iter().rev() {
354        let release = releases.last_mut().unwrap();
355        let mut commit = Commit::from(git_commit);
356        commit.statistics = repository.commit_statistics(git_commit)?;
357        let commit_id = commit.id.clone();
358        release.commits.push(commit);
359        release.repository = Some(repository_path.clone());
360        release.commit_id = Some(commit_id);
361        if let Some(tag) = tags.get(release.commit_id.as_ref().unwrap()) {
362            release.version = Some(tag.name.clone());
363            release.message.clone_from(&tag.message);
364            release.timestamp = if args.tag.as_deref() == Some(tag.name.as_str()) {
365                match tag_timestamp {
366                    Some(timestamp) => Some(timestamp),
367                    None => Some(
368                        SystemTime::now()
369                            .duration_since(UNIX_EPOCH)?
370                            .as_secs()
371                            .try_into()?,
372                    ),
373                }
374            } else {
375                Some(git_commit.time().seconds())
376            };
377            if first_processed_tag.is_none() {
378                first_processed_tag = Some(tag);
379            }
380            previous_release.previous = None;
381            release.previous = Some(Box::new(previous_release));
382            previous_release = release.clone();
383            releases.push(Release::default());
384        }
385    }
386
387    debug_assert!(!releases.is_empty());
388
389    if releases.len() > 1 {
390        previous_release.previous = None;
391        releases.last_mut().unwrap().previous = Some(Box::new(previous_release));
392    }
393
394    if args.sort == Sort::Newest {
395        for release in &mut releases {
396            release.commits.reverse();
397        }
398    }
399
400    // Add custom commit messages to the latest release.
401    if let Some(custom_commits) = &args.with_commit {
402        releases
403            .last_mut()
404            .unwrap()
405            .commits
406            .extend(custom_commits.iter().cloned().map(Commit::from));
407    }
408
409    // Set the previous release if the first release does not have one set.
410    if releases[0]
411        .previous
412        .as_ref()
413        .and_then(|p| p.version.as_ref())
414        .is_none()
415    {
416        // Get the previous tag of the first processed tag in the release loop.
417        let first_tag = first_processed_tag
418            .map(|tag| {
419                tags.iter()
420                    .enumerate()
421                    .find(|(_, (_, v))| v.name == tag.name)
422                    .and_then(|(i, _)| i.checked_sub(1))
423                    .and_then(|i| tags.get_index(i))
424            })
425            .or_else(|| Some(tags.last()))
426            .flatten();
427
428        // Set the previous release if the first tag is found.
429        if let Some((commit_id, tag)) = first_tag {
430            let previous_release = Release {
431                commit_id: Some(commit_id.clone()),
432                version: Some(tag.name.clone()),
433                timestamp: Some(
434                    repository
435                        .find_commit(commit_id)
436                        .map(|v| v.time().seconds())
437                        .unwrap_or_default(),
438                ),
439                ..Default::default()
440            };
441            releases[0].previous = Some(Box::new(previous_release));
442        }
443    }
444
445    for release in &mut releases {
446        // Set the commit ranges for all releases
447        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    // Set custom message for the latest release.
465    if let Some(message) = &args.with_tag_message {
466        if let Some(latest_release) = releases
467            .iter_mut()
468            .rfind(|release| !release.commits.is_empty())
469        {
470            latest_release.message = Some(message.to_owned());
471        }
472    }
473
474    Ok(releases)
475}
476
477/// Runs `git-cliff`.
478///
479/// # Example
480///
481/// ```no_run
482/// use clap::Parser;
483/// use git_cliff::args::Opt;
484/// use git_cliff_core::error::Result;
485///
486/// fn main() -> Result<()> {
487///     let args = Opt::parse();
488///     git_cliff::run(args)?;
489///     Ok(())
490/// }
491/// ```
492pub fn run<'a>(args: Opt) -> Result<Changelog<'a>> {
493    run_with_changelog_modifier(args, |_| Ok(()))
494}
495
496/// Runs `git-cliff` with a changelog modifier.
497///
498/// This is useful if you want to modify the [`Changelog`] before
499/// it's written or the context is printed (depending how git-cliff is started).
500///
501/// # Example
502///
503/// ```no_run
504/// use clap::Parser;
505/// use git_cliff::args::Opt;
506/// use git_cliff_core::error::Result;
507///
508/// fn main() -> Result<()> {
509///     let args = Opt::parse();
510///
511///     git_cliff::run_with_changelog_modifier(args, |changelog| {
512///         println!("Releases: {:?}", changelog.releases);
513///         Ok(())
514///     })?;
515///
516///     Ok(())
517/// }
518/// ```
519pub fn run_with_changelog_modifier<'a>(
520    mut args: Opt,
521    changelog_modifier: impl FnOnce(&mut Changelog) -> Result<()>,
522) -> Result<Changelog<'a>> {
523    // Retrieve the built-in configuration.
524    let builtin_config = BuiltinConfig::parse(args.config.to_string_lossy().to_string());
525
526    // Set the working directory.
527    if let Some(ref workdir) = args.workdir {
528        args.config = workdir.join(args.config);
529        match args.repository.as_mut() {
530            Some(repository) => {
531                repository
532                    .iter_mut()
533                    .for_each(|r| *r = workdir.join(r.clone()));
534            }
535            None => args.repository = Some(vec![workdir.clone()]),
536        }
537        if let Some(changelog) = args.prepend {
538            args.prepend = Some(workdir.join(changelog));
539        }
540        // pushing an empty component force-adds a trailing path separator
541        // which is needed for correct glob expansion
542        args.include_path = Some(vec![Pattern::new(
543            workdir.join("").to_string_lossy().as_ref(),
544        )?]);
545    }
546
547    // Set path for the configuration file.
548    let mut path = args.config.clone();
549    if !path.exists() {
550        if let Some(config_path) = Config::retrieve_user_config_path() {
551            path = config_path;
552        }
553    }
554
555    // Parse the configuration file.
556    // Load the default configuration if necessary.
557    let mut config = if let Some(url) = &args.config_url {
558        tracing::debug!("Using configuration file from: {url}");
559        #[cfg(feature = "remote")]
560        {
561            reqwest::blocking::get(url.clone())?
562                .error_for_status()?
563                .text()?
564                .parse()?
565        }
566        #[cfg(not(feature = "remote"))]
567        unreachable!("This option is not available without the 'remote' build-time feature");
568    } else if let Ok((config, name)) = builtin_config {
569        tracing::info!("Using built-in configuration file: {name}");
570        config
571    } else if path.exists() {
572        Config::load(&path)?
573    } else if let Some(contents) = Config::read_from_manifest()? {
574        contents.parse()?
575    } else if let Some(discovered_path) = env::current_dir()?
576        .ancestors()
577        .find_map(Config::retrieve_project_config_path)
578    {
579        tracing::info!(
580            "Using configuration from parent directory: {}",
581            discovered_path.display()
582        );
583        Config::load(&discovered_path)?
584    } else {
585        #[allow(clippy::unnecessary_debug_formatting)]
586        if !args.context {
587            tracing::warn!(
588                "{:?} is not found, using the default configuration",
589                args.config
590            );
591        }
592        EmbeddedConfig::parse()?
593    };
594
595    // Update the configuration based on command line arguments and vice versa.
596    let output = args.output.clone().or(config.changelog.output.clone());
597    match args.strip {
598        Some(Strip::Header) => {
599            config.changelog.header = None;
600        }
601        Some(Strip::Footer) => {
602            config.changelog.footer = None;
603        }
604        Some(Strip::All) => {
605            config.changelog.header = None;
606            config.changelog.footer = None;
607        }
608        None => {}
609    }
610    if args.prepend.is_some() {
611        config.changelog.footer = None;
612        if !(args.unreleased || args.latest || args.range.is_some()) {
613            return Err(Error::ArgumentError(String::from(
614                "'-u' or '-l' is not specified",
615            )));
616        }
617    }
618    if output.is_some() && args.prepend.is_some() && output.as_ref() == args.prepend.as_ref() {
619        return Err(Error::ArgumentError(String::from(
620            "'-o' and '-p' can only be used together if they point to different files",
621        )));
622    }
623    if let Some(body) = args.body.clone() {
624        config.changelog.body = body;
625    }
626    if args.sort == Sort::Oldest {
627        args.sort = Sort::from_str(&config.git.sort_commits, true)
628            .expect("Incorrect config value for 'sort_commits'");
629    }
630    if !args.topo_order {
631        args.topo_order = config.git.topo_order;
632    }
633
634    if !args.use_branch_tags {
635        args.use_branch_tags = config.git.use_branch_tags;
636    }
637
638    if args.github_token.is_some() {
639        config.remote.github.token.clone_from(&args.github_token);
640    }
641    if args.gitlab_token.is_some() {
642        config.remote.gitlab.token.clone_from(&args.gitlab_token);
643    }
644    if args.gitea_token.is_some() {
645        config.remote.gitea.token.clone_from(&args.gitea_token);
646    }
647    if args.bitbucket_token.is_some() {
648        config
649            .remote
650            .bitbucket
651            .token
652            .clone_from(&args.bitbucket_token);
653    }
654    if args.azure_devops_token.is_some() {
655        config
656            .remote
657            .azure_devops
658            .token
659            .clone_from(&args.azure_devops_token);
660    }
661    if args.offline {
662        config.remote.offline = args.offline;
663    }
664    if let Some(ref remote) = args.github_repo {
665        config.remote.github.owner.clone_from(&remote.0.owner);
666        config.remote.github.repo.clone_from(&remote.0.repo);
667        config.remote.github.is_custom = true;
668    }
669    if let Some(ref remote) = args.gitlab_repo {
670        config.remote.gitlab.owner.clone_from(&remote.0.owner);
671        config.remote.gitlab.repo.clone_from(&remote.0.repo);
672        config.remote.gitlab.is_custom = true;
673    }
674    if let Some(ref remote) = args.bitbucket_repo {
675        config.remote.bitbucket.owner.clone_from(&remote.0.owner);
676        config.remote.bitbucket.repo.clone_from(&remote.0.repo);
677        config.remote.bitbucket.is_custom = true;
678    }
679    if let Some(ref remote) = args.gitea_repo {
680        config.remote.gitea.owner.clone_from(&remote.0.owner);
681        config.remote.gitea.repo.clone_from(&remote.0.repo);
682        config.remote.gitea.is_custom = true;
683    }
684    if let Some(ref remote) = args.azure_devops_repo {
685        config.remote.azure_devops.owner.clone_from(&remote.0.owner);
686        config.remote.azure_devops.repo.clone_from(&remote.0.repo);
687        config.remote.azure_devops.is_custom = true;
688    }
689    if args.no_exec {
690        config
691            .git
692            .commit_preprocessors
693            .iter_mut()
694            .for_each(|v| v.replace_command = None);
695        config
696            .changelog
697            .postprocessors
698            .iter_mut()
699            .for_each(|v| v.replace_command = None);
700    }
701    if args.skip_tags.is_some() {
702        config.git.skip_tags.clone_from(&args.skip_tags);
703    }
704    config.git.skip_tags = config.git.skip_tags.filter(|r| !r.as_str().is_empty());
705    if args.tag_pattern.is_some() {
706        config.git.tag_pattern.clone_from(&args.tag_pattern);
707    }
708    if args.tag.is_some() {
709        config.bump.initial_tag.clone_from(&args.tag);
710    }
711    if args.ignore_tags.is_some() {
712        config.git.ignore_tags.clone_from(&args.ignore_tags);
713    }
714    if args.count_tags.is_some() {
715        config.git.count_tags.clone_from(&args.count_tags);
716    }
717    if let Some(include_path) = &args.include_path {
718        config
719            .git
720            .include_paths
721            .extend(include_path.iter().cloned());
722    }
723    if let Some(exclude_path) = &args.exclude_path {
724        config
725            .git
726            .exclude_paths
727            .extend(exclude_path.iter().cloned());
728    }
729
730    // Process commits and releases for the changelog.
731    if let Some(BumpOption::Specific(bump_type)) = args.bump {
732        config.bump.bump_type = Some(bump_type);
733    }
734
735    // Generate changelog from context.
736    let mut changelog: Changelog = if let Some(context_path) = args.from_context {
737        let mut input: Box<dyn io::Read> = if context_path == Path::new("-") {
738            Box::new(io::stdin())
739        } else {
740            Box::new(File::open(context_path)?)
741        };
742        let mut changelog = Changelog::from_context(&mut input, config)?;
743        changelog.add_remote_context()?;
744        changelog
745    } else {
746        // Process the repositories.
747        let repositories: Vec<Repository> = if let Some(paths) = &args.repository {
748            paths
749                .iter()
750                .map(|p| {
751                    let abs_path = fs::canonicalize(p)?;
752                    Repository::discover(abs_path)
753                })
754                .collect::<Result<Vec<_>>>()?
755        } else {
756            let cwd = env::current_dir()?;
757            vec![Repository::discover(cwd)?]
758        };
759        let mut releases = Vec::<Release>::new();
760        let mut commit_range = None;
761        for repository in repositories {
762            // Skip commits
763            let mut skip_list = Vec::new();
764            let ignore_file = repository.root_path()?.join(IGNORE_FILE);
765            if ignore_file.exists() {
766                let contents = fs::read_to_string(ignore_file)?;
767                let commits = contents
768                    .lines()
769                    .filter(|v| !(v.starts_with('#') || v.trim().is_empty()))
770                    .map(|v| String::from(v.trim()))
771                    .collect::<Vec<String>>();
772                skip_list.extend(commits);
773            }
774            if let Some(ref skip_commit) = args.skip_commit {
775                skip_list.extend(skip_commit.clone());
776            }
777            for sha1 in skip_list {
778                config.git.commit_parsers.insert(0, CommitParser {
779                    sha: Some(sha1.clone()),
780                    skip: Some(true),
781                    ..Default::default()
782                });
783            }
784
785            // The commit range, used for determining the remote commits to include
786            // in the changelog, doesn't make sense if multiple repositories are
787            // specified. As such, pick the commit range from the last given
788            // repository.
789            commit_range = determine_commit_range(&args, &config, &repository)?;
790
791            releases.extend(process_repository(
792                Box::leak(Box::new(repository)),
793                &mut config,
794                &args,
795            )?);
796        }
797        Changelog::new(releases, config, commit_range.as_deref())?
798    };
799    changelog_modifier(&mut changelog)?;
800
801    Ok(changelog)
802}
803
804/// Writes the changelog to a file.
805pub fn write_changelog<W: io::Write>(
806    args: &Opt,
807    mut changelog: Changelog<'_>,
808    mut out: W,
809) -> Result<()> {
810    let output = args
811        .output
812        .clone()
813        .or(changelog.config.changelog.output.clone());
814    if args.bump.is_some() || args.bumped_version {
815        let current_version = changelog.releases.first().and_then(|release| {
816            release.version.clone().or_else(|| {
817                release
818                    .previous
819                    .as_ref()
820                    .and_then(|previous| previous.version.clone())
821            })
822        });
823        let next_version = if let Some(next_version) = changelog.bump_version()? {
824            if current_version.as_ref() == Some(&next_version) {
825                tracing::warn!(
826                    "The next version is the same as the current version, there is nothing to bump"
827                );
828            }
829            next_version
830        } else if let Some(last_version) =
831            changelog.releases.first().cloned().and_then(|v| v.version)
832        {
833            tracing::warn!("There is nothing to bump");
834            last_version
835        } else if changelog.releases.is_empty() {
836            changelog.config.bump.get_initial_tag()
837        } else {
838            return Ok(());
839        };
840        if let Some(tag_pattern) = &changelog.config.git.tag_pattern {
841            if !tag_pattern.is_match(&next_version) {
842                return Err(Error::ChangelogError(format!(
843                    "Next version ({next_version}) does not match the tag pattern: {tag_pattern}",
844                )));
845            }
846        }
847        if args.bumped_version {
848            if changelog.config.changelog.output.is_none() {
849                writeln!(out, "{next_version}")?;
850            } else {
851                writeln!(io::stdout(), "{next_version}")?;
852            }
853            return Ok(());
854        }
855    }
856    if args.context {
857        changelog.write_context(&mut out)?;
858        return Ok(());
859    }
860    if let Some(path) = &args.prepend {
861        let changelog_before = fs::read_to_string(path)?;
862        let mut out = io::BufWriter::new(File::create(path)?);
863        changelog.prepend(changelog_before, &mut out)?;
864    }
865    if output.is_some() || args.prepend.is_none() {
866        changelog.generate(&mut out)?;
867    }
868
869    Ok(())
870}