Repository: cocogitto/cocogitto
Pull request #366 (2024-02-20 13:14:37)

Refactor/revspec pattern
Work in progress refactoring.
Needed for #359


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) Report
Attention: Patch coverage is `96.34740%` with `45 lines` in your changes are missing coverage. Please review.
> Project coverage is 86.53%. Comparing base [(`f9d3dd3`)](https://app.codecov.io/gh/cocogitto/cocogitto/commit/f9d3dd35a1adeffc465b5aa7e3645bdbcdff7bbf?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) to head [(`004de99`)](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto).

| [Files](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) | Patch % | Lines |
|---|---|---|
| [src/git/rev/revwalk.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2dpdC9yZXYvcmV2d2Fsay5ycw==) | 97.84% | [10 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| [src/git/tag.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2dpdC90YWcucnM=) | 95.00% | [10 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| [src/git/rev/revspec.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2dpdC9yZXYvcmV2c3BlYy5ycw==) | 94.96% | [7 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| [src/command/edit.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2NvbW1hbmQvZWRpdC5ycw==) | 0.00% | [6 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| [src/command/log.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2NvbW1hbmQvbG9nLnJz) | 42.85% | [4 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| [src/command/bump/monorepo.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2NvbW1hbmQvYnVtcC9tb25vcmVwby5ycw==) | 93.33% | [2 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| [src/git/error.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2dpdC9lcnJvci5ycw==) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| [src/conventional/changelog/error.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2NvbnZlbnRpb25hbC9jaGFuZ2Vsb2cvZXJyb3IucnM=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| [src/git/rev/filters.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2dpdC9yZXYvZmlsdGVycy5ycw==) | 97.77% | [1 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| [src/git/rev/mod.rs](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto#diff-c3JjL2dpdC9yZXYvbW9kLnJz) | 98.03% | [1 Missing :warning: ](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) |
| ... and [1 more](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto) | |

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


```diff
@@            Coverage Diff             @@
##             main     #366      +/-   ##
==========================================
+ Coverage   86.49%   86.53%   +0.04%     
==========================================
  Files          45       48       +3     
  Lines        6731     6901     +170     
==========================================
+ Hits         5822     5972     +150     
- Misses        909      929      +20     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/cocogitto/cocogitto/pull/366?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocogitto).   
: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=cocogitto).

oknozor: ** ⚠️ This is not stabilized and a bunch of testing should be done before pushing that to main. ⚠️** 

This PR simplify the revspec and tag lookup implementation. Unfortunately I caught few cases were some exception were applied  to the revwalk depending on the context. For instance, the very first commit was never included in changelogs. 
Annotated tag were not correctly resolved etc. 

This may or may not result in a breaking change. 

Previously revspec that looks related produced different commit range depending on the context. 
This implementation should be easier to maintain and there are still some cleanups that can be done. 

Performance wise, this is not an improvement and I used some greedy techniques to build a global cache containing all the repository tags, with their respective commit oids and target oids for annotated tags. 

**Todos:**  
- [ ] [**feature**] Handle case where multiple lightweight tags point to the same commit ?
- [ ] [**perf**/**feature**] Access the cache using commit shorthand of arbitrary length (an intermediary structure to resolve oids before querying the cache is needed here). 
- [ ] [**perf**] On each repository mutation (tag creation, commits) the cache should be updated instead of fully rebuilt. 
- [ ] Simplify as much as possible repository access using the cache.  
- [ ] Test & Refactorings 
  - [ ] `run_cmd!` could be factorized for readability/writability 
  - [ ]  better module organization (often different use-cases are mixed in test, making them hard to navigate)
  - [ ] Method visibility (many method are exposed publicly while they should just be relocated to be made private. this would also ease documenting the public API.

Patch:
diff --git a/.gitignore b/.gitignore
index 72c2a63..f77e506 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,8 @@
 .idea
 .vscode
 issues
+flamegraph.svg
+massif.out.*
+perf.data
+perf.data.old
+stacks.folded
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 99def35..6c1f3ab 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -85,14 +85,13 @@ mod test {
   #[sealed_test]
   fn create_commit_ok() -> Result<()> {
     // Arrange
+    let repo = git_init_no_gpg()?;
+    
     run_cmd!(
-            git init;
             echo changes > file;
             git add .;
         )?;
-
-    let repo = Repository::open(".")?;
-
+    
     // Act
     let oid = repo.commit("feat: a test commit");
 
diff --git a/docs/website/ci_cd/README.md b/docs/website/ci_cd/README.md
index 4c1ff33..d73547c 100644
--- a/docs/website/ci_cd/README.md
+++ b/docs/website/ci_cd/README.md
@@ -24,7 +24,7 @@ jobs:
           fetch-depth: 0
 
       - name: Conventional commits check
-        uses: cocogitto/cocogitto-action@v3
+        uses: oknozor/cocogitto-action@v3
 ```
 
 ::: warning
@@ -36,7 +36,7 @@ full git history before running cocogitto-action.
 If you want the action to check only commits since the latest SemVer tag you can do the following: 
 ```yaml
   - name: Conventional commits check
-    uses: cocogitto/cocogitto-action@v3
+    uses: oknozor/cocogitto-action@v3
     with:
       check-latest-tag-only: true
 ```
@@ -47,7 +47,7 @@ To create a release with cocogitto-action simply add the `release` option :
 
 ```yaml
   - name: Semver release
-    uses: cocogitto/cocogitto-action@v3
+    uses: oknozor/cocogitto-action@v3
     with:
       release: true
       git-user: 'Cog Bot'
@@ -98,7 +98,7 @@ jobs:
 
       - name: Cocogitto release
         id: release
-        uses: cocogitto/cocogitto-action@v3
+        uses: oknozor/cocogitto-action@v3
         with:
           release: true
           git-user: 'Cog Bot'
diff --git a/justfile b/justfile
index a063985..f0a1d20 100644
--- a/justfile
+++ b/justfile
@@ -23,3 +23,13 @@ build-all: build-x86 build-arm-v7 build-aarch64
 docker-build: build-all
     docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64/v8  . -t cocogitto/cog:latest -f docker/Dockerfile
 
+changelog-inferno:
+    cargo build --release
+    perf record --call-graph dwarf target/release/cog changelog
+    perf script | inferno-collapse-perf > stacks.folded
+    cat stacks.folded | inferno-flamegraph > flamegraph.svg
+    chromium flamegraph.svg
+
+changelog-massif:
+    valgrind --tool=massif cog changelog
+
diff --git a/src/bin/cog/main.rs b/src/bin/cog/main.rs
index d2c616a..9dd7eab 100644
--- a/src/bin/cog/main.rs
+++ b/src/bin/cog/main.rs
@@ -3,12 +3,11 @@ mod mangen;
 
 use std::fs;
 use std::path::PathBuf;
-use std::str::FromStr;
 
 use cocogitto::conventional::changelog::template::{RemoteContext, Template};
 use cocogitto::conventional::commit as conv_commit;
 use cocogitto::conventional::version::IncrementCommand;
-use cocogitto::git::revspec::RevspecPattern;
+
 use cocogitto::log::filter::{CommitFilter, CommitFilters};
 use cocogitto::log::output::Output;
 use cocogitto::{CocoGitto, CommitHook, SETTINGS};
@@ -19,6 +18,8 @@ use clap::builder::{PossibleValue, PossibleValuesParser};
 use clap::{ArgAction, ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum};
 use clap_complete::{shells, Generator};
 use clap_complete_nushell::Nushell;
+use cocogitto::command::bump::{BumpOptions, PackageBumpOptions};
+use cocogitto::command::commit::CommitOptions;
 use cocogitto::settings::GitHookType;
 
 fn hook_profiles() -> PossibleValuesParser {
@@ -411,40 +412,49 @@ fn main() -> Result<()> {
                     Some(package_name) => {
                         // Safe unwrap here, package name is validated by clap
                         let package = SETTINGS.packages.get(&package_name).unwrap();
-                        cocogitto.create_package_version(
-                            (&package_name, package),
+
+                        let opts = PackageBumpOptions {
+                            package_name: &package_name,
+                            package,
                             increment,
-                            pre.as_deref(),
-                            hook_profile.as_deref(),
+                            pre_release: pre.as_deref(),
+                            hooks_config: hook_profile.as_deref(),
                             annotated,
                             dry_run,
                             skip_ci,
                             skip_ci_override,
                             skip_untracked,
-                        )?
+                        };
+
+                        cocogitto.create_package_version(opts)?
+                    }
+                    None => {
+                        let opts = BumpOptions {
+                            increment,
+                            pre_release: pre.as_deref(),
+                            hooks_config: hook_profile.as_deref(),
+                            annotated,
+                            dry_run,
+                            skip_ci,
+                            skip_ci_override,
+                            skip_untracked,
+                        };
+
+                        cocogitto.create_monorepo_version(opts)?
                     }
-                    None => cocogitto.create_monorepo_version(
-                        increment,
-                        pre.as_deref(),
-                        hook_profile.as_deref(),
-                        annotated,
-                        dry_run,
-                        skip_ci,
-                        skip_ci_override,
-                        skip_untracked,
-                    )?,
                 }
             } else {
-                cocogitto.create_version(
+                let opts = BumpOptions {
                     increment,
-                    pre.as_deref(),
-                    hook_profile.as_deref(),
+                    pre_release: pre.as_deref(),
+                    hooks_config: hook_profile.as_deref(),
                     annotated,
                     dry_run,
                     skip_ci,
                     skip_ci_override,
                     skip_untracked,
-                )?
+                };
+                cocogitto.create_version(opts)?
             }
         }
         Command::Verify {
@@ -559,15 +569,12 @@ fn main() -> Result<()> {
                 Template::default()
             };
 
-            let pattern = pattern
-                .as_deref()
-                .map(RevspecPattern::from_str)
-                .transpose()?;
-
+            // TODO: fallback to tag here
+            let pattern = pattern.as_deref().unwrap_or("..");
             let result = match at {
                 Some(at) => cocogitto.get_changelog_at_tag(&at, template)?,
                 None => {
-                    let changelog = cocogitto.get_changelog(pattern.unwrap_or_default(), true)?;
+                    let changelog = cocogitto.get_changelog(pattern, true)?;
                     changelog.into_markdown(template)?
                 }
             };
@@ -633,15 +640,18 @@ fn main() -> Result<()> {
             } else {
                 (None, None, breaking_change)
             };
-            cocogitto.conventional_commit(
-                &typ,
+
+            let opts = CommitOptions {
+                commit_type: &typ,
                 scope,
-                commit_message,
+                summary: commit_message,
                 body,
                 footer,
                 breaking,
                 sign,
-            )?;
+            };
+
+            cocogitto.conventional_commit(opts)?;
             cocogitto.run_commit_hook(CommitHook::PostCommit)?;
         }
     }
diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs
index 90167a5..a7b5c48 100644
--- a/src/command/bump/mod.rs
+++ b/src/command/bump/mod.rs
@@ -2,7 +2,8 @@ use crate::conventional::changelog::release::Release;
 use crate::conventional::commit::Commit;
 use crate::git::error::TagError;
 use crate::git::oid::OidOf;
-use crate::git::revspec::RevspecPattern;
+
+use crate::conventional::version::IncrementCommand;
 use crate::git::tag::Tag;
 use crate::hook::{Hook, HookVersion, Hooks};
 use crate::settings::{HookType, MonoRepoPackage, Settings};
@@ -15,6 +16,7 @@ use conventional_commit_parser::commit::CommitType;
 use globset::Glob;
 use itertools::Itertools;
 use log::{error, info, warn};
+use std::default::Default;
 use std::fmt;
 use std::fmt::Write;
 use std::process::exit;
@@ -23,6 +25,32 @@ mod monorepo;
 mod package;
 mod standard;
 
+#[derive(Default)]
+pub struct BumpOptions<'a> {
+    pub increment: IncrementCommand,
+    pub pre_release: Option<&'a str>,
+    pub hooks_config: Option<&'a str>,
+    pub annotated: Option<String>,
+    pub dry_run: bool,
+    pub skip_ci: bool,
+    pub skip_ci_override: Option<String>,
+    pub skip_untracked: bool,
+}
+
+#[derive(Default)]
+pub struct PackageBumpOptions<'a> {
+    pub package_name: &'a str,
+    pub package: &'a MonoRepoPackage,
+    pub increment: IncrementCommand,
+    pub pre_release: Option<&'a str>,
+    pub hooks_config: Option<&'a str>,
+    pub annotated: Option<String>,
+    pub dry_run: bool,
+    pub skip_ci: bool,
+    pub skip_ci_override: Option<String>,
+    pub skip_untracked: bool,
+}
+
 struct HookRunOptions<'a> {
     hook_type: HookType,
     current_tag: Option<&'a HookVersion>,
@@ -109,6 +137,14 @@ fn tag_or_fallback_to_zero(tag: Result<Tag, TagError>) -> Result<Tag> {
 }
 
 impl CocoGitto {
+    fn get_bump_revspec(&mut self, current_tag: &Tag) -> String {
+        if current_tag.is_zero() {
+            "..".to_string()
+        } else {
+            format!("{current_tag}..")
+        }
+    }
+
     pub fn unwrap_or_stash_and_exit<T>(&mut self, tag: &Tag, result: Result<T>) -> T {
         match result {
             Ok(res) => res,
@@ -172,14 +208,9 @@ impl CocoGitto {
     }
 
     /// The target version is not created yet when generating the changelog.
-    pub fn get_changelog_with_target_version(
-        &self,
-        pattern: RevspecPattern,
-        tag: Tag,
-    ) -> Result<Release> {
-        let commit_range = self.repository.get_commit_range(&pattern)?;
-
-        let mut release = Release::from(commit_range);
+    pub fn get_changelog_with_target_version(&self, pattern: &str, tag: Tag) -> Result<Release> {
+        let commit_range = self.repository.revwalk(pattern)?;
+        let mut release = Release::try_from(commit_range)?;
         release.version = OidOf::Tag(tag);
         Ok(release)
     }
@@ -187,31 +218,44 @@ impl CocoGitto {
     /// The target package version is not created yet when generating the changelog.
     pub fn get_package_changelog_with_target_version(
         &self,
-        pattern: RevspecPattern,
+        pattern: &str,
         tag: Tag,
         package: &str,
     ) -> Result<Release> {
         let commit_range = self
             .repository
-            .get_commit_range_for_package(&pattern, package)?;
+            .get_commit_range_for_package(pattern, package)?;
 
-        let mut release = Release::from(commit_range);
+        let mut release = Release::try_from(commit_range)?;
         release.version = OidOf::Tag(tag);
         Ok(release)
     }
 
     /// The target global monorepo version is not created yet when generating the changelog.
-    pub fn get_monorepo_global_changelog_with_target_version(
+    pub fn get_monorepo_global_changelog_for_version(
         &self,
-        pattern: RevspecPattern,
+        pattern: &str,
+        from: OidOf,
         tag: Tag,
     ) -> Result<Release> {
         let commit_range = self
             .repository
-            .get_commit_range_for_monorepo_global(&pattern)?;
+            .get_commit_range_for_monorepo_global(pattern)?;
+
+        let release = match Release::try_from(commit_range) {
+            Ok(mut release) => {
+                release.version = OidOf::Tag(tag);
+                release
+            }
+            Err(_) => Release {
+                version: OidOf::Tag(tag),
+                from,
+                date: Default::default(),
+                commits: vec![],
+                previous: None,
+            },
+        };
 
-        let mut release = Release::from(commit_range);
-        release.version = OidOf::Tag(tag);
         Ok(release)
     }
 
@@ -298,18 +342,6 @@ impl CocoGitto {
 
         Ok(())
     }
-
-    fn get_revspec_for_tag(&mut self, tag: &Tag) -> Result<RevspecPattern> {
-        let origin = if tag.is_zero() {
-            self.repository.get_first_commit()?.to_string()
-        } else {
-            tag.oid_unchecked().to_string()
-        };
-
-        let target = self.repository.get_head_commit_oid()?.to_string();
-        let pattern = (origin.as_str(), target.as_str());
-        Ok(RevspecPattern::from(pattern))
-    }
 }
 
 impl Release<'_> {
diff --git a/src/command/bump/monorepo.rs b/src/command/bump/monorepo.rs
index 0ce1e19..b3f4acc 100644
--- a/src/command/bump/monorepo.rs
+++ b/src/command/bump/monorepo.rs
@@ -1,5 +1,5 @@
 use crate::command::bump::{
-    ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero, HookRunOptions,
+    ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero, BumpOptions, HookRunOptions,
 };
 
 use crate::conventional::changelog::template::{
@@ -9,7 +9,7 @@ use crate::conventional::changelog::ReleaseType;
 
 use crate::conventional::version::{Increment, IncrementCommand};
 
-use crate::git::tag::Tag;
+use crate::git::tag::{Tag, TagLookUpOptions};
 use crate::hook::HookVersion;
 use crate::{settings, CocoGitto, SETTINGS};
 use anyhow::Result;
@@ -22,6 +22,7 @@ use tera::Tera;
 use crate::conventional::error::BumpError;
 use crate::git::oid::OidOf;
 
+#[derive(Debug)]
 struct PackageBumpData {
     package_name: String,
     package_path: String,
@@ -31,6 +32,7 @@ struct PackageBumpData {
     increment: Increment,
 }
 
+#[derive(Debug)]
 struct PackageData {
     package_name: String,
     package_path: String,
@@ -38,94 +40,52 @@ struct PackageData {
 }
 
 impl CocoGitto {
-    #[allow(clippy::too_many_arguments)]
-    pub fn create_monorepo_version(
-        &mut self,
-        increment: IncrementCommand,
-        pre_release: Option<&str>,
-        hooks_config: Option<&str>,
-        annotated: Option<String>,
-        dry_run: bool,
-        skip_ci: bool,
-        skip_ci_override: Option<String>,
-        skip_untracked: bool,
-    ) -> Result<()> {
-        match increment {
+    pub fn create_monorepo_version(&mut self, opts: BumpOptions) -> Result<()> {
+        match opts.increment {
             IncrementCommand::Auto => {
                 if SETTINGS.generate_mono_repository_global_tag {
-                    self.create_monorepo_version_auto(
-                        pre_release,
-                        hooks_config,
-                        annotated,
-                        dry_run,
-                        skip_ci,
-                        skip_ci_override,
-                        skip_untracked,
-                    )
+                    self.create_monorepo_version_auto(opts)
                 } else {
-                    if annotated.is_some() {
+                    if opts.annotated.is_some() {
                         warn!("--annotated flag is not supported for package bumps without a global tag");
                     }
-                    self.create_all_package_version_auto(
-                        pre_release,
-                        hooks_config,
-                        dry_run,
-                        skip_ci,
-                        skip_ci_override,
-                        skip_untracked,
-                    )
+                    self.create_all_package_version_auto(opts)
                 }
             }
-            _ => self.create_monorepo_version_manual(
-                increment,
-                pre_release,
-                hooks_config,
-                annotated,
-                dry_run,
-                skip_ci,
-                skip_ci_override,
-                skip_untracked,
-            ),
+            _ => self.create_monorepo_version_manual(opts),
         }
     }
 
-    pub fn create_all_package_version_auto(
-        &mut self,
-        pre_release: Option<&str>,
-        hooks_config: Option<&str>,
-        dry_run: bool,
-        skip_ci: bool,
-        skip_ci_override: Option<String>,
-        skip_untracked: bool,
-    ) -> Result<()> {
-        self.pre_bump_checks(skip_untracked)?;
+    pub fn create_all_package_version_auto(&mut self, opts: BumpOptions) -> Result<()> {
+        self.pre_bump_checks(opts.skip_untracked)?;
         // Get package bumps
-        let bumps = self.get_packages_bumps(pre_release)?;
+        let bumps = self.get_packages_bumps(opts.pre_release)?;
 
         if bumps.is_empty() {
             print!("No conventional commits found for your packages that required a bump. Changelogs will be updated on the next bump.\nPre-Hooks and Post-Hooks have been skiped.\n");
             return Ok(());
         }
 
-        if dry_run {
+        if opts.dry_run {
             for bump in bumps {
                 println!("{}", bump.new_version.prefixed_tag)
             }
             return Ok(());
         }
 
-        let hook_result = self.run_hooks(HookRunOptions::pre_bump().hook_profile(hooks_config));
+        let hook_result =
+            self.run_hooks(HookRunOptions::pre_bump().hook_profile(opts.hooks_config));
 
         self.repository.add_all()?;
         self.unwrap_or_stash_and_exit(&Tag::default(), hook_result);
-        self.bump_packages(pre_release, hooks_config, &bumps)?;
+        self.bump_packages(opts.pre_release, opts.hooks_config, &bumps)?;
 
         let sign = self.repository.gpg_sign();
 
         let mut skip_ci_pattern = String::new();
 
-        if skip_ci || skip_ci_override.is_some() {
-            skip_ci_pattern = skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
+        if opts.skip_ci || opts.skip_ci_override.is_some() {
+            skip_ci_pattern = opts.skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
         }
 
         self.repository.commit(
@@ -149,32 +109,21 @@ impl CocoGitto {
                 HookRunOptions::post_bump()
                     .current_tag(bump.old_version.as_ref())
                     .next_version(&bump.new_version)
-                    .hook_profile(hooks_config)
+                    .hook_profile(opts.hooks_config)
                     .package(&bump.package_name, package),
             )?;
         }
 
         // Run global post hooks
-        self.run_hooks(HookRunOptions::post_bump().hook_profile(hooks_config))?;
+        self.run_hooks(HookRunOptions::post_bump().hook_profile(opts.hooks_config))?;
 
         Ok(())
     }
 
-    #[allow(clippy::too_many_arguments)]
-    fn create_monorepo_version_auto(
-        &mut self,
-        pre_release: Option<&str>,
-        hooks_config: Option<&str>,
-        annotated: Option<String>,
-        dry_run: bool,
-        skip_ci: bool,
-        skip_ci_override: Option<String>,
-        skip_untracked: bool,
-    ) -> Result<()> {
-        self.pre_bump_checks(skip_untracked)?;
+    fn create_monorepo_version_auto(&mut self, opts: BumpOptions) -> Result<()> {
+        self.pre_bump_checks(opts.skip_untracked)?;
         // Get package bumps
-        let bumps = self.get_packages_bumps(pre_release)?;
-
+        let bumps = self.get_packages_bumps(opts.pre_release)?;
         if bumps.is_empty() {
             print!("No conventional commits found for your packages that required a bump. Changelogs will be updated on the next bump.\nPre-Hooks and Post-Hooks have been skiped.\n");
             return Ok(());
@@ -188,21 +137,24 @@ impl CocoGitto {
             .max();
 
         // Get current global tag
-        let old = self.repository.get_latest_tag();
+        let old = self
+            .repository
+            .get_latest_tag(TagLookUpOptions::default().include_pre_release());
         let old = tag_or_fallback_to_zero(old)?;
         let mut tag = old.bump(
             IncrementCommand::AutoMonoRepoGlobal(increment_from_package_bumps),
             &self.repository,
         )?;
+
         ensure_tag_is_greater_than_previous(&old, &tag)?;
 
-        if let Some(pre_release) = pre_release {
+        if let Some(pre_release) = opts.pre_release {
             tag.version.pre = Prerelease::new(pre_release)?;
         }
 
         let tag = Tag::create(tag.version, None);
 
-        if dry_run {
+        if opts.dry_run {
             for bump in bumps {
                 println!("{}", bump.new_version.prefixed_tag)
             }
@@ -232,9 +184,12 @@ impl CocoGitto {
         }
 
         if !SETTINGS.disable_changelog {
-            let pattern = self.get_revspec_for_tag(&old)?;
-            let changelog =
-                self.get_monorepo_global_changelog_with_target_version(pattern, tag.clone())?;
+            let pattern = self.get_bump_revspec(&old);
+            let changelog = self.get_monorepo_global_changelog_for_version(
+                &pattern,
+                OidOf::Tag(old.clone()),
+                tag.clone(),
+            )?;
 
             changelog.pretty_print_bump_summary()?;
 
@@ -251,28 +206,32 @@ impl CocoGitto {
             )?;
         }
 
-        let current = self.repository.get_latest_tag().map(HookVersion::new).ok();
+        let current = self
+            .repository
+            .get_latest_tag(TagLookUpOptions::default())
+            .map(HookVersion::new)
+            .ok();
         let next_version = HookVersion::new(tag.clone());
 
         let hook_result = self.run_hooks(
             HookRunOptions::pre_bump()
                 .current_tag(current.as_ref())
                 .next_version(&next_version)
-                .hook_profile(hooks_config),
+                .hook_profile(opts.hooks_config),
         );
 
         self.repository.add_all()?;
 
         self.unwrap_or_stash_and_exit(&tag, hook_result);
 
-        self.bump_packages(pre_release, hooks_config, &bumps)?;
+        self.bump_packages(opts.pre_release, opts.hooks_config, &bumps)?;
 
         let sign = self.repository.gpg_sign();
 
         let mut skip_ci_pattern = String::new();
 
-        if skip_ci || skip_ci_override.is_some() {
-            skip_ci_pattern = skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
+        if opts.skip_ci || opts.skip_ci_override.is_some() {
+            skip_ci_pattern = opts.skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
         }
 
         self.repository.commit(
@@ -288,7 +247,7 @@ impl CocoGitto {
             self.repository.create_tag(&bump.new_version.prefixed_tag)?;
         }
 
-        if let Some(msg_tmpl) = annotated {
+        if let Some(msg_tmpl) = opts.annotated {
             let mut context = tera::Context::new();
             context.insert("latest", &old.version.to_string());
             context.insert("version", &tag.version.to_string());
@@ -308,7 +267,7 @@ impl CocoGitto {
                 HookRunOptions::post_bump()
                     .current_tag(bump.old_version.as_ref())
                     .next_version(&bump.new_version)
-                    .hook_profile(hooks_config)
+                    .hook_profile(opts.hooks_config)
                     .package(&bump.package_name, package),
             )?;
         }
@@ -318,41 +277,30 @@ impl CocoGitto {
             HookRunOptions::post_bump()
                 .current_tag(current.as_ref())
                 .next_version(&next_version)
-                .hook_profile(hooks_config),
+                .hook_profile(opts.hooks_config),
         )?;
 
         Ok(())
     }
 
-    #[allow(clippy::too_many_arguments)]
-    fn create_monorepo_version_manual(
-        &mut self,
-        increment: IncrementCommand,
-        pre_release: Option<&str>,
-        hooks_config: Option<&str>,
-        annotated: Option<String>,
-        dry_run: bool,
-        skip_ci: bool,
-        skip_ci_override: Option<String>,
-        skip_untracked: bool,
-    ) -> Result<()> {
-        self.pre_bump_checks(skip_untracked)?;
+    fn create_monorepo_version_manual(&mut self, opts: BumpOptions) -> Result<()> {
+        self.pre_bump_checks(opts.skip_untracked)?;
         // Get package bumps
         let bumps = self.get_current_packages()?;
 
         // Get current global tag
-        let old = self.repository.get_latest_tag();
+        let old = self.repository.get_latest_tag(TagLookUpOptions::default());
         let old = tag_or_fallback_to_zero(old)?;
-        let mut tag = old.bump(increment, &self.repository)?;
+        let mut tag = old.bump(opts.increment, &self.repository)?;
         ensure_tag_is_greater_than_previous(&old, &tag)?;
 
-        if let Some(pre_release) = pre_release {
+        if let Some(pre_release) = opts.pre_release {
             tag.version.pre = Prerelease::new(pre_release)?;
         }
 
         let tag = Tag::create(tag.version, None);
 
-        if dry_run {
+        if opts.dry_run {
             print!("{tag}");
             return Ok(());
         }
@@ -368,9 +316,12 @@ impl CocoGitto {
         }
 
         if !SETTINGS.disable_changelog {
-            let pattern = self.get_revspec_for_tag(&old)?;
-            let changelog =
-                self.get_monorepo_global_changelog_with_target_version(pattern, tag.clone())?;
+            let pattern = self.get_bump_revspec(&old);
+            let changelog = self.get_monorepo_global_changelog_for_version(
+                &pattern,
+                OidOf::Tag(old.clone()),
+                tag.clone(),
+            )?;
 
             changelog.pretty_print_bump_summary()?;
 
@@ -387,14 +338,18 @@ impl CocoGitto {
             )?;
         }
 
-        let current = self.repository.get_latest_tag().map(HookVersion::new).ok();
+        let current = self
+            .repository
+            .get_latest_tag(TagLookUpOptions::default())
+            .map(HookVersion::new)
+            .ok();
         let next_version = HookVersion::new(tag.clone());
 
         let hook_result = self.run_hooks(
             HookRunOptions::pre_bump()
                 .current_tag(current.as_ref())
                 .next_version(&next_version)
-                .hook_profile(hooks_config),
+                .hook_profile(opts.hooks_config),
         );
 
         self.repository.add_all()?;
@@ -404,8 +359,8 @@ impl CocoGitto {
 
         let mut skip_ci_pattern = String::new();
 
-        if skip_ci || skip_ci_override.is_some() {
-            skip_ci_pattern = skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
+        if opts.skip_ci || opts.skip_ci_override.is_some() {
+            skip_ci_pattern = opts.skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
         }
 
         self.repository.commit(
@@ -417,7 +372,7 @@ impl CocoGitto {
             true,
         )?;
 
-        if let Some(msg_tmpl) = annotated {
+        if let Some(msg_tmpl) = opts.annotated {
             let mut context = tera::Context::new();
             context.insert("latest", &old.version.to_string());
             context.insert("version", &tag.version.to_string());
@@ -432,7 +387,7 @@ impl CocoGitto {
             HookRunOptions::post_bump()
                 .current_tag(current.as_ref())
                 .next_version(&next_version)
-                .hook_profile(hooks_config),
+                .hook_profile(opts.hooks_config),
         )?;
 
         Ok(())
@@ -533,7 +488,7 @@ impl CocoGitto {
             }
 
             let tag = Tag::create(next_version.version, Some(package_name.to_string()));
-            let pattern = self.get_revspec_for_tag(&old)?;
+            let pattern = self.get_bump_revspec(&old);
 
             let package = SETTINGS
                 .packages
@@ -541,7 +496,7 @@ impl CocoGitto {
                 .expect("package exists");
 
             let changelog = self.get_package_changelog_with_target_version(
-                pattern,
+                &pattern,
                 tag.clone(),
                 package_name.as_str(),
             )?;
diff --git a/src/command/bump/package.rs b/src/command/bump/package.rs
index 37af85b..85fd202 100644
--- a/src/command/bump/package.rs
+++ b/src/command/bump/package.rs
@@ -1,12 +1,11 @@
 use crate::command::bump::{
     ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero, HookRunOptions,
+    PackageBumpOptions,
 };
 use crate::conventional::changelog::template::PackageContext;
 use crate::conventional::changelog::ReleaseType;
-use crate::conventional::version::IncrementCommand;
 use crate::git::tag::Tag;
 use crate::hook::HookVersion;
-use crate::settings::MonoRepoPackage;
 use crate::{CocoGitto, SETTINGS};
 use anyhow::Result;
 use colored::*;
@@ -15,72 +14,70 @@ use semver::Prerelease;
 use tera::Tera;
 
 impl CocoGitto {
-    #[allow(clippy::too_many_arguments)]
-    pub fn create_package_version(
-        &mut self,
-        (package_name, package): (&str, &MonoRepoPackage),
-        increment: IncrementCommand,
-        pre_release: Option<&str>,
-        hooks_config: Option<&str>,
-        annotated: Option<String>,
-        dry_run: bool,
-        skip_ci: bool,
-        skip_ci_override: Option<String>,
-        skip_untracked: bool,
-    ) -> Result<()> {
-        self.pre_bump_checks(skip_untracked)?;
-
-        let current_tag = self.repository.get_latest_package_tag(package_name);
+    pub fn create_package_version(&mut self, opts: PackageBumpOptions) -> Result<()> {
+        self.pre_bump_checks(opts.skip_untracked)?;
+
+        let current_tag = self.repository.get_latest_package_tag(opts.package_name);
         let current_tag = tag_or_fallback_to_zero(current_tag)?;
-        let mut next_version = current_tag.bump(increment, &self.repository)?;
+        let mut next_version = current_tag.bump(opts.increment, &self.repository)?;
         if current_tag == next_version {
-            print!("No conventional commits found for {package_name} that required a bump. Changelog will be updated on the next bump.\nPre-Hooks and Post-Hooks have been skiped.\n");
+            print!("No conventional commits found for {} that required a bump. Changelog will be updated on the next bump.\nPre-Hooks and Post-Hooks have been skiped.\n",
+                   opts.package_name
+            );
             return Ok(());
         }
 
         ensure_tag_is_greater_than_previous(&current_tag, &next_version)?;
 
-        if let Some(pre_release) = pre_release {
+        if let Some(pre_release) = opts.pre_release {
             next_version.version.pre = Prerelease::new(pre_release)?;
         }
 
-        let tag = Tag::create(next_version.version.clone(), Some(package_name.to_string()));
+        let tag = Tag::create(
+            next_version.version.clone(),
+            Some(opts.package_name.to_string()),
+        );
 
-        if dry_run {
+        if opts.dry_run {
             print!("{tag}");
             return Ok(());
         }
 
         if !SETTINGS.disable_changelog {
-            let pattern = self.get_revspec_for_tag(&current_tag)?;
-            let changelog =
-                self.get_package_changelog_with_target_version(pattern, tag.clone(), package_name)?;
+            let pattern = self.get_bump_revspec(&current_tag);
+            let changelog = self.get_package_changelog_with_target_version(
+                &pattern,
+                tag.clone(),
+                opts.package_name,
+            )?;
 
             changelog.pretty_print_bump_summary()?;
 
-            let path = package.changelog_path();
+            let path = opts.package.changelog_path();
             let template = SETTINGS.get_package_changelog_template()?;
-            let additional_context = ReleaseType::Package(PackageContext { package_name });
+            let additional_context = ReleaseType::Package(PackageContext {
+                package_name: opts.package_name,
+            });
             changelog.write_to_file(path, template, additional_context)?;
         }
 
         let current = self
             .repository
-            .get_latest_package_tag(package_name)
+            .get_latest_package_tag(opts.package_name)
             .map(HookVersion::new)
             .ok();
 
         let next_version = HookVersion::new(Tag::create(
             next_version.version,
-            Some(package_name.to_string()),
+            Some(opts.package_name.to_string()),
         ));
 
         let hook_result = self.run_hooks(
             HookRunOptions::pre_bump()
                 .current_tag(current.as_ref())
                 .next_version(&next_version)
-                .hook_profile(hooks_config)
-                .package(package_name, package),
+                .hook_profile(opts.hooks_config)
+                .package(opts.package_name, opts.package),
         );
 
         self.repository.add_all()?;
@@ -90,8 +87,8 @@ impl CocoGitto {
 
         let mut skip_ci_pattern = String::new();
 
-        if skip_ci || skip_ci_override.is_some() {
-            skip_ci_pattern = skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
+        if opts.skip_ci || opts.skip_ci_override.is_some() {
+            skip_ci_pattern = opts.skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
         }
 
         self.repository.commit(
@@ -100,7 +97,7 @@ impl CocoGitto {
             true,
         )?;
 
-        if let Some(msg_tmpl) = annotated {
+        if let Some(msg_tmpl) = opts.annotated {
             let mut context = tera::Context::new();
             context.insert("latest", &current_tag.version.to_string());
             context.insert("version", &tag.version.to_string());
@@ -114,15 +111,15 @@ impl CocoGitto {
             HookRunOptions::post_bump()
                 .current_tag(current.as_ref())
                 .next_version(&next_version)
-                .hook_profile(hooks_config)
-                .package(package_name, package),
+                .hook_profile(opts.hooks_config)
+                .package(opts.package_name, opts.package),
         )?;
 
         let current = current
             .map(|current| current.prefixed_tag.to_string())
             .unwrap_or_else(|| "...".to_string());
         let bump = format!("{} -> {}", current, next_version.prefixed_tag).green();
-        info!("Bumped package {package_name} version: {}", bump);
+        info!("Bumped package {} version: {}", opts.package_name, bump);
 
         Ok(())
     }
diff --git a/src/command/bump/standard.rs b/src/command/bump/standard.rs
index a37202b..f301c67 100644
--- a/src/command/bump/standard.rs
+++ b/src/command/bump/standard.rs
@@ -1,10 +1,10 @@
 use crate::command::bump::{
-    ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero, HookRunOptions,
+    ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero, BumpOptions, HookRunOptions,
 };
 
 use crate::conventional::changelog::ReleaseType;
-use crate::conventional::version::IncrementCommand;
-use crate::git::tag::Tag;
+
+use crate::git::tag::{Tag, TagLookUpOptions};
 use crate::hook::HookVersion;
 use crate::{settings, CocoGitto, SETTINGS};
 use anyhow::Result;
@@ -14,23 +14,12 @@ use semver::Prerelease;
 use tera::Tera;
 
 impl CocoGitto {
-    #[allow(clippy::too_many_arguments)]
-    pub fn create_version(
-        &mut self,
-        increment: IncrementCommand,
-        pre_release: Option<&str>,
-        hooks_config: Option<&str>,
-        annotated: Option<String>,
-        dry_run: bool,
-        skip_ci: bool,
-        skip_ci_override: Option<String>,
-        skip_untracked: bool,
-    ) -> Result<()> {
-        self.pre_bump_checks(skip_untracked)?;
-
-        let current_tag = self.repository.get_latest_tag();
+    pub fn create_version(&mut self, opts: BumpOptions) -> Result<()> {
+        self.pre_bump_checks(opts.skip_untracked)?;
+
+        let current_tag = self.repository.get_latest_tag(TagLookUpOptions::default());
         let current_tag = tag_or_fallback_to_zero(current_tag)?;
-        let mut tag = current_tag.bump(increment, &self.repository)?;
+        let mut tag = current_tag.bump(opts.increment, &self.repository)?;
         if current_tag == tag {
             print!("No conventional commits for your repository that required a bump. Changelogs will be updated on the next bump.\nPre-Hooks and Post-Hooks have been skiped.\n");
             return Ok(());
@@ -38,21 +27,21 @@ impl CocoGitto {
 
         ensure_tag_is_greater_than_previous(&current_tag, &tag)?;
 
-        if let Some(pre_release) = pre_release {
+        if let Some(pre_release) = opts.pre_release {
             tag.version.pre = Prerelease::new(pre_release)?;
         }
 
         let tag = Tag::create(tag.version, None);
 
-        if dry_run {
+        if opts.dry_run {
             print!("{tag}");
             return Ok(());
         }
 
-        let pattern = self.get_revspec_for_tag(&current_tag)?;
+        let pattern = self.get_bump_revspec(&current_tag);
 
         if !SETTINGS.disable_changelog {
-            let changelog = self.get_changelog_with_target_version(pattern, tag.clone())?;
+            let changelog = self.get_changelog_with_target_version(&pattern, tag.clone())?;
             changelog.pretty_print_bump_summary()?;
 
             let path = settings::changelog_path();
@@ -61,7 +50,11 @@ impl CocoGitto {
             changelog.write_to_file(path, template, ReleaseType::Standard)?;
         }
 
-        let current = self.repository.get_latest_tag().map(HookVersion::new).ok();
+        let current = self
+            .repository
+            .get_latest_tag(TagLookUpOptions::default())
+            .map(HookVersion::new)
+            .ok();
 
         let next_version = HookVersion::new(tag.clone());
 
@@ -69,7 +62,7 @@ impl CocoGitto {
             HookRunOptions::pre_bump()
                 .current_tag(current.as_ref())
                 .next_version(&next_version)
-                .hook_profile(hooks_config),
+                .hook_profile(opts.hooks_config),
         );
 
         self.repository.add_all()?;
@@ -79,8 +72,8 @@ impl CocoGitto {
 
         let mut skip_ci_pattern = String::new();
 
-        if skip_ci || skip_ci_override.is_some() {
-            skip_ci_pattern = skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
+        if opts.skip_ci || opts.skip_ci_override.is_some() {
+            skip_ci_pattern = opts.skip_ci_override.unwrap_or(SETTINGS.skip_ci.clone());
         }
 
         self.repository.commit(
@@ -92,7 +85,7 @@ impl CocoGitto {
             true,
         )?;
 
-        if let Some(msg_tmpl) = annotated {
+        if let Some(msg_tmpl) = opts.annotated {
             let mut context = tera::Context::new();
             context.insert("latest", &current_tag.version.to_string());
             context.insert("version", &tag.version.to_string());
@@ -106,7 +99,7 @@ impl CocoGitto {
             HookRunOptions::post_bump()
                 .current_tag(current.as_ref())
                 .next_version(&next_version)
-                .hook_profile(hooks_config),
+                .hook_profile(opts.hooks_config),
         )?;
 
         let current = current
diff --git a/src/command/changelog.rs b/src/command/changelog.rs
index 21679c0..c396508 100644
--- a/src/command/changelog.rs
+++ b/src/command/changelog.rs
@@ -1,35 +1,21 @@
 use crate::conventional::changelog::release::Release;
 use crate::conventional::changelog::template::Template;
-use crate::git::revspec::RevspecPattern;
+
 use crate::CocoGitto;
 use anyhow::anyhow;
 use anyhow::Result;
-use std::str::FromStr;
 
 impl CocoGitto {
     /// ## Get a changelog between two oids
     /// - `from` default value:latest tag or else first commit
     /// - `to` default value:`HEAD` or else first commit
-    pub fn get_changelog(
-        &self,
-        pattern: RevspecPattern,
-        with_child_releases: bool,
-    ) -> Result<Release> {
-        if with_child_releases {
-            self.repository
-                .get_release_range(pattern)
-                .map_err(Into::into)
-        } else {
-            let commit_range = self.repository.get_commit_range(&pattern)?;
-
-            Ok(Release::from(commit_range))
-        }
+    pub fn get_changelog(&self, pattern: &str, _with_child_releases: bool) -> Result<Release> {
+        let commit_range = self.repository.revwalk(pattern)?;
+        Release::try_from(commit_range).map_err(Into::into)
     }
 
     pub fn get_changelog_at_tag(&self, tag: &str, template: Template) -> Result<String> {
-        let pattern = format!("..{tag}");
-        let pattern = RevspecPattern::from_str(pattern.as_str())?;
-        let changelog = self.get_changelog(pattern, false)?;
+        let changelog = self.get_changelog(tag, false)?;
 
         changelog
             .into_markdown(template)
diff --git a/src/command/check.rs b/src/command/check.rs
index a293730..2c360b0 100644
--- a/src/command/check.rs
+++ b/src/command/check.rs
@@ -1,12 +1,12 @@
 use crate::conventional::commit::Commit;
 use crate::error::CogCheckReport;
-use crate::git::revspec::RevspecPattern;
+
+use crate::git::tag::TagLookUpOptions;
 use crate::CocoGitto;
 use anyhow::anyhow;
 use anyhow::Result;
 use colored::*;
 use log::info;
-use std::str::FromStr;
 
 impl CocoGitto {
     pub fn check(
@@ -16,27 +16,26 @@ impl CocoGitto {
         range: Option<String>,
     ) -> Result<()> {
         let commit_range = if let Some(range) = range {
-            self.repository
-                .get_commit_range(&RevspecPattern::from_str(range.as_str())?)?
+            self.repository.revwalk(&range)?
         } else if check_from_latest_tag {
-            self.repository
-                .get_commit_range(&RevspecPattern::default())?
+            let tag = self
+                .repository
+                .get_latest_tag(TagLookUpOptions::default())?;
+            self.repository.revwalk(&format!("{tag}.."))?
         } else {
-            self.repository.all_commits()?
+            self.repository.revwalk("..")?
         };
 
         let errors: Vec<_> = if ignore_merge_commits {
             commit_range
-                .commits
-                .iter()
+                .iter_commits()
                 .filter(|commit| commit.parent_count() <= 1)
                 .map(Commit::from_git_commit)
                 .filter_map(Result::err)
                 .collect()
         } else {
             commit_range
-                .commits
-                .iter()
+                .iter_commits()
                 .map(Commit::from_git_commit)
                 .filter_map(Result::err)
                 .collect()
@@ -48,7 +47,7 @@ impl CocoGitto {
             Ok(())
         } else {
             let report = CogCheckReport {
-                from: commit_range.from,
+                from: commit_range.from_oid(),
                 errors: errors.into_iter().map(|err| *err).collect(),
             };
             Err(anyhow!("{}", report))
diff --git a/src/command/commit.rs b/src/command/commit.rs
index 559cdb2..61a9b1d 100644
--- a/src/command/commit.rs
+++ b/src/command/commit.rs
@@ -7,34 +7,35 @@ use conventional_commit_parser::parse_footers;
 use log::info;
 use std::fs;
 
+#[derive(Default)]
+pub struct CommitOptions<'a> {
+    pub commit_type: &'a str,
+    pub scope: Option<String>,
+    pub summary: String,
+    pub body: Option<String>,
+    pub footer: Option<String>,
+    pub breaking: bool,
+    pub sign: bool,
+}
+
 impl CocoGitto {
-    #[allow(clippy::too_many_arguments)]
-    pub fn conventional_commit(
-        &self,
-        commit_type: &str,
-        scope: Option<String>,
-        summary: String,
-        body: Option<String>,
-        footer: Option<String>,
-        is_breaking_change: bool,
-        sign: bool,
-    ) -> Result<()> {
+    pub fn conventional_commit(&self, opts: CommitOptions) -> Result<()> {
         // Ensure commit type is known
-        let commit_type = CommitType::from(commit_type);
+        let commit_type = CommitType::from(opts.commit_type);
 
         // Ensure footers are correctly formatted
-        let footers = match footer {
+        let footers = match opts.footer {
             Some(footers) => parse_footers(&footers)?,
             None => Vec::with_capacity(0),
         };
 
         let conventional_message = ConventionalCommit {
             commit_type,
-            scope,
-            body,
+            scope: opts.scope,
+            body: opts.body,
             footers,
-            summary,
-            is_breaking_change,
+            summary: opts.summary,
+            is_breaking_change: opts.breaking,
         }
         .to_string();
 
@@ -42,7 +43,7 @@ impl CocoGitto {
         conventional_commit_parser::parse(&conventional_message)?;
 
         // Git commit
-        let sign = sign || self.repository.gpg_sign();
+        let sign = opts.sign || self.repository.gpg_sign();
         fs::write(self.prepare_edit_message_path(), &conventional_message)?;
         self.run_commit_hook(CommitMessage)?;
         let oid = self.repository.commit(&conventional_message, sign, false)?;
diff --git a/src/command/edit.rs b/src/command/edit.rs
index aff864e..9101af0 100644
--- a/src/command/edit.rs
+++ b/src/command/edit.rs
@@ -1,5 +1,6 @@
 use crate::conventional::commit::{verify, Commit};
-use crate::git::revspec::RevspecPattern;
+
+use crate::git::tag::TagLookUpOptions;
 use crate::{CocoGitto, SETTINGS};
 use anyhow::{anyhow, Result};
 use colored::*;
@@ -13,10 +14,12 @@ use tempfile::TempDir;
 impl CocoGitto {
     pub fn check_and_edit(&self, from_latest_tag: bool) -> Result<()> {
         let commits = if from_latest_tag {
-            self.repository
-                .get_commit_range(&RevspecPattern::default())?
+            let tag = self
+                .repository
+                .get_latest_tag(TagLookUpOptions::default())?;
+            self.repository.revwalk(&format!("{tag}.."))?
         } else {
-            self.repository.all_commits()?
+            self.repository.revwalk("..")?
         };
 
         let editor = std::env::var("EDITOR")
@@ -25,8 +28,7 @@ impl CocoGitto {
         let dir = TempDir::new()?;
 
         let errored_commits: Vec<Oid> = commits
-            .commits
-            .iter()
+            .iter_commits()
             .map(|commit| {
                 let conv_commit = Commit::from_git_commit(commit);
                 (commit.id(), conv_commit)
diff --git a/src/command/get_version.rs b/src/command/get_version.rs
index ae1f23a..b66f542 100644
--- a/src/command/get_version.rs
+++ b/src/command/get_version.rs
@@ -4,6 +4,7 @@ use log::warn;
 use semver::Version;
 
 use crate::git::error::TagError;
+use crate::git::tag::TagLookUpOptions;
 use crate::CocoGitto;
 
 impl CocoGitto {
@@ -23,9 +24,12 @@ impl CocoGitto {
             None => None,
         };
 
+        let options = TagLookUpOptions::default();
         let current_tag = match package {
-            Some(pkg) => self.repository.get_latest_package_tag(&pkg),
-            None => self.repository.get_latest_tag(),
+            Some(pkg) => self
+                .repository
+                .get_latest_tag(TagLookUpOptions::package(&pkg)),
+            None => self.repository.get_latest_tag(options),
         };
 
         let current_version = match current_tag {
diff --git a/src/command/log.rs b/src/command/log.rs
index 4bcaa3d..5af8672 100644
--- a/src/command/log.rs
+++ b/src/command/log.rs
@@ -1,4 +1,5 @@
 use crate::conventional::commit::Commit;
+use crate::git::tag::TagLookUpOptions;
 use crate::log::filter::CommitFilters;
 use crate::CocoGitto;
 use anyhow::Result;
@@ -6,14 +7,13 @@ use std::fmt::Write;
 
 impl CocoGitto {
     pub fn get_log(&self, filters: CommitFilters) -> Result<String> {
-        let commits = self.repository.all_commits()?;
+        let commits = self.repository.revwalk("..")?;
         let logs = commits
-            .commits
-            .iter()
+            .iter_commits()
             // Remove merge commits
             .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge"))
             .filter(|commit| filters.filter_git2_commit(commit))
-            .map(Commit::from_git_commit)
+            .map(|commit| Commit::from_git_commit(commit))
             // Apply filters
             .filter(|commit| match commit {
                 Ok(commit) => filters.filters(commit),
@@ -38,7 +38,10 @@ impl CocoGitto {
             write!(&mut repo_tag_name, " on {branch_shorthand}").unwrap();
         }
 
-        if let Ok(latest_tag) = self.repository.get_latest_tag() {
+        if let Ok(latest_tag) = self
+            .repository
+            .get_latest_tag(TagLookUpOptions::default().include_pre_release())
+        {
             write!(&mut repo_tag_name, " {latest_tag}").unwrap();
         };
 
diff --git a/src/conventional/bump.rs b/src/conventional/bump.rs
index 9c3a7c3..839238d 100644
--- a/src/conventional/bump.rs
+++ b/src/conventional/bump.rs
@@ -1,12 +1,11 @@
-use std::str::FromStr;
-
 use git2::Commit as Git2Commit;
 use once_cell::sync::Lazy;
 use semver::{BuildMetadata, Prerelease, Version};
 
 use crate::conventional::error::BumpError;
 use crate::conventional::version::Increment;
-use crate::{Commit, IncrementCommand, Repository, RevspecPattern, Tag, SETTINGS};
+use crate::git::tag::TagLookUpOptions;
+use crate::{Commit, IncrementCommand, Repository, Tag, SETTINGS};
 
 static FILTER_MERGE_COMMITS: Lazy<fn(&&git2::Commit) -> bool> = Lazy::new(|| {
     |commit| {
@@ -138,22 +137,14 @@ impl Tag {
 
     fn get_version_from_commit_history(&self, repository: &Repository) -> Result<Tag, BumpError> {
         let changelog_start_oid = repository
-            .get_latest_tag_oid()
+            .get_latest_tag_oid(TagLookUpOptions::default())
             .ok()
             .unwrap_or_else(|| repository.get_first_commit().expect("non empty repository"));
-        let changelog_start_oid = changelog_start_oid.to_string();
-        let changelog_start_oid = Some(changelog_start_oid.as_str());
 
-        let pattern = changelog_start_oid
-            .map(|oid| format!("{oid}.."))
-            .unwrap_or_else(|| "..".to_string());
-        let pattern = pattern.as_str();
-        let pattern = RevspecPattern::from_str(pattern)?;
-        let commits = repository.get_commit_range(&pattern)?;
+        let commits = repository.revwalk(&format!("{changelog_start_oid}.."))?;
 
         let commits: Vec<&Git2Commit> = commits
-            .commits
-            .iter()
+            .iter_commits()
             .filter(&*FILTER_MERGE_COMMITS)
             .collect();
 
@@ -184,18 +175,10 @@ impl Tag {
             .and_then(|tag| tag.oid)
             .unwrap_or_else(|| repository.get_first_commit().expect("non empty repository"));
 
-        let changelog_start_oid = changelog_start_oid.to_string();
-        let changelog_start_oid = Some(changelog_start_oid.as_str());
-
-        let pattern = changelog_start_oid
-            .map(|oid| format!("{oid}.."))
-            .unwrap_or_else(|| "..".to_string());
-        let pattern = pattern.as_str();
-        let pattern = RevspecPattern::from_str(pattern)?;
-        let commits = repository.get_commit_range_for_package(&pattern, package)?;
+        let commits = repository
+            .get_commit_range_for_package(&format!("{changelog_start_oid}.."), package)?;
         let commits: Vec<&Git2Commit> = commits
-            .commits
-            .iter()
+            .iter_commits()
             .filter(&*FILTER_MERGE_COMMITS)
             .collect();
 
@@ -220,23 +203,15 @@ impl Tag {
         repository: &Repository,
     ) -> Result<Tag, BumpError> {
         let changelog_start_oid = repository
-            .get_latest_tag_oid()
+            .get_latest_tag_oid(TagLookUpOptions::default())
             .ok()
             .unwrap_or_else(|| repository.get_first_commit().expect("non empty repository"));
 
-        let changelog_start_oid = changelog_start_oid.to_string();
-        let changelog_start_oid = Some(changelog_start_oid.as_str());
-
-        let pattern = changelog_start_oid
-            .map(|oid| format!("{oid}.."))
-            .unwrap_or_else(|| "..".to_string());
-        let pattern = pattern.as_str();
-        let pattern = RevspecPattern::from_str(pattern)?;
-        let commits = repository.get_commit_range_for_monorepo_global(&pattern)?;
+        let commits =
+            repository.get_commit_range_for_monorepo_global(&format!("{changelog_start_oid}.."))?;
 
         let commits: Vec<&Git2Commit> = commits
-            .commits
-            .iter()
+            .iter_commits()
             .filter(&*FILTER_MERGE_COMMITS)
             .collect();
 
@@ -266,7 +241,7 @@ impl Tag {
 
         let is_patch_bump = || commits.iter().any(Commit::is_patch_bump);
 
-        // At this point, it is not a major, minor or patch bump but we might have found conventional commits
+        // At this point, it is not a major, minor or patch bump, but we might have found conventional commits
         // -> Must be only chore, docs, refactor ... which means commits that don't require bump but shouldn't throw error
         let no_bump_required = !commits.is_empty();
 
@@ -306,6 +281,7 @@ mod test {
     use crate::git::repository::Repository;
     use crate::git::tag::Tag;
     use crate::settings::{MonoRepoPackage, Settings};
+    use crate::test_helpers::{git_init_no_gpg, git_tag};
 
     impl Commit {
         fn commit_fixture(commit_type: CommitType, is_breaking_change: bool) -> Self {
@@ -328,8 +304,8 @@ mod test {
     #[sealed_test]
     fn major_bump() -> Result<()> {
         // Arrange
-        let repository = Repository::init(".")?;
-        let base_version = Tag::from_str("1.0.0", None)?;
+        let repository = git_init_no_gpg()?;
+        let base_version = Tag::from_str("1.0.0", None, None)?;
 
         // Act
         let tag = base_version.bump(IncrementCommand::Major, &repository)?;
@@ -342,8 +318,8 @@ mod test {
     #[sealed_test]
     fn minor_bump() -> Result<()> {
         // Arrange
-        let repository = Repository::init(".")?;
-        let base_version = Tag::from_str("1.0.0", None)?;
+        let repository = git_init_no_gpg()?;
+        let base_version = Tag::from_str("1.0.0", None, None)?;
 
         // Act
         let tag = base_version.bump(IncrementCommand::Minor, &repository)?;
@@ -356,8 +332,8 @@ mod test {
     #[sealed_test]
     fn patch_bump() -> Result<()> {
         // Arrange
-        let repository = Repository::init(".")?;
-        let base_version = Tag::from_str("1.0.0", None)?;
+        let repository = git_init_no_gpg()?;
+        let base_version = Tag::from_str("1.0.0", None, None)?;
 
         // Act
         let tag = base_version.bump(IncrementCommand::Patch, &repository)?;
@@ -370,8 +346,8 @@ mod test {
     #[sealed_test]
     fn no_bump() -> Result<()> {
         // Arrange
-        let repository = Repository::init(".")?;
-        let base_version = Tag::from_str("1.0.0", None)?;
+        let repository = git_init_no_gpg()?;
+        let base_version = Tag::from_str("1.0.0", None, None)?;
 
         // Act
         let tag = base_version.bump(IncrementCommand::NoBump, &repository)?;
@@ -385,7 +361,7 @@ mod test {
     fn should_get_next_auto_version_patch() -> Result<()> {
         // Arrange
         let patch = Commit::commit_fixture(CommitType::BugFix, false);
-        let base_version = Tag::from_str("1.0.0", None)?;
+        let base_version = Tag::from_str("1.0.0", None, None)?;
 
         // Act
         let increment = base_version.version_increment_from_commit_history(&[patch]);
@@ -411,7 +387,7 @@ mod test {
         let build = Commit::commit_fixture(CommitType::Build, false);
         let ci = Commit::commit_fixture(CommitType::Ci, false);
 
-        let base_version = Tag::from_str("1.0.0", None)?;
+        let base_version = Tag::from_str("1.0.0", None, None)?;
 
         // Act
         let increment = base_version.version_increment_from_commit_history(&[
@@ -437,8 +413,8 @@ mod test {
     #[test]
     fn increment_minor_version_should_set_patch_to_zero() -> Result<()> {
         // Arrange
-        let repository = Repository::init(".")?;
-        let version = Tag::from_str("1.1.1", None)?;
+        let repository = git_init_no_gpg()?;
+        let version = Tag::from_str("1.1.1", None, None)?;
 
         // Act
         let tag = version.bump(IncrementCommand::Minor, &repository)?;
@@ -452,8 +428,8 @@ mod test {
     #[sealed_test]
     fn increment_major_version_should_set_minor_and_patch_to_zero() -> Result<()> {
         // Arrange
-        let repository = Repository::init(".")?;
-        let version = Tag::from_str("1.1.1", None)?;
+        let repository = git_init_no_gpg()?;
+        let version = Tag::from_str("1.1.1", None, None)?;
 
         // Act
         let tag = version.bump(IncrementCommand::Major, &repository)?;
@@ -467,8 +443,8 @@ mod test {
     #[sealed_test]
     fn increment_should_strip_metadata() -> Result<()> {
         // Arrange
-        let repository = Repository::init(".")?;
-        let version = Tag::from_str("1.1.1-pre+10.1", None)?;
+        let repository = git_init_no_gpg()?;
+        let version = Tag::from_str("1.1.1-pre+10.1", None, None)?;
 
         // Act
         let tag = version.bump(IncrementCommand::Patch, &repository)?;
@@ -484,7 +460,7 @@ mod test {
         // Arrange
         let feature = Commit::commit_fixture(CommitType::Feature, false);
         let breaking_change = Commit::commit_fixture(CommitType::Feature, true);
-        let base_version = Tag::from_str("1.0.0", None)?;
+        let base_version = Tag::from_str("1.0.0", None, None)?;
 
         // Act
         let version =
@@ -501,7 +477,7 @@ mod test {
         // Arrange
         let feature = Commit::commit_fixture(CommitType::Feature, false);
         let breaking_change = Commit::commit_fixture(CommitType::Feature, true);
-        let base_version = Tag::from_str("0.1.0", None)?;
+        let base_version = Tag::from_str("0.1.0", None, None)?;
 
         // Act
         let version =
@@ -518,7 +494,7 @@ mod test {
         // Arrange
         let patch = Commit::commit_fixture(CommitType::BugFix, false);
         let feature = Commit::commit_fixture(CommitType::Feature, false);
-        let base_version = Tag::from_str("0.1.0", None)?;
+        let base_version = Tag::from_str("0.1.0", None, None)?;
 
         // Act
         let version = base_version.version_increment_from_commit_history(&[patch, feature]);
@@ -532,7 +508,7 @@ mod test {
     #[sealed_test]
     fn should_get_next_auto_version_minor_with_custom_commit_type() -> Result<()> {
         // Arrange
-        Repository::init(".")?;
+        git_init_no_gpg()?;
         let mut commit_types = HashMap::new();
         commit_types.insert("ex".to_string(), CommitConfig::new("Ex").with_minor_bump());
         let settings = Settings {
@@ -545,7 +521,7 @@ mod test {
 
         let patch = Commit::commit_fixture(CommitType::BugFix, false);
         let feature = Commit::commit_fixture(CommitType::Custom("ex".to_string()), false);
-        let base_version = Tag::from_str("0.1.0", None)?;
+        let base_version = Tag::from_str("0.1.0", None, None)?;
 
         // Act
         let version = base_version.version_increment_from_commit_history(&[patch, feature]);
@@ -559,7 +535,7 @@ mod test {
     #[sealed_test]
     fn should_get_next_auto_version_patch_with_custom_commit_type() -> Result<()> {
         // Arrange
-        Repository::init(".")?;
+        git_init_no_gpg()?;
         let mut commit_types = HashMap::new();
         commit_types.insert("ex".to_string(), CommitConfig::new("Ex").with_patch_bump());
         let settings = Settings {
@@ -572,7 +548,7 @@ mod test {
 
         let patch = Commit::commit_fixture(CommitType::Chore, false);
         let feature = Commit::commit_fixture(CommitType::Custom("ex".to_string()), false);
-        let base_version = Tag::from_str("0.1.0", None)?;
+        let base_version = Tag::from_str("0.1.0", None, None)?;
 
         // Act
         let version = base_version.version_increment_from_commit_history(&[patch, feature]);
@@ -586,7 +562,7 @@ mod test {
     #[sealed_test]
     fn should_override_bump_behavior_for_existing_commit_type() -> Result<()> {
         // Arrange
-        Repository::init(".")?;
+        git_init_no_gpg()?;
         let mut commit_types = HashMap::new();
         commit_types.insert(
             "perf".to_string(),
@@ -602,7 +578,7 @@ mod test {
 
         let patch = Commit::commit_fixture(CommitType::Chore, false);
         let feature = Commit::commit_fixture(CommitType::Performances, false);
-        let base_version = Tag::from_str("0.1.0", None)?;
+        let base_version = Tag::from_str("0.1.0", None, None)?;
 
         // Act
         let version = base_version.version_increment_from_commit_history(&[patch, feature]);
@@ -618,7 +594,7 @@ mod test {
         // Arrange
         let chore = Commit::commit_fixture(CommitType::Chore, false);
         let docs = Commit::commit_fixture(CommitType::Documentation, false);
-        let base_version = Tag::from_str("0.1.0", None)?;
+        let base_version = Tag::from_str("0.1.0", None, None)?;
 
         // Act
         let version = base_version.version_increment_from_commit_history(&[chore, docs]);
@@ -639,8 +615,9 @@ mod test {
             git add .;
             git commit -m "feat: feature package one";
         )?;
+        git_tag("1.0.0")?;
 
-        let base_version = Tag::from_str("0.1.0", None)?;
+        let base_version = Tag::from_str("1.0.0", None, None)?;
 
         // Act
         let tag = base_version.get_monorepo_global_version_from_commit_history(&repository);
@@ -656,13 +633,13 @@ mod test {
     #[sealed_test]
     fn monorepo_auto_bump_should_succeed_with_only_package_commits() -> Result<()> {
         // Arrange
-        let repository = init_monorepo()?;
+        let repository = git_init_no_gpg()?;
         run_cmd!(
             echo "feature" > one;
             git add .;
             git commit -m "feat: feature package one";
         )?;
-        let base_version = Tag::from_str("0.1.0", None)?;
+        let base_version = Tag::from_str("0.1.0", None, None)?;
 
         // Act
         let tag = base_version.auto_global_bump(&repository, Some(Increment::Minor))?;
@@ -788,8 +765,8 @@ mod test {
         )?;
 
         // Act
-        let tag =
-            Tag::from_str("0.1.0", None)?.auto_global_bump(&repository, Some(Increment::Minor))?;
+        let tag = Tag::from_str("0.1.0", None, None)?
+            .auto_global_bump(&repository, Some(Increment::Minor))?;
 
         // Assert
         assert_that!(tag.version).is_equal_to(Version::new(0, 2, 0));
@@ -798,7 +775,7 @@ mod test {
     }
 
     fn init_monorepo() -> Result<Repository> {
-        let repository = Repository::init(".")?;
+        let repository = git_init_no_gpg()?;
         let mut packages = HashMap::new();
         packages.insert(
             "one".to_string(),
diff --git a/src/conventional/changelog/error.rs b/src/conventional/changelog/error.rs
index e78f1af..21129b8 100644
--- a/src/conventional/changelog/error.rs
+++ b/src/conventional/changelog/error.rs
@@ -9,6 +9,7 @@ pub enum ChangelogError {
     TeraError(tera::Error),
     WriteError(io::Error),
     SeparatorNotFound(PathBuf),
+    EmptyRelease,
 }
 
 impl Display for ChangelogError {
@@ -28,6 +29,7 @@ impl Display for ChangelogError {
                 "cannot find default separator '- - -' in {}",
                 path.as_path().display()
             ),
+            ChangelogError::EmptyRelease => writeln!(f, "No commit found to create a changelog",),
         }
     }
 }
diff --git a/src/conventional/changelog/release.rs b/src/conventional/changelog/release.rs
index 225b4bf..d3f98a3 100644
--- a/src/conventional/changelog/release.rs
+++ b/src/conventional/changelog/release.rs
@@ -4,10 +4,11 @@ use serde::Serialize;
 
 use crate::conventional::commit::Commit;
 use crate::git::oid::OidOf;
-use crate::git::revspec::CommitRange;
-use crate::{settings, SETTINGS};
+use crate::git::rev::CommitIter;
+use crate::settings;
 use colored::Colorize;
-use git2::Oid;
+
+use crate::conventional::changelog::error::ChangelogError;
 use log::warn;
 
 #[derive(Debug, Serialize)]
@@ -19,66 +20,62 @@ pub struct Release<'a> {
     pub previous: Option<Box<Release<'a>>>,
 }
 
-impl Release<'_> {
-    pub fn drain_to_target(&mut self, target: &Oid) {
-        let target_idx = self
-            .commits
-            .iter()
-            .enumerate()
-            .find(|(_idx, commit)| commit.commit.oid == target.to_string())
-            .map(|(idx, _)| idx);
-
-        match target_idx {
-            None => {
-                if let Some(previous) = &mut self.previous {
-                    previous.drain_to_target(target)
-                }
-            }
-            Some(idx) => {
-                if self.commits.get(idx + 1).is_some() {
-                    self.commits.drain(idx + 1..);
-                }
-            }
-        }
-    }
+impl TryFrom<CommitIter<'_>> for Release<'_> {
+    type Error = ChangelogError;
 
-    pub fn contains_oid(&self, oid: &Oid) -> bool {
-        self.commits
-            .iter()
-            .any(|commit| commit.commit.oid == oid.to_string())
-    }
-}
+    fn try_from(commits: CommitIter<'_>) -> Result<Self, Self::Error> {
+        let mut releases = vec![];
+        let mut commit_iter = commits.into_iter().rev().peekable();
 
-impl<'a> From<CommitRange<'a>> for Release<'a> {
-    fn from(commit_range: CommitRange<'a>) -> Self {
-        let mut commits = vec![];
+        while let Some((_oid, _commit)) = commit_iter.peek() {
+            let mut release_commits = vec![];
 
-        for commit in commit_range.commits {
-            // Ignore merge commits
-            if commit.parent_count() > 1 && SETTINGS.ignore_merge_commits {
-                continue;
+            for (oid, commit) in commit_iter.by_ref() {
+                if matches!(oid, OidOf::Tag(_)) {
+                    release_commits.push((oid, commit));
+                    break;
+                }
+                release_commits.push((oid, commit));
             }
 
-            match Commit::from_git_commit(&commit) {
-                Ok(commit) => {
-                    if !commit.should_omit() {
-                        commits.push(ChangelogCommit::from(commit))
-                    }
-                }
-                Err(err) => {
-                    let err = err.to_string().red();
-                    warn!("{}", err);
-                }
-            };
+            release_commits.reverse();
+            releases.push(release_commits);
         }
 
-        Release {
-            version: commit_range.to,
-            from: commit_range.from,
-            date: Utc::now().naive_utc(),
-            commits,
-            previous: None,
+        let mut current = None;
+
+        for release in releases {
+            let next = Release {
+                version: release.first().unwrap().0.clone(),
+                from: current
+                    .as_ref()
+                    .map(|current: &Release| current.version.clone())
+                    .unwrap_or(release.last().unwrap().0.clone()),
+                date: Utc::now().naive_local(),
+                commits: release
+                    .iter()
+                    .filter_map(|(_, commit)| match Commit::from_git_commit(commit) {
+                        Ok(commit) => {
+                            if !commit.should_omit() {
+                                Some(ChangelogCommit::from(commit))
+                            } else {
+                                None
+                            }
+                        }
+                        Err(err) => {
+                            let err = err.to_string().red();
+                            warn!("{}", err);
+                            None
+                        }
+                    })
+                    .collect(),
+                previous: current.map(Box::new),
+            };
+
+            current = Some(next);
         }
+
+        current.ok_or(ChangelogError::EmptyRelease)
     }
 }
 
@@ -122,6 +119,7 @@ mod test {
     use git2::Oid;
     use indoc::indoc;
     use pretty_assertions::assert_eq;
+    use speculoos::prelude::*;
 
     use crate::conventional::changelog::release::{ChangelogCommit, Release};
     use crate::conventional::changelog::renderer::Renderer;
@@ -130,8 +128,21 @@ mod test {
     };
     use crate::conventional::commit::Commit;
     use crate::git::oid::OidOf;
+    use crate::git::repository::Repository;
+
     use crate::git::tag::Tag;
 
+    #[test]
+    fn should_get_a_release() -> anyhow::Result<()> {
+        let repo = Repository::open(".")?;
+        let iter = repo.revwalk("..")?;
+        let release = Release::try_from(iter);
+        assert_that!(release)
+            .is_ok()
+            .matches(|r| !r.commits.is_empty());
+        Ok(())
+    }
+
     #[test]
     fn should_render_default_template() -> Result<()> {
         // Arrange
@@ -545,11 +556,13 @@ mod test {
             let version = Tag::from_str(
                 "1.0.0",
                 Some(Oid::from_str("9bb5facac5724bc81385fdd740fedbb49056da00").unwrap()),
+                None,
             )
             .unwrap();
             let from = Tag::from_str(
                 "0.1.0",
                 Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()),
+                None,
             )
             .unwrap();
             Release {
@@ -633,10 +646,12 @@ mod test {
                     version: OidOf::Tag(Tag::from_str(
                         "0.1.0",
                         Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()),
+                        None,
                     )?),
                     from: Some(OidOf::Tag(Tag::from_str(
                         "0.2.0",
                         Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()),
+                        None,
                     )?)),
                 },
                 PackageBumpContext {
@@ -645,10 +660,12 @@ mod test {
                     version: OidOf::Tag(Tag::from_str(
                         "0.2.0",
                         Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()),
+                        None,
                     )?),
                     from: Some(OidOf::Tag(Tag::from_str(
                         "0.3.0",
                         Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()),
+                        None,
                     )?)),
                 },
             ],
@@ -667,6 +684,7 @@ mod test {
                     version: OidOf::Tag(Tag::from_str(
                         "0.1.0",
                         Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()),
+                        None,
                     )?),
                     from: None,
                 },
@@ -676,6 +694,7 @@ mod test {
                     version: OidOf::Tag(Tag::from_str(
                         "0.2.0",
                         Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()),
+                        None,
                     )?),
                     from: None,
                 },
diff --git a/src/conventional/changelog/serde.rs b/src/conventional/changelog/serde.rs
index a9248ca..9ddf429 100644
--- a/src/conventional/changelog/serde.rs
+++ b/src/conventional/changelog/serde.rs
@@ -66,7 +66,7 @@ impl Serialize for OidOf {
                     oidof.serialize_field("id", &oid.to_string())?;
                 }
             }
-            OidOf::Head(oid) | OidOf::Other(oid) => {
+            OidOf::FirstCommit(oid) | OidOf::Head(oid) | OidOf::Other(oid) => {
                 oidof.serialize_field("id", &oid.to_string())?
             }
         };
@@ -87,7 +87,8 @@ mod test {
 
     #[test]
     fn should_serialize_tag() {
-        let tag = Tag::from_str("1.0.0", Some(Oid::from_str("1234567890").unwrap())).unwrap();
+        let oid = Oid::from_str("1234567890").unwrap();
+        let tag = Tag::from_str("1.0.0", Some(oid), None).unwrap();
 
         let result = toml::to_string(&tag);
 
diff --git a/src/conventional/commit.rs b/src/conventional/commit.rs
index e9a1f70..a82bff0 100644
--- a/src/conventional/commit.rs
+++ b/src/conventional/commit.rs
@@ -3,7 +3,7 @@ use std::fmt::{self, Formatter};
 
 pub use crate::conventional::error::ConventionalCommitError;
 use crate::SETTINGS;
-use chrono::{NaiveDateTime, Utc};
+use chrono::{DateTime, NaiveDateTime, Utc};
 use colored::*;
 use conventional_commit_parser::commit::ConventionalCommit;
 use git2::Commit as Git2Commit;
@@ -12,10 +12,10 @@ use serde::{Deserialize, Serialize};
 
 #[derive(Debug, Eq, PartialEq)]
 pub struct Commit {
-    pub(crate) oid: String,
-    pub(crate) conventional: ConventionalCommit,
-    pub(crate) author: String,
-    pub(crate) date: NaiveDateTime,
+    pub oid: String,
+    pub conventional: ConventionalCommit,
+    pub author: String,
+    pub date: NaiveDateTime,
 }
 
 /// Configurations to create new conventional commit types or override behaviors of the existing ones.
@@ -62,8 +62,9 @@ impl Commit {
         let oid = commit.id().to_string();
 
         let commit = commit.to_owned();
-        let date = NaiveDateTime::from_timestamp_opt(commit.time().seconds(), 0)
-            .expect("valid commit date");
+        let date = DateTime::from_timestamp(commit.time().seconds(), 0)
+            .expect("valid commit date")
+            .naive_utc();
         let message = commit.message();
         let git2_message = message.unwrap().to_owned();
         let author = commit.author().name().unwrap_or("").to_string();
@@ -302,11 +303,10 @@ pub(crate) fn format_summary(commit: &ConventionalCommit) -> String {
 mod test {
     use crate::conventional::commit::{format_summary, verify, Commit, CommitConfig};
 
-    use chrono::NaiveDateTime;
-    use cmd_lib::run_fun;
-
+    use crate::test_helpers::{commit, git_init_no_gpg};
     use crate::Repository;
     use anyhow::Result;
+    use chrono::NaiveDateTime;
     use conventional_commit_parser::commit::{CommitType, ConventionalCommit, Footer, Separator};
     use git2::Oid;
     use indoc::indoc;
@@ -527,17 +527,11 @@ mod test {
     }
 
     #[sealed_test]
-    fn should_map_conventional_commit() {
+    fn should_map_conventional_commit() -> Result<()> {
         // Arrange
-        let oid = run_fun!(
-            git init;
-            git commit --allow-empty -q -m "feat: a commit";
-            git log --format=%H -n 1;
-        )
-        .unwrap();
-
+        let repo = git_init_no_gpg()?;
+        let oid = commit("feat: a commit")?;
         let oid = Oid::from_str(&oid).unwrap();
-        let repo = Repository::open(".").unwrap();
         let commit = repo.0.find_commit(oid).expect("Unable to find commit");
 
         // Act
@@ -545,20 +539,15 @@ mod test {
 
         // Assert
         assert_that!(commit).is_ok();
+        Ok(())
     }
 
     #[sealed_test]
-    fn map_conventional_commit_should_fail_with_invalid_type() {
+    fn map_conventional_commit_should_fail_with_invalid_type() -> Result<()> {
         // Arrange
-        let oid_str = run_fun!(
-            git init;
-            git commit --allow-empty -q -m "toto: a commit";
-            git log --format=%H -n 1;
-        )
-        .unwrap();
-
+        let repo = git_init_no_gpg()?;
+        let oid_str = commit("toto: a commit")?;
         let oid = Oid::from_str(&oid_str).unwrap();
-        let repo = Repository::open(".").unwrap();
         let commit = repo.0.find_commit(oid).expect("Unable to find commit");
 
         // Act
@@ -566,20 +555,16 @@ mod test {
 
         // Assert
         assert_that!(commit).is_err();
+        Ok(())
     }
 
     #[sealed_test]
-    fn map_conventional_commit_should_fail() {
+    fn map_conventional_commit_should_fail() -> Result<()> {
         // Arrange
-        let oid_str = run_fun!(
-            git init;
-            git commit --allow-empty -q -m "a commit";
-            git log --format=%H -n 1;
-        )
-        .unwrap();
-
+        let repo = git_init_no_gpg()?;
+        let oid_str = commit("a commit")?;
         let oid = Oid::from_str(&oid_str).unwrap();
-        let repo = Repository::open(".").unwrap();
+        let _ = Repository::open(".").unwrap();
         let commit = repo.0.find_commit(oid).expect("Unable to find commit");
 
         // Act
@@ -587,5 +572,6 @@ mod test {
 
         // Assert
         assert_that!(commit).is_err();
+        Ok(())
     }
 }
diff --git a/src/conventional/version.rs b/src/conventional/version.rs
index f483435..9976c5e 100644
--- a/src/conventional/version.rs
+++ b/src/conventional/version.rs
@@ -1,10 +1,11 @@
 use std::cmp::Ordering;
 
-#[derive(Debug, PartialEq, Eq)]
+#[derive(Debug, PartialEq, Eq, Default)]
 pub enum IncrementCommand {
     Major,
     Minor,
     Patch,
+    #[default]
     Auto,
     NoBump,
     AutoPackage(String),
diff --git a/src/git/commit.rs b/src/git/commit.rs
index 589e76a..28fc662 100644
--- a/src/git/commit.rs
+++ b/src/git/commit.rs
@@ -110,6 +110,7 @@ fn gpg_sign_string(key: Option<String>, content: &str) -> Result<String, Git2Err
 #[cfg(test)]
 mod test {
     use crate::git::repository::Repository;
+    use crate::test_helpers::git_init_no_gpg;
     use anyhow::Result;
     use cmd_lib::run_cmd;
     use sealed_test::prelude::*;
@@ -118,14 +119,13 @@ mod test {
     #[sealed_test]
     fn create_commit_ok() -> Result<()> {
         // Arrange
+        let repo = git_init_no_gpg()?;
+
         run_cmd!(
-            git init;
             echo changes > file;
             git add .;
         )?;
 
-        let repo = Repository::open(".")?;
-
         // Act
         let oid = repo.commit("feat: a test commit", false, false);
 
@@ -145,7 +145,8 @@ mod test {
             gpg --import $crate_dir/tests/assets/privkey.key;
             echo -e "5\ny\n" | gpg --no-tty --command-fd 0 --expert --edit-key test@cocogitto.org trust;
             git init;
-            git config --local user.signingkey 35B66CC21AEBFC9B0E8C89F1FD753A01E06E05D7;
+            git config --local user.signingkey 24CAC643C7098768E2A90E1A2A8180559460836E;
+            git config --local commit.gpgsign true;
             echo changes > file;
             git add .;
         )?;
@@ -163,11 +164,7 @@ mod test {
     #[sealed_test]
     fn create_empty_commit() -> Result<()> {
         // Arrange
-        run_cmd!(
-            git init;
-        )?;
-
-        let repo = Repository::open(".")?;
+        let repo = git_init_no_gpg()?;
 
         // Act
         let oid = repo.commit("feat: a test commit", false, true);
@@ -182,6 +179,7 @@ mod test {
         // Arrange
         run_cmd!(
             git init -b main;
+            git config --local commit.gpgsign false;
             echo changes > file;
             git add .;
         )
@@ -199,7 +197,7 @@ mod test {
     #[sealed_test]
     fn not_create_empty_commit() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
 
         // Act
         let oid = repo.commit("feat: a test commit", false, false);
@@ -212,12 +210,8 @@ mod test {
     #[sealed_test]
     fn not_create_empty_commit_with_unstaged_changed() -> Result<()> {
         // Arrange
-        run_cmd!(
-            git init;
-            echo changes > file;
-        )?;
-
-        let repo = Repository::open(".")?;
+        let repo = git_init_no_gpg()?;
+        run_cmd!(echo changes > file;)?;
 
         // Act
         let oid = repo.commit("feat: a test commit", false, false);
diff --git a/src/git/diff.rs b/src/git/diff.rs
index 8ea0941..4b50027 100644
--- a/src/git/diff.rs
+++ b/src/git/diff.rs
@@ -1,12 +1,12 @@
 use crate::git::repository::Repository;
-use git2::{Diff, DiffOptions};
+use git2::{Diff, DiffOptions, Object};
 
 impl Repository {
     pub(crate) fn get_diff(&self, include_untracked: bool) -> Option<Diff> {
         let mut options = DiffOptions::new();
         options.include_untracked(include_untracked);
 
-        let diff = match &self.get_head() {
+        let diff = match self.get_head() {
             Some(head) => self
                 .0
                 .diff_tree_to_index(head.as_tree(), None, Some(&mut options)),
@@ -26,25 +26,69 @@ impl Repository {
             Err(..) => None,
         }
     }
+
+    fn get_head(&self) -> Option<Object> {
+        self.tree_to_treeish(Some(&"HEAD".to_string()))
+            .ok()
+            .flatten()
+    }
 }
 
 #[cfg(test)]
 mod test {
-    use crate::git::repository::Repository;
+    use crate::test_helpers::git_init_no_gpg;
     use anyhow::Result;
     use cmd_lib::run_cmd;
     use sealed_test::prelude::*;
+    use speculoos::prelude::*;
 
     #[sealed_test]
-    fn get_diff_some() -> Result<()> {
+    fn get_head_some() -> Result<()> {
         // Arrange
+        let repo = git_init_no_gpg()?;
+
+        run_cmd!(
+            echo changes > file;
+            git add .;
+        )?;
+
+        repo.commit("first commit", false, false)?;
+
+        // Act
+        let head = repo.get_head();
+
+        // Assert
+        assert_that!(head).is_some();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn get_head_none() -> Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+
         run_cmd!(
-            git init;
             echo changes > file;
             git add .;
         )?;
 
-        let repo = Repository::open(".")?;
+        // Act
+        let head = repo.get_head();
+
+        // Assert
+        assert_that!(head).is_none();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn get_diff_some() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+
+        // Arrange
+        run_cmd!(
+            echo changes > file;
+            git add .;
+        )?;
 
         // Act
         let diffs = repo.get_diff(false);
@@ -56,32 +100,30 @@ mod test {
 
     #[sealed_test]
     fn get_diff_none() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+
         // Arrange
         run_cmd!(
-            git init;
             echo changes > file;
         )?;
 
-        let repo = Repository::open(".")?;
-
         // Act
         let diffs = repo.get_diff(false);
 
         // Assert
-        assert!(diffs.is_none());
+        assert_that!(diffs.is_none());
         Ok(())
     }
 
     #[sealed_test]
     fn get_diff_include_untracked_some() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+
         // Arrange
         run_cmd!(
-            git init;
             echo changes > file;
         )?;
 
-        let repo = Repository::open(".")?;
-
         // Act
         let diffs = repo.get_diff(true);
 
@@ -93,7 +135,7 @@ mod test {
     #[sealed_test]
     fn get_diff_include_untracked_none() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
 
         // Act
         let diffs = repo.get_diff(true);
diff --git a/src/git/error.rs b/src/git/error.rs
index c47b6e6..60adb60 100644
--- a/src/git/error.rs
+++ b/src/git/error.rs
@@ -1,4 +1,5 @@
 use crate::git::status::Statuses;
+
 use colored::Colorize;
 use serde::de::StdError;
 use std::fmt::{Display, Formatter};
@@ -27,6 +28,7 @@ pub enum Git2Error {
     TagError(TagError),
     GitHookNonZeroExit(i32),
     InvalidCommitRangePattern(String),
+    UnknownRevision(String),
 }
 
 #[derive(Debug)]
@@ -126,6 +128,9 @@ impl Display for Git2Error {
             Git2Error::InvalidCommitRangePattern(pattern) => {
                 writeln!(f, "invalid commit range pattern: `{pattern}`")
             }
+            Git2Error::UnknownRevision(rev) => {
+                writeln!(f, "Unknown revision: `{rev}`")
+            }
         }?;
 
         match self {
diff --git a/src/git/hook.rs b/src/git/hook.rs
index bbca63e..1420872 100644
--- a/src/git/hook.rs
+++ b/src/git/hook.rs
@@ -53,6 +53,7 @@ mod tests {
     use crate::CocoGitto;
 
     use crate::settings::{GitHook, GitHookType, Settings};
+    use crate::test_helpers::git_init_no_gpg;
     use anyhow::Result;
     use cmd_lib::run_cmd;
     use sealed_test::prelude::*;
@@ -62,7 +63,7 @@ mod tests {
     #[sealed_test]
     fn add_pre_commit_hook() -> Result<()> {
         // Arrange
-        run_cmd!(git init)?;
+        let _ = git_init_no_gpg()?;
         let mut git_hooks = HashMap::new();
         let hooks_script = r#"
 if cog check; then
@@ -104,7 +105,7 @@ exit 1"#
     #[sealed_test]
     fn add_all() -> Result<()> {
         // Arrange
-        run_cmd!(git init)?;
+        let _ = git_init_no_gpg()?;
         run_cmd!(echo "echo toto" > pre-push;)?;
 
         let mut git_hooks = HashMap::new();
diff --git a/src/git/mod.rs b/src/git/mod.rs
index 0df9293..09d9757 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -5,7 +5,7 @@ pub mod hook;
 pub mod monorepo;
 pub mod oid;
 pub mod repository;
-pub mod revspec;
+pub mod rev;
 pub mod stash;
 pub mod status;
 pub mod tag;
diff --git a/src/git/monorepo.rs b/src/git/monorepo.rs
index 2a4dc0a..9e4f546 100644
--- a/src/git/monorepo.rs
+++ b/src/git/monorepo.rs
@@ -1,33 +1,26 @@
 use crate::git::repository::Repository;
 
+use crate::git::tag::TagLookUpOptions;
 use crate::{Tag, TagError};
 
 impl Repository {
     /// Get the latest SemVer tag for a given monorepo package.
-    pub fn get_latest_package_tag(&self, package_prefix: &str) -> Result<Tag, TagError> {
-        let tags: Vec<Tag> = self.all_tags()?;
+    pub fn get_latest_package_tag(&self, package: &str) -> Result<Tag, TagError> {
+        let tags: Vec<Tag> = self.tag_lookup(TagLookUpOptions::package(package))?;
 
-        tags.into_iter()
-            .filter(|tag| {
-                tag.package
-                    .as_ref()
-                    .map(|package| package == package_prefix)
-                    .unwrap_or_default()
-            })
-            .max()
-            .ok_or(TagError::NoTag)
+        tags.into_iter().max().ok_or(TagError::NoTag)
     }
 }
 
 #[cfg(test)]
 mod test {
-    use crate::{Repository, RevspecPattern};
+
+    use crate::test_helpers::git_init_no_gpg;
     use anyhow::Result;
     use cmd_lib::run_cmd;
     use indoc::formatdoc;
     use sealed_test::prelude::*;
     use speculoos::prelude::*;
-    use std::str::FromStr;
 
     #[sealed_test]
     fn get_repo_packages() -> Result<()> {
@@ -44,13 +37,12 @@ mod test {
             "
         );
 
+        let repo = git_init_no_gpg()?;
         run_cmd!(
-            git init -b master;
             echo $settings > cog.toml;
             git add .;
         )?;
 
-        let repo = Repository::open(".")?;
         repo.commit("chore: init", false, false)?;
 
         run_cmd!(
@@ -68,13 +60,11 @@ mod test {
         )?;
 
         // Act
-        let range =
-            repo.get_commit_range_for_package(&RevspecPattern::from_str("..HEAD")?, "two")?;
+        let range = repo.get_commit_range_for_package("..HEAD", "two")?;
+        let range = range.into_iter().collect::<Vec<_>>();
 
         // Assert
-        assert_that!(range)
-            .map(|range| &range.commits)
-            .has_length(2);
+        assert_that!(range).has_length(2);
 
         Ok(())
     }
diff --git a/src/git/oid.rs b/src/git/oid.rs
index 5efcaab..beb3337 100644
--- a/src/git/oid.rs
+++ b/src/git/oid.rs
@@ -10,14 +10,14 @@ pub enum OidOf {
     Tag(Tag),
     Head(Oid),
     Other(Oid),
+    FirstCommit(Oid),
 }
 
 impl OidOf {
     pub fn oid(&self) -> &Oid {
         match self {
             OidOf::Tag(t) => t.oid_unchecked(),
-            OidOf::Head(o) => o,
-            OidOf::Other(o) => o,
+            OidOf::Head(o) | OidOf::Other(o) | OidOf::FirstCommit(o) => o,
         }
     }
 }
@@ -29,6 +29,7 @@ impl Display for OidOf {
             OidOf::Tag(tag) => write!(f, "{tag}"),
             OidOf::Head(_) => write!(f, "HEAD"),
             OidOf::Other(oid) => write!(f, "{}", &oid.to_string()),
+            OidOf::FirstCommit(oid) => write!(f, "{}", &oid.to_string()),
         }
     }
 }
diff --git a/src/git/repository.rs b/src/git/repository.rs
index f3ee888..a41061a 100644
--- a/src/git/repository.rs
+++ b/src/git/repository.rs
@@ -61,12 +61,6 @@ impl Repository {
             .map_err(Git2Error::CommitNotFound)
     }
 
-    pub(crate) fn get_head(&self) -> Option<Object> {
-        self.tree_to_treeish(Some(&"HEAD".to_string()))
-            .ok()
-            .flatten()
-    }
-
     pub(crate) fn get_branch_shorthand(&self) -> Option<String> {
         self.0
             .head()
@@ -113,10 +107,11 @@ mod test {
     use speculoos::prelude::*;
 
     use crate::git::repository::Repository;
+    use crate::test_helpers::git_init_no_gpg;
 
     #[sealed_test]
     fn init_repo() -> Result<()> {
-        let repo = Repository::init(".");
+        let repo = git_init_no_gpg();
 
         assert_that!(repo).is_ok();
         Ok(())
@@ -142,12 +137,12 @@ mod test {
     #[sealed_test]
     fn get_repo_head_oid_ok() -> Result<()> {
         // Arrange
+        let repo = git_init_no_gpg()?;
+
         run_cmd!(
-            git init;
             echo changes > file;
             git add .;
         )?;
-        let repo = Repository::open(".")?;
         let commit_oid = repo.commit("first commit", false, false)?;
 
         // Act
@@ -161,7 +156,7 @@ mod test {
     #[sealed_test]
     fn get_repo_head_oid_err() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
 
         // Act
         let oid = repo.get_head_commit_oid();
@@ -174,12 +169,13 @@ mod test {
     #[sealed_test]
     fn get_repo_head_obj_ok() -> Result<()> {
         // Arrange
+        let repo = git_init_no_gpg()?;
+
         run_cmd!(
-            git init;
             echo changes > file;
             git add .;
         )?;
-        let repo = Repository::open(".")?;
+
         let commit_oid = repo.commit("first commit", false, false)?;
 
         // Act
@@ -193,12 +189,12 @@ mod test {
     #[sealed_test]
     fn get_repo_head_obj_err() -> Result<()> {
         // Arrange
+        let repo = git_init_no_gpg()?;
+
         run_cmd!(
-            git init;
             echo changes > file;
             git add .;
         )?;
-        let repo = Repository::open(".")?;
 
         // Act
         let head = repo.get_head_commit();
@@ -209,53 +205,15 @@ mod test {
     }
 
     #[sealed_test]
-    fn get_head_some() -> Result<()> {
+    fn get_branch_short_hand() -> Result<()> {
         // Arrange
-        run_cmd!(
-            git init;
-            echo changes > file;
-            git add .;
-        )?;
-        let repo = Repository::open(".")?;
+        let repo = git_init_no_gpg()?;
 
-        repo.commit("first commit", false, false)?;
-
-        // Act
-        let head = repo.get_head();
-
-        // Assert
-        assert_that!(head).is_some();
-        Ok(())
-    }
-
-    #[sealed_test]
-    fn get_head_none() -> Result<()> {
-        // Arrange
         run_cmd!(
-            git init;
             echo changes > file;
             git add .;
         )?;
 
-        let repo = Repository::open(".")?;
-
-        // Act
-        let head = repo.get_head();
-
-        // Assert
-        assert_that!(head).is_none();
-        Ok(())
-    }
-
-    #[sealed_test]
-    fn get_branch_short_hand() -> Result<()> {
-        // Arrange
-        run_cmd!(
-            git init -b master;
-            echo changes > file;
-            git add .;
-        )?;
-        let repo = Repository::open(".")?;
         repo.commit("hello one", false, false)?;
 
         // Act
diff --git a/src/git/rev/cache.rs b/src/git/rev/cache.rs
new file mode 100644
index 0000000..7133165
--- /dev/null
+++ b/src/git/rev/cache.rs
@@ -0,0 +1,136 @@
+use crate::git::error::TagError;
+use crate::git::oid::OidOf;
+use crate::git::repository::Repository;
+use crate::git::tag::Tag;
+use once_cell::sync::{Lazy, OnceCell};
+use std::collections::BTreeMap;
+use std::sync::{Arc, Mutex, MutexGuard};
+
+static REPO_CACHE: Lazy<Arc<Mutex<BTreeMap<String, OidOf>>>> =
+    Lazy::new(|| Arc::new(Mutex::new(BTreeMap::new())));
+
+static FIRST_COMMIT: OnceCell<OidOf> = OnceCell::new();
+
+pub(crate) fn get_cache(repository: &Repository) -> MutexGuard<'_, BTreeMap<String, OidOf>> {
+    let mut cache = REPO_CACHE.lock().unwrap();
+    if cache.is_empty() {
+        let head = repository.get_head_commit().expect("HEAD");
+        let first = FIRST_COMMIT.get_or_init(|| {
+            OidOf::FirstCommit(repository.get_first_commit().expect("first commit"))
+        });
+
+        cache.insert(head.id().to_string(), OidOf::Head(head.id()));
+        cache.insert(
+            first.to_string(),
+            FIRST_COMMIT.get().expect("first commit").clone(),
+        );
+
+        let tag_iter = repository.0.tag_names(None).expect("tags");
+
+        let tag_iter = tag_iter
+            .into_iter()
+            .flatten()
+            .filter_map(|tag| repository.resolve_tag(tag).ok());
+
+        for tag in tag_iter {
+            if let Some(target) = tag.target.as_ref() {
+                let target = target.to_string();
+                cache.insert(target, OidOf::Tag(tag.clone()));
+            }
+
+            if let Some(oid) = tag.oid.as_ref() {
+                let oid = oid.to_string();
+                cache.insert(oid, OidOf::Tag(tag.clone()));
+            }
+
+            cache.insert(tag.to_string(), OidOf::Tag(tag));
+        }
+    }
+
+    cache
+}
+
+impl Repository {
+    fn resolve_tag(&self, tag: &str) -> Result<Tag, TagError> {
+        self.0
+            .resolve_reference_from_short_name(tag)
+            .map_err(|err| TagError::not_found(tag, err))
+            .map(|reference| reference.target().unwrap())
+            .map(|commit_oid| {
+                if let Ok(annotated_tag) = self.0.find_tag(commit_oid) {
+                    Tag::from_str(tag, Some(commit_oid), Some(annotated_tag.target_id()))
+                } else {
+                    Tag::from_str(tag, Some(commit_oid), None)
+                }
+            })?
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use crate::git::repository::Repository;
+    use crate::git::rev::cache::get_cache;
+    use crate::test_helpers::git_init_no_gpg;
+    use cmd_lib::run_cmd;
+    use sealed_test::prelude::*;
+    use speculoos::prelude::*;
+
+    #[test]
+    fn init_cache_ok() -> anyhow::Result<()> {
+        let repo = Repository::open(".")?;
+        let cache = get_cache(&repo);
+        assert_that!(cache.is_empty()).is_false();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn resolve_annotated_tag_ok() -> anyhow::Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+        run_cmd!(
+            git commit --allow-empty -m "first commit";
+            git tag -a 1.0.0 -m "annotated tag";
+        )?;
+
+        // Act
+        let tag = repo.resolve_tag("1.0.0");
+
+        // Assert
+        assert_that!(tag).is_ok();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn resolve_lightweight_tag_ok() -> anyhow::Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+        run_cmd!(
+            git commit --allow-empty -m "first commit";
+            git tag 1.0.0;
+        )?;
+
+        // Act
+        let tag = repo.resolve_tag("1.0.0");
+
+        // Assert
+        assert_that!(tag).is_ok();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn resolve_lightweight_tag_err() -> anyhow::Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+        run_cmd!(
+            git commit --allow-empty -m "first commit";
+            git tag the_tag;
+        )?;
+
+        // Act
+        let tag = repo.resolve_tag("the_taaaag");
+
+        // Assert
+        assert_that!(tag).is_err();
+        Ok(())
+    }
+}
diff --git a/src/git/rev/filters.rs b/src/git/rev/filters.rs
new file mode 100644
index 0000000..0c89c7e
--- /dev/null
+++ b/src/git/rev/filters.rs
@@ -0,0 +1,59 @@
+use crate::settings::MonoRepoPackage;
+use globset::{Candidate, GlobBuilder, GlobSet, GlobSetBuilder};
+use std::path::Path;
+
+#[derive(Debug)]
+pub(crate) struct PackagePathFilter {
+    include: GlobSet,
+    exclude: GlobSet,
+}
+
+impl PackagePathFilter {
+    pub(super) fn from_package(package: &MonoRepoPackage) -> Self {
+        Self::new(
+            package.path.to_str().expect("valid package path"),
+            &package.include,
+            &package.ignore,
+        )
+    }
+
+    pub(super) fn is_match<P: AsRef<Path> + ?Sized>(&self, path: &P) -> bool {
+        let candidate = Candidate::new(path);
+        self.include.is_match_candidate(&candidate) && !self.exclude.is_match_candidate(&candidate)
+    }
+
+    fn new(package_path: &str, include_paths: &[String], exclude_paths: &[String]) -> Self {
+        let include = {
+            let mut builder = GlobSetBuilder::new();
+            builder.add(
+                GlobBuilder::new(format!("{}/**", package_path).as_str())
+                    .literal_separator(true)
+                    .build()
+                    .expect("glob should be valid"),
+            );
+            for include in include_paths {
+                builder.add(
+                    GlobBuilder::new(include)
+                        .literal_separator(true)
+                        .build()
+                        .expect("glob should be valid"),
+                );
+            }
+            builder.build().expect("valid globset")
+        };
+        let exclude = {
+            let mut builder = GlobSetBuilder::new();
+            for exclude in exclude_paths {
+                builder.add(
+                    GlobBuilder::new(exclude)
+                        .literal_separator(true)
+                        .build()
+                        .expect("glob should be valid"),
+                );
+            }
+            builder.build().expect("valid globset")
+        };
+
+        PackagePathFilter { include, exclude }
+    }
+}
diff --git a/src/git/rev/mod.rs b/src/git/rev/mod.rs
new file mode 100644
index 0000000..dcb2b76
--- /dev/null
+++ b/src/git/rev/mod.rs
@@ -0,0 +1,34 @@
+use git2::Commit;
+
+use crate::git::oid::OidOf;
+
+pub mod cache;
+pub mod filters;
+pub mod revspec;
+pub mod revwalk;
+
+#[derive(Debug)]
+pub struct CommitIter<'repo>(Vec<(OidOf, Commit<'repo>)>);
+
+impl<'repo> CommitIter<'repo> {
+    pub fn from_oid(&self) -> OidOf {
+        self.0.last().expect("non empty commit range").0.clone()
+    }
+
+    pub fn to_oid(&self) -> OidOf {
+        self.0.first().expect("non empty commit range").0.clone()
+    }
+
+    pub fn iter_commits(&self) -> impl Iterator<Item = &Commit> {
+        self.0.iter().map(|(_, commit)| commit)
+    }
+}
+
+impl<'repo> IntoIterator for CommitIter<'repo> {
+    type Item = (OidOf, Commit<'repo>);
+    type IntoIter = std::vec::IntoIter<Self::Item>;
+
+    fn into_iter(self) -> Self::IntoIter {
+        self.0.into_iter()
+    }
+}
diff --git a/src/git/rev/revspec.rs b/src/git/rev/revspec.rs
new file mode 100644
index 0000000..fd351a1
--- /dev/null
+++ b/src/git/rev/revspec.rs
@@ -0,0 +1,229 @@
+use crate::git::error::Git2Error;
+use crate::git::oid::OidOf;
+use crate::git::repository::Repository;
+use crate::git::rev::cache::get_cache;
+use crate::git::tag::Tag;
+use git2::Oid;
+use std::fmt;
+use std::fmt::Formatter;
+
+#[derive(Debug, Clone)]
+pub enum RevSpecPattern2 {
+    Range { from: OidOf, to: OidOf },
+    AtTag { from: OidOf, to: OidOf },
+}
+
+impl RevSpecPattern2 {
+    pub fn from(&self) -> &Oid {
+        match self {
+            RevSpecPattern2::AtTag { from, .. } | RevSpecPattern2::Range { from, .. } => from.oid(),
+        }
+    }
+
+    pub fn to(&self) -> &Oid {
+        match self {
+            RevSpecPattern2::AtTag { to, .. } | RevSpecPattern2::Range { to, .. } => to.oid(),
+        }
+    }
+}
+
+impl Repository {
+    pub(super) fn revspec_from_str(&self, s: &str) -> Result<RevSpecPattern2, Git2Error> {
+        if let Some((from, to)) = s.split_once("..") {
+            let from = if from.is_empty() {
+                OidOf::Other(self.get_first_commit()?)
+            } else {
+                self.resolve_oid_of(from)?
+            };
+
+            let to = if to.is_empty() {
+                OidOf::Head(self.get_head_commit_oid()?)
+            } else {
+                self.resolve_oid_of(to)?
+            };
+
+            Ok(RevSpecPattern2::Range { from, to })
+        } else if let Ok(tag) = Tag::from_str(s, None, None) {
+            let previous = self.get_previous_tag(&tag)?.map(OidOf::Tag);
+
+            let previous = match previous {
+                None => OidOf::Other(self.get_first_commit()?),
+                Some(previous) => previous,
+            };
+
+            Ok(RevSpecPattern2::AtTag {
+                from: previous,
+                to: self.resolve_oid_of(s)?,
+            })
+        } else {
+            Err(Git2Error::InvalidCommitRangePattern(s.to_string()))
+        }
+    }
+
+    pub(super) fn resolve_oid_of(&self, from: &str) -> Result<OidOf, Git2Error> {
+        let cache = get_cache(self);
+
+        let oid = cache
+            .iter()
+            .find(|(k, _)| k.starts_with(from))
+            .map(|(_, v)| v);
+
+        match oid {
+            None => {
+                let object = self
+                    .0
+                    .revparse_single(from)
+                    .map_err(|_| Git2Error::UnknownRevision(from.to_string()))?;
+                Ok(OidOf::Other(object.id()))
+            }
+            Some(oid) => Ok(oid.clone()),
+        }
+    }
+}
+
+impl fmt::Display for RevSpecPattern2 {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            RevSpecPattern2::AtTag { from, to } | RevSpecPattern2::Range { from, to } => {
+                write!(f, "{from}..{to}")
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use crate::git::oid::OidOf;
+    use crate::git::rev::revspec::RevSpecPattern2;
+    use crate::git::tag::Tag;
+    use crate::test_helpers::{commit, git_init_no_gpg, git_tag};
+    use anyhow::Result;
+    use git2::Oid;
+    use sealed_test::prelude::*;
+    use semver::Version;
+    use speculoos::prelude::*;
+
+    #[sealed_test]
+    fn should_resolve_tag_oid() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+        commit("chore: first commit")?;
+        let commit_oid = commit("chore: v1")?;
+        git_tag("1.0.0")?;
+
+        let oid = repo.resolve_oid_of("1.0.0")?;
+
+        assert_that!(oid).is_equal_to(OidOf::Tag(Tag {
+            package: None,
+            prefix: None,
+            version: Version::new(1, 0, 0),
+            oid: Some(Oid::from_str(&commit_oid)?),
+            target: None,
+        }));
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn should_resolve_head_oid() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+        commit("chore: first commit")?;
+        let commit_oid = commit("chore: other")?;
+
+        let oid = repo.resolve_oid_of(&commit_oid)?;
+
+        assert_that!(oid).is_equal_to(OidOf::Head(Oid::from_str(&commit_oid)?));
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn should_resolve_commit_oid() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+        commit("chore: first commit")?;
+        let commit_oid = commit("chore: other")?;
+        commit("chore: head")?;
+
+        let oid = repo.resolve_oid_of(&commit_oid)?;
+
+        assert_that!(oid).is_equal_to(OidOf::Other(Oid::from_str(&commit_oid)?));
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn should_error_on_unknown_rev() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+        commit("chore: first commit")?;
+        commit("chore: other")?;
+
+        let oid = repo.resolve_oid_of("v32");
+
+        assert_that!(oid).is_err();
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn convert_str_to_pattern_to() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+        commit("chore: first commit")?;
+        commit("chore: v1")?;
+        git_tag("1.0.0")?;
+
+        let pattern = repo.revspec_from_str("..1.0.0")?;
+
+        assert_that!(matches!(pattern, RevSpecPattern2::Range { .. })).is_true();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn convert_str_to_pattern_from() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+        commit("chore: first commit")?;
+        commit("chore: v1")?;
+        git_tag("1.0.0")?;
+        commit("chore: more commits")?;
+
+        let pattern = repo.revspec_from_str("1.0.0..")?;
+
+        assert_that!(matches!(pattern, RevSpecPattern2::Range { .. })).is_true();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn convert_empty_pattern() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+        commit("chore: first commit")?;
+        let pattern = repo.revspec_from_str("..")?;
+        assert_that!(matches!(pattern, RevSpecPattern2::Range { .. })).is_true();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn error_invalid_pattern() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+        commit("chore: first commit")?;
+        commit("chore: v1")?;
+        git_tag("1.0.0")?;
+        let pattern = repo.revspec_from_str("1.0.0")?;
+
+        assert_that!(matches!(pattern, RevSpecPattern2::AtTag { .. })).is_true();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn convert_full_pattern() -> Result<()> {
+        let repo = git_init_no_gpg()?;
+        commit("chore: first commit")?;
+        commit("chore: first commit")?;
+        commit("chore: v1")?;
+        git_tag("1.0.0")?;
+        commit("chore: more commits")?;
+        git_tag("2.0.0")?;
+
+        let pattern = repo.revspec_from_str("1.0.0..2.0.0")?;
+
+        assert_that!(matches!(pattern, RevSpecPattern2::Range { .. })).is_true();
+        Ok(())
+    }
+}
diff --git a/src/git/rev/revwalk.rs b/src/git/rev/revwalk.rs
new file mode 100644
index 0000000..735de82
--- /dev/null
+++ b/src/git/rev/revwalk.rs
@@ -0,0 +1,660 @@
+use git2::Commit;
+
+use crate::git::error::Git2Error;
+use crate::git::oid::OidOf;
+use crate::git::repository::Repository;
+use crate::git::rev::filters::PackagePathFilter;
+use crate::git::rev::CommitIter;
+use crate::SETTINGS;
+
+impl Repository {
+    /// Return a commit range for the given package from a [`RevspecPattern2`]
+    pub fn get_commit_range_for_package(
+        &self,
+        pattern: &str,
+        package: &str,
+    ) -> Result<CommitIter, Git2Error> {
+        let mut commit_range = self.revwalk(pattern)?;
+        let mut commits = vec![];
+        let package = SETTINGS.packages.get(package).expect("package exists");
+        let package_path_filter = PackagePathFilter::from_package(package);
+
+        for (oid_of, commit) in commit_range.into_iter() {
+            let parent = commit.parent(0).ok().map(|commit| commit.id().to_string());
+
+            let parent_tree = self.tree_to_treeish(parent.as_ref())?;
+
+            let current_tree = self
+                .tree_to_treeish(Some(&commit.id().to_string()))?
+                .expect("Failed to get commit tree");
+
+            let diff = match parent_tree {
+                None => self
+                    .0
+                    .diff_tree_to_tree(None, current_tree.as_tree(), None)?,
+                Some(tree) => {
+                    self.0
+                        .diff_tree_to_tree(tree.as_tree(), current_tree.as_tree(), None)?
+                }
+            };
+
+            for delta in diff.deltas() {
+                if let Some(old) = delta.old_file().path() {
+                    if package_path_filter.is_match(old) {
+                        commits.push((oid_of, commit));
+                        break;
+                    }
+                }
+
+                if let Some(new) = delta.new_file().path() {
+                    if package_path_filter.is_match(new) {
+                        commits.push((oid_of, commit));
+                        break;
+                    }
+                }
+            }
+        }
+
+        commit_range = CommitIter(commits);
+        Ok(commit_range)
+    }
+
+    pub fn get_commit_range_for_monorepo_global(
+        &self,
+        pattern: &str,
+    ) -> Result<CommitIter, Git2Error> {
+        let mut commit_range = self.revwalk(pattern)?;
+        let mut commits = vec![];
+        let package_paths: Vec<_> = SETTINGS
+            .packages
+            .values()
+            .map(|package| &package.path)
+            .collect();
+
+        for (oid_of, commit) in commit_range.into_iter().rev() {
+            let parent = commit.parent(0);
+
+            // First commit is always included in monorepo global tag
+            if parent.is_err() {
+                commits.push((oid_of, commit));
+                continue;
+            }
+
+            let parent = parent?.id().to_string();
+            let t1 = self
+                .tree_to_treeish(Some(&parent))?
+                .expect("Failed to get parent tree");
+
+            let t2 = self
+                .tree_to_treeish(Some(&commit.id().to_string()))?
+                .expect("Failed to get commit tree");
+
+            let diff = self.0.diff_tree_to_tree(t1.as_tree(), t2.as_tree(), None)?;
+
+            for delta in diff.deltas() {
+                if let Some(old) = delta.old_file().path() {
+                    if package_paths.iter().all(|path| !old.starts_with(path)) {
+                        commits.push((oid_of, commit));
+                        break;
+                    }
+                }
+
+                if let Some(new) = delta.new_file().path() {
+                    if package_paths.iter().all(|path| !new.starts_with(path)) {
+                        commits.push((oid_of, commit));
+                        break;
+                    }
+                }
+            }
+        }
+
+        commit_range = CommitIter(commits);
+        Ok(commit_range)
+    }
+
+    /// Return a commit range from a [`RevspecPattern2`]
+    pub fn revwalk(&self, spec: &str) -> Result<CommitIter, Git2Error> {
+        let spec = self.revspec_from_str(spec)?;
+        let mut revwalk = self.0.revwalk()?;
+        revwalk.push_range(&spec.to_string())?;
+
+        let mut commits: Vec<(OidOf, Commit)> = vec![];
+
+        for oid in revwalk {
+            let oid = oid?;
+            // TODO: can we avoid allocating strings here ?
+            let oid_of = self.resolve_oid_of(&oid.to_string())?;
+            let commit = self.0.find_commit(oid)?;
+            commits.push((oid_of, commit));
+        }
+
+        // TODO: can we avoid allocating strings here ?
+        let first_oid = self.resolve_oid_of(&spec.from().to_string())?;
+        let include_start = match &first_oid {
+            OidOf::Head(_) | OidOf::FirstCommit(_) => true,
+            OidOf::Tag(_) | OidOf::Other(_) => false,
+        };
+
+        if include_start {
+            let first_commit = self.0.find_commit(*spec.from())?;
+            commits.push((first_oid, first_commit));
+        }
+
+        Ok(CommitIter(commits))
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use std::collections::HashMap;
+    use std::path::PathBuf;
+
+    use anyhow::Result;
+
+    use cmd_lib::run_cmd;
+    use git2::Oid;
+    use sealed_test::prelude::*;
+    use speculoos::prelude::*;
+
+    use crate::conventional::changelog::release::Release;
+    use crate::git::oid::OidOf;
+    use crate::git::repository::Repository;
+    use crate::git::tag::{Tag, TagLookUpOptions};
+    use crate::settings::{MonoRepoPackage, Settings};
+    use crate::test_helpers::{commit, git_init_no_gpg, git_tag};
+
+    const COCOGITTO_REPOSITORY: &str = env!("CARGO_MANIFEST_DIR");
+
+    #[test]
+    fn all_commits() -> Result<()> {
+        // Arrange
+        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
+
+        // Act
+        let range = repo.revwalk("..")?;
+
+        // Assert
+        assert_that!(range.0).is_not_empty();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn shoud_get_range_for_a_single_release() -> Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+        let one = commit("chore: first commit")?;
+        let two = commit("feat: feature 1")?;
+        let three = commit("feat: feature 2")?;
+        git_tag("0.1.0")?;
+
+        let range = repo.revwalk("0.1.0");
+
+        let range = range?;
+
+        // Act
+        let release = Release::try_from(range)?;
+
+        // Assert
+        assert_that!(release.previous).is_none();
+        assert_that!(release.version.oid()).is_equal_to(&Oid::from_str(&three)?);
+        assert_that!(release.from).is_equal_to(OidOf::FirstCommit(Oid::from_str(&one)?));
+
+        let expected_commits: Vec<String> = release
+            .commits
+            .into_iter()
+            .map(|commit| commit.commit.oid)
+            .collect();
+
+        assert_that!(expected_commits).is_equal_to(vec![three, two, one]);
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn shoud_get_range_for_a_multiple_release() -> Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+        let one = commit("chore: first commit")?;
+        let two = commit("feat: feature 1")?;
+        let three = commit("feat: feature 2")?;
+        git_tag("0.1.0")?;
+        let four = commit("feat: feature 3")?;
+        let five = commit("feat: feature 4")?;
+        git_tag("0.2.0")?;
+
+        let range = repo.revwalk("..0.2.0")?;
+
+        // Act
+        let release = Release::try_from(range)?;
+
+        // Assert
+        assert_that!(release.previous).is_some().matches(|_child| {
+            let commits: Vec<String> = release
+                .previous
+                .as_ref()
+                .unwrap()
+                .commits
+                .iter()
+                .map(|commit| commit.commit.oid.clone())
+                .collect();
+
+            commits == [three.clone(), two.clone(), one.clone()]
+        });
+
+        assert_that!(release.version.to_string()).is_equal_to("0.2.0".to_string());
+        assert_that!(release.from.to_string()).is_equal_to("0.1.0".to_string());
+
+        let expected_commits: Vec<String> = release
+            .commits
+            .into_iter()
+            .map(|commit| commit.commit.oid)
+            .collect();
+
+        assert_that!(expected_commits).is_equal_to(vec![five, four]);
+
+        Ok(())
+    }
+
+    #[test]
+    fn get_release_range_integration_test() -> Result<()> {
+        // Arrange
+        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
+        let range = repo.revwalk("0.32.1..0.32.3")?;
+
+        // Act
+        let release = Release::try_from(range)?;
+
+        // Assert
+        assert_that!(release.version.to_string()).is_equal_to("0.32.3".to_string());
+
+        let release = *release.previous.unwrap();
+        assert_that!(release.version.to_string()).is_equal_to("0.32.2".to_string());
+
+        assert_that!(release.previous).is_none();
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn get_annotated_tag_commits() -> Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+        let first = commit("chore: init")?;
+        let _second = commit("chore: 1.0.0")?;
+
+        // Create an annotated tag
+        let head = repo.get_head_commit()?;
+        let sig = git2::Signature::now("Author", "email@example.com")?;
+        repo.0
+            .tag("1.0.0", &head.into_object(), &sig, "the_msg", false)?;
+
+        commit("feat: a commit")?;
+
+        // Act
+        let commit_range = repo.revwalk("..1.0.0")?;
+
+        // Assert
+        assert_that!(commit_range.from_oid())
+            .is_equal_to(OidOf::FirstCommit(Oid::from_str(&first)?));
+        assert_that!(commit_range.to_oid().to_string()).is_equal_to("1.0.0".to_string());
+        assert_that!(commit_range.0).has_length(2);
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn get_package_commit_range() -> Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+        let mut packages = HashMap::new();
+        packages.insert(
+            "one".to_string(),
+            MonoRepoPackage {
+                path: PathBuf::from("one"),
+                ..Default::default()
+            },
+        );
+
+        let settings = Settings {
+            packages,
+            ..Default::default()
+        };
+
+        let settings = toml::to_string(&settings)?;
+
+        run_cmd!(
+            echo $settings > cog.toml;
+            git add .;
+            git commit -m "chore: First commit";
+            mkdir one;
+            echo changes > one/file;
+            git add .;
+            git commit -m "feat: package one";
+            echo changes > global;
+            git add .;
+            git commit -m "feat: global change";
+        )?;
+
+        let commit_range_package = repo.get_commit_range_for_package("..HEAD", "one")?;
+        let commit_range_global = repo.get_commit_range_for_monorepo_global("..HEAD");
+        let commit_range_global = commit_range_global?;
+        assert_that!(commit_range_package.0).has_length(1);
+        assert_that!(commit_range_global.0).has_length(2);
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn package_with_changes_have_some_commits() -> Result<()> {
+        let repo = init_mono_repo_for_range_filtering()?;
+
+        run_cmd!(
+            echo changes > one/file;
+            git add .;
+            git commit -m "feat: commit to non-ignored path";
+        )?;
+
+        let commit_range_package = repo.get_commit_range_for_package("HEAD~1..HEAD", "one")?;
+
+        asserting!("package with changes have some commits")
+            .that(&commit_range_package.0)
+            .mapped_contains(
+                |(_, commit)| commit.message(),
+                &Some("feat: commit to non-ignored path\n"),
+            );
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn package_with_no_changes_should_have_no_commit() -> Result<()> {
+        let repo = init_mono_repo_for_range_filtering()?;
+
+        run_cmd!(
+            echo changes > one/ignored/file;
+            git add .;
+            git commit -m "feat: commit to ignored path";
+        )?;
+
+        let commit_range_package = repo.get_commit_range_for_package("HEAD~1..HEAD", "one")?;
+
+        asserting!("package with no changes should have some commits for HEAD~1 revspec")
+            .that(&commit_range_package.0)
+            .has_length(0);
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn package_with_shared_changes_should_have_some_commits() -> Result<()> {
+        let repo = init_mono_repo_for_range_filtering()?;
+
+        run_cmd!(
+            echo changes > shared/file;
+            git add .;
+            git commit -m "feat: commit to extra included path";
+        )?;
+
+        let commit_range_package = repo.get_commit_range_for_package("HEAD~1..HEAD", "one")?;
+
+        asserting!("package with shared changes should have some commits for HEAD~1 revspec")
+            .that(&commit_range_package.0)
+            .mapped_contains(
+                |(_, commit)| commit.message(),
+                &Some("feat: commit to extra included path\n"),
+            );
+
+        assert_that!(commit_range_package.0).has_length(1);
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn package_with_extra_included_changes_should_have_some_commits() -> Result<()> {
+        let repo = init_mono_repo_for_range_filtering()?;
+
+        run_cmd!(
+            echo changes > README.md;
+            git add .;
+            git commit -m "feat: commit to extra included file";
+        )?;
+
+        let commit_range_package = repo.get_commit_range_for_package("HEAD~1..HEAD", "one")?;
+
+        asserting!(
+            "package with extra included changes should have some commits for HEAD~1 revspec"
+        )
+        .that(&commit_range_package.0)
+        .mapped_contains(
+            |(_, commit)| commit.message(),
+            &Some("feat: commit to extra included file\n"),
+        );
+
+        assert_that!(commit_range_package.0).has_length(1);
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn package_with_with_only_ignored_path_should_have_no_commit() -> Result<()> {
+        let repo = init_mono_repo_for_range_filtering()?;
+        let commit_range_package = repo.get_commit_range_for_package("..HEAD", "one")?;
+        asserting!("package with with only ignored path commit should have no commits for full range revspec")
+            .that(&commit_range_package.0).has_length(0);
+
+        Ok(())
+    }
+
+    fn init_mono_repo_for_range_filtering() -> Result<Repository> {
+        let repo = git_init_no_gpg()?;
+        let mut packages = HashMap::new();
+        packages.insert(
+            "one".to_string(),
+            MonoRepoPackage {
+                path: PathBuf::from("one"),
+                include: vec![String::from("shared/**"), String::from("README.md")],
+                ignore: vec![String::from("one/ignored/**")],
+                ..Default::default()
+            },
+        );
+
+        let settings = Settings {
+            packages,
+            ..Default::default()
+        };
+
+        let settings = toml::to_string(&settings)?;
+
+        run_cmd!(
+            echo $settings > cog.toml;
+            mkdir -p shared one/ignored;
+            git add .;
+            git commit -m "chore: First commit";
+        )?;
+        Ok(repo)
+    }
+
+    #[sealed_test]
+    fn get_tag_commits() -> Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+
+        let first = commit("chore: init")?;
+        commit("chore: 1.0.0")?;
+        git_tag("1.0.0")?;
+        commit("feat: a commit")?;
+
+        // Act
+        let commit_range = repo.revwalk("..1.0.0")?;
+
+        // Assert
+        assert_that!(commit_range.from_oid().to_string()).is_equal_to(first);
+        assert_that!(commit_range.to_oid().to_string()).is_equal_to("1.0.0".to_string());
+        assert_that!(commit_range.0).has_length(2);
+        Ok(())
+    }
+
+    #[test]
+    fn from_tag_to_tag_ok() -> Result<()> {
+        // Arrange
+        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
+        let v1_0_0 = Oid::from_str("549070fa99986b059cbaa9457b6b6f065bbec46b")?;
+        let _v1_0_0 = OidOf::Tag(Tag::from_str("1.0.0", Some(v1_0_0), None)?);
+        let v3_0_0 = Oid::from_str("c6508e243e2816e2d2f58828ee0c6721502958dd")?;
+        let v3_0_0 = OidOf::Tag(Tag::from_str("3.0.0", Some(v3_0_0), None)?);
+
+        // Act
+        let range = repo.revwalk("1.0.0..3.0.0")?;
+
+        // Assert
+        assert_that!(range.from_oid().to_string())
+            .is_equal_to("7c4a1cb692b445f36a44857ce17db32b91acd24c".to_string());
+        assert_that!(range.to_oid()).is_equal_to(v3_0_0);
+
+        Ok(())
+    }
+
+    #[test]
+    fn from_tag_to_head() -> Result<()> {
+        // Arrange
+        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
+        let head = repo.get_head_commit_oid()?;
+        let head = OidOf::Head(head);
+        let tag = repo.get_latest_tag(TagLookUpOptions::default())?;
+
+        // Cover the case when we release a version and run the test in the CI right after that
+        let head = if tag.oid() == Some(head.oid()) {
+            OidOf::Tag(tag)
+        } else {
+            head
+        };
+
+        let v1_0_0 = Oid::from_str("549070fa99986b059cbaa9457b6b6f065bbec46b")?;
+        let _v1_0_0 = OidOf::Tag(Tag::from_str("1.0.0", Some(v1_0_0), None)?);
+
+        // Act
+        let range = repo.revwalk("1.0.0..")?;
+
+        // Assert
+        assert_that!(range.from_oid().oid())
+            .is_equal_to(&Oid::from_str("7c4a1cb692b445f36a44857ce17db32b91acd24c")?);
+        assert_that!(range.to_oid()).is_equal_to(head);
+
+        Ok(())
+    }
+
+    #[test]
+    fn from_previous_to_tag() -> Result<()> {
+        // Arrange
+        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
+        let v2_1_1 = Oid::from_str("9dcf728d2eef6b5986633dd52ecbe9e416234898")?;
+        let _v2_1_1 = OidOf::Tag(Tag::from_str("2.1.1", Some(v2_1_1), None)?);
+        let v3_0_0 = Oid::from_str("c6508e243e2816e2d2f58828ee0c6721502958dd")?;
+        let v3_0_0 = OidOf::Tag(Tag::from_str("3.0.0", Some(v3_0_0), None)?);
+
+        // Act
+        let range = repo.revwalk("2.1.1..3.0.0")?;
+
+        // Assert
+        assert_that!(range.from_oid().oid())
+            .is_equal_to(&Oid::from_str("434c22295390fda0f276e3a3ee32fa4658489c5d")?);
+        assert_that!(range.to_oid()).is_equal_to(v3_0_0);
+
+        Ok(())
+    }
+
+    #[test]
+    fn recursive_from_origin_to_head() -> Result<()> {
+        // Arrange
+        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
+        let mut tag_count = repo.0.tag_names(None)?.len();
+        let head = repo.get_head_commit_oid()?;
+        let latest = repo.get_latest_tag(TagLookUpOptions::default())?;
+        let latest = latest.oid();
+        if latest == Some(&head) {
+            tag_count -= 1;
+        };
+
+        let range = repo.revwalk("..")?;
+
+        // Act
+        let mut release = Release::try_from(range)?;
+        let mut count = 0;
+
+        while let Some(previous) = release.previous {
+            release = *previous;
+            count += 1;
+        }
+
+        // Assert
+        assert_that!(count).is_equal_to(tag_count);
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn from_commit_to_head() -> Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+
+        commit("chore: init")?;
+        commit("feat: a commit")?;
+        let one = commit("chore: another commit")?;
+        let two = commit("feat: a feature")?;
+        let three = commit("chore: 1.0.0")?;
+        let four = commit("fix: the bug")?;
+
+        let range = repo.revwalk(&format!("{}..", &one[0..7]))?;
+
+        // Act
+        let release = Release::try_from(range)?;
+
+        // Assert
+        let actual_oids: Vec<String> = release
+            .commits
+            .iter()
+            .map(|commit| commit.commit.oid.to_string())
+            .collect();
+
+        assert_that!(actual_oids).is_equal_to(vec![four, three, two]);
+
+        Ok(())
+    }
+
+    #[sealed_test]
+    fn from_commit_to_head_with_overlapping_tag() -> Result<()> {
+        // Arrange
+        let repo = git_init_no_gpg()?;
+
+        commit("chore: init")?;
+        commit("feat: a commit")?;
+
+        let from = commit("chore: another commit")?;
+        let one = commit("feat: a feature")?;
+        let two = commit("chore: 1.0.0")?;
+        git_tag("1.0.0")?;
+        let three = commit("fix: the bug")?;
+
+        let range = repo.revwalk(&format!("{}..", &from[0..7]))?;
+
+        // Act
+        let release = Release::try_from(range)?;
+
+        // Assert
+        let head_to_v1: Vec<String> = release
+            .commits
+            .iter()
+            .map(|commit| commit.commit.oid.to_string())
+            .collect();
+
+        let commit_before_v1: Vec<String> = release
+            .previous
+            .unwrap()
+            .commits
+            .iter()
+            .map(|commit| commit.commit.oid.to_string())
+            .collect();
+
+        assert_that!(head_to_v1).is_equal_to(vec![three]);
+        assert_that!(commit_before_v1).is_equal_to(vec![two, one]);
+
+        Ok(())
+    }
+}
diff --git a/src/git/revspec.rs b/src/git/revspec.rs
deleted file mode 100644
index bec3aa5..0000000
--- a/src/git/revspec.rs
+++ /dev/null
@@ -1,934 +0,0 @@
-use std::fmt;
-use std::fmt::Formatter;
-use std::path::Path;
-use std::str::FromStr;
-
-use git2::{Commit, DescribeFormatOptions, DescribeOptions, ErrorCode, Oid};
-use globset::{Candidate, GlobBuilder, GlobSet, GlobSetBuilder};
-
-use crate::conventional::changelog::release::Release;
-use crate::git::error::Git2Error;
-use crate::git::oid::OidOf;
-use crate::git::repository::Repository;
-use crate::git::tag::Tag;
-use crate::settings::MonoRepoPackage;
-use crate::SETTINGS;
-
-#[derive(Debug)]
-pub struct CommitRange<'repo> {
-    pub from: OidOf,
-    pub to: OidOf,
-    pub commits: Vec<Commit<'repo>>,
-}
-
-#[derive(Debug, Default)]
-pub struct RevspecPattern {
-    from: Option<String>,
-    to: Option<String>,
-}
-
-impl fmt::Display for RevspecPattern {
-    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-        let from = self.from.as_deref().unwrap_or("");
-        let to = self.to.as_deref().unwrap_or("");
-        write!(f, "{from}..{to}")
-    }
-}
-
-impl FromStr for RevspecPattern {
-    type Err = Git2Error;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        if let Some((from, to)) = s.split_once("..") {
-            let from = if from.is_empty() {
-                None
-            } else {
-                Some(from.to_string())
-            };
-
-            let to = if to.is_empty() {
-                None
-            } else {
-                Some(to.to_string())
-            };
-
-            Ok(RevspecPattern { from, to })
-        } else {
-            Err(Git2Error::InvalidCommitRangePattern(s.to_string()))
-        }
-    }
-}
-
-impl From<(&str, &str)> for RevspecPattern {
-    fn from((from, to): (&str, &str)) -> Self {
-        Self {
-            from: Some(from.to_string()),
-            to: Some(to.to_string()),
-        }
-    }
-}
-
-#[derive(Debug)]
-struct PackagePathFilter {
-    include: GlobSet,
-    exclude: GlobSet,
-}
-
-impl PackagePathFilter {
-    fn new(package_path: &str, include_paths: &[String], exclude_paths: &[String]) -> Self {
-        let include = {
-            let mut builder = GlobSetBuilder::new();
-            builder.add(
-                GlobBuilder::new(format!("{}/**", package_path).as_str())
-                    .literal_separator(true)
-                    .build()
-                    .expect("glob should be valid"),
-            );
-            for include in include_paths {
-                builder.add(
-                    GlobBuilder::new(include)
-                        .literal_separator(true)
-                        .build()
-                        .expect("glob should be valid"),
-                );
-            }
-            builder.build().expect("valid globset")
-        };
-        let exclude = {
-            let mut builder = GlobSetBuilder::new();
-            for exclude in exclude_paths {
-                builder.add(
-                    GlobBuilder::new(exclude)
-                        .literal_separator(true)
-                        .build()
-                        .expect("glob should be valid"),
-                );
-            }
-            builder.build().expect("valid globset")
-        };
-
-        PackagePathFilter { include, exclude }
-    }
-
-    fn from_package(package: &MonoRepoPackage) -> Self {
-        Self::new(
-            package.path.to_str().expect("valid package path"),
-            &package.include,
-            &package.ignore,
-        )
-    }
-
-    fn is_match<P: AsRef<Path> + ?Sized>(&self, path: &P) -> bool {
-        let candidate = Candidate::new(path);
-        self.include.is_match_candidate(&candidate) && !self.exclude.is_match_candidate(&candidate)
-    }
-}
-
-impl Repository {
-    /// Return a [`CommitRange`] containing all commit in the current repository
-    pub fn all_commits(&self) -> Result<CommitRange, Git2Error> {
-        let mut revwalk = self.0.revwalk()?;
-        revwalk.push_head()?;
-        let mut commits = vec![];
-
-        for oid in revwalk {
-            match oid {
-                Ok(oid) => {
-                    let commit = self.0.find_commit(oid)?;
-                    commits.push(commit)
-                }
-                Err(e) if e.code() == ErrorCode::NotFound => {
-                    break;
-                }
-                Err(e) => return Err(Git2Error::from(e)),
-            }
-        }
-
-        let to = commits
-            .first()
-            .map(|commit| commit.id())
-            .map(OidOf::Head)
-            .expect("No commit found");
-
-        let from = commits
-            .last()
-            .map(|commit| commit.id())
-            .map(OidOf::Other)
-            .expect("No commit found");
-
-        Ok(CommitRange { from, to, commits })
-    }
-
-    pub(crate) fn get_release_range(&self, pattern: RevspecPattern) -> Result<Release, Git2Error> {
-        let target = if let Some(target) = pattern.from {
-            self.resolve_oid_of(&target)
-        } else {
-            OidOf::Other(self.get_first_commit()?)
-        };
-
-        let pattern = RevspecPattern {
-            from: None,
-            to: pattern.to,
-        };
-
-        let range = self.get_commit_range(&pattern)?;
-        let release = Release::from(range);
-
-        let mut release = if !release.contains_oid(target.oid()) {
-            self.populate_previous_release(release, target.oid())?
-        } else {
-            release
-        };
-
-        release.drain_to_target(target.oid());
-
-        Ok(release)
-    }
-
-    fn populate_previous_release<'a>(
-        &'a self,
-        mut release: Release<'a>,
-        target: &Oid,
-    ) -> Result<Release<'a>, Git2Error> {
-        let pattern = format!("..{}", release.from);
-        let pattern = RevspecPattern::from_str(pattern.as_str())?;
-        let range = self.get_commit_range(&pattern)?;
-
-        let target_in_range = range.commits.iter().any(|commit| commit.id() == *target);
-
-        // Target tag or commit reached
-        if range.to.oid() == target {
-            return Ok(release);
-        }
-        // We have reached the `from` target commit
-        else if target_in_range {
-            if range.from != range.to {
-                let previous = Release::from(range);
-                release.previous = Some(Box::new(previous));
-            }
-
-            return Ok(release);
-        }
-
-        let previous = Release::from(range);
-        let previous = self.populate_previous_release(previous, target)?;
-        release.previous = Some(Box::new(previous));
-
-        Ok(release)
-    }
-
-    /// Return a commit range
-    /// `from` : either a tag or an oid, latest tag if none, fallbacks to first commit
-    /// `to`: HEAD if none
-    pub fn get_commit_range(&self, pattern: &RevspecPattern) -> Result<CommitRange, Git2Error> {
-        let from = pattern.from.as_deref();
-        let to = pattern.to.as_deref();
-
-        // Is the given `to` arg a tag or an oid ?
-        let maybe_to_tag = match to {
-            // No target tag provided, check if HEAD is tagged
-            None => {
-                let head = self.get_head_commit_oid()?;
-                self.get_latest_tag()
-                    .ok()
-                    .filter(|tag| *tag.oid_unchecked() == head)
-            }
-            // Try to resolve a tag from the provided range, ex: ..1.0.0
-            Some(to) => self.resolve_tag(to).ok(),
-        };
-
-        // get/validate the target oid
-        let to = match to {
-            None => self.get_head_commit_oid()?,
-            Some(to) => self.0.revparse_single(to)?.id(),
-        };
-
-        // Either user input, latest tag since `to`, or first commit
-        let from = match from {
-            // No `from` arg provided get latest tag in `to` parents
-            None => self
-                .get_latest_tag_starting_from(to)
-                .map(OidOf::Tag)
-                // No tag in the tree, fallback to first commit
-                .unwrap_or_else(|_| {
-                    self.get_first_commit()
-                        .map(OidOf::Other)
-                        .expect("No commit found")
-                }),
-            // We might have a tag
-            Some(from) => self.resolve_oid_of(from),
-        };
-
-        // Resolve shorthands and tags
-        let spec = format!("{from}..{to}");
-        // Attempt to resolve tag names, fallback to oid
-        let to = maybe_to_tag
-            .map(OidOf::Tag)
-            .unwrap_or_else(|| OidOf::Other(to));
-
-        let commits = self.get_commit_range_from_spec(&spec)?;
-
-        Ok(CommitRange { from, to, commits })
-    }
-
-    pub fn get_commit_range_for_package(
-        &self,
-        pattern: &RevspecPattern,
-        package: &str,
-    ) -> Result<CommitRange, Git2Error> {
-        let mut commit_range = self.get_commit_range(pattern)?;
-        let mut commits = vec![];
-        let package = SETTINGS.packages.get(package).expect("package exists");
-        let package_path_filter = PackagePathFilter::from_package(package);
-
-        for commit in commit_range.commits {
-            let parent = commit.parent(0).ok().map(|commit| commit.id().to_string());
-
-            let parent_tree = self.tree_to_treeish(parent.as_ref())?;
-
-            let current_tree = self
-                .tree_to_treeish(Some(&commit.id().to_string()))?
-                .expect("Failed to get commit tree");
-
-            let diff = match parent_tree {
-                None => self
-                    .0
-                    .diff_tree_to_tree(None, current_tree.as_tree(), None)?,
-                Some(tree) => {
-                    self.0
-                        .diff_tree_to_tree(tree.as_tree(), current_tree.as_tree(), None)?
-                }
-            };
-
-            for delta in diff.deltas() {
-                if let Some(old) = delta.old_file().path() {
-                    if package_path_filter.is_match(old) {
-                        commits.push(commit);
-                        break;
-                    }
-                }
-
-                if let Some(new) = delta.new_file().path() {
-                    if package_path_filter.is_match(new) {
-                        commits.push(commit);
-                        break;
-                    }
-                }
-            }
-        }
-
-        commit_range.commits = commits;
-        Ok(commit_range)
-    }
-
-    pub fn get_commit_range_for_monorepo_global(
-        &self,
-        pattern: &RevspecPattern,
-    ) -> Result<CommitRange, Git2Error> {
-        let mut commit_range = self.get_commit_range(pattern)?;
-        let mut commits = vec![];
-        let package_paths: Vec<_> = SETTINGS
-            .packages
-            .values()
-            .map(|package| &package.path)
-            .collect();
-
-        for commit in commit_range.commits {
-            let parent = commit.parent(0)?.id().to_string();
-            let t1 = self
-                .tree_to_treeish(Some(&parent))?
-                .expect("Failed to get parent tree");
-
-            let t2 = self
-                .tree_to_treeish(Some(&commit.id().to_string()))?
-                .expect("Failed to get commit tree");
-
-            let diff = self.0.diff_tree_to_tree(t1.as_tree(), t2.as_tree(), None)?;
-
-            for delta in diff.deltas() {
-                if let Some(old) = delta.old_file().path() {
-                    if package_paths.iter().all(|path| !old.starts_with(path)) {
-                        commits.push(commit);
-                        break;
-                    }
-                }
-
-                if let Some(new) = delta.new_file().path() {
-                    if package_paths.iter().all(|path| !new.starts_with(path)) {
-                        commits.push(commit);
-                        break;
-                    }
-                }
-            }
-        }
-
-        commit_range.commits = commits;
-        Ok(commit_range)
-    }
-
-    fn resolve_oid_of(&self, from: &str) -> OidOf {
-        // either we have a tag name
-        self.resolve_tag(from)
-            .map(OidOf::Tag)
-            // Or an oid
-            .unwrap_or_else(|_| {
-                let object = self.0.revparse_single(from).expect("Expected oid or tag");
-
-                // Is the oid pointing to a tag ?
-                let tag = self
-                    .all_tags()
-                    .expect("Error trying to get repository tags")
-                    .into_iter()
-                    .find(|tag| *tag.oid_unchecked() == object.id());
-
-                match tag {
-                    None => OidOf::Other(object.id()),
-                    Some(tag) => OidOf::Tag(tag),
-                }
-            })
-    }
-
-    fn get_commit_range_from_spec(&self, spec: &str) -> Result<Vec<Commit>, Git2Error> {
-        let mut revwalk = self.0.revwalk()?;
-
-        revwalk.push_range(spec)?;
-
-        let mut commits: Vec<Commit> = vec![];
-
-        for oid in revwalk {
-            let oid = oid?;
-            let commit = self.0.find_commit(oid)?;
-            commits.push(commit);
-        }
-
-        Ok(commits)
-    }
-
-    // Hide all commit after `starting_point` and get the closest tag
-    fn get_latest_tag_starting_from(&self, starting_point: Oid) -> Result<Tag, Git2Error> {
-        let starting_point = self.0.find_commit(starting_point)?;
-        let starting_point = starting_point.parent(0)?;
-        let describe = starting_point
-            .as_object()
-            .describe(DescribeOptions::new().describe_tags())?;
-        let tag_name = describe.format(Some(DescribeFormatOptions::new().abbreviated_size(0)))?;
-        let tag_oid = self.0.refname_to_id(&format!("refs/tags/{tag_name}")).ok();
-        Tag::from_str(&tag_name, tag_oid).map_err(|_| Git2Error::NoTagFound)
-    }
-}
-
-#[cfg(test)]
-mod test {
-    use crate::conventional::changelog::release::Release;
-    use anyhow::{anyhow, Result};
-    use cmd_lib::{run_cmd, run_fun};
-    use git2::Oid;
-    use sealed_test::prelude::*;
-    use speculoos::prelude::*;
-    use std::collections::HashMap;
-    use std::path::PathBuf;
-    use std::str::FromStr;
-
-    use crate::git::oid::OidOf;
-    use crate::git::repository::Repository;
-    use crate::git::revspec::RevspecPattern;
-    use crate::git::tag::Tag;
-    use crate::settings::{MonoRepoPackage, Settings};
-
-    const COCOGITTO_REPOSITORY: &str = env!("CARGO_MANIFEST_DIR");
-
-    #[test]
-    fn convert_str_to_pattern_to() -> Result<()> {
-        let pattern = RevspecPattern::from_str("..1.0.0")?;
-
-        assert_that!(pattern.from).is_none();
-        assert_that!(pattern.to)
-            .is_some()
-            .is_equal_to("1.0.0".to_string());
-        Ok(())
-    }
-
-    #[test]
-    fn convert_str_to_pattern_from() -> Result<()> {
-        let pattern = RevspecPattern::from_str("1.0.0..")?;
-
-        assert_that!(pattern.from)
-            .is_some()
-            .is_equal_to("1.0.0".to_string());
-        assert_that!(pattern.to).is_none();
-        Ok(())
-    }
-
-    #[test]
-    fn convert_empty_pattern() -> Result<()> {
-        let pattern = RevspecPattern::from_str("..")?;
-
-        assert_that!(pattern.from).is_none();
-        assert_that!(pattern.to).is_none();
-        Ok(())
-    }
-
-    #[test]
-    fn error_invalid_pattern() {
-        let result = RevspecPattern::from_str("1.0.0");
-        assert!(result.is_err());
-        assert_eq!(
-            result.unwrap_err().to_string(),
-            "invalid commit range pattern: `1.0.0`\n"
-        );
-    }
-
-    #[test]
-    fn convert_full_pattern() -> Result<()> {
-        let pattern = RevspecPattern::from_str("1.0.0..2.0.0")?;
-
-        assert_that!(pattern.from)
-            .is_some()
-            .is_equal_to("1.0.0".to_string());
-        assert_that!(pattern.to)
-            .is_some()
-            .is_equal_to("2.0.0".to_string());
-        Ok(())
-    }
-
-    #[test]
-    fn all_commits() -> Result<()> {
-        // Arrange
-        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
-
-        // Act
-        let range = repo.all_commits()?;
-
-        // Assert
-        assert_that!(range.commits).is_not_empty();
-        Ok(())
-    }
-
-    #[test]
-    fn get_release_range_integration_test() -> Result<()> {
-        // Arrange
-        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
-        let format_version = |release: &Release| format!("{}", release.version);
-
-        // Act
-        let release = repo.get_release_range(RevspecPattern::from_str("0.32.1..0.32.3")?)?;
-
-        // Assert
-        assert_that!(format_version(&release)).is_equal_to("0.32.3".to_string());
-
-        let release = *release.previous.unwrap();
-        assert_that!(format_version(&release)).is_equal_to("0.32.2".to_string());
-
-        assert_that!(release.previous).is_none();
-        Ok(())
-    }
-
-    #[sealed_test]
-    fn get_annotated_tag_commits() -> Result<()> {
-        // Arrange
-        let repo = Repository::init(".")?;
-        run_cmd!(
-            git init;
-            echo changes > file;
-            git add .;
-        )?;
-
-        let start = repo.commit("chore: init", false, false)?;
-
-        run_cmd!(
-            git init;
-            echo changes > file2;
-            git add .;
-        )?;
-
-        let _end = repo.commit("chore: 1.0.0", false, false)?;
-
-        // Create an annotated tag
-        let head = repo.get_head_commit().unwrap();
-        let sig = git2::Signature::now("Author", "email@example.com")?;
-        repo.0
-            .tag("1.0.0", &head.into_object(), &sig, "the_msg", false)?;
-
-        run_cmd!(
-            git init;
-            echo changes > file3;
-            git add .;
-        )?;
-
-        repo.commit("feat: a commit", false, false)?;
-
-        let commit_range = repo.get_commit_range(&RevspecPattern::from_str("..1.0.0")?)?;
-
-        assert_that!(commit_range.from).is_equal_to(OidOf::Other(start));
-        assert_that!(commit_range.to.to_string()).is_equal_to("1.0.0".to_string());
-        assert_that!(commit_range.commits).has_length(1);
-        Ok(())
-    }
-
-    #[sealed_test]
-    fn get_package_commit_range() -> Result<()> {
-        // Arrange
-        let repo = Repository::init(".")?;
-        let mut packages = HashMap::new();
-        packages.insert(
-            "one".to_string(),
-            MonoRepoPackage {
-                path: PathBuf::from("one"),
-                ..Default::default()
-            },
-        );
-
-        let settings = Settings {
-            packages,
-            ..Default::default()
-        };
-
-        let settings = toml::to_string(&settings)?;
-
-        run_cmd!(
-            git init;
-            echo $settings > cog.toml;
-            git add .;
-            git commit -m "chore: First commit";
-            mkdir one;
-            echo changes > one/file;
-            git add .;
-            git commit -m "feat: package one";
-            echo changes > global;
-            git add .;
-            git commit -m "feat: global change";
-        )?;
-
-        let commit_range_package =
-            repo.get_commit_range_for_package(&RevspecPattern::from_str("..HEAD")?, "one")?;
-        let commit_range_global =
-            repo.get_commit_range_for_monorepo_global(&RevspecPattern::from_str("..HEAD")?)?;
-
-        assert_that!(commit_range_package.commits).has_length(1);
-        assert_that!(commit_range_global.commits).has_length(1);
-        Ok(())
-    }
-
-    #[sealed_test]
-    fn get_package_commit_range_with_filtering() -> Result<()> {
-        // Arrange
-        let repo = Repository::init(".")?;
-        let mut packages = HashMap::new();
-        packages.insert(
-            "one".to_string(),
-            MonoRepoPackage {
-                path: PathBuf::from("one"),
-                include: vec![String::from("shared/**"), String::from("README.md")],
-                ignore: vec![String::from("one/ignored/**")],
-                ..Default::default()
-            },
-        );
-
-        let settings = Settings {
-            packages,
-            ..Default::default()
-        };
-
-        let settings = toml::to_string(&settings)?;
-
-        run_cmd!(
-            git init;
-            echo $settings > cog.toml;
-            mkdir -p shared one/ignored;
-            git add .;
-            git commit -m "chore: First commit";
-        )?;
-
-        let commit_range_package =
-            repo.get_commit_range_for_package(&RevspecPattern::from_str("..HEAD")?, "one")?;
-        assert_that!(commit_range_package.commits).has_length(0);
-
-        run_cmd!(
-            echo changes > one/file;
-            git add .;
-            git commit -m "feat: commit to non-ignored path";
-        )?;
-
-        let commit_range_package =
-            repo.get_commit_range_for_package(&RevspecPattern::from_str("HEAD~1..HEAD")?, "one")?;
-        assert_that!(commit_range_package.commits).has_length(1);
-
-        run_cmd!(
-            echo changes > one/ignored/file;
-            git add .;
-            git commit -m "feat: commit to ignored path";
-        )?;
-
-        let commit_range_package =
-            repo.get_commit_range_for_package(&RevspecPattern::from_str("HEAD~1..HEAD")?, "one")?;
-        assert_that!(commit_range_package.commits).has_length(0);
-
-        run_cmd!(
-            echo changes > shared/file;
-            git add .;
-            git commit -m "feat: commit to extra included path";
-        )?;
-
-        let commit_range_package =
-            repo.get_commit_range_for_package(&RevspecPattern::from_str("HEAD~1..HEAD")?, "one")?;
-        assert_that!(commit_range_package.commits).has_length(1);
-
-        run_cmd!(
-            echo changes > README.md;
-            git add .;
-            git commit -m "feat: commit to extra included file";
-        )?;
-
-        let commit_range_package =
-            repo.get_commit_range_for_package(&RevspecPattern::from_str("HEAD~1..HEAD")?, "one")?;
-        assert_that!(commit_range_package.commits).has_length(1);
-
-        Ok(())
-    }
-
-    #[sealed_test]
-    fn get_tag_commits() -> Result<()> {
-        // Arrange
-        let repo = Repository::init(".")?;
-
-        run_cmd!(
-            git init;
-            echo changes > file;
-            git add .;
-        )?;
-
-        let start = repo.commit("chore: init", false, false)?;
-
-        run_cmd!(
-            git commit --allow-empty -m "chore: 1.0.0";
-            git tag 1.0.0;
-            git commit --allow-empty -m "feat: a commit";
-        )?;
-
-        // Act
-        let commit_range = repo.get_commit_range(&RevspecPattern::from_str("..1.0.0")?)?;
-
-        // Assert
-        assert_that!(commit_range.from).is_equal_to(OidOf::Other(start));
-        assert_that!(commit_range.to.to_string()).is_equal_to("1.0.0".to_string());
-        assert_that!(commit_range.commits).has_length(1);
-        Ok(())
-    }
-
-    #[test]
-    fn from_tag_to_tag_ok() -> Result<()> {
-        // Arrange
-        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
-        let v1_0_0 = Oid::from_str("549070fa99986b059cbaa9457b6b6f065bbec46b")?;
-        let v1_0_0 = OidOf::Tag(Tag::from_str("1.0.0", Some(v1_0_0))?);
-        let v3_0_0 = Oid::from_str("c6508e243e2816e2d2f58828ee0c6721502958dd")?;
-        let v3_0_0 = OidOf::Tag(Tag::from_str("3.0.0", Some(v3_0_0))?);
-
-        // Act
-        let range = repo.get_commit_range(&RevspecPattern::from_str("1.0.0..3.0.0")?)?;
-
-        // Assert
-        assert_that!(range.from).is_equal_to(v1_0_0);
-        assert_that!(range.to).is_equal_to(v3_0_0);
-
-        Ok(())
-    }
-
-    #[test]
-    fn from_tag_to_head() -> Result<()> {
-        // Arrange
-        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
-        let head = repo.get_head_commit_oid()?;
-        let head = OidOf::Other(head);
-        let tag = repo.get_latest_tag()?;
-
-        // Cover the case when we release a version and run the test in the CI right after that
-        let head = if tag.oid() == Some(head.oid()) {
-            OidOf::Tag(tag)
-        } else {
-            head
-        };
-
-        let v1_0_0 = Oid::from_str("549070fa99986b059cbaa9457b6b6f065bbec46b")?;
-        let v1_0_0 = OidOf::Tag(Tag::from_str("1.0.0", Some(v1_0_0))?);
-
-        // Act
-        let range = repo.get_commit_range(&RevspecPattern::from_str("1.0.0..")?)?;
-
-        // Assert
-        assert_that!(range.from).is_equal_to(v1_0_0);
-        assert_that!(range.to).is_equal_to(head);
-
-        Ok(())
-    }
-
-    #[test]
-    fn from_latest_to_head() -> Result<()> {
-        // Arrange
-        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
-        let head = repo.get_head_commit_oid()?;
-        let head = OidOf::Other(head);
-        let mut tags = repo.all_tags()?;
-        tags.sort();
-        let mut latest = tags.last().unwrap();
-
-        if latest.oid().unwrap() == head.oid() {
-            latest = &tags[tags.len() - 2];
-        }
-
-        let latest = OidOf::Tag(latest.clone());
-
-        // Act
-        let range = repo.get_commit_range(&RevspecPattern::default())?;
-
-        // Assert
-        assert_that!(range.from).is_equal_to(latest);
-        assert_that!(range.to.oid()).is_equal_to(head.oid());
-
-        Ok(())
-    }
-
-    #[test]
-    fn from_previous_to_tag() -> Result<()> {
-        // Arrange
-        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
-        let v2_1_1 = Oid::from_str("9dcf728d2eef6b5986633dd52ecbe9e416234898")?;
-        let v2_1_1 = OidOf::Tag(Tag::from_str("2.1.1", Some(v2_1_1))?);
-        let v3_0_0 = Oid::from_str("c6508e243e2816e2d2f58828ee0c6721502958dd")?;
-        let v3_0_0 = OidOf::Tag(Tag::from_str("3.0.0", Some(v3_0_0))?);
-
-        // Act
-        let range = repo.get_commit_range(&RevspecPattern::from_str("..3.0.0")?)?;
-
-        // Assert
-        assert_that!(range.from).is_equal_to(v2_1_1);
-        assert_that!(range.to).is_equal_to(v3_0_0);
-
-        Ok(())
-    }
-
-    #[test]
-    fn recursive_from_origin_to_head() -> Result<()> {
-        // Arrange
-        let repo = Repository::open(COCOGITTO_REPOSITORY)?;
-        let mut tag_count = repo.0.tag_names(None)?.len();
-        let head = repo.get_head_commit_oid()?;
-        let latest = repo.get_latest_tag()?;
-        let latest = latest.oid();
-        if latest == Some(&head) {
-            tag_count -= 1;
-        };
-
-        // Act
-        let mut release = repo.get_release_range(RevspecPattern::from_str("..")?)?;
-        let mut count = 0;
-
-        while let Some(previous) = release.previous {
-            release = *previous;
-            count += 1;
-        }
-
-        // Assert
-        assert_that!(count).is_equal_to(tag_count);
-
-        Ok(())
-    }
-
-    #[sealed_test]
-    fn from_commit_to_head() -> Result<()> {
-        // Arrange
-        let repo = Repository::init(".")?;
-
-        let commit: fn(&str) -> Result<String> = |message| {
-            run_fun!(
-                git commit --allow-empty -q -m $message;
-                git log --format=%H -n 1;
-            )
-            .map_err(|e| anyhow!(e))
-        };
-
-        run_cmd!(
-            git init;
-            echo changes > file;
-            git add .;
-        )?;
-
-        commit("chore: init")?;
-        commit("feat: a commit")?;
-
-        let from = commit("chore: another commit")?;
-        let one = commit("feat: a feature")?;
-        let two = commit("chore: 1.0.0")?;
-        let three = commit("fix: the bug")?;
-
-        let pattern = format!("{}..", &from[0..7]);
-
-        // Act
-        let release = repo.get_release_range(RevspecPattern::from_str(pattern.as_str())?)?;
-
-        // Assert
-        let oids: Vec<String> = release
-            .commits
-            .iter()
-            .map(|commit| commit.commit.oid.to_string())
-            .collect();
-
-        assert_that!(oids).is_equal_to(vec![three, two, one, from]);
-
-        Ok(())
-    }
-
-    #[sealed_test]
-    fn from_commit_to_head_with_overlapping_tag() -> Result<()> {
-        // Arrange
-        let repo = Repository::init(".")?;
-
-        let commit: fn(&str) -> Result<String> = |message| {
-            run_fun!(
-                git commit --allow-empty -q -m $message;
-                git log --format=%H -n 1;
-            )
-            .map_err(|e| anyhow!(e))
-        };
-
-        run_cmd!(
-            git init;
-            echo changes > file;
-            git add .;
-        )?;
-
-        commit("chore: init")?;
-        commit("feat: a commit")?;
-
-        let from = commit("chore: another commit")?;
-        let one = commit("feat: a feature")?;
-        let two = commit("chore: 1.0.0")?;
-        run_cmd!(git tag 1.0.0)?;
-        let three = commit("fix: the bug")?;
-
-        let pattern = format!("{}..", &from[0..7]);
-
-        // Act
-        let release = repo.get_release_range(RevspecPattern::from_str(pattern.as_str())?)?;
-
-        // Assert
-        let head_to_v1: Vec<String> = release
-            .commits
-            .iter()
-            .map(|commit| commit.commit.oid.to_string())
-            .collect();
-
-        let commit_before_v1: Vec<String> = release
-            .previous
-            .unwrap()
-            .commits
-            .iter()
-            .map(|commit| commit.commit.oid.to_string())
-            .collect();
-
-        assert_that!(head_to_v1).is_equal_to(vec![three]);
-        assert_that!(commit_before_v1).is_equal_to(vec![two, one, from]);
-
-        Ok(())
-    }
-}
diff --git a/src/git/stash.rs b/src/git/stash.rs
index 8ed0c89..e7e1620 100644
--- a/src/git/stash.rs
+++ b/src/git/stash.rs
@@ -15,7 +15,7 @@ impl Repository {
 
 #[cfg(test)]
 mod test {
-    use crate::git::repository::Repository;
+    use crate::test_helpers::git_init_no_gpg;
     use crate::Tag;
     use anyhow::Result;
     use cmd_lib::run_cmd;
@@ -24,7 +24,7 @@ mod test {
 
     #[sealed_test]
     fn should_stash_failed_bump() -> Result<()> {
-        let mut repo = Repository::init(".")?;
+        let mut repo = git_init_no_gpg()?;
         run_cmd!(git commit -m "Initial commit" --allow-empty;)?;
 
         let statuses = repo.get_statuses()?.0;
@@ -37,7 +37,7 @@ mod test {
         let statuses = repo.get_statuses()?.0;
 
         assert_that!(statuses).has_length(1);
-        repo.stash_failed_version(Tag::from_str("1.0.0", None)?)?;
+        repo.stash_failed_version(Tag::from_str("1.0.0", None, None)?)?;
 
         let statuses = repo.get_statuses()?.0;
         assert_that!(statuses).is_empty();
diff --git a/src/git/status.rs b/src/git/status.rs
index c8dab4c..6b629e3 100644
--- a/src/git/status.rs
+++ b/src/git/status.rs
@@ -130,7 +130,7 @@ mod test {
 
     use crate::git::status::{Changes, Statuses};
 
-    use crate::git::repository::Repository;
+    use crate::test_helpers::git_init_no_gpg;
     use anyhow::{anyhow, Result};
     use git2::StatusOptions;
     use sealed_test::prelude::*;
@@ -139,7 +139,7 @@ mod test {
     #[sealed_test]
     fn get_repo_statuses_empty() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
 
         // Act
         let statuses = repo.get_statuses()?;
@@ -152,7 +152,7 @@ mod test {
     #[sealed_test]
     fn get_repo_statuses_not_empty() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
         std::fs::write("file", "changes")?;
 
         // Act
@@ -166,7 +166,7 @@ mod test {
     #[sealed_test]
     fn should_get_statuses_from_git_statuses() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
         fs::write("file", "content")?;
 
         let mut options = StatusOptions::new();
diff --git a/src/git/tag.rs b/src/git/tag.rs
index 7c56bd2..698e656 100644
--- a/src/git/tag.rs
+++ b/src/git/tag.rs
@@ -1,30 +1,55 @@
+use std::cmp::Ordering;
+use std::fmt;
+use std::fmt::Formatter;
+
+use git2::Oid;
+use semver::Version;
+
 use crate::conventional::version::Increment;
 use crate::git::error::{Git2Error, TagError};
+use crate::git::oid::OidOf;
 use crate::git::repository::Repository;
 use crate::SETTINGS;
-use git2::Oid;
-use semver::Version;
-use std::cmp::Ordering;
-use std::fmt;
-use std::fmt::Formatter;
 
-impl Repository {
-    /// Given a tag name return a [`Tag`], this will fail if the requested
-    /// tag (without configured prefix) is not semver compliant or if the tag
-    /// does not exist.
-    pub fn resolve_tag(&self, tag: &str) -> Result<Tag, TagError> {
-        self.resolve_lightweight_tag(tag)
+#[derive(Debug, Default)]
+pub struct TagLookUpOptions<'a> {
+    include_pre_release: bool,
+    package_name: Option<&'a str>,
+    packages_only: bool,
+    include_packages: bool,
+}
+
+impl<'a> TagLookUpOptions<'a> {
+    /// Perform a tag lookup with pre-release tag included
+    pub fn include_pre_release(mut self) -> Self {
+        self.include_pre_release = true;
+        self
     }
 
-    /// Resolve a tag from a given `&str`, return an error if the tag is not found.
-    fn resolve_lightweight_tag(&self, tag: &str) -> Result<Tag, TagError> {
-        self.0
-            .resolve_reference_from_short_name(tag)
-            .map_err(|err| TagError::not_found(tag, err))
-            .map(|reference| reference.target().unwrap())
-            .map(|oid| Tag::from_str(tag, Some(oid)))?
+    /// Perform a tag lookup, keeping only tags for the given package
+    pub fn package(package: &'a str) -> Self {
+        TagLookUpOptions {
+            include_pre_release: false,
+            package_name: Some(package),
+            packages_only: true,
+            include_packages: true,
+        }
+    }
+
+    /// Perform a tag lookup, keeping only packages tags.
+    pub fn packages_only(mut self) -> Self {
+        self.packages_only = true;
+        self
     }
 
+    /// Perform a tag lookup, mixing non package and package tags.
+    pub fn include_packages(mut self) -> Self {
+        self.include_packages = true;
+        self
+    }
+}
+
+impl Repository {
     pub(crate) fn create_tag(&self, tag: &Tag) -> Result<(), Git2Error> {
         if self.get_diff(true).is_some() {
             let statuses = self.get_statuses()?;
@@ -53,64 +78,74 @@ impl Repository {
     }
 
     /// Get the latest tag, will ignore package tag if on a monorepo
-    pub(crate) fn get_latest_tag(&self) -> Result<Tag, TagError> {
-        let tags: Vec<Tag> = self.all_tags()?;
-        tags.into_iter()
-            .filter(|tag| tag.package.is_none())
-            .filter(|tag| tag.version.pre.is_empty())
-            .max()
-            .ok_or(TagError::NoTag)
-    }
-
-    pub(crate) fn all_tags(&self) -> Result<Vec<Tag>, TagError> {
-        Ok(self
-            .tags()?
-            .iter()
-            .map(|tag| self.resolve_lightweight_tag(tag))
-            .filter_map(Result::ok)
-            .collect())
+    pub(crate) fn get_latest_tag(&self, options: TagLookUpOptions) -> Result<Tag, TagError> {
+        let tags: Vec<Tag> = self.tag_lookup(options)?;
+        tags.into_iter().max().ok_or(TagError::NoTag)
     }
 
-    pub(crate) fn get_latest_tag_oid(&self) -> Result<Oid, TagError> {
-        self.get_latest_tag()
-            .map(|tag| tag.oid_unchecked().to_owned())
-    }
+    pub(crate) fn get_previous_tag(&self, current: &Tag) -> Result<Option<Tag>, TagError> {
+        let mut options = match &current.package {
+            None => TagLookUpOptions::default(),
+            Some(package) => TagLookUpOptions::package(package),
+        };
 
-    fn tags(&self) -> Result<Vec<String>, TagError> {
-        let packages: Vec<&str> = SETTINGS
-            .packages
-            .keys()
-            .map(|profile| -> &str { profile })
+        if !current.version.pre.is_empty() {
+            options.include_pre_release = true
+        }
+
+        let mut tags: Vec<Tag> = self
+            .tag_lookup(options)?
+            .into_iter()
+            .filter(|tag| tag.package == current.package)
             .collect();
 
-        let pattern = SETTINGS
-            .tag_prefix
-            .as_ref()
-            .map(|prefix| format!("{prefix}*"));
+        tags.sort();
 
-        // Collect non packages tags
-        let mut tags: Vec<String> = self
-            .0
-            .tag_names(pattern.as_deref())
-            .map_err(|err| TagError::NoMatchFound { pattern, err })?
+        let Some(current_idx) = tags
             .iter()
-            .flatten()
-            .map(str::to_string)
-            .collect();
+            .enumerate()
+            .find(|(_, tag)| tag == &current)
+            .map(|(idx, _)| idx)
+        else {
+            return Ok(None);
+        };
+
+        if current_idx == 0 {
+            return Ok(None);
+        }
 
-        // Extends with packages tags if we are in a mono-repository context
-        if !packages.is_empty() {
-            let package_tags = self.0.tag_names(None).map_err(|_| TagError::NoTag)?;
-            let package_tags = package_tags
-                .into_iter()
-                .flatten()
-                .filter(|tag| packages.iter().any(|package| tag.starts_with(package)))
-                .map(str::to_string);
+        Ok(tags.get(current_idx - 1).cloned())
+    }
 
-            tags.extend(package_tags);
+    pub(crate) fn get_latest_tag_oid(&self, options: TagLookUpOptions) -> Result<Oid, TagError> {
+        self.get_latest_tag(options)
+            .map(|tag| tag.oid_unchecked().to_owned())
+    }
+
+    pub fn tag_lookup(&self, option: TagLookUpOptions) -> Result<Vec<Tag>, TagError> {
+        let prefix = SETTINGS.tag_prefix.as_ref();
+        let repo_cache = crate::git::rev::cache::get_cache(self);
+        let include_pre_release = option.include_pre_release;
+
+        let tag_filter = |tag: &Tag| {
+            tag.prefix.as_ref() == prefix
+                && tag.package.as_deref() == option.package_name
+                && option.include_packages != tag.package.is_none()
+                && if include_pre_release {
+                    true
+                } else {
+                    tag.version.pre.is_empty()
+                }
         };
 
-        Ok(tags)
+        Ok(repo_cache
+            .values()
+            .filter_map(|oid| match oid {
+                OidOf::Tag(tag) if tag_filter(tag) => Some(tag),
+                _ => None,
+            })
+            .cloned()
+            .collect())
     }
 }
 
@@ -120,6 +155,7 @@ pub struct Tag {
     pub prefix: Option<String>,
     pub version: Version,
     pub oid: Option<Oid>,
+    pub target: Option<Oid>,
 }
 
 impl Ord for Tag {
@@ -170,6 +206,7 @@ impl Tag {
             prefix: SETTINGS.tag_prefix.clone(),
             version,
             oid: None,
+            target: None,
         }
     }
 
@@ -177,7 +214,7 @@ impl Tag {
         self.oid.as_ref()
     }
 
-    pub fn from_str(raw: &str, oid: Option<Oid>) -> Result<Tag, TagError> {
+    pub fn from_str(raw: &str, oid: Option<Oid>, target: Option<Oid>) -> Result<Tag, TagError> {
         let prefix = SETTINGS.tag_prefix.as_ref();
 
         let package_tag: Option<Tag> = SETTINGS
@@ -200,6 +237,7 @@ impl Tag {
                         prefix: SETTINGS.tag_prefix.clone(),
                         version,
                         oid,
+                        target,
                     })
             })
             .next();
@@ -218,6 +256,7 @@ impl Tag {
                 prefix: prefix.cloned(),
                 version,
                 oid,
+                target,
             })
         }
     }
@@ -263,38 +302,73 @@ impl fmt::Display for Tag {
 
 #[cfg(test)]
 mod test {
-    use crate::git::repository::Repository;
-    use crate::git::tag::Tag;
-    use crate::settings::{MonoRepoPackage, Settings};
+    use std::collections::HashMap;
+    use std::fs;
+    use std::path::PathBuf;
+
     use anyhow::Result;
     use cmd_lib::run_cmd;
     use sealed_test::prelude::*;
     use semver::Version;
     use speculoos::prelude::*;
-    use std::collections::HashMap;
-    use std::fs;
-    use std::path::PathBuf;
+
+    use crate::git::tag::{Tag, TagLookUpOptions};
+    use crate::settings::{MonoRepoPackage, Settings};
+    use crate::test_helpers::{commit, git_init_no_gpg, git_tag};
 
     #[test]
     fn should_compare_tags() -> Result<()> {
-        let v1_0_0 = Tag::from_str("1.0.0", None)?;
-        let v1_1_0 = Tag::from_str("1.1.0", None)?;
-        let v2_1_0 = Tag::from_str("2.1.0", None)?;
-        let v0_1_0 = Tag::from_str("0.1.0", None)?;
-        let v0_2_0 = Tag::from_str("0.2.0", None)?;
-        let v0_0_1 = Tag::from_str("0.0.1", None)?;
+        let v1_0_0 = Tag::from_str("1.0.0", None, None)?;
+        let v1_1_0 = Tag::from_str("1.1.0", None, None)?;
+        let v2_1_0 = Tag::from_str("2.1.0", None, None)?;
+        let v0_1_0 = Tag::from_str("0.1.0", None, None)?;
+        let v0_2_0 = Tag::from_str("0.2.0", None, None)?;
+        let v0_0_1 = Tag::from_str("0.0.1", None, None)?;
         assert_that!([v1_0_0, v1_1_0, v2_1_0, v0_1_0, v0_2_0, v0_0_1,]
             .iter()
             .max())
         .is_some()
-        .is_equal_to(&Tag::from_str("2.1.0", None)?);
+        .is_equal_to(&Tag::from_str("2.1.0", None, None)?);
 
         Ok(())
     }
 
+    #[sealed_test]
+    fn tag_lookup() -> Result<()> {
+        let repository = git_init_no_gpg()?;
+        let settings = Settings {
+            tag_prefix: Some("v".to_string()),
+            ..Default::default()
+        };
+        let settings = toml::to_string(&settings)?;
+        fs::write("cog.toml", settings)?;
+
+        commit("first commit")?;
+        git_tag("v1.0.0")?;
+        git_tag("v1.1.0")?;
+        git_tag("v2.1.0")?;
+        git_tag("v0.1.0")?;
+        git_tag("v0.2.0")?;
+        git_tag("v0.0.1")?;
+
+        let result = repository.tag_lookup(TagLookUpOptions::default())?;
+
+        let tags: Vec<_> = result.into_iter().map(|tag| tag.to_string()).collect();
+
+        assert_that!(tags).contains_all_of(&[
+            &"v1.0.0".to_string(),
+            &"v1.1.0".to_string(),
+            &"v2.1.0".to_string(),
+            &"v0.1.0".to_string(),
+            &"v0.2.0".to_string(),
+            &"v0.0.1".to_string(),
+        ]);
+        Ok(())
+    }
+
     #[sealed_test]
     fn should_compare_tags_with_prefix() -> Result<()> {
-        Repository::init(".")?;
+        git_init_no_gpg()?;
         let settings = Settings {
             tag_prefix: Some("v".to_string()),
             ..Default::default()
@@ -302,29 +376,30 @@ mod test {
         let settings = toml::to_string(&settings)?;
         fs::write("cog.toml", settings)?;
 
-        let v1_0_0 = Tag::from_str("v1.0.0", None)?;
-        let v1_1_0 = Tag::from_str("v1.1.0", None)?;
-        let v2_1_0 = Tag::from_str("v2.1.0", None)?;
-        let v0_1_0 = Tag::from_str("v0.1.0", None)?;
-        let v0_2_0 = Tag::from_str("v0.2.0", None)?;
-        let v0_0_1 = Tag::from_str("v0.0.1", None)?;
+        let v1_0_0 = Tag::from_str("v1.0.0", None, None)?;
+        let v1_1_0 = Tag::from_str("v1.1.0", None, None)?;
+        let v2_1_0 = Tag::from_str("v2.1.0", None, None)?;
+        let v0_1_0 = Tag::from_str("v0.1.0", None, None)?;
+        let v0_2_0 = Tag::from_str("v0.2.0", None, None)?;
+        let v0_0_1 = Tag::from_str("v0.0.1", None, None)?;
         assert_that!([v1_0_0, v1_1_0, v2_1_0, v0_1_0, v0_2_0, v0_0_1,]
             .iter()
             .max())
         .is_some()
-        .is_equal_to(&Tag::from_str("2.1.0", None)?);
+        .is_equal_to(&Tag::from_str("2.1.0", None, None)?);
 
         Ok(())
     }
 
     #[test]
     fn should_get_tag_from_str() -> Result<()> {
-        let tag = Tag::from_str("1.0.0", None);
+        let tag = Tag::from_str("1.0.0", None, None);
         assert_that!(tag).is_ok().is_equal_to(Tag {
             package: None,
             prefix: None,
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         });
 
         Ok(())
@@ -332,7 +407,7 @@ mod test {
 
     #[sealed_test]
     fn should_get_tag_from_str_with_prefix() -> Result<()> {
-        Repository::init(".")?;
+        git_init_no_gpg()?;
 
         let settings = Settings {
             tag_prefix: Some("v".to_string()),
@@ -342,13 +417,14 @@ mod test {
         let settings = toml::to_string(&settings)?;
         fs::write("cog.toml", settings)?;
 
-        let tag = Tag::from_str("v1.0.0", None);
+        let tag = Tag::from_str("v1.0.0", None, None);
 
         assert_that!(tag).is_ok().is_equal_to(Tag {
             package: None,
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         });
 
         Ok(())
@@ -356,7 +432,7 @@ mod test {
 
     #[sealed_test]
     fn should_get_tag_from_str_with_separator() -> Result<()> {
-        Repository::init(".")?;
+        git_init_no_gpg()?;
 
         let mut packages = HashMap::new();
         packages.insert("one".to_string(), Default::default());
@@ -368,13 +444,14 @@ mod test {
         let settings = toml::to_string(&settings)?;
         fs::write("cog.toml", settings)?;
 
-        let tag = Tag::from_str("one-1.0.0", None);
+        let tag = Tag::from_str("one-1.0.0", None, None);
 
         assert_that!(tag).is_ok().is_equal_to(Tag {
             package: Some("one".to_string()),
             prefix: None,
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         });
 
         Ok(())
@@ -382,7 +459,7 @@ mod test {
 
     #[sealed_test]
     fn should_get_tag_from_str_with_prefix_and_separator() -> Result<()> {
-        Repository::init(".")?;
+        git_init_no_gpg()?;
 
         let mut packages = HashMap::new();
         packages.insert("one".to_string(), Default::default());
@@ -395,13 +472,14 @@ mod test {
         let settings = toml::to_string(&settings)?;
         fs::write("cog.toml", settings)?;
 
-        let tag = Tag::from_str("one-v1.0.0", None);
+        let tag = Tag::from_str("one-v1.0.0", None, None);
 
         assert_that!(tag).is_ok().is_equal_to(Tag {
             package: Some("one".to_string()),
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         });
 
         Ok(())
@@ -409,7 +487,7 @@ mod test {
 
     #[sealed_test]
     fn should_get_tag_from_str_with_prefix_and_custom_separator() -> Result<()> {
-        Repository::init(".")?;
+        git_init_no_gpg()?;
 
         let mut packages = HashMap::new();
         packages.insert("one".to_string(), Default::default());
@@ -423,81 +501,98 @@ mod test {
         let settings = toml::to_string(&settings)?;
         fs::write("cog.toml", settings)?;
 
-        let tag = Tag::from_str("one#v1.0.0", None);
+        let tag = Tag::from_str("one#v1.0.0", None, None);
 
         assert_that!(tag).is_ok().is_equal_to(Tag {
             package: Some("one".to_string()),
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         });
 
         Ok(())
     }
 
     #[sealed_test]
-    fn resolve_lightweight_tag_ok() -> Result<()> {
+    fn get_latest_tag_ok() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
         run_cmd!(
             git commit --allow-empty -m "first commit";
-            git tag 1.0.0;
+            git tag 0.1.0;
+            git commit --allow-empty -m "second commit";
+            git tag 0.2.0;
         )?;
 
         // Act
-        let tag = repo.resolve_lightweight_tag("1.0.0");
+        let tag = repo.get_latest_tag(TagLookUpOptions::default())?;
 
         // Assert
-        assert_that!(tag).is_ok();
+        assert_that!(tag.to_string()).is_equal_to("0.2.0".to_string());
         Ok(())
     }
 
     #[sealed_test]
-    fn resolve_lightweight_tag_err() -> Result<()> {
+    fn get_previous_tag_ok() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
         run_cmd!(
             git commit --allow-empty -m "first commit";
-            git tag the_tag;
+            git tag 0.1.0;
+            git commit --allow-empty -m "second commit";
+            git tag 0.2.0;
         )?;
 
+        let tag = repo.get_latest_tag(TagLookUpOptions::default())?;
+
         // Act
-        let tag = repo.resolve_lightweight_tag("the_taaaag");
+        let previous = repo.get_previous_tag(&tag)?.map(|t| t.to_string());
 
         // Assert
-        assert_that!(tag).is_err();
+        assert_that!(tag.to_string()).is_equal_to("0.2.0".to_string());
+
+        assert_that!(previous)
+            .is_some()
+            .is_equal_to("0.1.0".to_string());
         Ok(())
     }
 
     #[sealed_test]
-    fn get_latest_tag_ok() -> Result<()> {
+    fn get_previous_tag_pre_release_ok() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
         run_cmd!(
             git commit --allow-empty -m "first commit";
             git tag 0.1.0;
             git commit --allow-empty -m "second commit";
-            git tag 0.2.0;
+            git tag 0.2.0-pre;
         )?;
 
+        let tag = repo.get_latest_tag(TagLookUpOptions::default().include_pre_release())?;
+
         // Act
-        let tag = repo.get_latest_tag()?;
+        let previous = repo.get_previous_tag(&tag)?.map(|t| t.to_string());
 
         // Assert
-        assert_that!(tag.to_string()).is_equal_to("0.2.0".to_string());
+        assert_that!(tag.to_string()).is_equal_to("0.2.0-pre".to_string());
+
+        assert_that!(previous)
+            .is_some()
+            .is_equal_to("0.1.0".to_string());
         Ok(())
     }
 
     #[sealed_test]
     fn get_latest_tag_err() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
         run_cmd!(
             git commit --allow-empty -m "first commit"
         )?;
 
         // Act
-        let tag = repo.get_latest_tag();
+        let tag = repo.get_latest_tag(TagLookUpOptions::default());
 
         // Assert
         assert_that!(tag).is_err();
@@ -507,14 +602,14 @@ mod test {
     #[sealed_test]
     fn get_latest_tag_oid_ok() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
         run_cmd!(
             git commit --allow-empty -m "first commit";
             git tag 0.1.0;
         )?;
 
         // Act
-        let tag = repo.get_latest_tag_oid();
+        let tag = repo.get_latest_tag_oid(TagLookUpOptions::default());
 
         // Assert
         assert_that!(tag).is_ok();
@@ -524,11 +619,11 @@ mod test {
     #[sealed_test]
     fn get_latest_tag_oid_err() -> Result<()> {
         // Arrange
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
         run_cmd!(git commit --allow-empty -m "first commit")?;
 
         // Act
-        let tag = repo.get_latest_tag_oid();
+        let tag = repo.get_latest_tag_oid(TagLookUpOptions::default());
 
         // Assert
         assert_that!(tag).is_err();
@@ -555,7 +650,7 @@ mod test {
             ..Default::default()
         };
 
-        let repo = Repository::init(".")?;
+        let repo = git_init_no_gpg()?;
         let settings = toml::to_string(&settings)?;
 
         run_cmd!(
diff --git a/src/hook/mod.rs b/src/hook/mod.rs
index 2cec382..71587f4 100644
--- a/src/hook/mod.rs
+++ b/src/hook/mod.rs
@@ -44,6 +44,7 @@ pub struct VersionSpan {
     tokens: VecDeque<Token>,
 }
 
+#[derive(Debug)]
 pub(crate) struct HookVersion {
     pub prefixed_tag: Tag,
 }
@@ -198,7 +199,6 @@ impl Hook {
 #[cfg(test)]
 mod test {
     use cmd_lib::run_cmd;
-    use git2::Repository;
     use std::collections::HashMap;
     use std::str::FromStr;
 
@@ -206,6 +206,7 @@ mod test {
 
     use crate::hook::{Hook, HookVersion};
     use crate::settings::{MonoRepoPackage, Settings};
+    use crate::test_helpers::git_init_no_gpg;
     use sealed_test::prelude::*;
     use semver::Version;
     use speculoos::prelude::*;
@@ -240,8 +241,11 @@ mod test {
     #[test]
     fn replace_version_cargo() -> Result<()> {
         let mut hook = Hook::from_str("cargo bump {{version}}")?;
-        hook.insert_versions(None, Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)))
-            .unwrap();
+        hook.insert_versions(
+            None,
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
+        )
+        .unwrap();
 
         assert_that!(hook.0.as_str()).is_equal_to("cargo bump 1.0.0");
         Ok(())
@@ -255,6 +259,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         };
 
         hook.insert_versions(None, Some(&HookVersion::new(tag)))
@@ -275,8 +280,9 @@ mod test {
 
         let settings = toml::to_string(&settings)?;
 
+        let _ = git_init_no_gpg()?;
+
         run_cmd!(
-            git init;
             echo $settings > cog.toml;
             git add .;
             git commit -m "first commit";
@@ -289,6 +295,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         };
 
         hook.insert_versions(None, Some(&HookVersion::new(tag)))
@@ -306,6 +313,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         };
 
         hook.insert_versions(Some(&HookVersion::new(tag)), None)
@@ -318,8 +326,11 @@ mod test {
     #[test]
     fn replace_maven_version() -> Result<()> {
         let mut hook = Hook::from_str("mvn versions:set -DnewVersion={{version}}")?;
-        hook.insert_versions(None, Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)))
-            .unwrap();
+        hook.insert_versions(
+            None,
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
+        )
+        .unwrap();
 
         assert_that!(hook.0.as_str()).is_equal_to("mvn versions:set -DnewVersion=1.0.0");
         Ok(())
@@ -328,8 +339,11 @@ mod test {
     #[test]
     fn replace_maven_version_with_expression() -> Result<()> {
         let mut hook = Hook::from_str("mvn versions:set -DnewVersion={{version+1minor-SNAPSHOT}}")?;
-        hook.insert_versions(None, Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)))
-            .unwrap();
+        hook.insert_versions(
+            None,
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
+        )
+        .unwrap();
 
         assert_that!(hook.0.as_str()).is_equal_to("mvn versions:set -DnewVersion=1.1.0-SNAPSHOT");
         Ok(())
@@ -344,6 +358,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         };
 
         hook.insert_versions(None, Some(&HookVersion::new(tag)))
@@ -364,8 +379,9 @@ mod test {
 
         let settings = toml::to_string(&settings)?;
 
+        let _ = git_init_no_gpg()?;
+
         run_cmd!(
-            git init;
             echo $settings > cog.toml;
             git add .;
             git commit -m "first commit";
@@ -379,6 +395,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         };
 
         hook.insert_versions(None, Some(&HookVersion::new(tag)))
@@ -392,8 +409,11 @@ mod test {
     #[test]
     fn leave_hook_untouched_when_no_version() -> Result<()> {
         let mut hook = Hook::from_str("echo \"Hello World\"")?;
-        hook.insert_versions(None, Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)))
-            .unwrap();
+        hook.insert_versions(
+            None,
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
+        )
+        .unwrap();
 
         assert_that!(hook.0.as_str()).is_equal_to("echo \"Hello World\"");
         Ok(())
@@ -402,8 +422,11 @@ mod test {
     #[test]
     fn replace_quoted_version() -> Result<()> {
         let mut hook = Hook::from_str("echo \"{{version}}\"")?;
-        hook.insert_versions(None, Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)))
-            .unwrap();
+        hook.insert_versions(
+            None,
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
+        )
+        .unwrap();
 
         assert_that!(hook.0.as_str()).is_equal_to("echo \"1.0.0\"");
         Ok(())
@@ -413,8 +436,11 @@ mod test {
     fn replace_version_with_nested_simple_quoted_arg() -> Result<()> {
         let mut hook =
             Hook::from_str("cog commit chore 'bump snapshot to {{version+1minor-pre}}'")?;
-        hook.insert_versions(None, Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)))
-            .unwrap();
+        hook.insert_versions(
+            None,
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
+        )
+        .unwrap();
 
         assert_that!(hook.0.as_str()).is_equal_to("cog commit chore 'bump snapshot to 1.1.0-pre'");
         Ok(())
@@ -424,8 +450,11 @@ mod test {
     fn replace_version_with_nested_double_quoted_arg() -> Result<()> {
         let mut hook =
             Hook::from_str("cog commit chore \"bump snapshot to {{version+1minor-pre}}\"")?;
-        hook.insert_versions(None, Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)))
-            .unwrap();
+        hook.insert_versions(
+            None,
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
+        )
+        .unwrap();
 
         assert_that!(hook.0.as_str())
             .is_equal_to("cog commit chore \"bump snapshot to 1.1.0-pre\"");
@@ -436,8 +465,8 @@ mod test {
     fn replace_version_with_multiple_placeholders() -> Result<()> {
         let mut hook = Hook::from_str("echo \"the latest {{latest}}, the greatest {{version}}\"")?;
         hook.insert_versions(
-            Some(&HookVersion::new(Tag::from_str("0.5.9", None)?)),
-            Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)),
+            Some(&HookVersion::new(Tag::from_str("0.5.9", None, None)?)),
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
         )
         .unwrap();
 
@@ -451,8 +480,8 @@ mod test {
             "echo \"the latest {{latest+3major+1minor}}, the greatest {{version+2patch}}\"",
         )?;
         hook.insert_versions(
-            Some(&HookVersion::new(Tag::from_str("0.5.9", None)?)),
-            Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)),
+            Some(&HookVersion::new(Tag::from_str("0.5.9", None, None)?)),
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
         )
         .unwrap();
 
@@ -464,8 +493,11 @@ mod test {
     fn replace_version_with_pre_and_build_metadata() -> Result<()> {
         let mut hook =
             Hook::from_str("echo \"the latest {{version+1major-pre.alpha-bravo+build.42}}\"")?;
-        hook.insert_versions(None, Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)))
-            .unwrap();
+        hook.insert_versions(
+            None,
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
+        )
+        .unwrap();
 
         assert_that!(hook.0.as_str())
             .is_equal_to("echo \"the latest 2.0.0-pre.alpha-bravo+build.42\"");
@@ -482,6 +514,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         };
 
         hook.insert_versions(None, Some(&HookVersion::new(tag)))
@@ -495,12 +528,15 @@ mod test {
 
     #[sealed_test]
     fn parenthesis_in_hook_works() -> Result<()> {
-        Repository::init(".")?;
+        git_init_no_gpg()?;
 
         let mut hook = Hook::from_str("git commit --allow-empty -m 'chore(snapshot): bump snapshot to {{version+1patch-SNAPSHOT}}'")?;
 
-        hook.insert_versions(None, Some(&HookVersion::new(Tag::from_str("1.0.0", None)?)))
-            .unwrap();
+        hook.insert_versions(
+            None,
+            Some(&HookVersion::new(Tag::from_str("1.0.0", None, None)?)),
+        )
+        .unwrap();
 
         let outcome = hook.run(None);
 
@@ -520,8 +556,8 @@ mod test {
 
         let settings = toml::to_string(&settings)?;
 
+        let _ = git_init_no_gpg();
         run_cmd!(
-            git init;
             echo $settings > cog.toml;
             git add .;
             git commit -m "first commit";
@@ -536,6 +572,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         };
 
         let tag = Tag {
@@ -543,6 +580,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 1, 0),
             oid: None,
+            target: None,
         };
 
         hook.insert_versions(
@@ -567,8 +605,9 @@ mod test {
 
         let settings = toml::to_string(&settings)?;
 
+        let _ = git_init_no_gpg()?;
+
         run_cmd!(
-            git init;
             echo $settings > cog.toml;
             git add .;
             git commit -m "first commit";
@@ -583,6 +622,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 0, 0),
             oid: None,
+            target: None,
         };
 
         let tag = Tag {
@@ -590,6 +630,7 @@ mod test {
             prefix: Some("v".to_string()),
             version: Version::new(1, 2, 3),
             oid: None,
+            target: None,
         };
 
         hook.insert_versions(
diff --git a/src/lib.rs b/src/lib.rs
index ccce97d..46ef4bd 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
 use std::path::PathBuf;
 use std::process::{Command, Stdio};
 
@@ -16,8 +16,8 @@ use git::repository::Repository;
 use settings::Settings;
 
 use crate::git::error::{Git2Error, TagError};
+use crate::git::rev::cache::get_cache;
 
-use crate::git::revspec::RevspecPattern;
 use crate::git::tag::Tag;
 
 pub mod command;
@@ -146,4 +146,37 @@ impl CocoGitto {
             .map(|path| path.join(".git/COMMIT_EDITMSG"))
             .expect("git repository")
     }
+
+    // Currently only used in test to force rebuild the tag cache
+    pub fn clear_cache(&self) {
+        let mut cache = get_cache(&self.repository);
+        *cache = BTreeMap::new();
+    }
+}
+
+#[cfg(test)]
+pub mod test_helpers {
+    use crate::git::repository::Repository;
+    use cmd_lib::{run_cmd, run_fun};
+
+    pub(crate) fn git_init_no_gpg() -> anyhow::Result<Repository> {
+        run_cmd!(
+            git init -b master;
+            git config --local commit.gpgsign false;
+        )?;
+
+        Ok(Repository::open(".")?)
+    }
+
+    pub(crate) fn commit(message: &str) -> anyhow::Result<String> {
+        Ok(run_fun!(
+            git commit --allow-empty -q -m $message;
+            git log --format=%H -n 1;
+        )?)
+    }
+
+    pub(crate) fn git_tag(version: &str) -> anyhow::Result<()> {
+        run_fun!(git tag $version;)?;
+        Ok(())
+    }
 }
diff --git a/src/settings/mod.rs b/src/settings/mod.rs
index f399942..855a086 100644
--- a/src/settings/mod.rs
+++ b/src/settings/mod.rs
@@ -196,6 +196,23 @@ pub struct MonoRepoPackage {
     pub bump_profiles: HashMap<String, BumpProfile>,
 }
 
+impl Default for &MonoRepoPackage {
+    fn default() -> Self {
+        let package = Box::new(MonoRepoPackage {
+            path: Default::default(),
+            include: vec![],
+            ignore: vec![],
+            changelog_path: None,
+            pre_bump_hooks: None,
+            post_bump_hooks: None,
+            bump_profiles: Default::default(),
+            public_api: true,
+        });
+
+        Box::leak(package)
+    }
+}
+
 impl Default for MonoRepoPackage {
     fn default() -> Self {
         Self {


Test patch:
diff --git a/tests/assets/privkey.key b/tests/assets/privkey.key
index f4a6e46..8418f33 100644
--- a/tests/assets/privkey.key
+++ b/tests/assets/privkey.key
@@ -29,53 +29,53 @@ TU7lg6z+xKJ5oiDDJhtrth0/t5YucQPqU21pXoWqrkqUKVIQb9VGopAuX0Yv0JJa
 s5G8KUs+WFiHkLrgr9SaDyRl2g6kMZ7Aa0CQFdC6gQu/lKY5VRkoyqP+UBJLWnlw
 /n5nH4FnpTQeaw/3j1Ijgu4M6LxUJSApl4QSWe6365xBtXXogk5GAwCtl05nKw21
 PtyxtKyV3h4KTay+GFeBfo6bzj2YL0P+29qotBJ0ZXN0QGNvY29naXR0by5vcmeJ
-AdQEEwEIAD4WIQQkysZDxwmHaOKpDhoqgYBVlGCDbgUCYhSk5QIbAwUJA8JnAAUL
-CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRAqgYBVlGCDbi/uC/sHa8DBktk/8GuC
-Evs/TNG8K6P8RLBefN4ttzt83rK51c/JPQLZEocwyT+vfENwMxM5L0/qyYjX/eiy
-LdCIUnqDjZXiK3PIZ3JzKoLT209Ll1fEPtAygbPyj0Z7jm+EY8CdncFWcpXUlJi+
-V4Go2aTo4w0PuuyMeoXM/dI/rHaO4M2hAC9nUacFfuaPC8rzMJ7gKzrSKLAaXviV
-XKU2Fje7SmVGFQnpt56tjH6jzQj0wQHQ9QMTw64LjSXqJ8dvQmvQr5tZWMhRoB6F
-y0Bv6CYryLXAtissduDOrkwpAlR047Ep1GgrfdVHpjd94IlK0kT4DFQGyXrEUed6
-pqfKyz2aeHgYnooMKG5TCPsl8XHdqUk8APVUvDC0jj7xysGiV2hleyhU7dRHVKN2
-BU8e2CnTajcdunYNT2hHHaggGewAnEamE4HxvbeyCouclc66TvMULsRRRsTq8y8c
-ik2PdKtElUsuVerEBWrgs9YkekZxb5CCyNpyiNFxQOD4LOEvlfOdBVgEYhSk5QEM
-AMFFQ+azs6wdyCzmbHKEuLkWlV85vAFu08qMyoV/JxN0dN/WBpATYhPU2lERkNxL
-NOzGByrIx44SeZS0kpw+GtVcMbxK9GEq8tTlwneT7o+UygEpixHpgLHria89nbv2
-2IqErUNQg9m8Y1IMyc++EVs5CHpa6aKU21ncZaimHJyJexyMR8WEJc1Qs3+WwRMS
-uvb9S1ejg/jj5+CyBUEdPEh+L/wm6964h29zYZffb78stwwWPuLhzgKvbWJu/lSb
-Wvm7Yu2e7s1IiNkdju4vsqURKfn+3mncpX/ZGvgzCCxFsycU/UkdbyXRQDyPGvIf
-CV9aENMw/iz/iWzL2Dd3szgkSG33yl/EOboJb2+H8c3vanBXSMZxl7qkgNR9TR6n
-S3/I2q57XYVuZYujQ4h6/DsMCWO3TazkEnEdCTLH1wztT6C3aci2jL/Oi2lnr95w
-863YupqFhL9gr6RvFzMm6B9kJoo8YqKyVEkm+91ML8CAS5aTV88WdAwc/NAwBacE
-iwARAQABAAv8CkP8lE9tNrIqRMBEeFy2VxZs/q7ZXpYdZ7/MrgSP9ceUwaLjnFl7
-NyRyKCYvtJPGDuGB1fLuLoyqqoxg0S23TSfleGPe0R5v4YEGsEVKQZQcmq0zNtNZ
-Q5oIKSezrh0xaUIc1dt79V+9QsltGry0vUytN5qGmF0hvYcoWynfkSz4Pg7xbPtU
-6q/jqN5ACRxQk/fmPZRw2RJ56YRrJQdBgXQ6ZkFG/AB/KRtOzrtOezAL1PeFhZWK
-1LeurrDIj4Pk3HHL30nYtAtlpFase3zEtkoYzVzAHeGvE3IGpO2rhXyZlpGf+PCe
-AvmkJeL762hvM2I8HheRxhfcbl982XASlHyzmgIABENYZxDrrESCLh3fZX7xxbsh
-+ZRCGLk+lQswbTfbJEcaLfEh/tXDFsk4C9Y1sd07Xi5znXDHUytP0lolB1mc+iis
-k9dqs98ShlQf08q6My+apgWoDt/xcJEgXad8NpcAevEUTpkve9yXeQfGMVt/p4mJ
-/9UmkZlyQYrxBgDMSPcUMnqqZq0veD7fKRmvE6BPdl2JdzBxB3gLAsAFgbQrfi3B
-bOMjBvv+xd/Qbxv3JVYpPM2K8ANvvqyxF4/E5LzDJHM4p3MYAIFwRnMHxCeho/gV
-sIu5qWlzQTlQAT9JWYkAOAf9waqEUXLmZ4AbcjAxfR0upV5iw0LCMjAfu+OgQmkG
-hUtQrk2DJ5L1sTdIBcrS71Q8DCj0LRuJne0y+LWKnMRmn2xBOoCL0VtTC3OfZq5u
-b7wZAraIexh36mMGAPIyfWts5UCDKK5BdKu5uzotjdMDprRJJYykU/IXwl3pyojr
-2+rH79ZlbmpqmLjKSMJ7zreCNaih+wzedFZI5dqzg9HVB7U+HXS1jVlU4JzVnsgO
-fNbt7d9tsZlSfDc+BkyfruwYDsd8fdgMw7kSd7TyNPimNKeWv4lbjrLfalpCKVYW
-HA83c9iV8VInOgCaN866TPa910KqDLd2+9hpzaNBmw8gBHZaANadRVW9XFxzNZMu
-DtzsUs1KfRZb4K7BuQX/V+3FEJlKD32rA4jgcNoEABn+dobRauY+a3R3oQKTZbie
-CjoREsoJFXQ9Ix1/au8bk1aiAJjdUvQJw3/aOyNQAt+2aSTRLTcAtWnKhSAuJTDA
-WJqKPhJ5lSZ0LXszqI09RkfaZFPYOrkRTUDr51rfifJMxCTXr8xXK3r4ERJGxMla
-73MA8pH2HnesQADUHITx+tLDzGF0FHDvIBntxfB6Q/ReJjAgTiYrpsHSQEhbbXuk
-Hpe2wfufVr5T3I9jg6q+0ZyJAbYEGAEIACAWIQQkysZDxwmHaOKpDhoqgYBVlGCD
-bgUCYhSk5QIbDAAKCRAqgYBVlGCDbmKtDADNbN2WhdHVCU47PgvPDpd7fQilhCAL
-byauL0YQIH3G34Dim/NU/ITMH+KLH3gWIKawCH+hSdKjXQSgYXosU3zwGAg+mnNa
-MxD/+jdyXlp6QbwRBvogD4kN2EJEGefoOjF1dGMk65g4tJkV1vbcvnQOHuc1JRiD
-2MmY99UqcXMPhu/30SIYh4v0lgvdjjMOfRu5wOXchS+d5EbkcYPREhsJGcjkEAmq
-o2HGzvezfi7DwNx+qhqI0A3AFQv0Qki3yZSKOkzAUIiLRIARn1QeQ8uajW/485hx
-SCIr6hH+jGfPLAZj7+f/8qrT1usuS+mQhUSe7GhFAYWy83vTBHJOC08hkxo85Gjq
-PFYBxrdzhPfv+kGZUhtetXtyOAo4w7Jibl5kGNXEpOIMteBQ6eXnIbPDJnEsLvhA
-NRf355GNtkryAlS1mE2u8Kginvho3/7zhtzvq2S49OmrLgxPxNSeT+bBGhqEbpWb
-cXHDgyHb6w4YhL1mRhclOIrssqB4Ya+KTQI=
-=cpqc
+Ac4EEwEIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQQkysZDxwmHaOKp
+DhoqgYBVlGCDbgUCZdh8ywAKCRAqgYBVlGCDbme5DADDSnGlNp4KZED6OwX5Q08T
+GLdFFjHi3p2gkp8zMSKvWeTghiYhDEFT13p8+7JSm3qIRLrcu32qp/mrlX91NtI1
+DPXYxqHNYRLoN/xsNap76+nVm5uszyfdwFJloBGOCfwTZUzjjpuWxwaCVSlxWhUF
+A9mLYUz6dT5Fua/Vt6bLs1vMYekAojVZ/lUd4BefcdsRdMjWOJx68kpFGj8P0Ttg
+fu4TRW2Dr1Awb5vf0ztM6dQt/HoPZaC23AN1lGdU97J0IjBKX2cnteJNIym5+QTq
+KhXOmwmaccxzmGt83hR9JXv52oCppqHa5l3Akdv+5Rn/LH48MPjT/ENrjPcQzvCh
+B9EXwd+DNfqqQdx9DYuVS5bFj1zrFkPRogcag1sMNXFsc2iM+oY5k124OCWVSs5d
+S9AWeM9cDAItJh1Rhf0IlL+m8H64RHEcHMVQrLT4WGr9+8sQcGoWIBcFRphflpAO
+Hmtj81RaQ4vkH66y3ZPPNZ1HGOgHAhaihnjfMe/lKzGdBVgEYhSk5QEMAMFFQ+az
+s6wdyCzmbHKEuLkWlV85vAFu08qMyoV/JxN0dN/WBpATYhPU2lERkNxLNOzGByrI
+x44SeZS0kpw+GtVcMbxK9GEq8tTlwneT7o+UygEpixHpgLHria89nbv22IqErUNQ
+g9m8Y1IMyc++EVs5CHpa6aKU21ncZaimHJyJexyMR8WEJc1Qs3+WwRMSuvb9S1ej
+g/jj5+CyBUEdPEh+L/wm6964h29zYZffb78stwwWPuLhzgKvbWJu/lSbWvm7Yu2e
+7s1IiNkdju4vsqURKfn+3mncpX/ZGvgzCCxFsycU/UkdbyXRQDyPGvIfCV9aENMw
+/iz/iWzL2Dd3szgkSG33yl/EOboJb2+H8c3vanBXSMZxl7qkgNR9TR6nS3/I2q57
+XYVuZYujQ4h6/DsMCWO3TazkEnEdCTLH1wztT6C3aci2jL/Oi2lnr95w863YupqF
+hL9gr6RvFzMm6B9kJoo8YqKyVEkm+91ML8CAS5aTV88WdAwc/NAwBacEiwARAQAB
+AAv8CkP8lE9tNrIqRMBEeFy2VxZs/q7ZXpYdZ7/MrgSP9ceUwaLjnFl7NyRyKCYv
+tJPGDuGB1fLuLoyqqoxg0S23TSfleGPe0R5v4YEGsEVKQZQcmq0zNtNZQ5oIKSez
+rh0xaUIc1dt79V+9QsltGry0vUytN5qGmF0hvYcoWynfkSz4Pg7xbPtU6q/jqN5A
+CRxQk/fmPZRw2RJ56YRrJQdBgXQ6ZkFG/AB/KRtOzrtOezAL1PeFhZWK1LeurrDI
+j4Pk3HHL30nYtAtlpFase3zEtkoYzVzAHeGvE3IGpO2rhXyZlpGf+PCeAvmkJeL7
+62hvM2I8HheRxhfcbl982XASlHyzmgIABENYZxDrrESCLh3fZX7xxbsh+ZRCGLk+
+lQswbTfbJEcaLfEh/tXDFsk4C9Y1sd07Xi5znXDHUytP0lolB1mc+iisk9dqs98S
+hlQf08q6My+apgWoDt/xcJEgXad8NpcAevEUTpkve9yXeQfGMVt/p4mJ/9UmkZly
+QYrxBgDMSPcUMnqqZq0veD7fKRmvE6BPdl2JdzBxB3gLAsAFgbQrfi3BbOMjBvv+
+xd/Qbxv3JVYpPM2K8ANvvqyxF4/E5LzDJHM4p3MYAIFwRnMHxCeho/gVsIu5qWlz
+QTlQAT9JWYkAOAf9waqEUXLmZ4AbcjAxfR0upV5iw0LCMjAfu+OgQmkGhUtQrk2D
+J5L1sTdIBcrS71Q8DCj0LRuJne0y+LWKnMRmn2xBOoCL0VtTC3OfZq5ub7wZAraI
+exh36mMGAPIyfWts5UCDKK5BdKu5uzotjdMDprRJJYykU/IXwl3pyojr2+rH79Zl
+bmpqmLjKSMJ7zreCNaih+wzedFZI5dqzg9HVB7U+HXS1jVlU4JzVnsgOfNbt7d9t
+sZlSfDc+BkyfruwYDsd8fdgMw7kSd7TyNPimNKeWv4lbjrLfalpCKVYWHA83c9iV
+8VInOgCaN866TPa910KqDLd2+9hpzaNBmw8gBHZaANadRVW9XFxzNZMuDtzsUs1K
+fRZb4K7BuQX/V+3FEJlKD32rA4jgcNoEABn+dobRauY+a3R3oQKTZbieCjoREsoJ
+FXQ9Ix1/au8bk1aiAJjdUvQJw3/aOyNQAt+2aSTRLTcAtWnKhSAuJTDAWJqKPhJ5
+lSZ0LXszqI09RkfaZFPYOrkRTUDr51rfifJMxCTXr8xXK3r4ERJGxMla73MA8pH2
+HnesQADUHITx+tLDzGF0FHDvIBntxfB6Q/ReJjAgTiYrpsHSQEhbbXukHpe2wfuf
+Vr5T3I9jg6q+0ZyJAbYEGAEIACAWIQQkysZDxwmHaOKpDhoqgYBVlGCDbgUCYhSk
+5QIbDAAKCRAqgYBVlGCDbmKtDADNbN2WhdHVCU47PgvPDpd7fQilhCALbyauL0YQ
+IH3G34Dim/NU/ITMH+KLH3gWIKawCH+hSdKjXQSgYXosU3zwGAg+mnNaMxD/+jdy
+Xlp6QbwRBvogD4kN2EJEGefoOjF1dGMk65g4tJkV1vbcvnQOHuc1JRiD2MmY99Uq
+cXMPhu/30SIYh4v0lgvdjjMOfRu5wOXchS+d5EbkcYPREhsJGcjkEAmqo2HGzvez
+fi7DwNx+qhqI0A3AFQv0Qki3yZSKOkzAUIiLRIARn1QeQ8uajW/485hxSCIr6hH+
+jGfPLAZj7+f/8qrT1usuS+mQhUSe7GhFAYWy83vTBHJOC08hkxo85GjqPFYBxrdz
+hPfv+kGZUhtetXtyOAo4w7Jibl5kGNXEpOIMteBQ6eXnIbPDJnEsLvhANRf355GN
+tkryAlS1mE2u8Kginvho3/7zhtzvq2S49OmrLgxPxNSeT+bBGhqEbpWbcXHDgyHb
+6w4YhL1mRhclOIrssqB4Ya+KTQI=
+=5t+R
 -----END PGP PRIVATE KEY BLOCK-----
diff --git a/tests/assets/pubkey.key b/tests/assets/pubkey.key
index 2b31f01..30c06ab 100644
--- a/tests/assets/pubkey.key
+++ b/tests/assets/pubkey.key
@@ -8,34 +8,33 @@ OYohiM1WhPUcUy4rtUZ5xJLLC9Pbtds+10Zjo3nOmHuchPCOPAzqf/XeSBsS/W4E
 wvzk3KqsCkXhlP9KRaNJCoT7RjOzpjsCsTewPI9sv0YREI52nNBD39vYklL7tgH/
 kC+DhI60d1k/EBFuI3CPN9uruXj/lf+8pzHVkLZDOIqSZ2YW3OrrPz6VR48abAr3
 0N5wF2tQ0PI+LTowwTPg1M7g31XBq49L2gN/BiP2+CiM17T3ONB/zrwJY0aSfh7j
-DFTtuGOl2mH1gBkAEQEAAbQSdGVzdEBjb2NvZ2l0dG8ub3JniQHUBBMBCAA+FiEE
-JMrGQ8cJh2jiqQ4aKoGAVZRgg24FAmIUpOUCGwMFCQPCZwAFCwkIBwIGFQoJCAsC
-BBYCAwECHgECF4AACgkQKoGAVZRgg24v7gv7B2vAwZLZP/BrghL7P0zRvCuj/ESw
-XnzeLbc7fN6yudXPyT0C2RKHMMk/r3xDcDMTOS9P6smI1/3osi3QiFJ6g42V4itz
-yGdycyqC09tPS5dXxD7QMoGz8o9Ge45vhGPAnZ3BVnKV1JSYvleBqNmk6OMND7rs
-jHqFzP3SP6x2juDNoQAvZ1GnBX7mjwvK8zCe4Cs60iiwGl74lVylNhY3u0plRhUJ
-6beerYx+o80I9MEB0PUDE8OuC40l6ifHb0Jr0K+bWVjIUaAehctAb+gmK8i1wLYr
-LHbgzq5MKQJUdOOxKdRoK33VR6Y3feCJStJE+AxUBsl6xFHneqanyss9mnh4GJ6K
-DChuUwj7JfFx3alJPAD1VLwwtI4+8crBoldoZXsoVO3UR1SjdgVPHtgp02o3Hbp2
-DU9oRx2oIBnsAJxGphOB8b23sgqLnJXOuk7zFC7EUUbE6vMvHIpNj3SrRJVLLlXq
-xAVq4LPWJHpGcW+QgsjacojRcUDg+CzhL5XzuQGNBGIUpOUBDADBRUPms7OsHcgs
-5mxyhLi5FpVfObwBbtPKjMqFfycTdHTf1gaQE2IT1NpREZDcSzTsxgcqyMeOEnmU
-tJKcPhrVXDG8SvRhKvLU5cJ3k+6PlMoBKYsR6YCx64mvPZ279tiKhK1DUIPZvGNS
-DMnPvhFbOQh6WumilNtZ3GWophyciXscjEfFhCXNULN/lsETErr2/UtXo4P44+fg
-sgVBHTxIfi/8JuveuIdvc2GX32+/LLcMFj7i4c4Cr21ibv5Um1r5u2Ltnu7NSIjZ
-HY7uL7KlESn5/t5p3KV/2Rr4MwgsRbMnFP1JHW8l0UA8jxryHwlfWhDTMP4s/4ls
-y9g3d7M4JEht98pfxDm6CW9vh/HN72pwV0jGcZe6pIDUfU0ep0t/yNque12FbmWL
-o0OIevw7DAljt02s5BJxHQkyx9cM7U+gt2nItoy/zotpZ6/ecPOt2LqahYS/YK+k
-bxczJugfZCaKPGKislRJJvvdTC/AgEuWk1fPFnQMHPzQMAWnBIsAEQEAAYkBtgQY
-AQgAIBYhBCTKxkPHCYdo4qkOGiqBgFWUYINuBQJiFKTlAhsMAAoJECqBgFWUYINu
-Yq0MAM1s3ZaF0dUJTjs+C88Ol3t9CKWEIAtvJq4vRhAgfcbfgOKb81T8hMwf4osf
-eBYgprAIf6FJ0qNdBKBheixTfPAYCD6ac1ozEP/6N3JeWnpBvBEG+iAPiQ3YQkQZ
-5+g6MXV0YyTrmDi0mRXW9ty+dA4e5zUlGIPYyZj31Spxcw+G7/fRIhiHi/SWC92O
-Mw59G7nA5dyFL53kRuRxg9ESGwkZyOQQCaqjYcbO97N+LsPA3H6qGojQDcAVC/RC
-SLfJlIo6TMBQiItEgBGfVB5Dy5qNb/jzmHFIIivqEf6MZ88sBmPv5//yqtPW6y5L
-6ZCFRJ7saEUBhbLze9MEck4LTyGTGjzkaOo8VgHGt3OE9+/6QZlSG161e3I4CjjD
-smJuXmQY1cSk4gy14FDp5echs8MmcSwu+EA1F/fnkY22SvICVLWYTa7wqCKe+Gjf
-/vOG3O+rZLj06asuDE/E1J5P5sEaGoRulZtxccODIdvrDhiEvWZGFyU4iuyyoHhh
-r4pNAg==
-=/U37
+DFTtuGOl2mH1gBkAEQEAAbQSdGVzdEBjb2NvZ2l0dG8ub3JniQHOBBMBCAA4AhsD
+BQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEEJMrGQ8cJh2jiqQ4aKoGAVZRgg24F
+AmXYfMsACgkQKoGAVZRgg25nuQwAw0pxpTaeCmRA+jsF+UNPExi3RRYx4t6doJKf
+MzEir1nk4IYmIQxBU9d6fPuyUpt6iES63Lt9qqf5q5V/dTbSNQz12MahzWES6Df8
+bDWqe+vp1ZubrM8n3cBSZaARjgn8E2VM446blscGglUpcVoVBQPZi2FM+nU+Rbmv
+1bemy7NbzGHpAKI1Wf5VHeAXn3HbEXTI1jicevJKRRo/D9E7YH7uE0Vtg69QMG+b
+39M7TOnULfx6D2WgttwDdZRnVPeydCIwSl9nJ7XiTSMpufkE6ioVzpsJmnHMc5hr
+fN4UfSV7+dqAqaah2uZdwJHb/uUZ/yx+PDD40/xDa4z3EM7woQfRF8HfgzX6qkHc
+fQ2LlUuWxY9c6xZD0aIHGoNbDDVxbHNojPqGOZNduDgllUrOXUvQFnjPXAwCLSYd
+UYX9CJS/pvB+uERxHBzFUKy0+Fhq/fvLEHBqFiAXBUaYX5aQDh5rY/NUWkOL5B+u
+st2TzzWdRxjoBwIWooZ43zHv5SsxuQGNBGIUpOUBDADBRUPms7OsHcgs5mxyhLi5
+FpVfObwBbtPKjMqFfycTdHTf1gaQE2IT1NpREZDcSzTsxgcqyMeOEnmUtJKcPhrV
+XDG8SvRhKvLU5cJ3k+6PlMoBKYsR6YCx64mvPZ279tiKhK1DUIPZvGNSDMnPvhFb
+OQh6WumilNtZ3GWophyciXscjEfFhCXNULN/lsETErr2/UtXo4P44+fgsgVBHTxI
+fi/8JuveuIdvc2GX32+/LLcMFj7i4c4Cr21ibv5Um1r5u2Ltnu7NSIjZHY7uL7Kl
+ESn5/t5p3KV/2Rr4MwgsRbMnFP1JHW8l0UA8jxryHwlfWhDTMP4s/4lsy9g3d7M4
+JEht98pfxDm6CW9vh/HN72pwV0jGcZe6pIDUfU0ep0t/yNque12FbmWLo0OIevw7
+DAljt02s5BJxHQkyx9cM7U+gt2nItoy/zotpZ6/ecPOt2LqahYS/YK+kbxczJugf
+ZCaKPGKislRJJvvdTC/AgEuWk1fPFnQMHPzQMAWnBIsAEQEAAYkBtgQYAQgAIBYh
+BCTKxkPHCYdo4qkOGiqBgFWUYINuBQJiFKTlAhsMAAoJECqBgFWUYINuYq0MAM1s
+3ZaF0dUJTjs+C88Ol3t9CKWEIAtvJq4vRhAgfcbfgOKb81T8hMwf4osfeBYgprAI
+f6FJ0qNdBKBheixTfPAYCD6ac1ozEP/6N3JeWnpBvBEG+iAPiQ3YQkQZ5+g6MXV0
+YyTrmDi0mRXW9ty+dA4e5zUlGIPYyZj31Spxcw+G7/fRIhiHi/SWC92OMw59G7nA
+5dyFL53kRuRxg9ESGwkZyOQQCaqjYcbO97N+LsPA3H6qGojQDcAVC/RCSLfJlIo6
+TMBQiItEgBGfVB5Dy5qNb/jzmHFIIivqEf6MZ88sBmPv5//yqtPW6y5L6ZCFRJ7s
+aEUBhbLze9MEck4LTyGTGjzkaOo8VgHGt3OE9+/6QZlSG161e3I4CjjDsmJuXmQY
+1cSk4gy14FDp5echs8MmcSwu+EA1F/fnkY22SvICVLWYTa7wqCKe+Gjf/vOG3O+r
+ZLj06asuDE/E1J5P5sEaGoRulZtxccODIdvrDhiEvWZGFyU4iuyyoHhhr4pNAg==
+=tc6n
 -----END PGP PUBLIC KEY BLOCK-----
diff --git a/tests/cog_tests/bump.rs b/tests/cog_tests/bump.rs
index 1dcb8a1..d5313e9 100644
--- a/tests/cog_tests/bump.rs
+++ b/tests/cog_tests/bump.rs
@@ -457,7 +457,7 @@ fn bump_package_with_default_skip_ci_ok() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
 
     assert_tag_exists("one-0.1.0")?;
     assert!(commit_message.contains("[skip ci]"));
@@ -482,7 +482,7 @@ fn bump_package_with_cog_toml_defined_skip_ci_ok() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
 
     assert_tag_exists("one-0.1.0")?;
     assert!(commit_message.contains("[ci-skip]"));
@@ -508,7 +508,7 @@ fn bump_package_with_skip_ci_override_option_takes_predecence() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
 
     assert_tag_exists("one-0.1.0")?;
     assert!(commit_message.contains("[ci-skip-override]"));
@@ -529,7 +529,7 @@ fn bump_standard_repository_with_default_skip_ci_ok() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
     assert_tag_exists("0.1.0")?;
     assert!(commit_message.contains("[skip ci]"));
 
@@ -550,7 +550,7 @@ fn bump_standard_repository_with_cog_toml_defined_skip_ci_ok() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
     assert_tag_exists("0.1.0")?;
     assert!(commit_message.contains("[ci-skip]"));
 
@@ -572,7 +572,7 @@ fn bump_standard_repository_skip_ci_override_option_takes_precedence() -> Result
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
     assert_tag_exists("0.1.0")?;
     assert!(commit_message.contains("[ci-skip-override]"));
 
@@ -593,7 +593,7 @@ fn bump_monorepo_with_default_skip_ci_ok() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
 
     assert_tag_exists("0.1.0")?;
     assert!(commit_message.contains("[skip ci]"));
@@ -616,7 +616,7 @@ fn bump_monorepo_with_cog_toml_defined_skip_ci_ok() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
 
     assert_tag_exists("0.1.0")?;
     assert!(commit_message.contains("[ci-skip]"));
@@ -630,6 +630,7 @@ fn bump_monorepo_skip_ci_override_option_takes_precedence() -> Result<()> {
         skip_ci: String::from("[ci-skip]"),
         ..Default::default()
     };
+
     init_monorepo(&mut settings)?;
 
     Command::cargo_bin("cog")?
@@ -640,7 +641,7 @@ fn bump_monorepo_skip_ci_override_option_takes_precedence() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
 
     assert_tag_exists("0.1.0")?;
     assert!(commit_message.contains("[skip-ci-override]"));
diff --git a/tests/cog_tests/changelog.rs b/tests/cog_tests/changelog.rs
index eca728f..a545be0 100644
--- a/tests/cog_tests/changelog.rs
+++ b/tests/cog_tests/changelog.rs
@@ -78,7 +78,7 @@ fn get_changelog_range() -> Result<()> {
 fn get_changelog_from_untagged_repo() -> Result<()> {
     // Arrange
     git_init()?;
-    let _ = git_commit("chore: init")?;
+    let init = git_commit("chore: init")?;
     let commit_two = git_commit("feat(taef): feature")?;
     let commit_three = git_commit("fix: bug fix")?;
 
@@ -96,14 +96,17 @@ fn get_changelog_from_untagged_repo() -> Result<()> {
     assert_eq!(
         changelog.as_ref(),
         formatdoc!(
-            "## Unreleased ({commit_two}..{commit_three})
+            "## Unreleased ({init}..{commit_three})
                     #### Bug Fixes
                     - bug fix - ({commit_three}) - Tom
                     #### Features
                     - **(taef)** feature - ({commit_two}) - Tom
+                    #### Miscellaneous Chores
+                    - init - ({init}) - Tom
 
 
                     ",
+            init = &init[0..7],
             commit_two = &commit_two[0..7],
             commit_three = &commit_three[0..7]
         )
@@ -115,7 +118,7 @@ fn get_changelog_from_untagged_repo() -> Result<()> {
 fn get_changelog_from_tagged_repo() -> Result<()> {
     // Arrange
     git_init()?;
-    git_commit("chore: init")?;
+    let init = git_commit("chore: init")?;
     let commit_one = git_commit("feat(taef): feature")?;
     git_tag("1.0.0")?;
     let commit_two = git_commit("fix: bug fix")?;
@@ -144,9 +147,12 @@ fn get_changelog_from_tagged_repo() -> Result<()> {
                     ## 1.0.0 - {today}
                     #### Features
                     - **(taef)** feature - ({commit_one}) - Tom
+                    #### Miscellaneous Chores
+                    - init - ({init}) - Tom
 
 
                     ",
+            init = &init[0..7],
             commit_one = &commit_one[0..7],
             commit_two = &commit_two[0..7],
             today = today
@@ -159,7 +165,7 @@ fn get_changelog_from_tagged_repo() -> Result<()> {
 fn get_changelog_at_tag() -> Result<()> {
     // Arrange
     git_init()?;
-    git_commit("chore: init")?;
+    let init = git_commit("chore: init")?;
     let commit_one = git_commit("feat(taef): feature")?;
     let commit_two = git_commit("feat: feature 2")?;
     git_tag("1.0.0")?;
@@ -186,10 +192,13 @@ fn get_changelog_at_tag() -> Result<()> {
                     #### Features
                     - **(taef)** feature - ({commit_one}) - Tom
                     - feature 2 - ({commit_two}) - Tom
+                    #### Miscellaneous Chores
+                    - init - ({init}) - Tom
 
 
                     ",
             today = today,
+            init = &init[0..7],
             commit_one = &commit_one[0..7],
             commit_two = &commit_two[0..7]
         )
@@ -206,10 +215,10 @@ fn get_changelog_with_tag_prefix() -> Result<()> {
     };
 
     let settings = toml::to_string(&settings);
-    std::fs::write("cog.toml", settings?)?;
+    fs::write("cog.toml", settings?)?;
 
     git_init()?;
-    let _ = git_commit("chore: init")?;
+    let init = git_commit("chore: init")?;
     let commit_one = git_commit("feat: feature 1")?;
     git_tag("v1.0.0")?;
     let commit_two = git_commit("fix: bug fix 1")?;
@@ -238,10 +247,13 @@ fn get_changelog_with_tag_prefix() -> Result<()> {
                     ## v1.0.0 - {today}
                     #### Features
                     - feature 1 - ({commit_one}) - Tom
+                    #### Miscellaneous Chores
+                    - init - ({init}) - Tom
 
 
                     ",
             today = today,
+            init = &init[0..7],
             commit_one = &commit_one[0..7],
             commit_two = &commit_two[0..7]
         )
@@ -310,7 +322,7 @@ fn get_changelog_at_tag_prefix() -> Result<()> {
 fn get_changelog_from_tag_to_tagged_head() -> Result<()> {
     // Arrange
     git_init()?;
-    git_commit("chore: init")?;
+    let init = git_commit("chore: init")?;
     let commit_one = git_commit("feat: start")?;
     let commit_two = git_commit("feat: feature 1")?;
     git_tag("1.0.0")?;
@@ -329,7 +341,6 @@ fn get_changelog_from_tag_to_tagged_head() -> Result<()> {
     let changelog = changelog.get_output();
     let changelog = String::from_utf8_lossy(&changelog.stdout);
     let today = Utc::now().date_naive();
-
     assert_eq!(
         changelog.as_ref(),
         formatdoc!(
@@ -347,10 +358,13 @@ fn get_changelog_from_tag_to_tagged_head() -> Result<()> {
                 #### Features
                 - feature 1 - ({commit_two}) - Tom
                 - start - ({commit_one}) - Tom
+                #### Miscellaneous Chores
+                - init - ({init}) - Tom
 
 
                 ",
             today = today,
+            init = &init[0..7],
             commit_one = &commit_one[0..7],
             commit_two = &commit_two[0..7],
             commit_three = &commit_three[0..7],
@@ -358,6 +372,7 @@ fn get_changelog_from_tag_to_tagged_head() -> Result<()> {
             commit_five = &commit_five[0..7],
         )
     );
+
     Ok(())
 }
 
@@ -369,7 +384,7 @@ fn get_changelog_is_unaffected_by_disable_changelog() -> Result<()> {
     let cog_toml = indoc!("disable_changelog = true");
 
     run_cmd!(echo $cog_toml > cog.toml;)?;
-    git_commit("chore: init")?;
+    let init = git_commit("chore: init")?;
     let commit_one = git_commit("feat: start")?;
     let commit_two = git_commit("feat: feature 1")?;
     git_tag("1.0.0")?;
@@ -392,10 +407,13 @@ fn get_changelog_is_unaffected_by_disable_changelog() -> Result<()> {
                 #### Features
                 - feature 1 - ({commit_two}) - Tom
                 - start - ({commit_one}) - Tom
+                #### Miscellaneous Chores
+                - init - ({init}) - Tom
 
 
                 ",
             today = today,
+            init = &init[0..7],
             commit_one = &commit_one[0..7],
             commit_two = &commit_two[0..7],
         )
@@ -421,7 +439,7 @@ fn get_changelog_with_custom_template() -> Result<()> {
     run_cmd!(echo $cog_toml > cog.toml;)?;
 
     let _string = fs::read_to_string("cog.toml")?;
-    let init_commit = git_commit("chore: init")?;
+    let init = git_commit("chore: init")?;
     let commit_one = git_commit("feat(scope1): start")?;
     let commit_two = git_commit("feat: feature 1")?;
     git_tag("1.0.0")?;
@@ -456,15 +474,17 @@ fn get_changelog_with_custom_template() -> Result<()> {
 
             - - -
 
-            ## [1.0.0](https://github.com/test/test/compare/{init_commit}..1.0.0) - {today}
+            ## [1.0.0](https://github.com/test/test/compare/{init}..1.0.0) - {today}
             #### Features
             -  feature 1 - ([{commit_two_short}](https://github.com/test/test/commit/{commit_two})) - Tom
             - **(scope1)** start - ([{commit_one_short}](https://github.com/test/test/commit/{commit_one})) - Tom
+            #### Miscellaneous Chores
+            -  init - ([{init_commit}](https://github.com/test/test/commit/{init})) - Tom
 
 
             ",
             today = today,
-            init_commit = &init_commit,
+            init_commit = &init[0..7],
             commit_one = &commit_one,
             commit_one_short = &commit_one[0..7],
             commit_two = &commit_two,
@@ -542,3 +562,48 @@ fn ensure_omit_from_changelog_is_honored() -> Result<()> {
 
     Ok(())
 }
+
+//TODO
+/*// see: https://github.com/cocogitto/cocogitto/issues/359
+#[sealed_test]
+fn changelog_from_commit_range_should_be_the_same_as_changelog_from_tag_range() -> Result<()> {
+    // Arrange
+    git_init()?;
+
+    git_commit("feat: feature 1")?;
+    let sha_0_1 = git_commit("feat: feature 2")?;
+    let _ = git_tag("0.1.0");
+    git_commit("feat: feature 3")?;
+    let sha_0_2 = git_commit("feat: feature 4")?;
+
+    Command::cargo_bin("cog")?
+        .arg("bump")
+        .arg("--auto")
+        .assert()
+        .success();
+
+    // Act
+    let changelog_from_commit_range = Command::cargo_bin("cog")?
+        .arg("changelog")
+        .arg(&format!("{sha_0_1}..{sha_0_2}"))
+        .assert()
+        .success();
+
+    let changelog_from_commit_range =
+        String::from_utf8_lossy(&changelog_from_commit_range.get_output().stdout);
+
+    let changelog_from_tag_range = Command::cargo_bin("cog")?
+        .arg("changelog")
+        .arg(&"0.1.0..0.2.0".to_string())
+        .assert()
+        .success();
+
+    let changelog_from_tag_range =
+        String::from_utf8_lossy(&changelog_from_tag_range.get_output().stdout);
+
+    // Assert
+    pretty_assertions::assert_eq!(changelog_from_commit_range, changelog_from_tag_range);
+
+    Ok(())
+}
+*/
diff --git a/tests/cog_tests/commit.rs b/tests/cog_tests/commit.rs
index 731f014..ac0f494 100644
--- a/tests/cog_tests/commit.rs
+++ b/tests/cog_tests/commit.rs
@@ -148,7 +148,7 @@ fn commit_with_default_skip_ci_ok() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
 
     assert!(commit_message.contains("[skip ci]"));
 
@@ -173,7 +173,7 @@ fn commit_with_cog_toml_defined_skip_ci_ok() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
 
     assert!(commit_message.contains("[ci-skip]"));
 
@@ -199,7 +199,7 @@ fn commit_with_skip_ci_override_option_takes_precedence() -> Result<()> {
         .assert()
         .success();
 
-    let commit_message = git_log_head()?;
+    let commit_message = git_log_head_message()?;
 
     assert!(commit_message.contains("[skip-ci-override]"));
 
diff --git a/tests/helpers.rs b/tests/helpers.rs
index 6f74be6..3fcc764 100644
--- a/tests/helpers.rs
+++ b/tests/helpers.rs
@@ -43,6 +43,7 @@ pub fn init_monorepo(settings: &mut Settings) -> Result<()> {
 pub fn git_init() -> Result<()> {
     run_cmd!(
         git init -b master;
+        git config --local commit.gpgsign false;
         git config --local user.name Tom;
         git config --local user.email toml.bombadil@themail.org;
     )?;
@@ -57,8 +58,7 @@ pub fn git_init_and_set_current_path(path: &str) -> Result<()> {
     run_cmd!(
         git init $path;
         cd $path;
-        git config --local user.name "Tom";
-        git config --local user.email "toml.bombadil@themail.org";
+        git config --local commit.gpgsign false;
     )?;
 
     std::env::set_current_dir(path).expect("Unable to move into to test repository");
@@ -117,10 +117,10 @@ pub fn assert_tag_does_not_exist(tag: &str) -> Result<()> {
 
 pub fn assert_latest_tag(tag: &str) -> Result<()> {
     let tags = run_fun!(git --no-pager tag)?;
-    let tag = Tag::from_str(tag, None)?;
+    let tag = Tag::from_str(tag, None, None)?;
     let mut tags: Vec<Tag> = tags
         .split('\n')
-        .filter_map(|tag| Tag::from_str(tag, None).ok())
+        .filter_map(|tag| Tag::from_str(tag, None, None).ok())
         .collect();
 
     tags.sort();
@@ -136,11 +136,16 @@ pub fn assert_tag_is_annotated(tag: &str) -> Result<()> {
     Ok(())
 }
 
-/// Git log showing only the HEAD commit, this can be used to make assertion on the last commit
-pub fn git_log_head() -> Result<String> {
+/// Git log showing only the HEAD commit message, this can be used to make assertion on the last commit
+pub fn git_log_head_message() -> Result<String> {
     run_fun!(git log -1 --pretty=%B).map_err(|e| anyhow!(e))
 }
 
+/// Git log showing only the HEAD commit sha, this can be used to make assertion on the last commit
+pub fn git_log_head_sha() -> Result<String> {
+    run_fun!(git log -1 --pretty=%H).map_err(|e| anyhow!(e))
+}
+
 /// Create an empty `cog.toml` config file in the current directory
 pub fn create_empty_config() -> Result<()> {
     std::fs::File::create(CONFIG_PATH)?;
diff --git a/tests/lib_tests/bump.rs b/tests/lib_tests/bump.rs
index b3c829e..862a290 100644
--- a/tests/lib_tests/bump.rs
+++ b/tests/lib_tests/bump.rs
@@ -7,11 +7,11 @@ use cmd_lib::run_cmd;
 use sealed_test::prelude::*;
 use speculoos::prelude::*;
 
+use crate::helpers::*;
+use cocogitto::command::bump::{BumpOptions, PackageBumpOptions};
 use cocogitto::settings::{MonoRepoPackage, Settings};
 use cocogitto::{conventional::version::IncrementCommand, CocoGitto};
 
-use crate::helpers::*;
-
 #[sealed_test]
 fn bump_ok() -> Result<()> {
     // Arrange
@@ -24,16 +24,10 @@ fn bump_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_version(BumpOptions {
+        increment: IncrementCommand::Auto,
+        ..Default::default()
+    });
 
     // Assert
     assert_that!(result).is_ok();
@@ -53,16 +47,10 @@ fn annotated_bump_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        Some(String::from("Release version {{version}}")),
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_version(BumpOptions {
+        annotated: Some(String::from("Release version {{version}}")),
+        ..Default::default()
+    });
 
     // Assert
     assert_that!(result).is_ok();
@@ -83,16 +71,7 @@ fn monorepo_bump_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_monorepo_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_monorepo_version(BumpOptions::default());
 
     // Assert
     assert_that!(result).is_ok();
@@ -116,16 +95,10 @@ fn monorepo_bump_manual_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_monorepo_version(
-        IncrementCommand::Major,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_monorepo_version(BumpOptions {
+        increment: IncrementCommand::Major,
+        ..Default::default()
+    });
 
     // Assert
     assert_that!(result).is_ok();
@@ -149,16 +122,10 @@ fn monorepo_bump_manual_disable_changelog_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_monorepo_version(
-        IncrementCommand::Major,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_monorepo_version(BumpOptions {
+        increment: IncrementCommand::Major,
+        ..Default::default()
+    });
 
     // Assert
     assert_that!(result).is_ok();
@@ -180,16 +147,7 @@ fn monorepo_with_tag_prefix_bump_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_monorepo_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_monorepo_version(BumpOptions::default());
 
     // Assert
     assert_that!(result).is_ok();
@@ -210,17 +168,12 @@ fn package_bump_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_package_version(
-        ("one", package),
-        IncrementCommand::AutoPackage("one".to_string()),
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_package_version(PackageBumpOptions {
+        package_name: "one",
+        package,
+        increment: IncrementCommand::AutoPackage("one".to_string()),
+        ..Default::default()
+    });
 
     // Assert
     assert_that!(result).is_ok();
@@ -280,29 +233,21 @@ fn consecutive_package_bump_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    cocogitto.create_package_version(
-        ("thumbor", &thumbor()),
-        IncrementCommand::AutoPackage("thumbor".to_owned()),
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    )?;
+    cocogitto.create_package_version(PackageBumpOptions {
+        package_name: "thumbor",
+        package: &thumbor(),
+        increment: IncrementCommand::AutoPackage("thumbor".to_owned()),
+        ..Default::default()
+    })?;
 
-    cocogitto.create_package_version(
-        ("jenkins", &jenkins()),
-        IncrementCommand::AutoPackage("jenkins".to_owned()),
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    )?;
+    cocogitto.clear_cache();
+
+    cocogitto.create_package_version(PackageBumpOptions {
+        package_name: "jenkins",
+        package: &jenkins(),
+        increment: IncrementCommand::AutoPackage("jenkins".to_owned()),
+        ..Default::default()
+    })?;
 
     run_cmd!(
         echo "fix jenkins bug" > jenkins/fix;
@@ -310,17 +255,14 @@ fn consecutive_package_bump_ok() -> Result<()> {
         git commit -m "fix(jenkins): bug fix on jenkins package";
     )?;
 
-    cocogitto.create_package_version(
-        ("jenkins", &jenkins()),
-        IncrementCommand::AutoPackage("jenkins".to_owned()),
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    )?;
+    cocogitto.clear_cache();
+
+    cocogitto.create_package_version(PackageBumpOptions {
+        package_name: "jenkins",
+        package: &jenkins(),
+        increment: IncrementCommand::AutoPackage("jenkins".to_owned()),
+        ..Default::default()
+    })?;
 
     // Assert
     assert_tag_exists("jenkins-0.1.0")?;
@@ -341,16 +283,7 @@ fn should_fallback_to_0_0_0_when_there_is_no_tag() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_version(BumpOptions::default());
 
     // Assert
     assert_that!(result).is_ok();
@@ -366,29 +299,14 @@ fn should_ignore_latest_prerelease_tag() -> Result<()> {
     git_commit("feat: add a feature commit")?;
 
     let mut cocogitto = CocoGitto::get()?;
-    cocogitto.create_version(
-        IncrementCommand::Auto,
-        Some("alpha1"),
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    )?;
+    cocogitto.create_version(BumpOptions {
+        pre_release: Some("alpha1"),
+        ..Default::default()
+    })?;
 
     git_commit("feat: more features")?;
     // Act
-    let result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_version(BumpOptions::default());
 
     // Assert
     assert_that!(result).is_ok();
@@ -449,19 +367,21 @@ fn auto_bump_package_only_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    cocogitto.create_all_package_version_auto(None, None, false, false, None, false)?;
+    cocogitto.create_all_package_version_auto(BumpOptions::default())?;
 
     assert_tag_exists("jenkins-0.1.0")?;
     assert_tag_exists("thumbor-0.1.0")?;
     assert_tag_does_not_exist("0.1.0")?;
 
+    cocogitto.clear_cache();
+
     run_cmd!(
         echo "fix jenkins bug" > jenkins/fix;
         git add .;
         git commit -m "fix(jenkins): bug fix on jenkins package";
     )?;
 
-    cocogitto.create_all_package_version_auto(None, None, false, false, None, false)?;
+    cocogitto.create_all_package_version_auto(BumpOptions::default())?;
 
     // Assert
     assert_tag_exists("jenkins-0.1.1")?;
@@ -513,16 +433,7 @@ fn bump_with_whitelisted_branch_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_version(BumpOptions::default());
 
     // Assert
     assert_that!(result).is_ok();
@@ -547,16 +458,7 @@ fn bump_with_whitelisted_branch_fails() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_version(BumpOptions::default());
 
     // Assert
     assert_that!(result.unwrap_err().to_string()).is_equal_to(
@@ -585,16 +487,7 @@ fn bump_with_whitelisted_branch_pattern_ok() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_version(BumpOptions::default());
 
     // Assert
     assert_that!(result).is_ok();
@@ -619,16 +512,7 @@ fn bump_with_whitelisted_branch_pattern_err() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_version(BumpOptions::default());
 
     // Assert
     assert_that!(result).is_err();
@@ -685,16 +569,7 @@ fn bump_no_error_should_be_thrown_on_only_chore_docs_commit() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    cocogitto.create_monorepo_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    )?;
+    cocogitto.create_monorepo_version(BumpOptions::default())?;
 
     run_cmd!(
         echo "chore on jenkins" > jenkins/fix;
@@ -705,28 +580,18 @@ fn bump_no_error_should_be_thrown_on_only_chore_docs_commit() -> Result<()> {
         git commit -m "docs(jenkins): jenkins docs";
     )?;
 
-    cocogitto.create_monorepo_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    )?;
+    cocogitto.clear_cache();
 
-    cocogitto.create_package_version(
-        ("jenkins", &jenkins()),
-        IncrementCommand::AutoPackage("jenkins".to_owned()),
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    )?;
+    cocogitto.create_monorepo_version(BumpOptions::default())?;
+
+    cocogitto.clear_cache();
+
+    cocogitto.create_package_version(PackageBumpOptions {
+        package_name: "jenkins",
+        package: &jenkins(),
+        increment: IncrementCommand::AutoPackage("jenkins".to_owned()),
+        ..Default::default()
+    })?;
 
     run_cmd!(
         echo "more feat on thumbor" > thumbor/feat;
@@ -734,16 +599,9 @@ fn bump_no_error_should_be_thrown_on_only_chore_docs_commit() -> Result<()> {
         git commit -m "feat(thumbor): more feat on thumbor";
     )?;
 
-    cocogitto.create_monorepo_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    )?;
+    cocogitto.clear_cache();
+
+    cocogitto.create_monorepo_version(BumpOptions::default())?;
 
     // Assert
     assert_tag_exists("jenkins-0.1.0")?;
@@ -784,16 +642,7 @@ fn error_on_no_conventionnal_commits_found_for_monorepo() -> Result<()> {
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let first_result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let first_result = cocogitto.create_version(BumpOptions::default());
 
     // Assert
     assert_that!(first_result).is_ok();
@@ -806,16 +655,7 @@ fn error_on_no_conventionnal_commits_found_for_monorepo() -> Result<()> {
     git_commit("second unconventional feature commit")?;
 
     // Act
-    let second_result = cocogitto.create_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let second_result = cocogitto.create_version(BumpOptions::default());
 
     // Assert
     assert_that!(second_result).is_err();
@@ -861,17 +701,12 @@ fn error_on_no_conventionnal_commits_found_for_package() -> Result<()> {
 
     let mut cocogitto = CocoGitto::get()?;
 
-    let first_result = cocogitto.create_package_version(
-        ("jenkins", &jenkins()),
-        IncrementCommand::AutoPackage("jenkins".to_owned()),
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let first_result = cocogitto.create_package_version(PackageBumpOptions {
+        package_name: "jenkins",
+        package: &jenkins(),
+        increment: IncrementCommand::AutoPackage("jenkins".to_owned()),
+        ..Default::default()
+    });
 
     assert_that!(first_result).is_ok();
 
@@ -881,17 +716,12 @@ fn error_on_no_conventionnal_commits_found_for_package() -> Result<()> {
         git commit -m "some other jenkins stuff";
     )?;
 
-    let second_result = cocogitto.create_package_version(
-        ("jenkins", &jenkins()),
-        IncrementCommand::AutoPackage("jenkins".to_owned()),
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let second_result = cocogitto.create_package_version(PackageBumpOptions {
+        package_name: "jenkins",
+        package: &jenkins(),
+        increment: IncrementCommand::AutoPackage("jenkins".to_owned()),
+        ..Default::default()
+    });
 
     assert_that!(second_result).is_err();
 
@@ -947,16 +777,7 @@ fn bump_with_unconventionnal_and_conventional_commits_found_for_packages() -> Re
     let mut cocogitto = CocoGitto::get()?;
 
     // Act
-    let result = cocogitto.create_monorepo_version(
-        IncrementCommand::Auto,
-        None,
-        None,
-        None,
-        false,
-        false,
-        None,
-        false,
-    );
+    let result = cocogitto.create_monorepo_version(BumpOptions::default());
 
     // Assert
     assert_that!(result).is_ok();
diff --git a/tests/lib_tests/changelog.rs b/tests/lib_tests/changelog.rs
new file mode 100644
index 0000000..69aab8b
--- /dev/null
+++ b/tests/lib_tests/changelog.rs
@@ -0,0 +1,109 @@
+use anyhow::Result;
+use assert_cmd::Command;
+use cmd_lib::run_cmd;
+use sealed_test::prelude::*;
+use speculoos::prelude::*;
+
+use cocogitto::CocoGitto;
+
+use crate::helpers::{git_commit, git_init, git_log_head_sha, git_tag};
+
+#[sealed_test]
+fn getting_changelog_from_tags_should_produce_the_same_range_either_from_tags_or_from_commits(
+) -> Result<()> {
+    // Arrange
+    git_init()?;
+
+    git_commit("feat: feature 1")?;
+    let sha_0_1 = git_commit("feat: feature 2")?;
+    git_tag("0.1.0")?;
+    git_commit("feat: feature 3")?;
+    git_commit("feat: feature 4")?;
+
+    Command::cargo_bin("cog")?
+        .arg("bump")
+        .arg("--auto")
+        .assert()
+        .success();
+
+    let head = git_log_head_sha()?;
+
+    run_cmd!(
+        git log --graph --abbrev-commit;
+    )
+    .unwrap();
+
+    // Act
+    let cocogitto = CocoGitto::get()?;
+    let changelog_from_commit_range =
+        cocogitto.get_changelog(&format!("{sha_0_1}..{head}"), false)?;
+    let changelog_tag_range = cocogitto.get_changelog("0.1.0..0.2.0", false)?;
+    let at_tag = cocogitto.get_changelog("..0.2.0", false)?;
+
+    let commit_range_oids: Vec<String> = changelog_from_commit_range
+        .commits
+        .into_iter()
+        .map(|commit| commit.commit.conventional.summary)
+        .collect();
+
+    let tag_range_oids: Vec<String> = changelog_tag_range
+        .commits
+        .into_iter()
+        .map(|commit| commit.commit.conventional.summary)
+        .collect();
+
+    let at_tag_oids: Vec<String> = at_tag
+        .commits
+        .into_iter()
+        .map(|commit| commit.commit.conventional.summary)
+        .collect();
+
+    // Assert
+    asserting!("Changelog commits generated from a commit range should be equivalent to when generated from the same tag range")
+        .that(&commit_range_oids)
+        .is_equal_to(&tag_range_oids);
+
+    asserting!("Changelog commits generated from a commit range should be equivalent to when generated from the same tag")
+        .that(&commit_range_oids)
+        .is_equal_to(&at_tag_oids);
+
+    Ok(())
+}
+
+#[sealed_test]
+fn from_commit_should_be_drained() -> Result<()> {
+    // Arrange
+    git_init()?;
+
+    git_commit("feat: feature 1")?;
+    git_commit("feat: feature 2")?;
+    git_tag("0.1.0")?;
+    git_commit("feat: feature 3")?;
+    let unttaged_sha = git_commit("feat: feature 4")?;
+
+    Command::cargo_bin("cog")?
+        .arg("bump")
+        .arg("--auto")
+        .assert()
+        .success();
+
+    let head = git_log_head_sha()?;
+
+    // Act
+    let cocogitto = CocoGitto::get()?;
+    let changelog_from_commit_range =
+        cocogitto.get_changelog(&format!("{unttaged_sha}..{head}"), true)?;
+
+    let commit_range_oids: Vec<String> = changelog_from_commit_range
+        .commits
+        .into_iter()
+        .map(|commit| commit.commit.oid)
+        .collect();
+
+    // Assert
+    asserting!("Changelog commits generated from a commit range should be equivalent to when generated from the same tag range")
+        .that(&commit_range_oids)
+        .is_equal_to(vec![head]);
+
+    Ok(())
+}
diff --git a/tests/lib_tests/cocogitto.rs b/tests/lib_tests/cocogitto.rs
index 2378925..2512362 100644
--- a/tests/lib_tests/cocogitto.rs
+++ b/tests/lib_tests/cocogitto.rs
@@ -2,6 +2,7 @@ use crate::helpers::*;
 
 use anyhow::Result;
 use cmd_lib::run_cmd;
+use cocogitto::command::commit::CommitOptions;
 use cocogitto::CocoGitto;
 use sealed_test::prelude::*;
 use speculoos::prelude::*;
@@ -150,7 +151,11 @@ fn long_commit_summary_does_not_panic() -> Result<()> {
 
     let cocogitto = CocoGitto::get()?;
     git_add("Hello", "file")?;
-    cocogitto.conventional_commit("feat", None, message, None, None, false, false)?;
+    cocogitto.conventional_commit(CommitOptions {
+        commit_type: "feat",
+        summary: message,
+        ..Default::default()
+    })?;
 
     let check = cocogitto.check(false, false, None);
 
diff --git a/tests/lib_tests/init.rs b/tests/lib_tests/init.rs
index 4168660..a217c45 100644
--- a/tests/lib_tests/init.rs
+++ b/tests/lib_tests/init.rs
@@ -14,7 +14,7 @@ fn should_init_a_cog_repository() -> Result<()> {
 
     // Assert
     assert_that!(Path::new("cog.toml")).exists();
-    assert_that!(git_log_head()?).is_equal_to("chore: initial commit".to_string());
+    assert_that!(git_log_head_message()?).is_equal_to("chore: initial commit".to_string());
     Ok(())
 }
 
@@ -29,7 +29,7 @@ fn should_skip_initialization_if_repository_exists() -> Result<()> {
 
     // Assert
     assert_that!(Path::new("cog.toml")).exists();
-    assert_that!(git_log_head()?).is_equal_to("The first commit\n".to_string());
+    assert_that!(git_log_head_message()?).is_equal_to("The first commit\n".to_string());
     if cfg!(target_os = "macos") {
         assert_that!(git_status()?)
             .contains("On branch master\nChanges to be committed:\n\tnew file:   cog.toml\n");
diff --git a/tests/lib_tests/mod.rs b/tests/lib_tests/mod.rs
index ab27f74..0c28d92 100644
--- a/tests/lib_tests/mod.rs
+++ b/tests/lib_tests/mod.rs
@@ -2,3 +2,5 @@ mod bump;
 mod cocogitto;
 mod init;
 mod log;
+
+mod changelog;