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;
9mod config_path;
10
11/// Custom logger implementation.
12pub mod logger;
13
14use std::collections::{HashMap, HashSet};
15use std::env;
16use std::fs::{self, File};
17use std::io::{self, Write};
18use std::path::{Path, PathBuf};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use args::{BumpOption, Opt, Sort, Strip};
22use clap::ValueEnum;
23use git_cliff_core::changelog::Changelog;
24use git_cliff_core::commit::{Commit, CommitStatistics, Range};
25use git_cliff_core::config::{CommitParser, Config};
26use git_cliff_core::embed::{BuiltinConfig, EmbeddedConfig};
27use git_cliff_core::error::{Error, Result};
28use git_cliff_core::release::Release;
29use git_cliff_core::repo::{Repository, SubmoduleRange};
30use git_cliff_core::{DEFAULT_CONFIG, IGNORE_FILE};
31use glob::Pattern;
32
33/// Checks for a new version on crates.io
34#[cfg(feature = "update-informer")]
35pub fn check_new_version() {
36    use update_informer::Check;
37    let pkg_name = env!("CARGO_PKG_NAME");
38    let pkg_version = env!("CARGO_PKG_VERSION");
39    let informer = update_informer::new(update_informer::registry::Crates, pkg_name, pkg_version);
40    if let Some(new_version) = informer.check_version().ok().flatten() &&
41        new_version.semver().pre.is_empty()
42    {
43        tracing::info!("A new version of {pkg_name} is available: v{pkg_version} -> {new_version}",);
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    } else if commit_range.is_none() &&
111        let Some(tag_limit) = config.git.limit_tags.filter(|limit| *limit > 0)
112    {
113        let tag_index = tags.len().saturating_sub(tag_limit);
114        if let (Some((tag1, _)), Some((tag2, _))) = (tags.get_index(tag_index), tags.last()) {
115            if tag1 == tag2 {
116                commit_range = Some(tag2.to_owned());
117            } else {
118                commit_range = Some(format!("{tag1}..{tag2}"));
119            }
120        }
121    }
122
123    Ok(commit_range)
124}
125
126/// Process submodules and add commits to release.
127fn process_submodules(
128    repository: &'static Repository,
129    release: &mut Release,
130    topo_order_commits: bool,
131) -> Result<()> {
132    // Retrieve first and last commit of a release to create a commit range.
133    let first_commit = release
134        .previous
135        .as_ref()
136        .and_then(|previous_release| previous_release.commit_id.clone())
137        .and_then(|commit_id| repository.find_commit(&commit_id));
138    let last_commit = release
139        .commit_id
140        .clone()
141        .and_then(|commit_id| repository.find_commit(&commit_id));
142
143    tracing::debug!("Processing submodule commits in {first_commit:?}..{last_commit:?}");
144
145    // Query repository for submodule changes. For each submodule a
146    // SubmoduleRange is created, describing the range of commits in the context
147    // of that submodule.
148    if let Some(last_commit) = last_commit {
149        let submodule_ranges = repository.submodules_range(first_commit.as_ref(), &last_commit)?;
150        let submodule_commits = submodule_ranges.iter().filter_map(|submodule_range| {
151            // For each submodule, the commit range is exploded into a list of
152            // commits.
153            let SubmoduleRange {
154                repository: sub_repo,
155                range: range_str,
156            } = submodule_range;
157            let commits = sub_repo
158                .commits(Some(range_str), None, None, topo_order_commits)
159                .ok()
160                .map(|commits| commits.iter().map(Commit::from).collect());
161
162            let submodule_path = sub_repo.path().to_string_lossy().into_owned();
163            Some(submodule_path).zip(commits)
164        });
165        // Insert submodule commits into map.
166        for (submodule_path, commits) in submodule_commits {
167            release.submodule_commits.insert(submodule_path, commits);
168        }
169    }
170    Ok(())
171}
172
173/// Initializes the configuration file.
174pub fn init_config(name: Option<&str>, config_path: &Path) -> Result<()> {
175    init_config_from(name, None, config_path)
176}
177
178/// Initializes the configuration file using templates from the given directory.
179pub fn init_config_from(
180    name: Option<&str>,
181    templates_dir: Option<&Path>,
182    config_path: &Path,
183) -> Result<()> {
184    let contents = match name {
185        Some(name) => BuiltinConfig::get_config_from(name.to_string(), templates_dir)?,
186        None => {
187            if let Some(dir) = templates_dir {
188                BuiltinConfig::validate_templates_dir(dir)?;
189            }
190            EmbeddedConfig::get_config()?
191        }
192    };
193
194    tracing::info!(
195        "Saving the configuration file{} to {}",
196        name.map(|v| format!(" ({v})")).unwrap_or_default(),
197        config_path.display(),
198    );
199
200    fs::write(config_path, contents)?;
201
202    Ok(())
203}
204
205/// Processes the tags and commits for creating release entries for the
206/// changelog.
207///
208/// This function uses the configuration and arguments to process the given
209/// repository individually.
210fn process_repository<'a>(
211    repository: &'static Repository,
212    config: &mut Config,
213    args: &Opt,
214) -> Result<Vec<Release<'a>>> {
215    let mut tags = repository.tags(
216        &config.git.tag_pattern,
217        args.topo_order,
218        args.use_branch_tags,
219    )?;
220    let skip_regex = config.git.skip_tags.as_ref();
221    let ignore_regex = config.git.ignore_tags.as_ref();
222    let count_tags = config.git.count_tags.as_ref();
223    let recurse_submodules = config.git.recurse_submodules.unwrap_or(false);
224    let compute_commit_statistics = args.context || config.uses_commit_statistics()?;
225    tags.retain(|_, tag| {
226        let name = &tag.name;
227
228        // Keep skip tags to drop commits in the later stage.
229        let skip = skip_regex.is_some_and(|r| r.is_match(name));
230        if skip {
231            return true;
232        }
233
234        let count = count_tags.is_none_or(|r| {
235            let count_tag = r.is_match(name);
236            if count_tag {
237                tracing::debug!("Counting release: {name}");
238            }
239            count_tag
240        });
241
242        let ignore = ignore_regex.is_some_and(|r| {
243            if r.as_str().trim().is_empty() {
244                return false;
245            }
246
247            let ignore_tag = r.is_match(name);
248            if ignore_tag {
249                tracing::debug!("Ignoring release: {name}");
250            }
251            ignore_tag
252        });
253
254        count && !ignore
255    });
256
257    if !config.remote.is_any_set() {
258        match repository.upstream_remote() {
259            Ok(remote) => {
260                if !config.remote.github.is_set() {
261                    tracing::debug!("No GitHub remote is set, using remote: {remote}");
262                    config.remote.github.owner = remote.owner;
263                    config.remote.github.repo = remote.repo;
264                    config.remote.github.is_custom = remote.is_custom;
265                } else if !config.remote.gitlab.is_set() {
266                    tracing::debug!("No GitLab remote is set, using remote: {remote}");
267                    config.remote.gitlab.owner = remote.owner;
268                    config.remote.gitlab.repo = remote.repo;
269                    config.remote.gitlab.is_custom = remote.is_custom;
270                } else if !config.remote.gitea.is_set() {
271                    tracing::debug!("No Gitea remote is set, using remote: {remote}");
272                    config.remote.gitea.owner = remote.owner;
273                    config.remote.gitea.repo = remote.repo;
274                    config.remote.gitea.is_custom = remote.is_custom;
275                } else if !config.remote.bitbucket.is_set() {
276                    tracing::debug!("No Bitbucket remote is set, using remote: {remote}");
277                    config.remote.bitbucket.owner = remote.owner;
278                    config.remote.bitbucket.repo = remote.repo;
279                    config.remote.bitbucket.is_custom = remote.is_custom;
280                }
281            }
282            Err(e) => {
283                tracing::debug!("Failed to get remote from repository: {e:?}");
284            }
285        }
286    }
287    if args.use_native_tls {
288        config.remote.enable_native_tls();
289    }
290
291    // Print debug information about configuration and arguments.
292    tracing::trace!("Arguments: {args:#?}");
293    tracing::trace!("Config: {config:#?}");
294
295    // Parse commits.
296    let commit_range = determine_commit_range(args, config, repository)?;
297
298    // Include only the current directory if not running from the root repository.
299    //
300    // NOTE:
301    // The conditions for including the current directory when not running from the root repository
302    // have grown quite complex. This may warrant additional documentation to explain the behavior.
303    //
304    // Current logic triggers when all of the following are true:
305    // - `cwd` is a child of the repository root but not the root itself
306    // - `args.repository` is either None or empty
307    // - `args.workdir` is None
308    // - `include_path` is currently empty
309    //
310    // Additionally, if `include_path` is already explicitly set, it might be preferable to append.
311    let cwd = env::current_dir()?;
312    let mut include_path = config.git.include_paths.clone();
313    // When `--workdir` is set, scope the changelog to that directory by turning it
314    // into a repo-relative include pattern. Diff paths are relative to the repo
315    // root, so the pattern must be too; an absolute or cwd-relative pattern would
316    // match nothing and produce an empty changelog (see #1369). If `workdir`
317    // resolves to the repo root itself, no filter is added so everything is kept.
318    if let Some(workdir) = &args.workdir &&
319        let Ok(root) = repository.root_path()
320    {
321        let workdir_abs = fs::canonicalize(cwd.join(workdir)).unwrap_or_else(|_| cwd.join(workdir));
322        if let Ok(rel) = workdir_abs.strip_prefix(&root) &&
323            !rel.as_os_str().is_empty()
324        {
325            // Trailing separator makes the directory expand to a `**` glob.
326            let pattern = Pattern::new(rel.join("").to_string_lossy().as_ref())?;
327            if !include_path.iter().any(|p| p.as_str() == pattern.as_str()) {
328                include_path.push(pattern);
329            }
330        }
331    }
332    if let Ok(root) = repository.root_path() &&
333        cwd.starts_with(&root) &&
334        cwd != root &&
335        args.repository.as_ref().is_none_or(Vec::is_empty) &&
336        args.workdir.is_none() &&
337        include_path.is_empty()
338    {
339        let path = cwd.join("**").join("*");
340        if let Ok(stripped) = path.strip_prefix(root) {
341            tracing::info!(
342                "Including changes from the current directory: {}",
343                cwd.display()
344            );
345            include_path = vec![Pattern::new(stripped.to_string_lossy().as_ref())?];
346        }
347    }
348
349    let include_path = (!include_path.is_empty()).then_some(include_path);
350    let exclude_path =
351        (!config.git.exclude_paths.is_empty()).then_some(config.git.exclude_paths.clone());
352    let mut commits = repository.commits(
353        commit_range.as_deref(),
354        include_path,
355        exclude_path,
356        config.git.topo_order_commits,
357    )?;
358    repository.filter_git_blame_ignore_revs(&mut commits);
359    if let Some(commit_limit_value) = config.git.limit_commits {
360        commits.truncate(commit_limit_value);
361    }
362
363    // Update tags.
364    let mut releases = vec![Release::default()];
365    let mut tag_timestamp = None;
366    if let Some(ref tag) = args.tag {
367        if let Some(commit_id) = commits.first().map(|c| c.id().to_string()) {
368            match tags.get(&commit_id) {
369                Some(tag) => {
370                    tracing::warn!("There is already a tag ({}) for {}", tag.name, commit_id);
371                    tag_timestamp = Some(commits[0].time().seconds());
372                }
373                None => {
374                    tags.insert(commit_id, repository.resolve_tag(tag));
375                }
376            }
377        } else {
378            releases[0].version = Some(tag.clone());
379            releases[0].timestamp = Some(
380                SystemTime::now()
381                    .duration_since(UNIX_EPOCH)?
382                    .as_secs()
383                    .try_into()?,
384            );
385        }
386    }
387
388    // Assign commits to releases by graph reachability instead of their
389    // position in the linearized log.
390    // Only tags present in the walk can be release boundaries.
391    let commit_ids: HashSet<_> = commits.iter().map(|commit| commit.id()).collect();
392    let ownership = repository.commit_tag_ownership(&tags, &commit_ids)?;
393
394    // Group commits by owning tag in a single pass, keeping each group
395    // oldest-first. Unowned commits remain unreleased.
396    let mut groups: HashMap<&str, Vec<_>> = HashMap::new();
397    let mut unreleased = Vec::new();
398    for commit in commits.iter().rev() {
399        match ownership.get(&commit.id()) {
400            Some(tag_id) => groups.entry(tag_id).or_default().push(commit),
401            None => unreleased.push(commit),
402        }
403    }
404
405    // Emit tagged groups oldest to newest so the loop below closes releases in
406    // order, followed by the unreleased commits.
407    let mut ordered_commits = Vec::with_capacity(commits.len());
408    for tag_id in tags.keys() {
409        let Some(mut group) = groups.remove(tag_id.as_str()) else {
410            continue;
411        };
412        // The tagged commit is the release tip, so close the group with it.
413        if let Some(pos) = group
414            .iter()
415            .position(|commit| commit.id().to_string() == *tag_id)
416        {
417            let tag_commit = group.remove(pos);
418            group.push(tag_commit);
419        }
420        ordered_commits.extend(group);
421    }
422    ordered_commits.extend(unreleased);
423
424    // Process releases.
425    let mut previous_release = Release::default();
426    let mut first_processed_tag = None;
427    let repository_path = repository.root_path()?.to_string_lossy().into_owned();
428    for git_commit in ordered_commits {
429        let release = releases.last_mut().unwrap();
430        let mut commit = Commit::from(git_commit);
431        if compute_commit_statistics {
432            commit.statistics = match repository.commit_statistics(git_commit) {
433                Ok(statistics) => statistics,
434                Err(err)
435                    if matches!(
436                        &err,
437                        Error::GitError(git_err) if git_err.message().contains("object not found")
438                    ) =>
439                {
440                    tracing::warn!(
441                        "Skipping diff statistics for commit {} because a Git object is missing: \
442                         {err}",
443                        commit.id,
444                    );
445                    CommitStatistics::default()
446                }
447                Err(err) => return Err(err),
448            }
449        }
450        let commit_id = commit.id.clone();
451        release.commits.push(commit);
452        release.repository = Some(repository_path.clone());
453        release.commit_id = Some(commit_id);
454        if let Some(tag) = tags.get(release.commit_id.as_ref().unwrap()) {
455            release.version = Some(tag.name.clone());
456            release.message.clone_from(&tag.message);
457            release.timestamp = if args.tag.as_deref() == Some(tag.name.as_str()) {
458                match tag_timestamp {
459                    Some(timestamp) => Some(timestamp),
460                    None => Some(
461                        SystemTime::now()
462                            .duration_since(UNIX_EPOCH)?
463                            .as_secs()
464                            .try_into()?,
465                    ),
466                }
467            } else {
468                Some(git_commit.time().seconds())
469            };
470            if first_processed_tag.is_none() {
471                first_processed_tag = Some(tag);
472            }
473            previous_release.previous = None;
474            release.previous = Some(Box::new(previous_release));
475            previous_release = release.clone();
476            releases.push(Release::default());
477        }
478    }
479
480    debug_assert!(!releases.is_empty());
481
482    if releases.len() > 1 {
483        previous_release.previous = None;
484        releases.last_mut().unwrap().previous = Some(Box::new(previous_release));
485    }
486
487    if args.sort == Sort::Newest {
488        for release in &mut releases {
489            release.commits.reverse();
490        }
491    }
492
493    // Add custom commit messages to the latest release.
494    if let Some(custom_commits) = &args.with_commit {
495        releases
496            .last_mut()
497            .unwrap()
498            .commits
499            .extend(custom_commits.iter().cloned().map(Commit::from));
500    }
501
502    // Set the previous release if the first release does not have one set.
503    if releases[0]
504        .previous
505        .as_ref()
506        .and_then(|p| p.version.as_ref())
507        .is_none()
508    {
509        // Get the previous tag of the first processed tag in the release loop.
510        let first_tag = first_processed_tag
511            .map(|tag| {
512                tags.iter()
513                    .enumerate()
514                    .find(|(_, (_, v))| v.name == tag.name)
515                    .and_then(|(i, _)| i.checked_sub(1))
516                    .and_then(|i| tags.get_index(i))
517            })
518            .or_else(|| Some(tags.last()))
519            .flatten();
520
521        // Set the previous release if the first tag is found.
522        if let Some((commit_id, tag)) = first_tag {
523            let previous_release = Release {
524                commit_id: Some(commit_id.clone()),
525                version: Some(tag.name.clone()),
526                timestamp: Some(
527                    repository
528                        .find_commit(commit_id)
529                        .map(|v| v.time().seconds())
530                        .unwrap_or_default(),
531                ),
532                ..Default::default()
533            };
534            releases[0].previous = Some(Box::new(previous_release));
535        }
536    }
537
538    for release in &mut releases {
539        // Set the commit ranges for all releases
540        if !release.commits.is_empty() {
541            release.commit_range = Some(match args.sort {
542                Sort::Oldest => Range::new(
543                    release.commits.first().unwrap(),
544                    release.commits.last().unwrap(),
545                ),
546                Sort::Newest => Range::new(
547                    release.commits.last().unwrap(),
548                    release.commits.first().unwrap(),
549                ),
550            });
551        }
552        if recurse_submodules {
553            process_submodules(repository, release, config.git.topo_order_commits)?;
554        }
555    }
556
557    // Set custom message for the latest release.
558    if let Some(message) = &args.with_tag_message &&
559        let Some(latest_release) = releases
560            .iter_mut()
561            .rfind(|release| !release.commits.is_empty())
562    {
563        latest_release.message = Some(message.to_owned());
564    }
565
566    Ok(releases)
567}
568
569/// Runs `git-cliff`.
570///
571/// # Example
572///
573/// ```no_run
574/// use clap::Parser;
575/// use git_cliff::args::Opt;
576/// use git_cliff_core::error::Result;
577///
578/// fn main() -> Result<()> {
579///     let args = Opt::parse();
580///     git_cliff::run(args)?;
581///     Ok(())
582/// }
583/// ```
584pub fn run<'a>(args: Opt) -> Result<Changelog<'a>> {
585    run_with_changelog_modifier(args, |_| Ok(()))
586}
587
588/// Runs `git-cliff` with a changelog modifier.
589///
590/// This is useful if you want to modify the [`Changelog`] before
591/// it's written or the context is printed (depending how git-cliff is started).
592///
593/// # Example
594///
595/// ```no_run
596/// use clap::Parser;
597/// use git_cliff::args::Opt;
598/// use git_cliff_core::error::Result;
599///
600/// fn main() -> Result<()> {
601///     let args = Opt::parse();
602///
603///     git_cliff::run_with_changelog_modifier(args, |changelog| {
604///         println!("Releases: {:?}", changelog.releases);
605///         Ok(())
606///     })?;
607///
608///     Ok(())
609/// }
610/// ```
611pub fn run_with_changelog_modifier<'a>(
612    mut args: Opt,
613    changelog_modifier: impl FnOnce(&mut Changelog) -> Result<()>,
614) -> Result<Changelog<'a>> {
615    // Retrieve the built-in configuration.
616    let builtin_config = args
617        .config
618        .as_ref()
619        .map(|config| BuiltinConfig::parse(config.to_string_lossy().to_string()));
620
621    // Set the working directory.
622    if let Some(ref workdir) = args.workdir {
623        if let Some(config) = &args.config {
624            args.config = Some(workdir.join(config));
625        }
626        match args.repository.as_mut() {
627            Some(repository) => {
628                repository
629                    .iter_mut()
630                    .for_each(|r| *r = workdir.join(r.clone()));
631            }
632            None => args.repository = Some(vec![workdir.clone()]),
633        }
634        if let Some(changelog) = args.prepend {
635            args.prepend = Some(workdir.join(changelog));
636        }
637        if let Some(body_file) = args.body_file {
638            args.body_file = Some(workdir.join(body_file));
639        }
640    }
641
642    // Parse the configuration file, loading the default configuration if none
643    // is found. The filesystem is only consulted once `--config-url` and the
644    // built-in configurations have been ruled out, so that naming a built-in
645    // configuration does not report a missing file.
646    let mut config = if let Some(url) = &args.config_url {
647        tracing::debug!("Using configuration file from: {url}");
648        #[cfg(feature = "remote")]
649        {
650            reqwest::blocking::get(url.clone())?
651                .error_for_status()?
652                .text()?
653                .parse()?
654        }
655        #[cfg(not(feature = "remote"))]
656        unreachable!("This option is not available without the 'remote' build-time feature");
657    } else if let Some(Ok((config, name))) = builtin_config {
658        tracing::info!("Using built-in configuration file: {name}");
659        config
660    } else if let Some(config_path) = config_path::resolve_config_path(
661        args.config.as_deref(),
662        args.workdir.as_deref(),
663        &env::current_dir()?,
664        Config::retrieve_user_config_path,
665    ) {
666        #[allow(clippy::unnecessary_debug_formatting)]
667        {
668            tracing::info!("Using configuration from: {}", config_path.display());
669        }
670        Config::load(&config_path)?
671    } else if let Some(contents) = Config::read_from_manifest()? {
672        contents.parse()?
673    } else {
674        #[allow(clippy::unnecessary_debug_formatting)]
675        if !args.context {
676            tracing::warn!(
677                "{:?} is not found, using the default configuration",
678                args.config.as_deref().unwrap_or(Path::new(DEFAULT_CONFIG))
679            );
680        }
681        EmbeddedConfig::parse()?
682    };
683
684    // Update the configuration based on command line arguments and vice versa.
685    let output = args.output.clone().or(config.changelog.output.clone());
686    match args.strip {
687        Some(Strip::Header) => {
688            config.changelog.header = None;
689        }
690        Some(Strip::Footer) => {
691            config.changelog.footer = None;
692        }
693        Some(Strip::All) => {
694            config.changelog.header = None;
695            config.changelog.footer = None;
696        }
697        None => {}
698    }
699    if args.prepend.is_some() {
700        config.changelog.footer = None;
701        if !(args.unreleased || args.latest || args.range.is_some()) {
702            return Err(Error::ArgumentError(String::from(
703                "'-u' or '-l' is not specified",
704            )));
705        }
706    }
707    if output.is_some() && args.prepend.is_some() && output.as_ref() == args.prepend.as_ref() {
708        return Err(Error::ArgumentError(String::from(
709            "'-o' and '-p' can only be used together if they point to different files",
710        )));
711    }
712    if let Some(body) = if let Some(body_file) = &args.body_file {
713        Some(fs::read_to_string(body_file)?)
714    } else {
715        args.body.clone()
716    } {
717        config.changelog.body = body;
718    }
719    if args.sort == Sort::Oldest {
720        args.sort = Sort::from_str(&config.git.sort_commits, true)
721            .expect("Incorrect config value for 'sort_commits'");
722    }
723    if !args.topo_order {
724        args.topo_order = config.git.topo_order;
725    }
726
727    if !args.use_branch_tags {
728        args.use_branch_tags = config.git.use_branch_tags;
729    }
730
731    if args.github_token.is_some() {
732        config.remote.github.token.clone_from(&args.github_token);
733    }
734    if args.gitlab_token.is_some() {
735        config.remote.gitlab.token.clone_from(&args.gitlab_token);
736    }
737    if args.gitea_token.is_some() {
738        config.remote.gitea.token.clone_from(&args.gitea_token);
739    }
740    if args.bitbucket_token.is_some() {
741        config
742            .remote
743            .bitbucket
744            .token
745            .clone_from(&args.bitbucket_token);
746    }
747    if args.azure_devops_token.is_some() {
748        config
749            .remote
750            .azure_devops
751            .token
752            .clone_from(&args.azure_devops_token);
753    }
754    if let Some(http_timeout) = args.http_timeout {
755        let timeout = std::time::Duration::from_secs(http_timeout);
756        config.remote.github.http_timeout = timeout;
757        config.remote.gitlab.http_timeout = timeout;
758        config.remote.gitea.http_timeout = timeout;
759        config.remote.bitbucket.http_timeout = timeout;
760        config.remote.azure_devops.http_timeout = timeout;
761    }
762    if args.offline {
763        config.remote.offline = args.offline;
764    }
765    if let Some(ref remote) = args.github_repo {
766        config.remote.github.owner.clone_from(&remote.0.owner);
767        config.remote.github.repo.clone_from(&remote.0.repo);
768        config.remote.github.is_custom = true;
769    }
770    if let Some(ref remote) = args.gitlab_repo {
771        config.remote.gitlab.owner.clone_from(&remote.0.owner);
772        config.remote.gitlab.repo.clone_from(&remote.0.repo);
773        config.remote.gitlab.is_custom = true;
774    }
775    if let Some(ref remote) = args.bitbucket_repo {
776        config.remote.bitbucket.owner.clone_from(&remote.0.owner);
777        config.remote.bitbucket.repo.clone_from(&remote.0.repo);
778        config.remote.bitbucket.is_custom = true;
779    }
780    if let Some(ref remote) = args.gitea_repo {
781        config.remote.gitea.owner.clone_from(&remote.0.owner);
782        config.remote.gitea.repo.clone_from(&remote.0.repo);
783        config.remote.gitea.is_custom = true;
784    }
785    if let Some(ref remote) = args.azure_devops_repo {
786        config.remote.azure_devops.owner.clone_from(&remote.0.owner);
787        config.remote.azure_devops.repo.clone_from(&remote.0.repo);
788        config.remote.azure_devops.is_custom = true;
789    }
790    if args.no_exec {
791        config
792            .git
793            .commit_preprocessors
794            .iter_mut()
795            .for_each(|v| v.replace_command = None);
796        config
797            .changelog
798            .postprocessors
799            .iter_mut()
800            .for_each(|v| v.replace_command = None);
801    }
802    if args.skip_tags.is_some() {
803        config.git.skip_tags.clone_from(&args.skip_tags);
804    }
805    config.git.skip_tags = config.git.skip_tags.filter(|r| !r.as_str().is_empty());
806    if args.tag_pattern.is_some() {
807        config.git.tag_pattern.clone_from(&args.tag_pattern);
808    }
809    if args.tag.is_some() {
810        config.bump.initial_tag.clone_from(&args.tag);
811    }
812    if args.ignore_tags.is_some() {
813        config.git.ignore_tags.clone_from(&args.ignore_tags);
814    }
815    if args.count_tags.is_some() {
816        config.git.count_tags.clone_from(&args.count_tags);
817    }
818    if args.limit_tags.is_some() {
819        config.git.limit_tags = args.limit_tags;
820    }
821    if let Some(include_path) = &args.include_path {
822        config
823            .git
824            .include_paths
825            .extend(include_path.iter().cloned());
826    }
827    if let Some(exclude_path) = &args.exclude_path {
828        config
829            .git
830            .exclude_paths
831            .extend(exclude_path.iter().cloned());
832    }
833
834    // Process commits and releases for the changelog.
835    if let Some(BumpOption::Specific(bump_type)) = args.bump {
836        config.bump.bump_type = Some(bump_type);
837    }
838
839    // Generate changelog from context.
840    let mut changelog: Changelog = if let Some(context_path) = args.from_context {
841        let mut input: Box<dyn io::Read> = if context_path == Path::new("-") {
842            Box::new(io::stdin())
843        } else {
844            Box::new(File::open(context_path)?)
845        };
846        let mut changelog = Changelog::from_context(&mut input, config)?;
847        changelog.add_remote_context()?;
848        changelog
849    } else {
850        // Process the repositories.
851        let repositories: Vec<Repository> = if let Some(paths) = &args.repository {
852            paths
853                .iter()
854                .map(|p| {
855                    let abs_path = fs::canonicalize(p)?;
856                    Repository::discover(abs_path)
857                })
858                .collect::<Result<Vec<_>>>()?
859        } else {
860            let cwd = env::current_dir()?;
861            vec![Repository::discover(cwd)?]
862        };
863        let mut releases = Vec::<Release>::new();
864        let mut commit_range = None;
865        for repository in repositories {
866            // Skip commits
867            let mut skip_list = Vec::new();
868            let ignore_file = repository.root_path()?.join(IGNORE_FILE);
869            if ignore_file.exists() {
870                let contents = fs::read_to_string(ignore_file)?;
871                let commits = contents
872                    .lines()
873                    .filter(|v| !(v.starts_with('#') || v.trim().is_empty()))
874                    .map(|v| String::from(v.trim()))
875                    .collect::<Vec<String>>();
876                skip_list.extend(commits);
877            }
878            if let Some(ref skip_commit) = args.skip_commit {
879                skip_list.extend(skip_commit.clone());
880            }
881            for sha1 in skip_list {
882                config.git.commit_parsers.insert(0, CommitParser {
883                    sha: Some(sha1.clone()),
884                    skip: Some(true),
885                    ..Default::default()
886                });
887            }
888
889            // The commit range, used for determining the remote commits to include
890            // in the changelog, doesn't make sense if multiple repositories are
891            // specified. As such, pick the commit range from the last given
892            // repository.
893            commit_range = determine_commit_range(&args, &config, &repository)?;
894
895            releases.extend(process_repository(
896                Box::leak(Box::new(repository)),
897                &mut config,
898                &args,
899            )?);
900        }
901        Changelog::new(releases, config, commit_range.as_deref())?
902    };
903    changelog_modifier(&mut changelog)?;
904
905    Ok(changelog)
906}
907
908/// Writes the changelog to a file.
909pub fn write_changelog<W: io::Write>(
910    args: &Opt,
911    mut changelog: Changelog<'_>,
912    mut out: W,
913) -> Result<()> {
914    let output = args
915        .output
916        .clone()
917        .or(changelog.config.changelog.output.clone());
918    // Markdown formatting only makes sense for Markdown output. Detect it from
919    // the file extension (stdout and extension-less paths are treated as
920    // Markdown, matching git-cliff's default output). The prepend target is a
921    // destination too, so its extension is checked as well.
922    if changelog.config.changelog.format {
923        let is_markdown_path = |path: &PathBuf| {
924            path.extension()
925                .is_none_or(|ext| ext.eq_ignore_ascii_case("md"))
926        };
927        let is_markdown = output.as_ref().is_none_or(&is_markdown_path) &&
928            args.prepend.as_ref().is_none_or(&is_markdown_path);
929        if !is_markdown {
930            tracing::warn!(
931                "`changelog.format` is enabled but the output is not Markdown; skipping formatting"
932            );
933            changelog.config.changelog.format = false;
934        }
935    }
936    if args.bump.is_some() || args.bumped_version {
937        let current_version = changelog.releases.first().and_then(|release| {
938            release.version.clone().or_else(|| {
939                release
940                    .previous
941                    .as_ref()
942                    .and_then(|previous| previous.version.clone())
943            })
944        });
945        let next_version = if let Some(next_version) = changelog.bump_version()? {
946            if current_version.as_ref() == Some(&next_version) {
947                tracing::warn!(
948                    "The next version is the same as the current version, there is nothing to bump"
949                );
950            }
951            next_version
952        } else if let Some(last_version) =
953            changelog.releases.first().cloned().and_then(|v| v.version)
954        {
955            tracing::warn!("There is nothing to bump");
956            last_version
957        } else if changelog.releases.is_empty() {
958            changelog.config.bump.get_initial_tag()
959        } else {
960            return Ok(());
961        };
962        if let Some(tag_pattern) = &changelog.config.git.tag_pattern &&
963            !tag_pattern.is_match(&next_version)
964        {
965            return Err(Error::ChangelogError(format!(
966                "Next version ({next_version}) does not match the tag pattern: {tag_pattern}",
967            )));
968        }
969        if args.bumped_version {
970            if changelog.config.changelog.output.is_none() {
971                writeln!(out, "{next_version}")?;
972            } else {
973                writeln!(io::stdout(), "{next_version}")?;
974            }
975            return Ok(());
976        }
977    }
978    if args.context {
979        changelog.write_context(&mut out)?;
980        return Ok(());
981    }
982    if let Some(path) = &args.prepend {
983        let changelog_before = fs::read_to_string(path)?;
984        let mut out = io::BufWriter::new(File::create(path)?);
985        changelog.prepend(changelog_before, &mut out)?;
986    }
987    if output.is_some() || args.prepend.is_none() {
988        changelog.generate(&mut out)?;
989    }
990
991    Ok(())
992}