Repository: orhun/git-cliff
Pull request #1082 (2025-03-07 09:29:57)

feat: recurse into submodules
### Usage

If `recurse_submodules` is set to `true`, you can include submodule commits into your changelog.

For example:

```toml
[git]
recurse_submodules = true

[changelog]
body = """
{% 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 %}\n
"""
```



### Implementation details

Adds a feature which allows git-cliff to recurse into submodules of a repository. Between two subsequent releseases in the main repository, all submodule commits in between are expanded. For each submodule, there is an entry in `submodule_commits` mapping the submodules path to a list of commits. This feature is off by default (recurse_submodules=false). Nested submodules are not supported.

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/orhun/git-cliff/pull/1082?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) Report
Attention: Patch coverage is `0%` with `22 lines` in your changes missing coverage. Please review.
> Project coverage is 41.28%. Comparing base [(`9babe06`)](https://app.codecov.io/gh/orhun/git-cliff/commit/9babe0649c365f6c499ec6db62450625d8a21987?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) to head [(`1939b58`)](https://app.codecov.io/gh/orhun/git-cliff/commit/1939b583088a8e7d0c2fbc85af57a6720deb69f0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).

| [Files with missing lines](https://app.codecov.io/gh/orhun/git-cliff/pull/1082?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) | Patch % | Lines |
|---|---|---|
| [git-cliff-core/src/repo.rs](https://app.codecov.io/gh/orhun/git-cliff/pull/1082?src=pr&el=tree&filepath=git-cliff-core%2Fsrc%2Frepo.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z#diff-Z2l0LWNsaWZmLWNvcmUvc3JjL3JlcG8ucnM=) | 0.00% | [22 Missing :warning: ](https://app.codecov.io/gh/orhun/git-cliff/pull/1082?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1082      +/-   ##
==========================================
- Coverage   41.84%   41.28%   -0.56%     
==========================================
  Files          21       21              
  Lines        1788     1810      +22     
==========================================
- Hits          748      747       -1     
- Misses       1040     1063      +23     
```

| [Flag](https://app.codecov.io/gh/orhun/git-cliff/pull/1082/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) | Coverage Δ | |
|---|---|---|
| [unit-tests](https://app.codecov.io/gh/orhun/git-cliff/pull/1082/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) | `41.28% <0.00%> (-0.56%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/orhun/git-cliff/pull/1082?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
yudjinn: Nice! So for a given commit range of the containing repo, this returns all submodules that changed within those commits, and their deltas between them?
lehmanju: Well, this iteration sort of actually works! However, the submodule path is not correct ...
[context.json](https://github.com/user-attachments/files/19132065/context.json)

I used openstack/openstack for testing btw, that repository does seem like the ultimate stresstest.

lehmanju: So that is fixed as well, only config option, documentation and code cleanup/review are missing.
orhun: This is some good stuff. Let me know when this is ready for review :)
lehmanju: Thanks for the review! I'll have time to address your comments in about a week.
lehmanju: There does seem to be something wrong with the fixture tests in general: https://github.com/orhun/git-cliff/actions/runs/13907737767/job/38914649566#step:3:913
lehmanju: The more I test this in a real scenario the more corner cases I find:
unreleased and first release don't contain any submodule commits although they should
lehmanju: @orhun If CI turns out good (which it should), this is ready for a (hopefully) final review.

- docs updated
- fixture tests improved
- edge cases (submodule added/removed) working
- releases without commit id working

Patch:
diff --git a/config/cliff.toml b/config/cliff.toml
index f023a1e..c81800c 100644
--- a/config/cliff.toml
+++ b/config/cliff.toml
@@ -37,8 +37,8 @@ trim = true
 render_always = true
 # An array of regex based postprocessors to modify the changelog.
 postprocessors = [
-  # Replace the placeholder <REPO> with a URL.
-  #{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
+    # Replace the placeholder <REPO> with a URL.
+    #{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
 ]
 # render body even when there are no releases to process
 # render_always = true
@@ -58,11 +58,11 @@ require_conventional = false
 split_commits = false
 # An array of regex based parsers to modify commit messages prior to further processing.
 commit_preprocessors = [
-  # Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
-  #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
-  # Check spelling of the commit message using https://github.com/crate-ci/typos.
-  # If the spelling is incorrect, it will be fixed automatically.
-  #{ pattern = '.*', replace_command = 'typos --write-changes -' },
+    # Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
+    #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
+    # Check spelling of the commit message using https://github.com/crate-ci/typos.
+    # If the spelling is incorrect, it will be fixed automatically.
+    #{ pattern = '.*', replace_command = 'typos --write-changes -' },
 ]
 # Prevent commits that are breaking from being excluded by commit parsers.
 protect_breaking_commits = false
@@ -70,21 +70,21 @@ protect_breaking_commits = false
 # Assigns commits to groups.
 # Optionally sets the commit's scope and can decide to exclude commits from further processing.
 commit_parsers = [
-  { message = "^feat", group = "<!-- 0 -->🚀 Features" },
-  { message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
-  { message = "^doc", group = "<!-- 3 -->📚 Documentation" },
-  { message = "^perf", group = "<!-- 4 -->⚡ Performance" },
-  { message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
-  { message = "^style", group = "<!-- 5 -->🎨 Styling" },
-  { message = "^test", group = "<!-- 6 -->🧪 Testing" },
-  { message = "^chore\\(release\\): prepare for", skip = true },
-  { message = "^chore\\(deps.*\\)", skip = true },
-  { message = "^chore\\(pr\\)", skip = true },
-  { message = "^chore\\(pull\\)", skip = true },
-  { message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
-  { body = ".*security", group = "<!-- 8 -->🛡️ Security" },
-  { message = "^revert", group = "<!-- 9 -->◀️ Revert" },
-  { message = ".*", group = "<!-- 10 -->💼 Other" },
+    { message = "^feat", group = "<!-- 0 -->🚀 Features" },
+    { message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
+    { message = "^doc", group = "<!-- 3 -->📚 Documentation" },
+    { message = "^perf", group = "<!-- 4 -->⚡ Performance" },
+    { message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
+    { message = "^style", group = "<!-- 5 -->🎨 Styling" },
+    { message = "^test", group = "<!-- 6 -->🧪 Testing" },
+    { message = "^chore\\(release\\): prepare for", skip = true },
+    { message = "^chore\\(deps.*\\)", skip = true },
+    { message = "^chore\\(pr\\)", skip = true },
+    { message = "^chore\\(pull\\)", skip = true },
+    { message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
+    { body = ".*security", group = "<!-- 8 -->🛡️ Security" },
+    { message = "^revert", group = "<!-- 9 -->◀️ Revert" },
+    { message = ".*", group = "<!-- 10 -->💼 Other" },
 ]
 # Exclude commits that are not matched by any commit parser.
 filter_commits = false
@@ -99,3 +99,5 @@ topo_order_commits = true
 # Order of commits in each group/release within the changelog.
 # Allowed values: newest, oldest
 sort_commits = "oldest"
+# Process submodules commits
+recurse_submodules = false
diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs
index 885e0cd..e085a49 100644
--- a/git-cliff-core/src/changelog.rs
+++ b/git-cliff-core/src/changelog.rs
@@ -120,67 +120,26 @@ impl<'a> Changelog<'a> {
 		}
 	}
 
-	/// Processes the commits and omits the ones that doesn't match the
-	/// criteria set by configuration file.
-	fn process_commits(&mut self) -> Result<()> {
-		debug!("Processing the commits...");
-		self.releases.iter_mut().for_each(|release| {
-			release.commits = release
-				.commits
-				.iter()
-				.cloned()
-				.filter_map(|commit| Self::process_commit(&commit, &self.config.git))
-				.flat_map(|commit| {
-					if self.config.git.split_commits {
-						commit
-							.message
-							.lines()
-							.filter_map(|line| {
-								let mut c = commit.clone();
-								c.message = line.to_string();
-								if c.message.is_empty() {
-									None
-								} else {
-									Self::process_commit(&c, &self.config.git)
-								}
-							})
-							.collect()
-					} else {
-						vec![commit]
-					}
-				})
-				.collect::<Vec<Commit>>();
-		});
-
-		if self.config.git.require_conventional {
-			self.check_conventional_commits()?;
-		}
-
-		Ok(())
-	}
-
 	/// Checks the commits and returns an error if any unconventional commits
 	/// are found.
-	fn check_conventional_commits(&mut self) -> Result<()> {
+	fn check_conventional_commits(commits: &Vec<Commit<'a>>) -> Result<()> {
 		debug!("Verifying that all commits are conventional.");
 		let mut unconventional_count = 0;
 
-		self.releases.iter().for_each(|release| {
-			release.commits.iter().for_each(|commit| {
-				if commit.conv.is_none() {
-					error!(
-						"Commit {id} is not conventional:\n{message}",
-						id = &commit.id[..7],
-						message = commit
-							.message
-							.lines()
-							.map(|line| { format!("    | {}", line.trim()) })
-							.collect::<Vec<String>>()
-							.join("\n")
-					);
-					unconventional_count += 1;
-				}
-			});
+		commits.iter().for_each(|commit| {
+			if commit.conv.is_none() {
+				error!(
+					"Commit {id} is not conventional:\n{message}",
+					id = &commit.id[..7],
+					message = commit
+						.message
+						.lines()
+						.map(|line| { format!("    | {}", line.trim()) })
+						.collect::<Vec<String>>()
+						.join("\n")
+				);
+				unconventional_count += 1;
+			}
 		});
 
 		if unconventional_count > 0 {
@@ -190,6 +149,54 @@ impl<'a> Changelog<'a> {
 		Ok(())
 	}
 
+	fn process_commit_list(
+		commits: &mut Vec<Commit<'a>>,
+		git_config: &GitConfig,
+	) -> Result<()> {
+		*commits = commits
+			.iter()
+			.filter_map(|commit| Self::process_commit(commit, git_config))
+			.flat_map(|commit| {
+				if git_config.split_commits {
+					commit
+						.message
+						.lines()
+						.filter_map(|line| {
+							let mut c = commit.clone();
+							c.message = line.to_string();
+							if c.message.is_empty() {
+								None
+							} else {
+								Self::process_commit(&c, git_config)
+							}
+						})
+						.collect()
+				} else {
+					vec![commit]
+				}
+			})
+			.collect::<Vec<Commit>>();
+
+		if git_config.require_conventional {
+			Self::check_conventional_commits(commits)?;
+		}
+
+		Ok(())
+	}
+
+	/// Processes the commits and omits the ones that doesn't match the
+	/// criteria set by configuration file.
+	fn process_commits(&mut self) -> Result<()> {
+		debug!("Processing the commits...");
+		for release in self.releases.iter_mut() {
+			Self::process_commit_list(&mut release.commits, &self.config.git)?;
+			for submodule_commits in release.submodule_commits.values_mut() {
+				Self::process_commit_list(submodule_commits, &self.config.git)?;
+			}
+		}
+		Ok(())
+	}
+
 	/// Processes the releases and filters them out based on the configuration.
 	fn process_releases(&mut self) {
 		debug!("Processing {} release(s)...", self.releases.len());
@@ -890,6 +897,7 @@ mod test {
 				sort_commits:             String::from("oldest"),
 				link_parsers:             [].to_vec(),
 				limit_commits:            None,
+				recurse_submodules:       None,
 			},
 			remote:    RemoteConfig {
 				github:    Remote {
@@ -997,6 +1005,32 @@ mod test {
 			timestamp: 50000000,
 			previous: None,
 			repository: Some(String::from("/root/repo")),
+			submodule_commits: HashMap::from([(
+				String::from("submodule_one"),
+				vec![
+					Commit::new(
+						String::from("sub0jkl12"),
+						String::from("chore(app): submodule_one do nothing"),
+					),
+					Commit::new(
+						String::from("subqwerty"),
+						String::from("chore: submodule_one <preprocess>"),
+					),
+					Commit::new(
+						String::from("subqwertz"),
+						String::from(
+							"feat!: submodule_one support breaking commits",
+						),
+					),
+					Commit::new(
+						String::from("subqwert0"),
+						String::from(
+							"match(group): submodule_one support regex-replace for \
+							 groups",
+						),
+					),
+				],
+			)]),
 			#[cfg(feature = "github")]
 			github: crate::remote::RemoteReleaseMetadata {
 				contributors: vec![],
@@ -1059,6 +1093,22 @@ mod test {
 				timestamp: 1000,
 				previous: Some(Box::new(test_release)),
 				repository: Some(String::from("/root/repo")),
+				submodule_commits: HashMap::from([
+					(String::from("submodule_one"), vec![
+						Commit::new(
+							String::from("def349"),
+							String::from("sub_one merge #4"),
+						),
+						Commit::new(
+							String::from("da8912"),
+							String::from("sub_one merge #5"),
+						),
+					]),
+					(String::from("submodule_two"), vec![Commit::new(
+						String::from("ab76ef"),
+						String::from("sub_two bump"),
+					)]),
+				]),
 				#[cfg(feature = "github")]
 				github: crate::remote::RemoteReleaseMetadata {
 					contributors: vec![],
diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs
index 17d68f3..ca69a01 100644
--- a/git-cliff-core/src/commit.rs
+++ b/git-cliff-core/src/commit.rs
@@ -177,8 +177,8 @@ impl From<String> for Commit<'_> {
 }
 
 #[cfg(feature = "repo")]
-impl<'a> From<&GitCommit<'a>> for Commit<'a> {
-	fn from(commit: &GitCommit<'a>) -> Self {
+impl From<&GitCommit<'_>> for Commit<'_> {
+	fn from(commit: &GitCommit<'_>) -> Self {
 		Commit {
 			id: commit.id().to_string(),
 			message: commit.message().unwrap_or_default().trim_end().to_string(),
diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs
index 38d6a09..44df7d5 100644
--- a/git-cliff-core/src/config.rs
+++ b/git-cliff-core/src/config.rs
@@ -136,6 +136,8 @@ pub struct GitConfig {
 	pub sort_commits:             String,
 	/// Limit the total number of commits included in the changelog.
 	pub limit_commits:            Option<usize>,
+	/// Read submodule commits.
+	pub recurse_submodules:       Option<bool>,
 }
 
 /// Remote configuration.
diff --git a/git-cliff-core/src/release.rs b/git-cliff-core/src/release.rs
index 9930851..f382116 100644
--- a/git-cliff-core/src/release.rs
+++ b/git-cliff-core/src/release.rs
@@ -1,3 +1,5 @@
+use std::collections::HashMap;
+
 use crate::commit::commits_to_conventional_commits;
 use crate::error::Result;
 use crate::{
@@ -31,35 +33,40 @@ use serde_json::value::Value;
 #[serde(rename_all(serialize = "camelCase"))]
 pub struct Release<'a> {
 	/// Release version, git tag.
-	pub version:    Option<String>,
+	pub version:           Option<String>,
 	/// git tag's message.
-	pub message:    Option<String>,
+	pub message:           Option<String>,
 	/// Commits made for the release.
 	#[serde(deserialize_with = "commits_to_conventional_commits")]
-	pub commits:    Vec<Commit<'a>>,
+	pub commits:           Vec<Commit<'a>>,
 	/// Commit ID of the tag.
 	#[serde(rename = "commit_id")]
-	pub commit_id:  Option<String>,
+	pub commit_id:         Option<String>,
 	/// Timestamp of the release in seconds, from epoch.
-	pub timestamp:  i64,
+	pub timestamp:         i64,
 	/// Previous release.
-	pub previous:   Option<Box<Release<'a>>>,
+	pub previous:          Option<Box<Release<'a>>>,
 	/// Repository path.
-	pub repository: Option<String>,
+	pub repository:        Option<String>,
+	/// Submodule commits.
+	///
+	/// Maps submodule path to a list of commits.
+	#[serde(rename = "submodule_commits")]
+	pub submodule_commits: HashMap<String, Vec<Commit<'a>>>,
 	/// Arbitrary data to be used with the `--from-context` CLI option.
-	pub extra:      Option<Value>,
+	pub extra:             Option<Value>,
 	/// Contributors.
 	#[cfg(feature = "github")]
-	pub github:     RemoteReleaseMetadata,
+	pub github:            RemoteReleaseMetadata,
 	/// Contributors.
 	#[cfg(feature = "gitlab")]
-	pub gitlab:     RemoteReleaseMetadata,
+	pub gitlab:            RemoteReleaseMetadata,
 	/// Contributors.
 	#[cfg(feature = "gitea")]
-	pub gitea:      RemoteReleaseMetadata,
+	pub gitea:             RemoteReleaseMetadata,
 	/// Contributors.
 	#[cfg(feature = "bitbucket")]
-	pub bitbucket:  RemoteReleaseMetadata,
+	pub bitbucket:         RemoteReleaseMetadata,
 }
 
 #[cfg(feature = "github")]
@@ -202,6 +209,7 @@ mod test {
 					..Default::default()
 				})),
 				repository: Some(String::from("/root/repo")),
+				submodule_commits: HashMap::new(),
 				#[cfg(feature = "github")]
 				github: crate::remote::RemoteReleaseMetadata {
 					contributors: vec![],
@@ -418,6 +426,7 @@ mod test {
 				..Default::default()
 			})),
 			repository: Some(String::from("/root/repo")),
+			submodule_commits: HashMap::new(),
 			github: RemoteReleaseMetadata {
 				contributors: vec![],
 			},
@@ -786,6 +795,7 @@ mod test {
 				..Default::default()
 			})),
 			repository: Some(String::from("/root/repo")),
+			submodule_commits: HashMap::new(),
 			#[cfg(feature = "github")]
 			github: RemoteReleaseMetadata {
 				contributors: vec![],
@@ -1176,6 +1186,7 @@ mod test {
 				..Default::default()
 			})),
 			repository: Some(String::from("/root/repo")),
+			submodule_commits: HashMap::new(),
 			#[cfg(feature = "github")]
 			github: RemoteReleaseMetadata {
 				contributors: vec![],
@@ -1534,6 +1545,7 @@ mod test {
 				..Default::default()
 			})),
 			repository: Some(String::from("/root/repo")),
+			submodule_commits: HashMap::new(),
 			#[cfg(feature = "github")]
 			github: RemoteReleaseMetadata {
 				contributors: vec![],
diff --git a/git-cliff-core/src/repo.rs b/git-cliff-core/src/repo.rs
index 21b534e..b5e7f36 100644
--- a/git-cliff-core/src/repo.rs
+++ b/git-cliff-core/src/repo.rs
@@ -22,7 +22,10 @@ use lazy_regex::{
 	Regex,
 };
 use std::io;
-use std::path::PathBuf;
+use std::path::{
+	Path,
+	PathBuf,
+};
 use std::result::Result as StdResult;
 use url::Url;
 
@@ -46,6 +49,15 @@ pub struct Repository {
 	changed_files_cache_path: PathBuf,
 }
 
+/// Range of commits in a submodule.
+pub struct SubmoduleRange {
+	/// Repository object to which this range belongs.
+	pub repository: Repository,
+	/// Commit range in "<first_submodule_commit>..<last_submodule_commit>" or
+	/// "<last_submodule_commit>" format.
+	pub range:      String,
+}
+
 impl Repository {
 	/// Initializes (opens) the repository.
 	pub fn init(path: PathBuf) -> Result<Self> {
@@ -77,7 +89,7 @@ impl Repository {
 	}
 
 	/// Returns the path of the repository.
-	pub fn path(&self) -> Result<PathBuf> {
+	pub fn root_path(&self) -> Result<PathBuf> {
 		let mut path = if self.inner.is_worktree() {
 			let worktree = Worktree::open_from_repository(&self.inner)?;
 			worktree.path().to_path_buf()
@@ -90,6 +102,14 @@ impl Repository {
 		Ok(path)
 	}
 
+	/// Returns the initial path of the repository.
+	///
+	/// In case of a submodule this is the relative path to the toplevel
+	/// repository.
+	pub fn path(&self) -> &PathBuf {
+		&self.path
+	}
+
 	/// Sets the range for the commit search.
 	///
 	/// When a single SHA is provided as the range, start from the
@@ -155,6 +175,59 @@ impl Repository {
 		Ok(commits)
 	}
 
+	/// Returns submodule repositories for a given commit range.
+	///
+	/// For one or two given commits in this repository, a list of changed
+	/// submodules is calculated. If only one commit is given, then all
+	/// submodule commits up to the referenced commit will be included. This is
+	/// usually the case if a submodule is added to the repository.
+	///
+	///  For each submodule a [`SubmoduleRange`] object is created
+	///
+	/// This can then be used to query the submodule's commits by using
+	/// [`Repository::commits`].
+	pub fn submodules_range(
+		&self,
+		old_commit: Option<Commit<'_>>,
+		new_commit: Commit<'_>,
+	) -> Result<Vec<SubmoduleRange>> {
+		let old_tree = old_commit.and_then(|commit| commit.tree().ok());
+		let new_tree = new_commit.tree().ok();
+		let diff = self.inner.diff_tree_to_tree(
+			old_tree.as_ref(),
+			new_tree.as_ref(),
+			None,
+		)?;
+		// iterate through all diffs and accumulate old/new commit ids
+		let before_and_after_deltas = diff.deltas().filter_map(|delta| {
+			let old_file_id = delta.old_file().id();
+			let new_file_id = delta.new_file().id();
+			let range = if old_file_id == new_file_id || new_file_id.is_zero() {
+				// no changes or submodule removed
+				None
+			} else if old_file_id.is_zero() {
+				// submodule added
+				Some(new_file_id.to_string())
+			} else {
+				// submodule updated
+				Some(format!("{}..{}", old_file_id, new_file_id))
+			};
+			trace!("Release commit range for submodules: {:?}", range);
+			delta.new_file().path().and_then(Path::to_str).zip(range)
+		});
+		// iterate through all path diffs and find corresponding submodule if
+		// possible
+		let submodule_range = before_and_after_deltas.filter_map(|(path, range)| {
+			let repository = self
+				.inner
+				.find_submodule(path)
+				.ok()
+				.and_then(|submodule| Self::init(submodule.path().into()).ok());
+			repository.map(|repository| SubmoduleRange { repository, range })
+		});
+		Ok(submodule_range.collect())
+	}
+
 	/// Normalizes the glob pattern to match the git diff paths.
 	///
 	/// It removes the leading `./` and adds `**` to the end if the pattern is a
diff --git a/git-cliff-core/src/template.rs b/git-cliff-core/src/template.rs
index 0d28008..08c03da 100644
--- a/git-cliff-core/src/template.rs
+++ b/git-cliff-core/src/template.rs
@@ -219,6 +219,7 @@ mod test {
 			timestamp: 0,
 			previous: None,
 			repository: Some(String::from("/root/repo")),
+			submodule_commits: HashMap::new(),
 			#[cfg(feature = "github")]
 			github: crate::remote::RemoteReleaseMetadata {
 				contributors: vec![],
diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs
index b5b9eae..ca6aa6c 100644
--- a/git-cliff/src/lib.rs
+++ b/git-cliff/src/lib.rs
@@ -35,7 +35,10 @@ use git_cliff_core::error::{
 	Result,
 };
 use git_cliff_core::release::Release;
-use git_cliff_core::repo::Repository;
+use git_cliff_core::repo::{
+	Repository,
+	SubmoduleRange,
+};
 use git_cliff_core::{
 	DEFAULT_CONFIG,
 	IGNORE_FILE,
@@ -152,6 +155,53 @@ fn determine_commit_range(
 	Ok(commit_range)
 }
 
+/// Process submodules and add commits to release.
+fn process_submodules(
+	repository: &'static Repository,
+	release: &mut Release,
+	topo_order_commits: bool,
+) -> Result<()> {
+	// Retrieve first and last commit of a release to create a commit range.
+	let first_commit = release
+		.previous
+		.as_ref()
+		.and_then(|previous_release| previous_release.commit_id.clone())
+		.and_then(|commit_id| repository.find_commit(&commit_id));
+	let last_commit = release
+		.commit_id
+		.clone()
+		.and_then(|commit_id| repository.find_commit(&commit_id));
+
+	// Query repository for submodule changes. For each submodule a
+	// SubmoduleRange is created, describing the range of commits in the context
+	// of that submodule.
+	if let Some(last_commit) = last_commit {
+		let submodule_ranges =
+			repository.submodules_range(first_commit, last_commit)?;
+		let submodule_commits =
+			submodule_ranges.iter().filter_map(|submodule_range| {
+				// For each submodule, the commit range is exploded into a list of
+				// commits.
+				let SubmoduleRange {
+					repository: sub_repo,
+					range: range_str,
+				} = submodule_range;
+				let commits = sub_repo
+					.commits(Some(range_str), None, None, topo_order_commits)
+					.ok()
+					.map(|commits| commits.iter().map(Commit::from).collect());
+
+				let submodule_path = sub_repo.path().to_string_lossy().into_owned();
+				Some(submodule_path).zip(commits)
+			});
+		// Insert submodule commits into map.
+		for (submodule_path, commits) in submodule_commits {
+			release.submodule_commits.insert(submodule_path, commits);
+		}
+	}
+	Ok(())
+}
+
 /// Processes the tags and commits for creating release entries for the
 /// changelog.
 ///
@@ -170,6 +220,7 @@ fn process_repository<'a>(
 	let skip_regex = config.git.skip_tags.as_ref();
 	let ignore_regex = config.git.ignore_tags.as_ref();
 	let count_tags = config.git.count_tags.as_ref();
+	let recurse_submodules = config.git.recurse_submodules.unwrap_or(false);
 	tags.retain(|_, tag| {
 		let name = &tag.name;
 
@@ -246,7 +297,7 @@ fn process_repository<'a>(
 	// Include only the current directory if not running from the root repository
 	let mut include_path = args.include_path.clone();
 	if let Some(mut path_diff) =
-		pathdiff::diff_paths(env::current_dir()?, repository.path()?)
+		pathdiff::diff_paths(env::current_dir()?, repository.root_path()?)
 	{
 		if args.workdir.is_none() &&
 			include_path.is_none() &&
@@ -298,17 +349,17 @@ fn process_repository<'a>(
 	// Process releases.
 	let mut previous_release = Release::default();
 	let mut first_processed_tag = None;
-	let repository_path = repository.path()?.to_string_lossy().into_owned();
+	let repository_path = repository.root_path()?.to_string_lossy().into_owned();
 	for git_commit in commits.iter().rev() {
 		let release = releases.last_mut().unwrap();
 		let commit = Commit::from(git_commit);
 		let commit_id = commit.id.to_string();
 		release.commits.push(commit);
 		release.repository = Some(repository_path.clone());
-		if let Some(tag) = tags.get(&commit_id) {
+		release.commit_id = Some(commit_id);
+		if let Some(tag) = tags.get(release.commit_id.as_ref().unwrap()) {
 			release.version = Some(tag.name.to_string());
 			release.message.clone_from(&tag.message);
-			release.commit_id = Some(commit_id);
 			release.timestamp = if args.tag.as_deref() == Some(tag.name.as_str()) {
 				match tag_timestamp {
 					Some(timestamp) => timestamp,
@@ -337,6 +388,12 @@ fn process_repository<'a>(
 		releases.last_mut().unwrap().previous = Some(Box::new(previous_release));
 	}
 
+	if recurse_submodules {
+		for release in &mut releases {
+			process_submodules(repository, release, config.git.topo_order_commits)?;
+		}
+	}
+
 	if args.sort == Sort::Newest {
 		for release in &mut releases {
 			release.commits.reverse();
diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md
index 69f311f..c80b0d6 100644
--- a/website/docs/configuration/git.md
+++ b/website/docs/configuration/git.md
@@ -31,6 +31,7 @@ link_parsers = [
     { pattern = "RFC(\\d+)", text = "ietf-rfc$1", href = "https://datatracker.ietf.org/doc/html/rfc$1"},
 ]
 limit_commits = 42
+recurse_submodules = false
 ```
 
 ### conventional_commits
@@ -258,7 +259,7 @@ This can also be achieved by using the `--topo-order` command line flag.
 If set to `true`, commits are processed in topological order instead of chronological.
 
 ```toml
-# if false, sorting commit is equivalent to git log 
+# if false, sorting commit is equivalent to git log
 # if true (default), sorting commit is equivalent to git log --topo-order
 topo_order_commits = false
 ```
@@ -292,3 +293,9 @@ These extracted links can be used in the [template](/docs/templating/context) wi
 `limit_commits` is an **optional** positive integer number that limits the number of included commits in the generated changelog.
 
 `limit_commits` is not part of the default configuration.
+
+### recurse_submodules
+
+`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).
diff --git a/website/docs/templating/context.md b/website/docs/templating/context.md
index a2d53c7..78c631b 100644
--- a/website/docs/templating/context.md
+++ b/website/docs/templating/context.md
@@ -64,6 +64,15 @@ following context is generated to use for templating:
   "commit_id": "a440c6eb26404be4877b7e3ad592bfaa5d4eb210 (release commit)",
   "timestamp": 1625169301,
   "repository": "/path/to/repository",
+  "submodule_commits": {
+    "<relative/submodule_path/in/repository>": [
+      {
+        "id": "c10d0f12e85975bc1e8f41eed693c58eca1894eb",
+        "message": "release (#1671)",
+        ...
+      },
+    ],
+  },
   "previous": {
     "version": "previous release"
   }
diff --git a/website/docs/usage/adding-commits.md b/website/docs/usage/adding-commits.md
index 788a409..b86f5db 100644
--- a/website/docs/usage/adding-commits.md
+++ b/website/docs/usage/adding-commits.md
@@ -1,5 +1,5 @@
 ---
-sidebar_position: 8
+sidebar_position: 9
 ---
 
 # Adding custom commits
diff --git a/website/docs/usage/adding-tag-messages.md b/website/docs/usage/adding-tag-messages.md
index 7e4f569..b9bfc54 100644
--- a/website/docs/usage/adding-tag-messages.md
+++ b/website/docs/usage/adding-tag-messages.md
@@ -1,5 +1,5 @@
 ---
-sidebar_position: 9
+sidebar_position: 10
 ---
 
 # Adding version (tag) message
diff --git a/website/docs/usage/jujutsu.md b/website/docs/usage/jujutsu.md
index f8384a5..b017251 100644
--- a/website/docs/usage/jujutsu.md
+++ b/website/docs/usage/jujutsu.md
@@ -1,5 +1,5 @@
 ---
-sidebar_position: 12
+sidebar_position: 13
 ---
 
 # Jujutsu
diff --git a/website/docs/usage/load-context.md b/website/docs/usage/load-context.md
index fd4a5a8..2f6f2ca 100644
--- a/website/docs/usage/load-context.md
+++ b/website/docs/usage/load-context.md
@@ -1,5 +1,5 @@
 ---
-sidebar_position: 11
+sidebar_position: 12
 ---
 
 # Load context
diff --git a/website/docs/usage/print-context.md b/website/docs/usage/print-context.md
index fc314b9..3032ca6 100644
--- a/website/docs/usage/print-context.md
+++ b/website/docs/usage/print-context.md
@@ -1,5 +1,5 @@
 ---
-sidebar_position: 10
+sidebar_position: 11
 ---
 
 # Print context
diff --git a/website/docs/usage/skipping-commits.md b/website/docs/usage/skipping-commits.md
index a515cb2..0016643 100644
--- a/website/docs/usage/skipping-commits.md
+++ b/website/docs/usage/skipping-commits.md
@@ -1,5 +1,5 @@
 ---
-sidebar_position: 7
+sidebar_position: 8
 ---
 
 # Skipping commits
diff --git a/website/docs/usage/submodules.md b/website/docs/usage/submodules.md
new file mode 100644
index 00000000..ccc9c9a
--- /dev/null
+++ b/website/docs/usage/submodules.md
@@ -0,0 +1,33 @@
+---
+sidebar_position: 7
+---
+
+# Submodules
+
+If [`recurse_submodules`](/docs/configuration/git#recurse_submodules) is set to `true`, you can include submodule commits into your changelog.
+
+For example:
+
+```toml
+[git]
+recurse_submodules = true
+
+[changelog]
+body = """
+{% 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 %}\n
+"""
+```
+
+:::info
+
+If a release does not contain any submodule updates, `submodule_commits` is just an empty map.
+
+:::


Test patch:
diff --git a/.github/fixtures/test-from-context-does-not-discard-fields/context.json b/.github/fixtures/test-from-context-does-not-discard-fields/context.json
index e7750b3..22ccf42 100644
--- a/.github/fixtures/test-from-context-does-not-discard-fields/context.json
+++ b/.github/fixtures/test-from-context-does-not-discard-fields/context.json
@@ -375,6 +375,7 @@
       "timestamp": 0,
       "previous": null,
       "repository": null,
+      "submodule_commits": {},
       "extra": null,
       "github": { "contributors": [] },
       "gitlab": { "contributors": [] },
@@ -382,6 +383,7 @@
       "bitbucket": { "contributors": [] }
     },
     "repository": "/path/to/repository",
+    "submodule_commits": {},
     "extra": null,
     "github": { "contributors": [] },
     "gitlab": { "contributors": [] },
diff --git a/.github/fixtures/test-from-context/context.json b/.github/fixtures/test-from-context/context.json
index 5dee19a..a6ac41b 100644
--- a/.github/fixtures/test-from-context/context.json
+++ b/.github/fixtures/test-from-context/context.json
@@ -160,6 +160,7 @@
             "timestamp": 1649201112,
             "previous": null,
             "repository": "/home/johndoe/repo/",
+            "submodule_commits": {},
             "github": {
                 "contributors": []
             },
@@ -174,6 +175,7 @@
             }
         },
         "repository": "/home/johndoe/repo/",
+        "submodule_commits": {},
         "github": {
             "contributors": []
         },
@@ -449,6 +451,7 @@
             "timestamp": 1649201110,
             "previous": null,
             "repository": "/home/johndoe/repo/",
+            "submodule_commits": {},
             "github": {
                 "contributors": []
             },
@@ -466,6 +469,7 @@
             "note": "This is so awesome!"
         },
         "repository": "/home/johndoe/repo/",
+        "submodule_commits": {},
         "github": {
             "contributors": []
         },
@@ -596,6 +600,7 @@
             "timestamp": 0,
             "previous": null,
             "repository": null,
+            "submodule_commits": {},
             "github": {
                 "contributors": []
             },
@@ -610,6 +615,7 @@
             }
         },
         "repository": "/home/johndoe/repo/",
+        "submodule_commits": {},
         "github": {
             "contributors": []
         },
diff --git a/.github/fixtures/test-submodules-include-path/cliff.toml b/.github/fixtures/test-submodules-include-path/cliff.toml
new file mode 100644
index 00000000..3a26249
--- /dev/null
+++ b/.github/fixtures/test-submodules-include-path/cliff.toml
@@ -0,0 +1,38 @@
+# 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 %}\n
+"""
+
+[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
diff --git a/.github/fixtures/test-submodules-include-path/commit.sh b/.github/fixtures/test-submodules-include-path/commit.sh
new file mode 100755
index 00000000..05c2031
--- /dev/null
+++ b/.github/fixtures/test-submodules-include-path/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/expected.md b/.github/fixtures/test-submodules-include-path/expected.md
new file mode 100644
index 00000000..01a4425
--- /dev/null
+++ b/.github/fixtures/test-submodules-include-path/expected.md
@@ -0,0 +1,48 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [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_one
+
+#### Bug Fixes
+
+- Submodule_one fix A
+
+#### Features
+
+- Submodule_one feature A
+- Submodule_one initial commit
+
+
+### Submodule_two
+
+#### Bug Fixes
+
+- Submodule_two fix B
+
+#### Features
+
+- Submodule_two feature B
+
+
+<!-- generated by git-cliff -->
diff --git a/.github/fixtures/test-submodules/cliff.toml b/.github/fixtures/test-submodules/cliff.toml
new file mode 100644
index 00000000..3a26249
--- /dev/null
+++ b/.github/fixtures/test-submodules/cliff.toml
@@ -0,0 +1,38 @@
+# 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 %}\n
+"""
+
+[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
diff --git a/.github/fixtures/test-submodules/commit.sh b/.github/fixtures/test-submodules/commit.sh
new file mode 100755
index 00000000..05c2031
--- /dev/null
+++ b/.github/fixtures/test-submodules/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/expected.md b/.github/fixtures/test-submodules/expected.md
new file mode 100644
index 00000000..9fb8652
--- /dev/null
+++ b/.github/fixtures/test-submodules/expected.md
@@ -0,0 +1,55 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [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
+
+
+## [0.1.0] - 2022-04-06
+
+### Features
+
+- Add submodule_one
+- Submodule_one update 1
+
+### Submodule_one
+
+#### Bug Fixes
+
+- Submodule_one fix A
+
+#### Features
+
+- Submodule_one feature A
+- Submodule_one initial commit
+
+
+<!-- generated by git-cliff -->
diff --git a/.github/workflows/test-fixtures.yml b/.github/workflows/test-fixtures.yml
index 1213d9d..ff57be0 100644
--- a/.github/workflows/test-fixtures.yml
+++ b/.github/workflows/test-fixtures.yml
@@ -116,6 +116,9 @@ jobs:
             command: v2.6.1..v2.7.0 --include-path .github/fixtures/
           - fixtures-name: test-require-conventional-negative
           - fixtures-name: test-require-conventional-skipped
+          - fixtures-name: test-submodules
+          - fixtures-name: test-submodules-include-path
+            command: --include-path submodule_two
           - 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 fd04722..5d10f88 100644
--- a/git-cliff-core/tests/integration_test.rs
+++ b/git-cliff-core/tests/integration_test.rs
@@ -139,6 +139,7 @@ fn generate_changelog() -> Result<()> {
 			},
 		],
 		limit_commits:            None,
+		recurse_submodules:       None,
 	};
 
 	let mut commit_with_author = Commit::new(
@@ -202,6 +203,7 @@ fn generate_changelog() -> Result<()> {
 			timestamp: 0,
 			previous:  None,
 			repository: Some(String::from("/root/repo")),
+			submodule_commits: HashMap::new(),
 			#[cfg(feature = "github")]
 			github: git_cliff_core::remote::RemoteReleaseMetadata {
 				contributors: vec![],
@@ -245,6 +247,7 @@ fn generate_changelog() -> Result<()> {
 			timestamp: 0,
 			previous:  None,
 			repository: Some(String::from("/root/repo")),
+			submodule_commits: HashMap::new(),
 			#[cfg(feature = "github")]
 			github: git_cliff_core::remote::RemoteReleaseMetadata {
 				contributors: vec![],