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