Repository: orhun/git-cliff
Pull request #1173 (2025-06-11 13:38:41)

feat: add include and exclude path to the config
## Description

This PR adds the option of setting `include_path` and `exclude_path` in the config file.

## Motivation and Context

Closes #1168.
This is useful if you have tools calling git-cliff (like release-plz) that don't give the ability
to set command line arguments. 

## How Has This Been Tested?

### Testing `exclude_path`
1. Run `git-cliff | wc -l` and note the amount of lines
2. Run `git-cliff --exclude-path git-cliff-core/ | wc -l` and note the amount of lines
3. Set `exclude-paths = ["git-cliff-core/"]`
4. Run `git-cliff | wc -l` and note the amount of lines
The output of step 4 should equal the output of step 2 and not equal the output of step 1

### Testing `include_path`
1. Run `git-cliff | wc -l` and note the amount of lines
2. Run `git-cliff --include-path git-cliff/ --include-path git-cliff-core/ | wc -l` and note the amount of lines
3. Set `include_paths = ["git-cliff/", "git-cliff-core/"]`
4. Run `git-cliff | wc -l` and note the amount of lines
The output of step 4 should equal the output of step 2 and not equal the output of step 1

Both tests were run in the root of the git-cliff repository on a Linux machine.

I could not figure out how to mock commits with changed files and thus have not written any unit/integration tests.

## Types of Changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [x] Documentation (no code change)
- [ ] Refactor (refactoring production code)
- [ ] Other

## Checklist:

- [x] My code follows the code style of this project.
- [x] I have updated the documentation accordingly.
- [x] I have formatted the code with [rustfmt](https://github.com/rust-lang/rustfmt).
- [x] I checked the lints with [clippy](https://github.com/rust-lang/rust-clippy).
- [ ] I have added tests to cover my changes.
- [x] All new and existing tests passed.



Patch:
diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs
index cbf4ebf..bf8c16a 100644
--- a/git-cliff-core/src/changelog.rs
+++ b/git-cliff-core/src/changelog.rs
@@ -898,6 +898,8 @@ mod test {
 				link_parsers:             [].to_vec(),
 				limit_commits:            None,
 				recurse_submodules:       None,
+				include_paths:            Vec::new(),
+				exclude_paths:            Vec::new(),
 			},
 			remote:    RemoteConfig {
 				github:    Remote {
diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs
index 9c138e2..b6bd083 100644
--- a/git-cliff-core/src/config.rs
+++ b/git-cliff-core/src/config.rs
@@ -1,6 +1,7 @@
 use crate::command;
 use crate::embed::EmbeddedConfig;
 use crate::error::Result;
+use glob::Pattern;
 use regex::{
 	Regex,
 	RegexBuilder,
@@ -138,6 +139,45 @@ pub struct GitConfig {
 	pub limit_commits:            Option<usize>,
 	/// Read submodule commits.
 	pub recurse_submodules:       Option<bool>,
+	/// Include related commits with changes at the specified paths.
+	#[serde(with = "serde_pattern", default)]
+	pub include_paths:            Vec<Pattern>,
+	/// Exclude unrelated commits with changes at the specified paths.
+	#[serde(with = "serde_pattern", default)]
+	pub exclude_paths:            Vec<Pattern>,
+}
+
+/// Serialize and deserialize implementation for [`glob::Pattern`].
+mod serde_pattern {
+	use glob::Pattern;
+	use serde::Deserialize;
+	use serde::de::Error;
+	use serde::ser::SerializeSeq;
+
+	pub fn serialize<S>(
+		patterns: &[Pattern],
+		serializer: S,
+	) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		let mut seq = serializer.serialize_seq(Some(patterns.len()))?;
+		for pattern in patterns {
+			seq.serialize_element(pattern.as_str())?;
+		}
+		seq.end()
+	}
+
+	pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Pattern>, D::Error>
+	where
+		D: serde::Deserializer<'de>,
+	{
+		let patterns = Vec::<String>::deserialize(deserializer)?;
+		patterns
+			.into_iter()
+			.map(|pattern| pattern.parse().map_err(D::Error::custom))
+			.collect()
+	}
 }
 
 /// Remote configuration.
diff --git a/git-cliff-core/src/repo.rs b/git-cliff-core/src/repo.rs
index 28dbff0..d9646de 100644
--- a/git-cliff-core/src/repo.rs
+++ b/git-cliff-core/src/repo.rs
@@ -259,7 +259,7 @@ impl Repository {
 		match (include_patterns, exclude_patterns) {
 			(Some(include_pattern), Some(exclude_pattern)) => {
 				// check if the commit has any changed files that match any of the
-				// include patterns and non of the exclude patterns.
+				// include patterns and none of the exclude patterns.
 				changed_files.iter().any(|path| {
 					include_pattern
 						.iter()
diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs
index a18dd5e..5e1f260 100644
--- a/git-cliff/src/lib.rs
+++ b/git-cliff/src/lib.rs
@@ -300,12 +300,12 @@ fn process_repository<'a>(
 	let commit_range = determine_commit_range(args, config, repository)?;
 
 	// Include only the current directory if not running from the root repository
-	let mut include_path = args.include_path.clone();
+	let mut include_path = config.git.include_paths.clone();
 	if let Some(mut path_diff) =
 		pathdiff::diff_paths(env::current_dir()?, repository.root_path()?)
 	{
 		if args.workdir.is_none() &&
-			include_path.is_none() &&
+			include_path.is_empty() &&
 			path_diff != Path::new("")
 		{
 			info!(
@@ -313,15 +313,17 @@ fn process_repository<'a>(
 				path_diff.display()
 			);
 			path_diff.extend(["**", "*"]);
-			include_path =
-				Some(vec![Pattern::new(path_diff.to_string_lossy().as_ref())?]);
+			include_path = vec![Pattern::new(path_diff.to_string_lossy().as_ref())?];
 		}
 	}
 
+	let include_path = (!include_path.is_empty()).then_some(include_path);
+	let exclude_path = (!config.git.exclude_paths.is_empty())
+		.then_some(config.git.exclude_paths.clone());
 	let mut commits = repository.commits(
 		commit_range.as_deref(),
 		include_path,
-		args.exclude_path.clone(),
+		exclude_path,
 		config.git.topo_order_commits,
 	)?;
 	if let Some(commit_limit_value) = config.git.limit_commits {
@@ -727,6 +729,18 @@ pub fn run_with_changelog_modifier(
 	if args.count_tags.is_some() {
 		config.git.count_tags.clone_from(&args.count_tags);
 	}
+	if let Some(include_path) = &args.include_path {
+		config
+			.git
+			.include_paths
+			.extend(include_path.iter().cloned());
+	}
+	if let Some(exclude_path) = &args.exclude_path {
+		config
+			.git
+			.exclude_paths
+			.extend(exclude_path.iter().cloned());
+	}
 
 	// Process commits and releases for the changelog.
 	if let Some(BumpOption::Specific(bump_type)) = args.bump {
diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md
index 892efeb..b86efe1 100644
--- a/website/docs/configuration/git.md
+++ b/website/docs/configuration/git.md
@@ -32,6 +32,8 @@ link_parsers = [
 ]
 limit_commits = 42
 recurse_submodules = false
+include_paths = ["src/", "doc/**/*.md"]
+exclude_paths = ["unrelated/"]
 ```
 
 ### conventional_commits
@@ -301,3 +303,18 @@ These extracted links can be used in the [template](/docs/templating/context) wi
 `recurse_submodules` is an _optional_ boolean value that indicates whether **git-cliff** should read and process commits of submodules.
 
 This only considers submodules at the toplevel (depth 1). These commits can then be accessed by the variable `submodule_commits` during [templating](/docs/templating/context).
+
+### include_paths
+
+`include_paths` is an _optional_ array of glob patterns. Only commits that modify files matching these patterns will be included in the generated changelog.
+
+When this value is set, the current working directory will **not** be included.
+
+### exclude_paths
+
+`exclude_paths` is an _optional_ array of glob patterns. Commits that **only** modify files matching these patterns will be excluded from the changelog.
+
+This setting takes priority over `include_paths`.
+
+- If a commit touches both included and excluded paths, it **will be included**.
+- If a commit **only** modifies files that match both `include_paths` and `exclude_paths`, it **will be excluded**.


Test patch:
diff --git a/.github/fixtures/test-submodules-include-path-config/cliff.toml b/.github/fixtures/test-submodules-include-path-config/cliff.toml
new file mode 100644
index 00000000..f872014
--- /dev/null
+++ b/.github/fixtures/test-submodules-include-path-config/cliff.toml
@@ -0,0 +1,39 @@
+# git-cliff ~ configuration file
+# https://git-cliff.org/docs/configuration
+
+[changelog]
+# A Tera template to be rendered for each release in the changelog.
+# See https://keats.github.io/tera/docs/#introduction
+body = """
+{% if version %}\
+    ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+{% else %}\
+    ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+    ### {{ group | upper_first }}
+    {% for commit in commits %}
+        - {{ commit.message | upper_first }}\
+    {% endfor %}
+{% endfor %}\
+{% for submodule_path, commits in submodule_commits %}
+    ### {{ submodule_path | upper_first }}
+    {% for group, commits in commits | group_by(attribute="group") %}
+        #### {{ group | upper_first }}
+        {% for commit in commits %}
+            - {{ commit.message | upper_first }}\
+        {% endfor %}
+    {% endfor %}
+{% endfor %}
+"""
+
+[git]
+# An array of regex based parsers for extracting data from the commit message.
+# Assigns commits to groups.
+# Optionally sets the commit's `scope` and can decide to exclude commits from further processing.
+commit_parsers = [
+    { message = "^feat", group = "Features", default_scope = "app" },
+    { message = "^fix", group = "Bug Fixes", scope = "cli" },
+]
+recurse_submodules = true
+include_paths = ["submodule_two"]
diff --git a/.github/fixtures/test-submodules-include-path-config/commit.sh b/.github/fixtures/test-submodules-include-path-config/commit.sh
new file mode 100755
index 00000000..05c2031
--- /dev/null
+++ b/.github/fixtures/test-submodules-include-path-config/commit.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+set -e
+
+current_dir="$(pwd)"
+submodule_one_dir="$(mktemp -d)"
+submodule_two_dir="$(mktemp -d)"
+
+cd $submodule_one_dir && git init >&2
+GIT_COMMITTER_DATE="2022-04-05 01:00:8" git commit --allow-empty -m "feat: submodule_one initial commit"
+
+cd $submodule_two_dir && git init >&2
+GIT_COMMITTER_DATE="2022-04-05 01:00:12" git commit --allow-empty -m "feat: submodule_two feature B"
+
+cd $current_dir
+GIT_COMMITTER_DATE="2022-04-06 01:25:08" git commit --allow-empty -m "Initial commit"
+
+git -c protocol.file.allow=always submodule add $submodule_one_dir submodule_one
+GIT_COMMITTER_DATE="2022-04-06 01:25:09" git commit -a -m "feat: add submodule_one"
+
+cd submodule_one
+GIT_COMMITTER_DATE="2022-04-05 01:00:10" git commit --allow-empty -m "feat: submodule_one feature A"
+GIT_COMMITTER_DATE="2022-04-05 01:00:11" git commit --allow-empty -m "fix: submodule_one fix A"
+cd ..
+
+GIT_COMMITTER_DATE="2022-04-06 01:25:10" git commit -a -m "feat: submodule_one update 1"
+git tag v0.1.0
+
+git -c protocol.file.allow=always submodule add $submodule_two_dir submodule_two
+GIT_COMMITTER_DATE="2022-04-06 01:25:11" git commit -a -m "feat: submodule_two with initial commits"
+
+cd submodule_two
+GIT_COMMITTER_DATE="2022-04-05 01:00:13" git commit --allow-empty -m "fix: submodule_two fix B"
+cd ..
+
+GIT_COMMITTER_DATE="2022-04-06 01:25:12" git commit -a -m "feat: submodule_two update 1"
+git tag v0.2.0
+
+cd submodule_two
+GIT_COMMITTER_DATE="2022-04-05 01:00:14" git commit --allow-empty -m "fix: submodule_two fix C"
+cd ..
+
+GIT_COMMITTER_DATE="2022-04-06 01:25:13" git commit -a -m "feat: submodule_two update 2"
diff --git a/.github/fixtures/test-submodules-include-path-config/expected.md b/.github/fixtures/test-submodules-include-path-config/expected.md
new file mode 100644
index 00000000..eded778
--- /dev/null
+++ b/.github/fixtures/test-submodules-include-path-config/expected.md
@@ -0,0 +1,29 @@
+## [unreleased]
+
+### Features
+
+- Submodule_two update 2
+
+### Submodule_two
+
+#### Bug Fixes
+
+- Submodule_two fix C
+
+## [0.2.0] - 2022-04-06
+
+### Features
+
+- Submodule_two with initial commits
+- Submodule_two update 1
+
+### Submodule_two
+
+#### Bug Fixes
+
+- Submodule_two fix B
+
+#### Features
+
+- Submodule_two feature B
+
diff --git a/.github/workflows/test-fixtures.yml b/.github/workflows/test-fixtures.yml
index c6cd80f..4a09b82 100644
--- a/.github/workflows/test-fixtures.yml
+++ b/.github/workflows/test-fixtures.yml
@@ -121,6 +121,7 @@ jobs:
             command: v0.1.0..HEAD
           - fixtures-name: test-submodules-include-path
             command: --include-path submodule_two
+          - fixtures-name: test-submodules-include-path-config
           - fixtures-name: test-remote-config
             command: --config-url "https://github.com/orhun/git-cliff/blob/main/.github/fixtures/new-fixture-template/cliff.toml?raw=true"
           - fixtures-name: test-topo-order-commits
diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs
index 96e53c0..d723b20 100644
--- a/git-cliff-core/tests/integration_test.rs
+++ b/git-cliff-core/tests/integration_test.rs
@@ -143,6 +143,8 @@ fn generate_changelog() -> Result<()> {
 		],
 		limit_commits:            None,
 		recurse_submodules:       None,
+		include_paths:            Vec::new(),
+		exclude_paths:            Vec::new(),
 	};
 
 	let mut commit_with_author = Commit::new(