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