Repository: cocogitto/cocogitto
Pull request #367 (2024-02-22 09:23:13)

docs(commits-types): bump minor and patch options
Remembered seeing this in the commits but couldn't find it in the docs :D

oknozor: Thanks @ABWassim, strange though the commit signin test are broken. I'll need to fix this  before merging.
ABWassim: Ok, tell me if there is anything I can do !

Patch:
diff --git a/.github/workflows/Deploy.yaml b/.github/workflows/Deploy.yaml
index c4d0e82..f19dc23 100644
--- a/.github/workflows/Deploy.yaml
+++ b/.github/workflows/Deploy.yaml
@@ -183,9 +183,9 @@ jobs:
           mkdir -p target/armv7-unknown-linux-musleabihf/release/
           mkdir -p target/aarch64-unknown-linux-gnu/release/
           
-          cp -r /home/runner/artifacts/x86_64-unknown-linux-musl target/x86_64-unknown-linux-musl/release/cog
-          cp -r /home/runner/artifacts/armv7-unknown-linux-musleabihf target/armv7-unknown-linux-musleabihf/release/cog
-          cp -r /home/runner/artifacts/aarch64-unknown-linux-gnu target/aarch64-unknown-linux-gnu/release/cog
+          cp -r /home/runner/artifacts/x86_64-unknown-linux-musl/. target/x86_64-unknown-linux-musl/release
+          cp -r /home/runner/artifacts/armv7-unknown-linux-musleabihf/. target/armv7-unknown-linux-musleabihf/release
+          cp -r /home/runner/artifacts/aarch64-unknown-linux-gnu/. target/aarch64-unknown-linux-gnu/release
           
           chmod +x -R  target/x86_64-unknown-linux-musl/release
           chmod +x -R  target/armv7-unknown-linux-musleabihf/release
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/README.md b/README.md
index 45a7dea..f94b5c8 100644
--- a/README.md
+++ b/README.md
@@ -236,7 +236,7 @@ There is a docker image for `cog` available [ghcr.io/cocogitto/latest](https://g
 
 ```shell
 docker pull ghcr.io/cocogitto/cog:latest
-docker run -t -v "$(pwd)":/app/ cog check
+docker run -t -v "$(pwd)":/app/ check
 ```
 
 Note that you need to mount a volume pointing to your target directory for `cog` to be able to operate on your git log.
diff --git a/docs/website/ci_cd/README.md b/docs/website/ci_cd/README.md
index d73547c..4c1ff33 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: oknozor/cocogitto-action@v3
+        uses: cocogitto/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: oknozor/cocogitto-action@v3
+    uses: cocogitto/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: oknozor/cocogitto-action@v3
+    uses: cocogitto/cocogitto-action@v3
     with:
       release: true
       git-user: 'Cog Bot'
@@ -98,7 +98,7 @@ jobs:
 
       - name: Cocogitto release
         id: release
-        uses: oknozor/cocogitto-action@v3
+        uses: cocogitto/cocogitto-action@v3
         with:
           release: true
           git-user: 'Cog Bot'
diff --git a/docs/website/config/README.md b/docs/website/config/README.md
index dc86bca..df2d75f 100755
--- a/docs/website/config/README.md
+++ b/docs/website/config/README.md
@@ -83,6 +83,29 @@ The config reference list all value that can be set in the `cog.toml` file at th
     [commit_types]
     chore = { changelog_title = "Chore", omit_from_changelog = true }
     ```
+
+### `bump_patch`
+- Type: `Bool`
+- Optional: `true`
+- Default value: `false`
+- Description: commits of this type will bump the patch version of a tag.
+- Example:
+    ```toml
+    [commit_types]
+    chore = { changelog_title = "Chore", bump_patch = true }
+    ```
+
+### `bump_minor`
+- Type: `Bool`
+- Optional: `true`
+- Default value: `false`
+- Description: commits of this type will bump the minor version of a tag.
+- Example:
+    ```toml
+    [commit_types]
+    chore = { changelog_title = "Chore", bump_minor = true }
+    ```
+
 ## Bump config
 
 ### `pre_bump_hooks`
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;