Repository: knope-dev/knope (rust)

Issue:
Unreachable git commits are used for Go module version determination
I have a branch in which I've created a release `rc` tag. In a completely separate branch, from which the `rc` tag is _not_ reachable, I'm getting an "inconsistent versions" error that cites the `rc` tag, saying `Found <rc-tag> which does not match <actual>` (where `<rc-tag>` matches the tag that isn't reachable, and `<actual>` is the expected version).

I can verify that `<actual>` is the most recent reachable tag using `git describe`:

```
> git describe --match 'v*'
v<actual>-2-ga7d9350
```

Pull request:
Fix commit and tag resolution with branching histories
Closes #505

Patch:
diff --git a/.changeset/fix_commit_resolution.md b/.changeset/fix_commit_resolution.md
new file mode 100644
index 0000000..2d7e244
--- /dev/null
+++ b/.changeset/fix_commit_resolution.md
@@ -0,0 +1,9 @@
+---
+default: patch
+---
+
+#### Consistent commit selection in branching histories
+
+PR #574 fixes issue #505 from @BatmanAoD.
+
+Previous versions of Knope did not handle branching histories correctly. In some cases, this could result in commits from previous stable releases being included in a new release. It could _also_ result in missing some commits that _should_ have been included. This has been fixed—Knope should provide you the same commit list that `git rev-list {previous_stable_tag}..HEAD` would.
diff --git a/.changeset/fix_tag_resolution.md b/.changeset/fix_tag_resolution.md
new file mode 100644
index 0000000..ef82297
--- /dev/null
+++ b/.changeset/fix_tag_resolution.md
@@ -0,0 +1,9 @@
+---
+default: major
+---
+
+#### Ignore unreachable tags when determining version
+
+PR #574 fixes issue #505 from @BatmanAoD.
+
+Previously, the latests tags were always used to determine the current version, **even if those tags were not reachable from `HEAD`**. Now, only reachable tags will be considered. Use the `--verbose` flag to see tags which are being ignored.
diff --git a/Cargo.lock b/Cargo.lock
index b09777d..705f2f1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -80,6 +80,12 @@ version = "1.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6"
 
+[[package]]
+name = "arrayvec"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
+
 [[package]]
 name = "autocfg"
 version = "1.1.0"
@@ -230,6 +236,73 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "crossbeam"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-channel",
+ "crossbeam-deque",
+ "crossbeam-epoch",
+ "crossbeam-queue",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
+dependencies = [
+ "cfg-if",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "crossbeam-utils",
+ "memoffset",
+ "scopeguard",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
+dependencies = [
+ "cfg-if",
+]
+
 [[package]]
 name = "crossterm"
 version = "0.25.0"
@@ -646,11 +719,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f414c99e1a7abc69b21f3225a6539d203b0513f1d1d448607c4ea81cdcf9ee59"
 dependencies = [
  "crc32fast",
+ "crossbeam-channel",
  "flate2",
  "gix-hash",
  "gix-trace",
+ "jwalk",
  "libc",
  "once_cell",
+ "parking_lot",
  "prodash",
  "sha1_smol",
  "thiserror",
@@ -777,6 +853,7 @@ dependencies = [
  "parking_lot",
  "smallvec",
  "thiserror",
+ "uluru",
 ]
 
 [[package]]
@@ -1080,6 +1157,16 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "jwalk"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56"
+dependencies = [
+ "crossbeam",
+ "rayon",
+]
+
 [[package]]
 name = "knope"
 version = "0.10.0"
@@ -1185,6 +1272,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "memoffset"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "miette"
 version = "5.10.0"
@@ -1262,6 +1358,16 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "num_cpus"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
 [[package]]
 name = "num_threads"
 version = "0.1.6"
@@ -1370,6 +1476,28 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "rayon"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-deque",
+ "crossbeam-utils",
+ "num_cpus",
+]
+
 [[package]]
 name = "redox_syscall"
 version = "0.2.16"
@@ -1889,6 +2017,15 @@ version = "1.16.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba"
 
+[[package]]
+name = "uluru"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "794a32261a1f5eb6a4462c81b59cec87b5c27d5deea7dd1ac8fc781c41d226db"
+dependencies = [
+ "arrayvec",
+]
+
 [[package]]
 name = "unicase"
 version = "2.7.0"
diff --git a/Cargo.toml b/Cargo.toml
index 20915e4..63952b8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,7 +29,9 @@ clap = { version = "4.4.3", features = ["cargo", "string", "env"] }
 itertools = "0.11.0"
 miette = { version = "5.10.0", features = ["fancy"] }
 thiserror = "1.0.48"
-gix = { version = "0.53.1", default-features = false }
+gix = { version = "0.53.1", default-features = false, features = [
+  "max-performance-safe",
+] }
 log = "0.4.20"
 env_logger = "0.10.0"
 indexmap = { version = "2.0.0", features = ["serde"] }
diff --git a/src/command.rs b/src/command.rs
index 6891f70..742d895 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -7,7 +7,9 @@ use serde::{Deserialize, Serialize};
 use crate::{
     git::branch_name_from_issue,
     releases::{package, semver},
-    state, RunType, State,
+    state,
+    workflow::Verbose,
+    RunType, State,
 };
 
 /// Describes a value that you can replace an arbitrary string with when running a command.
@@ -26,13 +28,14 @@ pub(crate) fn run_command(
     mut run_type: RunType,
     mut command: String,
     variables: Option<HashMap<String, Variable>>,
+    verbose: Verbose,
 ) -> Result<RunType, Error> {
     let (state, dry_run_stdout) = match &mut run_type {
         RunType::DryRun { state, stdout } => (state, Some(stdout)),
         RunType::Real(state) => (state, None),
     };
     if let Some(variables) = variables {
-        command = replace_variables(command, variables, state)?;
+        command = replace_variables(command, variables, state, verbose)?;
     }
     if let Some(stdout) = dry_run_stdout {
         writeln!(stdout, "Would run {command}")?;
@@ -87,6 +90,7 @@ fn replace_variables(
     mut command: String,
     variables: HashMap<String, Variable>,
     state: &State,
+    verbose: Verbose,
 ) -> Result<String, Error> {
     for (var_name, var_type) in variables {
         match var_type {
@@ -102,7 +106,7 @@ fn replace_variables(
                     release.new_version.to_string()
                 } else {
                     package
-                        .get_version()?
+                        .get_version(verbose)?
                         .into_latest()
                         .ok_or(Error::NoCurrentVersion)?
                         .to_string()
@@ -135,6 +139,7 @@ mod test_run_command {
             RunType::Real(State::new(None, None, Vec::new())),
             command.clone(),
             None,
+            Verbose::No,
         );
 
         assert!(result.is_ok());
@@ -145,6 +150,7 @@ mod test_run_command {
             RunType::Real(State::new(None, None, Vec::new())),
             command,
             None,
+            Verbose::No,
         );
         assert!(result.is_err());
     }
@@ -159,6 +165,7 @@ mod test_replace_variables {
         issues::Issue,
         releases::{semver::Version, Package, Release},
         state,
+        workflow::Verbose,
     };
 
     fn packages() -> Vec<Package> {
@@ -188,14 +195,14 @@ mod test_replace_variables {
             packages: packages(),
         };
 
-        let command = replace_variables(command, variables, &state).unwrap();
+        let command = replace_variables(command, variables, &state, Verbose::No).unwrap();
 
         assert_eq!(
             command,
             format!(
                 "blah {} {}",
                 &state.packages[0]
-                    .get_version()
+                    .get_version(Verbose::No)
                     .unwrap()
                     .into_latest()
                     .unwrap(),
@@ -211,14 +218,14 @@ mod test_replace_variables {
         variables.insert("$$".to_string(), Variable::Version);
         let state = State::new(None, None, packages());
 
-        let command = replace_variables(command, variables, &state).unwrap();
+        let command = replace_variables(command, variables, &state, Verbose::No).unwrap();
 
         assert_eq!(
             command,
             format!(
                 "blah {} other blah",
                 &state.packages[0]
-                    .get_version()
+                    .get_version(Verbose::No)
                     .unwrap()
                     .into_latest()
                     .unwrap(),
@@ -235,7 +242,7 @@ mod test_replace_variables {
         let version = Version::new(1, 2, 3, None);
         state.packages[0].prepared_release = Some(Release::new(None, version.clone()));
 
-        let command = replace_variables(command, variables, &state).unwrap();
+        let command = replace_variables(command, variables, &state, Verbose::No).unwrap();
 
         assert_eq!(command, format!("blah {version} other blah"));
     }
@@ -258,7 +265,7 @@ mod test_replace_variables {
             packages: Vec::new(),
         };
 
-        let command = replace_variables(command, variables, &state).unwrap();
+        let command = replace_variables(command, variables, &state, Verbose::No).unwrap();
 
         assert_eq!(command, format!("blah {expected_branch_name} other blah"));
     }
diff --git a/src/git.rs b/src/git.rs
index 6381e4f..1fb18cc 100644
--- a/src/git.rs
+++ b/src/git.rs
@@ -1,7 +1,12 @@
-use std::{collections::VecDeque, env::current_dir, path::PathBuf, str::FromStr};
+use std::{
+    collections::{HashMap, HashSet, VecDeque},
+    env::current_dir,
+    path::PathBuf,
+    str::FromStr,
+};
 
 use git2::{build::CheckoutBuilder, Branch, BranchType, IndexAddOption, Repository};
-use gix::{object::Kind, refs::transaction::PreviousValue, trace::trace};
+use gix::{object::Kind, refs::transaction::PreviousValue, ObjectId};
 use itertools::Itertools;
 use log::error;
 use miette::Diagnostic;
@@ -13,7 +18,9 @@ use crate::{
     prompt,
     prompt::select,
     releases::{semver::Version, CurrentVersions},
-    state, RunType,
+    state,
+    workflow::Verbose,
+    RunType,
 };
 
 /// Based on the selected issue, either checks out an existing branch matching the name or creates
@@ -162,9 +169,8 @@ enum ErrorKind {
         help("Please check that the reference exists.")
     )]
     PeelOid(#[from] gix::reference::peel::Error),
-    #[error("Could not decode Git object: {0}")]
-    #[diagnostic(code(releases::git::decode))]
-    Decode(String),
+    #[error("Could not walk commits back from HEAD: {0}")]
+    RevisionWalk(#[from] gix::revision::walk::Error),
 }
 
 /// Rebase the current branch onto the selected one.
@@ -386,68 +392,58 @@ pub(crate) fn add_files(file_names: &[PathBuf]) -> Result<(), Error> {
     index.write().map_err(Error::from)
 }
 
-pub(crate) fn get_commit_messages_after_tag(tag: Option<String>) -> Result<Vec<String>, Error> {
-    let reference = tag.map(|tag| format!("refs/tags/{tag}"));
+/// Find every commit that appears only _after_ a specific tag.
+///
+/// This builds a complete set of every commit in the repository, because branching and merging
+/// means that there could be paths which jump _behind_ the target tag... and we want to exclude
+/// those as well. There's probably a way to optimize performance with some cool graph magic
+/// eventually, but this is good enough for now.
+pub(crate) fn get_commit_messages_after_tag(
+    tag: Option<String>,
+    verbose: Verbose,
+) -> Result<Vec<String>, Error> {
     let repo = gix::open(".")?;
-    let tag_ref = reference
+    let commits_to_exclude = tag
+        .map(|tag| format!("refs/tags/{tag}"))
         .as_ref()
         .map(|reference| {
+            if let Verbose::Yes = verbose {
+                println!("Finding all commits behind {reference}");
+            }
             repo.find_reference(reference)
                 .map_err(|err| ErrorKind::FindReference {
                     reference: reference.clone(),
                     source: err,
                 })
         })
-        .transpose()?;
-    let tag_oid = tag_ref
+        .transpose()?
         .map(gix::Reference::into_fully_peeled_id)
-        .transpose()?;
-    if tag_oid.is_none() {
-        if let Some(reference) = reference {
-            error!(
-                "Found tagged version {}, but could not parse it within Git",
-                reference
-            );
-        }
-    }
+        .transpose()?
+        .and_then(|tag_oid| repo.find_object(tag_oid).ok().map(gix::Object::into_commit))
+        .and_then(|commit| {
+            commit.ancestors().all().ok().map(|ancestors| {
+                ancestors
+                    .into_iter()
+                    .filter_map(Result::ok)
+                    .map(|info| info.id)
+                    .collect::<HashSet<ObjectId>>()
+            })
+        })
+        .unwrap_or_default();
     let head_commit = repo.head_commit()?;
-    let head_commit_message = head_commit
-        .decode()
-        .map_err(|err| ErrorKind::Decode(err.to_string()))?
-        .message
-        .to_string();
-    trace!("Checking commit message: {}", &head_commit_message);
-    let mut reverse_commits = [head_commit_message]
-        .into_iter()
-        .chain(
-            head_commit
-                .parent_ids()
-                .filter_map(|id| {
-                    repo.find_object(id)
-                        .ok()
-                        .and_then(|object| object.try_into_commit().ok())
-                        .and_then(|commit| commit.ancestors().all().ok())
-                        .map(|ancestors| {
-                            ancestors
-                                .into_iter()
-                                .filter_map(Result::ok)
-                                .take_while(|info| {
-                                    if let Some(tag_id) = tag_oid {
-                                        info.id != tag_id
-                                    } else {
-                                        true
-                                    }
-                                })
-                                .filter_map(|info| info.object().ok())
-                        })
-                })
-                .flatten()
-                .filter_map(|commit| {
-                    let message = commit.decode().ok()?.message.to_string();
-                    trace!("Checking commit message: {}", &message);
-                    Some(message)
-                }),
-        )
+    let mut reverse_commits = head_commit
+        .ancestors()
+        .all()?
+        .filter_map(Result::ok)
+        .filter(|info| !commits_to_exclude.contains(&info.id))
+        .filter_map(|info| {
+            info.object().ok().and_then(|commit| {
+                commit
+                    .decode()
+                    .ok()
+                    .map(|commit| commit.message.to_string())
+            })
+        })
         .collect_vec();
     reverse_commits.reverse();
     Ok(reverse_commits)
@@ -476,26 +472,60 @@ pub(crate) fn create_tag(dry_run: DryRun, name: String) -> Result<(), Error> {
 
 pub(crate) fn get_current_versions_from_tags(
     prefix: Option<&str>,
+    verbose: Verbose,
 ) -> Result<CurrentVersions, Error> {
     let repo = gix::open(current_dir().map_err(ErrorKind::CurrentDirectory)?)?;
-    let references = repo.references()?;
-    let tags = references.tags()?.flat_map(|tag| {
-        tag.map(|reference| {
-            reference
-                .name()
-                .as_bstr()
-                .to_string()
-                .replace("refs/tags/", "")
-        })
-    });
-    let mut current_versions = CurrentVersions::default();
     let pattern = prefix
         .as_ref()
         .map_or_else(|| String::from("v"), |prefix| format!("{prefix}/v"));
-    for tag in tags {
-        if !tag.starts_with(&pattern) {
-            continue;
+    let mut all_tags: HashMap<ObjectId, Vec<String>> = HashMap::new();
+    for (id, tag) in repo
+        .references()?
+        .tags()?
+        .filter_map(Result::ok)
+        .filter_map(|mut reference| {
+            reference.peel_to_id_in_place().ok().map(|id| {
+                (
+                    id.detach(),
+                    reference
+                        .name()
+                        .as_bstr()
+                        .to_string()
+                        .replace("refs/tags/", ""),
+                )
+            })
+        })
+        .filter(|(_id, tag_name)| tag_name.starts_with(&pattern))
+    {
+        all_tags.entry(id).or_default().push(tag);
+    }
+
+    let mut tags: Vec<String> = Vec::with_capacity(all_tags.len());
+    for commit_id in repo
+        .head_commit()?
+        .ancestors()
+        .all()?
+        .filter_map(|info| info.ok().map(|info| info.id))
+    {
+        if let Some(tag) = all_tags.remove(&commit_id) {
+            tags.extend(tag);
+        }
+    }
+
+    if let Verbose::Yes = verbose {
+        if !all_tags.is_empty() {
+            println!(
+                "Skipping relevant tags that are not on the current branch: {tags}",
+                tags = all_tags.values().flatten().join(", ")
+            );
         }
+        if tags.is_empty() {
+            println!("No tags found matching pattern {pattern}");
+        }
+    }
+
+    let mut current_versions = CurrentVersions::default();
+    for tag in tags {
         let version_string = tag.replace(&pattern, "");
         if let Ok(version) = Version::from_str(version_string.as_str()) {
             current_versions.update_version(version);
diff --git a/src/releases/conventional_commits.rs b/src/releases/conventional_commits.rs
index 778f825..3d3da45 100644
--- a/src/releases/conventional_commits.rs
+++ b/src/releases/conventional_commits.rs
@@ -9,6 +9,7 @@ use crate::{
     git,
     git::{get_commit_messages_after_tag, get_current_versions_from_tags},
     releases::{self, package::ChangelogSectionSource, tag_name, Change, ChangeType, Package},
+    workflow::Verbose,
 };
 
 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -343,10 +344,11 @@ mod test_conventional_commits {
 fn get_conventional_commits_after_last_stable_version(
     package: &Package,
     consider_scopes: bool,
+    verbose: Verbose,
 ) -> Result<Vec<ConventionalCommit>, Error> {
-    let target_version = get_current_versions_from_tags(package.name.as_deref())?.stable;
+    let target_version = get_current_versions_from_tags(package.name.as_deref(), verbose)?.stable;
     let tag = target_version.map(|version| tag_name(&version.into(), package.name.as_ref()));
-    let commit_messages = get_commit_messages_after_tag(tag).map_err(git::Error::from)?;
+    let commit_messages = get_commit_messages_after_tag(tag, verbose).map_err(git::Error::from)?;
     Ok(ConventionalCommit::from_commit_messages(
         &commit_messages,
         consider_scopes,
@@ -366,24 +368,31 @@ pub(crate) enum Error {
 
 pub(crate) fn add_releases_from_conventional_commits(
     packages: Vec<Package>,
+    verbose: Verbose,
 ) -> Result<Vec<Package>, Error> {
     let consider_scopes = packages.iter().any(|package| package.scopes.is_some());
     packages
         .into_iter()
-        .map(|package| add_release_for_package(package, consider_scopes))
+        .map(|package| add_release_for_package(package, consider_scopes, verbose))
         .collect()
 }
 
-fn add_release_for_package(mut package: Package, consider_scopes: bool) -> Result<Package, Error> {
-    get_conventional_commits_after_last_stable_version(&package, consider_scopes).map(|commits| {
-        if commits.is_empty() {
-            package
-        } else {
-            package.pending_changes = commits
-                .into_iter()
-                .map(Change::ConventionalCommit)
-                .collect();
-            package
-        }
-    })
+fn add_release_for_package(
+    mut package: Package,
+    consider_scopes: bool,
+    verbose: Verbose,
+) -> Result<Package, Error> {
+    get_conventional_commits_after_last_stable_version(&package, consider_scopes, verbose).map(
+        |commits| {
+            if commits.is_empty() {
+                package
+            } else {
+                package.pending_changes = commits
+                    .into_iter()
+                    .map(Change::ConventionalCommit)
+                    .collect();
+                package
+            }
+        },
+    )
 }
diff --git a/src/releases/go.rs b/src/releases/go.rs
index 22183d3..fc00b11 100644
--- a/src/releases/go.rs
+++ b/src/releases/go.rs
@@ -8,6 +8,7 @@ use crate::{
     fs, git,
     git::get_current_versions_from_tags,
     releases::{semver::Version, tag_name, versioned_file::VersionFromSource, PackageName},
+    workflow::Verbose,
 };
 
 #[derive(Debug, Diagnostic, Error)]
@@ -268,7 +269,11 @@ pub(crate) fn create_version_tag(
 
 /// Gets the version from the comment in the `go.mod` file, if any, or defers to the latest tag
 /// for the module.
-pub(crate) fn get_version(content: &str, path: &Path) -> Result<VersionFromSource, Error> {
+pub(crate) fn get_version(
+    content: &str,
+    path: &Path,
+    verbose: Verbose,
+) -> Result<VersionFromSource, Error> {
     let prefix = path.parent().map(Path::to_string_lossy).and_then(|prefix| {
         if prefix.is_empty() {
             None
@@ -288,7 +293,7 @@ pub(crate) fn get_version(content: &str, path: &Path) -> Result<VersionFromSourc
         });
     }
 
-    if let Some(version_from_tag) = get_current_versions_from_tags(prefix.as_deref())
+    if let Some(version_from_tag) = get_current_versions_from_tags(prefix.as_deref(), verbose)
         .map(|current_versions| {
             current_versions
                 .into_latest()
diff --git a/src/releases/mod.rs b/src/releases/mod.rs
index e9552c5..1500d57 100644
--- a/src/releases/mod.rs
+++ b/src/releases/mod.rs
@@ -55,7 +55,7 @@ pub(crate) fn prepare_release(
         return Err(package::Error::no_defined_packages_with_help().into());
     }
     let PrepareRelease { prerelease_label } = prepare_release;
-    state.packages = add_releases_from_conventional_commits(state.packages)
+    state.packages = add_releases_from_conventional_commits(state.packages, verbose)
         .map_err(Error::from)
         .and_then(|packages| {
             changesets::add_releases_from_changeset(packages, &mut dry_run_stdout)
@@ -314,7 +314,7 @@ impl From<Version> for CurrentVersions {
 /// Create a release for the package.
 ///
 /// If GitHub config is present, this creates a GitHub release. Otherwise, it tags the Git repo.
-pub(crate) fn release(run_type: RunType) -> Result<RunType, Error> {
+pub(crate) fn release(run_type: RunType, verbose: Verbose) -> Result<RunType, Error> {
     let (mut state, mut dry_run_stdout) = run_type.decompose();
 
     let mut releases = state
@@ -336,7 +336,7 @@ pub(crate) fn release(run_type: RunType) -> Result<RunType, Error> {
             .packages
             .iter()
             .map(|package| {
-                find_prepared_release(package).map(|release| {
+                find_prepared_release(package, verbose).map(|release| {
                     release.map(|release| PackageWithRelease {
                         package: package.clone(),
                         release,
@@ -382,11 +382,11 @@ struct PackageWithRelease {
 
 /// Given a package, figure out if there was a release prepared in a separate workflow. Basically,
 /// if the package version is newer than the latest tag, there's a release to release!
-fn find_prepared_release(package: &Package) -> Result<Option<Release>, Error> {
-    let Some(current_version) = package.version_from_files()? else {
+fn find_prepared_release(package: &Package, verbose: Verbose) -> Result<Option<Release>, Error> {
+    let Some(current_version) = package.version_from_files(verbose)? else {
         return Ok(None);
     };
-    let last_tag = get_current_versions_from_tags(package.name.as_deref())
+    let last_tag = get_current_versions_from_tags(package.name.as_deref(), verbose)
         .map(CurrentVersions::into_latest)?;
     let version_of_new_release = match last_tag {
         Some(last_tag) if last_tag != current_version => current_version,
diff --git a/src/releases/package.rs b/src/releases/package.rs
index 365d4a6..f38aa31 100644
--- a/src/releases/package.rs
+++ b/src/releases/package.rs
@@ -85,7 +85,7 @@ impl Package {
             }
             override_version
         } else {
-            let versions = self.get_version()?;
+            let versions = self.get_version(verbose)?;
             let bump_rule = self.bump_rule(verbose);
             let rule = if let Some(pre_label) = prerelease_label {
                 Rule::Pre {
diff --git a/src/releases/semver/mod.rs b/src/releases/semver/mod.rs
index 4eaff51..f78add2 100644
--- a/src/releases/semver/mod.rs
+++ b/src/releases/semver/mod.rs
@@ -128,7 +128,7 @@ pub(crate) fn bump_version_and_update_state(
             let version = if let Some(override_version) = package.override_version.clone() {
                 override_version
             } else {
-                bump(package.get_version()?, rule, verbose)?
+                bump(package.get_version(verbose)?, rule, verbose)?
             };
             let mut package = package.write_version(&version, &mut dry_run_stdout)?;
             package.prepared_release = Some(Release::new(None, version));
@@ -145,10 +145,10 @@ pub(crate) fn bump_version_and_update_state(
 impl Package {
     /// Get the current version of a package determined by the last tag for the package _and_ the
     /// version in versioned files. The version from files takes precedent over version from tag.
-    pub(crate) fn get_version(&self) -> Result<CurrentVersions, Error> {
-        let mut current_versions = get_current_versions_from_tags(self.name.as_deref())?;
+    pub(crate) fn get_version(&self, verbose: Verbose) -> Result<CurrentVersions, Error> {
+        let mut current_versions = get_current_versions_from_tags(self.name.as_deref(), verbose)?;
 
-        if let Some(version_from_files) = self.version_from_files()? {
+        if let Some(version_from_files) = self.version_from_files(verbose)? {
             current_versions.update_version(version_from_files);
         }
 
@@ -161,10 +161,10 @@ impl Package {
     ///
     /// 1. If the versions of all versioned files are not consistent
     /// 2. If the file cannot be parsed into a [`Version`]
-    pub(crate) fn version_from_files(&self) -> Result<Option<Version>, Error> {
+    pub(crate) fn version_from_files(&self, verbose: Verbose) -> Result<Option<Version>, Error> {
         self.versioned_files
             .iter()
-            .map(|versioned_file| versioned_file.get_version().map_err(Error::from))
+            .map(|versioned_file| versioned_file.get_version(verbose).map_err(Error::from))
             .reduce(|accumulator, version| match (version, accumulator) {
                 (Ok(version), Ok(accumulator)) => {
                     if version.version == accumulator.version {
diff --git a/src/releases/versioned_file.rs b/src/releases/versioned_file.rs
index 33ac711..b0e3b13 100644
--- a/src/releases/versioned_file.rs
+++ b/src/releases/versioned_file.rs
@@ -11,6 +11,7 @@ use thiserror::Error;
 use crate::{
     dry_run::DryRun,
     releases::{cargo, git, go, package_json, pyproject, semver::Version},
+    workflow::Verbose,
 };
 
 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -89,8 +90,8 @@ enum ErrorKind {
 type Result<T> = std::result::Result<T, Error>;
 
 impl VersionedFile {
-    pub(crate) fn get_version(&self) -> Result<VersionFromSource> {
-        self.format.get_version(&self.content, &self.path)
+    pub(crate) fn get_version(&self, verbose: Verbose) -> Result<VersionFromSource> {
+        self.format.get_version(&self.content, &self.path, verbose)
     }
 
     pub(crate) fn set_version(&mut self, dry_run: DryRun, version_str: &Version) -> Result<()> {
@@ -128,7 +129,12 @@ impl TryFrom<&PathBuf> for PackageFormat {
 impl PackageFormat {
     /// Get the version from `content` for package named `name` (if any name).
     /// `path` is used for error reporting.
-    pub(crate) fn get_version(self, content: &str, path: &Path) -> Result<VersionFromSource> {
+    pub(crate) fn get_version(
+        self,
+        content: &str,
+        path: &Path,
+        verbose: Verbose,
+    ) -> Result<VersionFromSource> {
         match self {
             PackageFormat::Cargo => cargo::get_version(content, path)
                 .map_err(ErrorKind::Cargo)
@@ -148,7 +154,7 @@ impl PackageFormat {
                     version,
                     source: path.display().to_string(),
                 }),
-            PackageFormat::Go => go::get_version(content, path).map_err(ErrorKind::Go),
+            PackageFormat::Go => go::get_version(content, path, verbose).map_err(ErrorKind::Go),
         }
         .map_err(Error::from)
     }
diff --git a/src/step.rs b/src/step.rs
index 38a1fd1..4ce402a 100644
--- a/src/step.rs
+++ b/src/step.rs
@@ -89,13 +89,13 @@ impl Step {
             Step::RebaseBranch { to } => git::rebase_branch(&to, run_type)?,
             Step::BumpVersion(rule) => releases::bump_version(run_type, &rule, verbose)?,
             Step::Command { command, variables } => {
-                command::run_command(run_type, command, variables)?
+                command::run_command(run_type, command, variables, verbose)?
             }
             Step::PrepareRelease(prepare_release) => {
                 releases::prepare_release(run_type, &prepare_release, verbose)?
             }
             Step::SelectIssueFromBranch => git::select_issue_from_current_branch(run_type)?,
-            Step::Release => releases::release(run_type)?,
+            Step::Release => releases::release(run_type, verbose)?,
             Step::CreateChangeFile => releases::create_change_file(run_type)?,
         })
     }


Test patch:
diff --git a/tests/helpers/git.rs b/tests/helpers/git.rs
index e4adfd2..45ccafd 100644
--- a/tests/helpers/git.rs
+++ b/tests/helpers/git.rs
@@ -130,6 +130,7 @@ pub fn merge_branch(path: &Path, name: &str) {
     let output = Command::new("git")
         .arg("merge")
         .arg("--no-ff")
+        .arg("--no-edit")
         .arg(name)
         .current_dir(path)
         .output()
diff --git a/tests/prepare_release.rs b/tests/prepare_release.rs
index f71a03b..d4fc684 100644
--- a/tests/prepare_release.rs
+++ b/tests/prepare_release.rs
@@ -1904,3 +1904,103 @@ fn go_mod_version_determination() {
         );
     }
 }
+
+/// Specifically designed to catch https://github.com/knope-dev/knope/issues/505
+#[test]
+fn pick_correct_commits_from_branching_history() {
+    // Arrange a Git repo with branching history
+    let temp_dir = tempfile::tempdir().unwrap();
+    let temp_path = temp_dir.path();
+
+    init(temp_path);
+    commit(temp_path, "Initial commit");
+    tag(temp_path, "v1.0.0");
+    create_branch(temp_path, "patch");
+    commit(temp_path, "fix: A bug");
+    switch_branch(temp_path, "main");
+    merge_branch(temp_path, "patch");
+    tag(temp_path, "v1.0.1");
+    create_branch(temp_path, "breaking");
+    commit(temp_path, "feat!: A breaking feature");
+    switch_branch(temp_path, "main");
+    merge_branch(temp_path, "breaking");
+    tag(temp_path, "v2.0.0");
+    switch_branch(temp_path, "breaking");
+    merge_branch(temp_path, "main");
+    commit(temp_path, "fix: Another bug");
+    switch_branch(temp_path, "main");
+    merge_branch(temp_path, "breaking");
+
+    let source_path =
+        Path::new("tests/prepare_release/pick_correct_commits_from_branching_history");
+    for file in ["knope.toml", "Cargo.toml", "CHANGELOG.md"] {
+        copy(source_path.join(file), temp_path.join(file)).unwrap();
+    }
+
+    // Act.
+    let dry_run_output = Command::new(cargo_bin!("knope"))
+        .arg("prepare-release")
+        .arg("--dry-run")
+        .current_dir(temp_dir.path())
+        .assert();
+    let actual_assert = Command::new(cargo_bin!("knope"))
+        .arg("prepare-release")
+        .current_dir(temp_dir.path())
+        .assert();
+
+    // Assert.
+    dry_run_output
+        .success()
+        .with_assert(assert())
+        .stdout_matches_path(source_path.join("dry_run_output.txt"));
+    actual_assert.success().stdout_eq("");
+    for file in ["CHANGELOG.md", "Cargo.toml"] {
+        assert().matches_path(
+            source_path.join(format!("EXPECTED_{file}")),
+            read_to_string(temp_path.join(file)).unwrap(),
+        );
+    }
+}
+
+#[test]
+fn pick_correct_tag_from_branching_history() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let temp_path = temp_dir.path();
+    init(temp_path);
+    commit(temp_path, "Initial commit");
+    tag(temp_path, "v1.0.0");
+    create_branch(temp_path, "v2");
+    commit(temp_path, "feat!: Something new");
+    tag(temp_path, "v2.0.0");
+    switch_branch(temp_path, "main");
+    commit(temp_path, "fix: A bug");
+
+    let source_path = Path::new("tests/prepare_release/pick_correct_tag_from_branching_history");
+    for file in ["knope.toml", "Cargo.toml", "CHANGELOG.md"] {
+        copy(source_path.join(file), temp_path.join(file)).unwrap();
+    }
+
+    // Act.
+    let dry_run_output = Command::new(cargo_bin!("knope"))
+        .arg("prepare-release")
+        .arg("--dry-run")
+        .current_dir(temp_dir.path())
+        .assert();
+    let actual_assert = Command::new(cargo_bin!("knope"))
+        .arg("prepare-release")
+        .current_dir(temp_dir.path())
+        .assert();
+
+    // Assert.
+    dry_run_output
+        .success()
+        .with_assert(assert())
+        .stdout_matches_path(source_path.join("dry_run_output.txt"));
+    actual_assert.success().stdout_eq("");
+    for file in ["CHANGELOG.md", "Cargo.toml"] {
+        assert().matches_path(
+            source_path.join(format!("EXPECTED_{file}")),
+            read_to_string(temp_path.join(file)).unwrap(),
+        );
+    }
+}
diff --git a/tests/prepare_release/pick_correct_commits_from_branching_history/CHANGELOG.md b/tests/prepare_release/pick_correct_commits_from_branching_history/CHANGELOG.md
new file mode 100644
index 0000000..9d405de
--- /dev/null
+++ b/tests/prepare_release/pick_correct_commits_from_branching_history/CHANGELOG.md
@@ -0,0 +1,13 @@
+# Changelog
+
+## 2.0.0
+
+### Breaking Changes
+
+#### A breaking feature
+
+## 1.0.1
+
+### Bug Fixes
+
+#### A bug
diff --git a/tests/prepare_release/pick_correct_commits_from_branching_history/Cargo.toml b/tests/prepare_release/pick_correct_commits_from_branching_history/Cargo.toml
new file mode 100644
index 0000000..9a19e8f
--- /dev/null
+++ b/tests/prepare_release/pick_correct_commits_from_branching_history/Cargo.toml
@@ -0,0 +1,2 @@
+[package]
+version = "2.0.0"
diff --git a/tests/prepare_release/pick_correct_commits_from_branching_history/EXPECTED_CHANGELOG.md b/tests/prepare_release/pick_correct_commits_from_branching_history/EXPECTED_CHANGELOG.md
new file mode 100644
index 0000000..7a76ae6
--- /dev/null
+++ b/tests/prepare_release/pick_correct_commits_from_branching_history/EXPECTED_CHANGELOG.md
@@ -0,0 +1,19 @@
+# Changelog
+
+## 2.0.1 ([DATE])
+
+### Fixes
+
+#### Another bug
+
+## 2.0.0
+
+### Breaking Changes
+
+#### A breaking feature
+
+## 1.0.1
+
+### Bug Fixes
+
+#### A bug
diff --git a/tests/prepare_release/pick_correct_commits_from_branching_history/EXPECTED_Cargo.toml b/tests/prepare_release/pick_correct_commits_from_branching_history/EXPECTED_Cargo.toml
new file mode 100644
index 0000000..9bbd8c1
--- /dev/null
+++ b/tests/prepare_release/pick_correct_commits_from_branching_history/EXPECTED_Cargo.toml
@@ -0,0 +1,2 @@
+[package]
+version = "2.0.1"
diff --git a/tests/prepare_release/pick_correct_commits_from_branching_history/dry_run_output.txt b/tests/prepare_release/pick_correct_commits_from_branching_history/dry_run_output.txt
new file mode 100644
index 0000000..a786f48
--- /dev/null
+++ b/tests/prepare_release/pick_correct_commits_from_branching_history/dry_run_output.txt
@@ -0,0 +1,11 @@
+Would add the following to Cargo.toml: 2.0.1
+Would add the following to CHANGELOG.md: 
+## 2.0.1 ([DATE])
+
+### Fixes
+
+#### Another bug
+
+Would add files to git:
+  Cargo.toml
+  CHANGELOG.md
diff --git a/tests/prepare_release/pick_correct_commits_from_branching_history/knope.toml b/tests/prepare_release/pick_correct_commits_from_branching_history/knope.toml
new file mode 100644
index 0000000..9092c50
--- /dev/null
+++ b/tests/prepare_release/pick_correct_commits_from_branching_history/knope.toml
@@ -0,0 +1,9 @@
+[package]
+versioned_files = ["Cargo.toml"]
+changelog = "CHANGELOG.md"
+
+[[workflows]]
+name = "prepare-release"
+
+[[workflows.steps]]
+type = "PrepareRelease"
diff --git a/tests/prepare_release/pick_correct_tag_from_branching_history/CHANGELOG.md b/tests/prepare_release/pick_correct_tag_from_branching_history/CHANGELOG.md
new file mode 100644
index 0000000..bf6fcfb
--- /dev/null
+++ b/tests/prepare_release/pick_correct_tag_from_branching_history/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Changelog
+
+## 1.0.0
diff --git a/tests/prepare_release/pick_correct_tag_from_branching_history/Cargo.toml b/tests/prepare_release/pick_correct_tag_from_branching_history/Cargo.toml
new file mode 100644
index 0000000..a12328a
--- /dev/null
+++ b/tests/prepare_release/pick_correct_tag_from_branching_history/Cargo.toml
@@ -0,0 +1,2 @@
+[package]
+version = "1.0.0"
diff --git a/tests/prepare_release/pick_correct_tag_from_branching_history/EXPECTED_CHANGELOG.md b/tests/prepare_release/pick_correct_tag_from_branching_history/EXPECTED_CHANGELOG.md
new file mode 100644
index 0000000..1fd2c4f
--- /dev/null
+++ b/tests/prepare_release/pick_correct_tag_from_branching_history/EXPECTED_CHANGELOG.md
@@ -0,0 +1,9 @@
+# Changelog
+
+## 1.0.1 ([DATE])
+
+### Fixes
+
+#### A bug
+
+## 1.0.0
diff --git a/tests/prepare_release/pick_correct_tag_from_branching_history/EXPECTED_Cargo.toml b/tests/prepare_release/pick_correct_tag_from_branching_history/EXPECTED_Cargo.toml
new file mode 100644
index 0000000..24b9ee6
--- /dev/null
+++ b/tests/prepare_release/pick_correct_tag_from_branching_history/EXPECTED_Cargo.toml
@@ -0,0 +1,2 @@
+[package]
+version = "1.0.1"
diff --git a/tests/prepare_release/pick_correct_tag_from_branching_history/dry_run_output.txt b/tests/prepare_release/pick_correct_tag_from_branching_history/dry_run_output.txt
new file mode 100644
index 0000000..b40c05e
--- /dev/null
+++ b/tests/prepare_release/pick_correct_tag_from_branching_history/dry_run_output.txt
@@ -0,0 +1,11 @@
+Would add the following to Cargo.toml: 1.0.1
+Would add the following to CHANGELOG.md: 
+## 1.0.1 ([DATE])
+
+### Fixes
+
+#### A bug
+
+Would add files to git:
+  Cargo.toml
+  CHANGELOG.md
diff --git a/tests/prepare_release/pick_correct_tag_from_branching_history/knope.toml b/tests/prepare_release/pick_correct_tag_from_branching_history/knope.toml
new file mode 100644
index 0000000..9092c50
--- /dev/null
+++ b/tests/prepare_release/pick_correct_tag_from_branching_history/knope.toml
@@ -0,0 +1,9 @@
+[package]
+versioned_files = ["Cargo.toml"]
+changelog = "CHANGELOG.md"
+
+[[workflows]]
+name = "prepare-release"
+
+[[workflows.steps]]
+type = "PrepareRelease"
diff --git a/tests/prepare_release/verbose/dry_run_output.txt b/tests/prepare_release/verbose/dry_run_output.txt
index aa31ed4..9a419ef 100644
--- a/tests/prepare_release/verbose/dry_run_output.txt
+++ b/tests/prepare_release/verbose/dry_run_output.txt
@@ -1,3 +1,5 @@
+Finding all commits behind refs/tags/first/v1.2.3
+Finding all commits behind refs/tags/second/v0.4.6
 Would delete: .changeset/breaking_change.md
 Would delete: .changeset/feature.md
 Would delete: .changeset/feature.md
diff --git a/tests/prepare_release/verbose/output.txt b/tests/prepare_release/verbose/output.txt
index 89cdae1..4a6fcd2 100644
--- a/tests/prepare_release/verbose/output.txt
+++ b/tests/prepare_release/verbose/output.txt
@@ -1,3 +1,5 @@
+Finding all commits behind refs/tags/first/v1.2.3
+Finding all commits behind refs/tags/second/v0.4.6
 Determining new version for first
 commit feat: A feature
 	implies rule MINOR