Repository: callowayproject/bump-my-version (python)

Issue:
Custom searches do not fail if the file also contains the version number
- bump-my-version version: `1.0.2`
- python version: `3.11.1`
- OS: `Windows 11, 23H2, 22631.4890`

**Expectation:**
When doing a custom search if the search pattern is not found, a `VersionNotFoundError` should be raised. I ran into this issue when migrating from bump2version to bump-my-version.

**My setup:**

pyproject.toml:
```
[tool.bumpversion]
current_version = "2.0.1"
parse = """(?x)
    (?P<major>0|[1-9]\\d*)\\.
    (?P<minor>0|[1-9]\\d*)\\.
    (?P<patch>0|[1-9]\\d*)
    (?:
        -                             # dash separator for pre-release section
        (?P<pre_l>[a-zA-Z-]+)         # pre-release label
        (?:0|[1-9]\\d*)               # pre-release version number
    )?                                # pre-release section is optional
"""

serialize = [
  "{major}.{minor}.{patch}-{pre_l}{distance_to_latest_tag}",
  "{major}.{minor}.{patch}",
]

[tool.bumpversion.parts.pre_l]
values = ["rc", "final"]
optional_value = "final"

# this is the first of many searches
[[tool.bumpversion.files]]
filename = "CHANGELOG.adoc"
search = ":approved-for-release: yes"
replace = ":approved-for-release: no"
```

CHANGELOG.adoc:
```
// Metadata
:doctitle: Changelog - bump_test
:orgname: 
:version-label: 
:revnumber: 2.0.1
:revdate: Mar 14, 2025, 14:18 PM

// IMPORTANT!  DO NOT CHANGE UNTIL YOU ARE READY TO BUMP VERSION
// AND UNRELEASED SECTION HAS BEEN REVIEWED!
// Once changged, please preview before bump to make sure no formatting mistakes were made
:approved-for-release: no
```

When calling bump to update minor I get the following output and no failures even though the line being searched for does not exist in CHANGELOG.adoc:
```
File CHANGELOG.adoc: replace ':approved-for-release: yes' with ':approved-for-release: no' D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\files.py:174
  Serializing the current version D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\files.py:219
    Serializing version '<bumpversion.Version:major=2, minor=0, patch=1, pre_l=final>' D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\versioning\serialization.py:102
      Using serialization format '{major}.{minor}.{patch}' D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\versioning\serialization.py:133
      Serialized to '2.0.1' D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\versioning\serialization.py:135
  Serializing the new version D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\files.py:219
    Serializing version '<bumpversion.Version:major=2, minor=1, patch=0, pre_l=rc>' D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\versioning\serialization.py:102
      Using serialization format '{major}.{minor}.{patch}-{pre_l}{distance_to_latest_tag}' D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\versioning\serialization.py:133
      Serialized to '2.1.0-rc4' D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\versioning\serialization.py:135
  Rendering search pattern with context D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\config\models.py:56
    No RegEx flag detected. Searching for the default pattern: ':approved\-for\-release:\ yes' D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\config\models.py:62
  Found '2\.0\.1' at line 5: 2.0.1 D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\files.py:27
  Rendering search pattern with context D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\config\models.py:56
    No RegEx flag detected. Searching for the default pattern: ':approved\-for\-release:\ yes' D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\config\models.py:62
  Not changing file CHANGELOG.adoc D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\files.py:67

```
As mentioned above, I would expect to be informed of the failure to find the search string in the document, instead it reports: 
```
 Found '2\.0\.1' at line 5: 2.0.1 D:\Projects\frf-bump_test\.venv\Lib\site-packages\bumpversion\files.py:27
```

When digging into this I found the following difference between bump2version and bump-my-version:

In bump2version there is a check to see if the search pattern is customized, where it checks the `self._versionconfig.search` against `"{current_version}"`

in bump-my-version this check was modified to:  `search_pattern_is_default = self.file_change.search == self.version_config.search` 

in every attempt to bump version both `self.file_change.search` and `self.version_config.search` are set to the custom search string from `pyproject.toml`  and because that evaluates to true the next if statement
```
if search_pattern_is_default and contains_pattern(re.compile(re.escape(version.original)), file_contents):
            # The original version is present, and we're not looking for something
            # more specific -> this is accepted as a match
            return True
``` 

and even though we were searching for something more specific the method `_contains_change_pattern` returns true allowing the rest of the bump to continue. 
If I remove the rev number from line 5 of the `CHANGELOG.adoc` then when it doesn't find my custom search or the version I get the expected error:
```
Did not find ':approved-for-release: yes' in file: 'CHANGELOG.adoc'   
```


Pull request:
Bug Fixes
- Fixes rendering of moveable tags
- Fixes #321 

Patch:
diff --git a/bumpversion/files.py b/bumpversion/files.py
index b806a517..37280e37 100644
--- a/bumpversion/files.py
+++ b/bumpversion/files.py
@@ -7,6 +7,7 @@ from difflib import context_diff
 from pathlib import Path
 from typing import Dict, List, MutableMapping, Optional
 
+from bumpversion.config import DEFAULTS as DEFAULT_CONFIG
 from bumpversion.config.models import FileChange
 from bumpversion.exceptions import VersionNotFoundError
 from bumpversion.ui import get_indented_logger
@@ -129,7 +130,7 @@ class ConfiguredFile:
             f.write(contents)
 
     def _contains_change_pattern(
-        self, search_expression: re.Pattern, raw_search_expression: str, version: Version, context: MutableMapping
+        self, search_expression: re.Pattern, raw_search_expression: str, version: Version
     ) -> bool:
         """
         Does the file contain the change pattern?
@@ -138,7 +139,6 @@ class ConfiguredFile:
             search_expression: The compiled search expression
             raw_search_expression: The raw search expression
             version: The version to check, in case it's not the same as the original
-            context: The context to use
 
         Raises:
             VersionNotFoundError: if the version number isn't present in this file.
@@ -155,7 +155,7 @@ class ConfiguredFile:
         # match instead. This is probably the case if environment variables are used.
 
         # check whether `search` isn't customized
-        search_pattern_is_default = self.file_change.search == self.version_config.search
+        search_pattern_is_default = self.file_change.search == DEFAULT_CONFIG["search"]
 
         if search_pattern_is_default and contains_pattern(re.compile(re.escape(version.original)), file_contents):
             # The original version is present, and we're not looking for something
@@ -195,7 +195,7 @@ class ConfiguredFile:
         search_for, raw_search_pattern = self.file_change.get_search_pattern(context)
         replace_with = self.version_config.replace.format(**context)
 
-        if not self._contains_change_pattern(search_for, raw_search_pattern, current_version, context):
+        if not self._contains_change_pattern(search_for, raw_search_pattern, current_version):
             logger.dedent()
             return
 
diff --git a/bumpversion/scm/git.py b/bumpversion/scm/git.py
index 4f715598..c3826a78 100644
--- a/bumpversion/scm/git.py
+++ b/bumpversion/scm/git.py
@@ -103,7 +103,7 @@ class Git:
             tag(tag_name, sign=self.config.sign_tags, message=tag_message)
 
             for m_tag_name in self.config.moveable_tags:
-                moveable_tag(m_tag_name)
+                moveable_tag(m_tag_name.format(**context))
 
     def assert_nondirty(self) -> None:
         """


Test patch:
diff --git a/tests/test_cli/test_replace.py b/tests/test_cli/test_replace.py
index b7b2129a..bb886e25 100644
--- a/tests/test_cli/test_replace.py
+++ b/tests/test_cli/test_replace.py
@@ -197,14 +197,7 @@ class TestReplaceCLI:
             )
             config_file = tmp_path / ".bumpversion.toml"
             config_file.write_text(
-                "[tool.bumpversion]\n"
-                'current_version = "0.0.1"\n'
-                "allow_dirty = true\n\n"
-                "[[tool.bumpversion.files]]\n"
-                'filename = "VERSION"\n'
-                "regex = false\n"
-                f'search = "{search}"\n'
-                f'replace = "{replace}"\n',
+                "[tool.bumpversion]\n" 'current_version = "0.0.1"\n' "allow_dirty = true\n\n",
                 encoding="utf-8",
             )
 
@@ -214,7 +207,7 @@ class TestReplaceCLI:
                     cli.cli,
                     [
                         "replace",
-                        "--verbose",
+                        "-vv",
                         "--no-regex",
                         "--no-configured-files",
                         "--search",
@@ -229,7 +222,10 @@ class TestReplaceCLI:
             if result.exit_code != 0:
                 print("Here is the output:")
                 print(result.output)
-                print(traceback.print_exception(result.exc_info[1]))
+                # print(traceback.print_exception(result.exc_info[1]))
+                print(caplog.text)
+                print("ouch")
+                print(version_path.read_text())
 
             assert result.exit_code == 0
             assert (
diff --git a/tests/test_files.py b/tests/test_files.py
index a1774adc..84c7c4ef 100644
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -69,7 +69,7 @@ class TestModifyFiles:
             """
         # Unreleased
 
-        * bullet point A
+        * bullet point A prep for 1.0.3
 
         # Release v'older' (2019-09-17)
 
diff --git a/tests/test_scm/test_git.py b/tests/test_scm/test_git.py
index 9c62859b..3b905e69 100644
--- a/tests/test_scm/test_git.py
+++ b/tests/test_scm/test_git.py
@@ -14,53 +14,56 @@ from bumpversion.utils import run_command
 from tests.conftest import inside_dir
 
 
+@pytest.fixture
+def git_instance(scm_config: SCMConfig) -> Git:
+    """Return a Git instance."""
+    return Git(config=scm_config)
+
+
 class TestGit:
     """Tests related to Git."""
 
     class TestIsAvailable:
         """Tests related to Git.is_available."""
 
-        def test_recognizes_a_git_repo(self, git_repo: Path, scm_config: SCMConfig) -> None:
+        def test_recognizes_a_git_repo(self, git_repo: Path, git_instance: Git) -> None:
             """Should return true if git is available, and it is a git repo."""
             with inside_dir(git_repo):
-                git_tool = Git(scm_config)
-                assert git_tool.is_available()
+                assert git_instance.is_available()
 
-        def test_recognizes_not_a_git_repo(self, tmp_path: Path, scm_config: SCMConfig) -> None:
+        def test_recognizes_not_a_git_repo(self, tmp_path: Path, git_instance: Git) -> None:
             """Should return false if it is not a git repo."""
             with inside_dir(tmp_path):
-                git_tool = Git(scm_config)
-                assert not git_tool.is_available()
+                assert not git_instance.is_available()
 
     class TestLatestTagInfo:
         """Test for the Git.latest_tag_info() function."""
 
-        def test_returns_default_if_not_available(self, tmp_path: Path, scm_config: SCMConfig) -> None:
+        def test_returns_default_if_not_available(self, tmp_path: Path, git_instance: Git) -> None:
             """If git is not available, it returns the default LatestTagInfo object, with all None values."""
             # Arrange
             expected = LatestTagInfo()
-            tool = Git(scm_config)
 
             # Act
             with inside_dir(tmp_path):
-                info = tool.latest_tag_info()
+                info = git_instance.latest_tag_info()
 
             # Assert
             assert info == expected
 
-        def test_returns_correct_commit_and_tag_info(self, git_repo: Path, scm_config: SCMConfig) -> None:
+        def test_returns_correct_commit_and_tag_info(self, git_repo: Path, git_instance: Git) -> None:
             """Should return information that it is a git repo."""
             # Arrange
             tag_prefix = "app/"
             readme = git_repo.joinpath("readme.md")
             readme.touch()
-            scm_config.tag_name = f"{tag_prefix}{{new_version}}"
+            git_instance.config.tag_name = f"{tag_prefix}{{new_version}}"
 
             # Act
             with inside_dir(git_repo):
                 commit_readme("first")
                 tag(f"{tag_prefix}0.1.0", sign=False, message="bumpversion")
-                tag_info = Git(scm_config).latest_tag_info()
+                tag_info = git_instance.latest_tag_info()
 
             # Assert
             assert tag_info.commit_sha is not None
@@ -79,7 +82,7 @@ class TestGit:
         @patch("bumpversion.scm.git.Git.latest_tag_info")
         @patch("bumpversion.scm.git.is_subpath")
         def test_valid_subpath_is_added(
-            self, mock_is_subpath, mock_latest_tag_info, mock_run_command, scm_config: SCMConfig, tmp_path: Path
+            self, mock_is_subpath, mock_latest_tag_info, mock_run_command, git_instance: Git, tmp_path: Path
         ):
             """A path that is a subpath of the repository root should be added."""
             # Arrange
@@ -89,7 +92,6 @@ class TestGit:
             mock_latest_tag_info.return_value.repository_root = repository_root
             mock_is_subpath.return_value = True
             mock_run_command.return_value = None
-            git_instance = Git(scm_config)
 
             # Act
             with inside_dir(repository_root):
@@ -102,7 +104,7 @@ class TestGit:
         @patch("bumpversion.scm.git.Git.latest_tag_info")
         @patch("bumpversion.scm.git.is_subpath")
         def test_invalid_subpath_is_not_added(
-            self, mock_is_subpath, mock_latest_tag_info, mock_run_command, scm_config, tmp_path: Path
+            self, mock_is_subpath, mock_latest_tag_info, mock_run_command, git_instance: Git, tmp_path: Path
         ):
             """A path that is not a subpath of the repository root should not be added."""
             # Arrange
@@ -111,7 +113,6 @@ class TestGit:
             path_to_add = repository_root / "file.txt"
             mock_latest_tag_info.return_value.repository_root = repository_root
             mock_is_subpath.return_value = False
-            git_instance = Git(scm_config)
 
             # Act
             git_instance.add_path(path_to_add)
@@ -123,7 +124,7 @@ class TestGit:
         @patch("bumpversion.scm.git.Git.latest_tag_info")
         @patch("bumpversion.scm.git.is_subpath")
         def test_raises_error_on_command_failure(
-            self, mock_is_subpath, mock_latest_tag_info, mock_run_command, scm_config: SCMConfig, tmp_path: Path
+            self, mock_is_subpath, mock_latest_tag_info, mock_run_command, git_instance: Git, tmp_path: Path
         ):
             """If the git command fails, a BumpVersionError should be raised."""
             # Arrange
@@ -133,13 +134,101 @@ class TestGit:
             mock_latest_tag_info.return_value.repository_root = repository_root
             mock_is_subpath.return_value = True
             mock_run_command.side_effect = subprocess.CalledProcessError(returncode=1, cmd="git add")
-            git_instance = Git(scm_config)
 
             # Act / Assert
             with inside_dir(repository_root):
                 with pytest.raises(BumpVersionError):
                     git_instance.add_path(path_to_add)
 
+    class TestGitCommitAndTag:
+        """Tests for the commit_and_tag() method."""
+
+        def test_dry_run_skips_method(self, git_instance: Git, mocker):
+            """If dry_run is True, the method is short-circuited."""
+            # Arrange
+            files = ["file1.txt", "file2.txt"]
+            context = {"new_version": "1.0.0", "new_major": "1", "current_version": "0.1.0"}
+            mock_add_path = mocker.patch.object(git_instance, "add_path")
+            mock_commit = mocker.patch.object(git_instance, "commit")
+            mock_tag = mocker.patch("bumpversion.scm.git.tag")
+            mock_moveable_tag = mocker.patch("bumpversion.scm.git.moveable_tag")
+
+            # Act
+            git_instance.commit_and_tag(files, context, dry_run=True)
+
+            # Assert
+            mock_add_path.assert_not_called()
+            mock_commit.assert_not_called()
+            mock_tag.assert_not_called()
+            mock_moveable_tag.assert_not_called()
+
+        def test_commits_and_tags_when_configured(self, git_instance: Git, mocker):
+            """Does both commit and tag functions when they are configured."""
+            # Arrange
+            files = ["file1.txt", "file2.txt"]
+            context = {"new_version": "1.0.0", "new_major": "1", "current_version": "0.1.0"}
+            git_instance.config.moveable_tags = ["v{new_major}"]
+            git_instance.config.tag = True
+            git_instance.config.commit = True
+            mock_add_path = mocker.patch.object(git_instance, "add_path")
+            mock_commit = mocker.patch.object(git_instance, "commit")
+            mock_tag = mocker.patch("bumpversion.scm.git.tag")
+            mock_moveable_tag = mocker.patch("bumpversion.scm.git.moveable_tag")
+
+            # Act
+            git_instance.commit_and_tag(files, context, dry_run=False)
+
+            # Assert
+            assert mock_add_path.call_count == len(files)
+            mock_add_path.assert_any_call("file1.txt")
+            mock_add_path.assert_any_call("file2.txt")
+            mock_commit.assert_called_once_with(context)
+            mock_tag.assert_called_once_with("v1.0.0", sign=False, message="Bump version: 0.1.0 → 1.0.0")
+            mock_moveable_tag.assert_called_once_with("v1")
+
+        def test_tags_when_commit_is_false(self, git_instance: Git, mocker):
+            """The method only tags when commit is False."""
+            # Arrange
+            files = ["file1.txt"]
+            context = {"new_version": "1.0.0", "new_major": "1", "current_version": "0.1.0"}
+            git_instance.config.moveable_tags = ["v{new_major}"]
+            git_instance.config.commit = False
+            git_instance.config.tag = True
+            mock_add_path = mocker.patch.object(git_instance, "add_path")
+            mock_commit = mocker.patch.object(git_instance, "commit")
+            mock_tag = mocker.patch("bumpversion.scm.git.tag")
+            mock_moveable_tag = mocker.patch("bumpversion.scm.git.moveable_tag")
+
+            # Act
+            git_instance.commit_and_tag(files, context, dry_run=False)
+
+            # Assert
+            mock_add_path.assert_not_called()
+            mock_commit.assert_not_called()
+            mock_tag.assert_called_once_with("v1.0.0", sign=False, message="Bump version: 0.1.0 → 1.0.0")
+            mock_moveable_tag.assert_called_once_with("v1")
+
+        def test_commits_when_tag_is_false(self, git_instance: Git, mocker):
+            """The method only commits when tag is False."""
+            # Arrange
+            files = ["file1.txt"]
+            context = {"new_version": "1.0.0", "new_major": "1", "current_version": "0.1.0"}
+            git_instance.config.moveable_tags = ["v{new_major}"]
+            git_instance.config.tag = False
+            git_instance.config.commit = True
+            mock_add_path = mocker.patch.object(git_instance, "add_path")
+            mock_commit = mocker.patch.object(git_instance, "commit")
+            mock_tag = mocker.patch("bumpversion.scm.git.tag")
+            mock_moveable_tag = mocker.patch("bumpversion.scm.git.moveable_tag")
+
+            # Act
+            git_instance.commit_and_tag(files, context, dry_run=False)
+
+            mock_add_path.assert_called_with("file1.txt")
+            mock_commit.assert_called_once_with(context)
+            mock_tag.assert_not_called()
+            mock_moveable_tag.assert_not_called()
+
 
 class TestRevisionInfo:
     """Tests for the revision_info function."""