Skip to content

Commit 4777b86

Browse files
VamikaSinghalclaude
andcommitted
feat(bump): add --allow-dirty/--no-allow-dirty and allow_dirty setting
`cz bump` commits with `git commit -a`, so any pending change to a tracked file silently ends up in the release commit. With `allow_dirty = false` (or `--no-allow-dirty`) the bump now aborts before touching any file and lists the dirty files, exiting with the new DIRTY_WORKING_TREE (33) exit code. The default stays `true`, so existing behavior is unchanged. Untracked files are ignored, and the check is skipped for --dry-run, --get-next and --version-files-only since those never create a commit. Closes #1194 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2ebac02 commit 4777b86

16 files changed

Lines changed: 282 additions & 5 deletions

‎commitizen/cli.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,16 @@ def __call__(
382382
"help": "Bump version without eligible commits.",
383383
"action": "store_true",
384384
},
385+
{
386+
"name": ["--allow-dirty"],
387+
"action": argparse.BooleanOptionalAction,
388+
"default": None,
389+
"help": (
390+
"Allow bumping when tracked files have uncommitted changes, "
391+
"which get included in the bump commit (default: allowed). "
392+
"Use --no-allow-dirty to abort instead."
393+
),
394+
},
385395
],
386396
},
387397
{

‎commitizen/commands/bump.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from commitizen.exceptions import (
1414
BumpCommitFailedError,
1515
BumpTagFailedError,
16+
DirtyWorkingTreeError,
1617
DryRunExit,
1718
ExpectedExit,
1819
InvalidManualVersion,
@@ -79,6 +80,7 @@ def __init__(self, config: BaseConfig, arguments: BumpArgs) -> None:
7980
**{
8081
k: v
8182
for k in (
83+
"allow_dirty",
8284
"annotated_tag_message",
8385
"annotated_tag",
8486
"bump_message",
@@ -196,6 +198,32 @@ def _validate_arguments(self, current_version: VersionProtocol) -> None:
196198
if errors:
197199
raise NotAllowed("\n".join(errors))
198200

201+
def _ensure_clean_working_tree(self) -> None:
202+
"""Abort the bump if tracked files have uncommitted changes.
203+
204+
The bump commit is created with `git commit -a`, so any pending edit to
205+
a tracked file silently ends up in the release. When `allow_dirty` is
206+
disabled we refuse to continue instead.
207+
208+
This runs before Commitizen touches any file (changelog, version files,
209+
provider), so every reported change is one the user made. That is why
210+
no exclusion list for `version_files` or the changelog is needed.
211+
"""
212+
if self.bump_settings.get("allow_dirty", True):
213+
return
214+
215+
dirty_files = git.get_uncommitted_tracked_files()
216+
if not dirty_files:
217+
return
218+
219+
raise DirtyWorkingTreeError(
220+
"[DIRTY_WORKING_TREE]\n"
221+
"Tracked files have uncommitted changes that would be included "
222+
"in the bump commit:\n"
223+
+ "\n".join(f" {path}" for path in dirty_files)
224+
+ "\nCommit or stash them, or use --allow-dirty to bump anyway."
225+
)
226+
199227
def _resolve_increment_and_new_version(
200228
self, current_version: VersionProtocol, current_tag: git.GitTag | None
201229
) -> tuple[Increment | None, VersionProtocol]:
@@ -257,6 +285,17 @@ def __call__(self) -> None:
257285
self._validate_arguments(current_version)
258286

259287
next_version_to_stdout = self.arguments["get_next"]
288+
289+
# Only guard runs that will create a commit, and do it before any
290+
# interactive prompt so the user isn't asked questions for nothing.
291+
if not (
292+
next_version_to_stdout
293+
or self.arguments["dry_run"]
294+
or self.arguments.get("files_only")
295+
or self.arguments.get("version_files_only")
296+
):
297+
self._ensure_clean_working_tree()
298+
260299
if next_version_to_stdout:
261300
for value, option in (
262301
(self.changelog_flag, "--changelog"),

‎commitizen/defaults.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class CzSettings(TypedDict, total=False):
3131

3232
class Settings(TypedDict, total=False):
3333
allow_abort: bool
34+
allow_dirty: bool
3435
allowed_prefixes: list[str]
3536
always_signoff: bool
3637
annotated_tag: bool
@@ -91,6 +92,7 @@ class Settings(TypedDict, total=False):
9192
"bump_message": None, # bumped v$current_version to $new_version
9293
"retry_after_failure": False,
9394
"allow_abort": False,
95+
"allow_dirty": True,
9496
"allowed_prefixes": [
9597
"Merge",
9698
"Revert",

‎commitizen/exceptions.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class ExitCode(IntEnum):
4040
CONFIG_FILE_NOT_FOUND = 30
4141
CONFIG_FILE_IS_EMPTY = 31
4242
COMMIT_MESSAGE_LENGTH_LIMIT_EXCEEDED = 32
43+
DIRTY_WORKING_TREE = 33
4344

4445
@classmethod
4546
def from_str(cls, value: str) -> ExitCode:
@@ -284,4 +285,10 @@ class CommitMessageLengthExceededError(CommitizenException):
284285
message = "Length of commit message exceeds the given limit."
285286

286287

288+
class DirtyWorkingTreeError(CommitizenException):
289+
"""Tracked files have uncommitted changes and `allow_dirty` is disabled."""
290+
291+
exit_code = ExitCode.DIRTY_WORKING_TREE
292+
293+
287294
# When adding / updating a new exit code, please update the documentation of the exit codes in docs/exit_codes.md

‎commitizen/git.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,27 @@ def is_staging_clean() -> bool:
311311
return not bool(c.out)
312312

313313

314+
def get_uncommitted_tracked_files() -> list[str]:
315+
"""Return the tracked files that have staged or unstaged modifications.
316+
317+
`cz bump` commits with `git commit -a`, which sweeps every modified tracked
318+
file into the release commit. This lists exactly those files so callers can
319+
refuse to bump from a dirty working tree.
320+
321+
Untracked files are deliberately excluded: `git commit -a` never picks them
322+
up, so they cannot leak into the release commit.
323+
324+
Returns:
325+
The paths reported by `git status`, relative to the repository root.
326+
Empty when the working tree is clean.
327+
"""
328+
c = cmd.run(["git", "status", "--porcelain", "--untracked-files=no"])
329+
if c.return_code != 0:
330+
raise GitCommandError(c.err)
331+
# Each porcelain line is "XY <path>" (or "XY <orig> -> <path>" for renames).
332+
return [line[3:] for line in c.out.splitlines() if line.strip()]
333+
334+
314335
def is_git_project() -> bool:
315336
c = cmd.run(["git", "rev-parse", "--is-inside-work-tree"])
316337
return c.out.strip() == "true"

‎docs/commands/bump.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,26 @@ cz bump --allow-no-commit 2.0.0
390390

391391
This makes the new release visible in the changelog while still showing that no commit-based changes were included.
392392

393+
### `--allow-dirty`
394+
395+
By default, `cz bump` creates the bump commit with `git commit -a`, so any uncommitted change to a tracked file is included in the release commit.
396+
This is convenient in CI, but on a local machine it can let unrelated work-in-progress end up in a release.
397+
398+
Use `--no-allow-dirty` (or set [`allow_dirty = false`](../config/bump.md#allow_dirty)) to make `cz bump` stop with exit code `33` (`DirtyWorkingTreeError`) and list the affected files instead.
399+
`--allow-dirty` overrides the configuration for a single run.
400+
401+
```sh
402+
# Refuse to bump if tracked files have pending changes
403+
cz bump --no-allow-dirty
404+
405+
# The configuration sets `allow_dirty = false`, but bump anyway this time
406+
cz bump --allow-dirty
407+
```
408+
409+
!!! note
410+
Untracked files never block the bump, because `git commit -a` does not include them.
411+
The check is skipped for `--dry-run`, `--get-next`, and `--version-files-only`, since those do not create a commit.
412+
393413
### `--tag-format`
394414

395415
`tag_format` and [version_scheme][version_scheme] are combined to make Git tag names from versions.

‎docs/config/bump.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
<!-- When adding a new option, please keep the alphabetical order. -->
44

5+
## `allow_dirty`
6+
7+
- Type: `bool`
8+
- Default: `true`
9+
10+
When set to `false`, `cz bump` aborts if any tracked file has uncommitted changes, instead of silently including them in the bump commit.
11+
Equivalent to `cz bump --no-allow-dirty`. See [`--allow-dirty`](../commands/bump.md#-allow-dirty).
12+
13+
```toml title="pyproject.toml"
14+
[tool.commitizen]
15+
allow_dirty = false
16+
```
17+
518
## `annotated_tag`
619

720
When set to `true`, `cz bump` is equivalent to `cz bump --annotated-tag`.

‎docs/exit_codes.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ All exit codes are defined in [commitizen/exceptions.py](https://github.com/comm
4242
| `ConfigFileNotFound` | 30 | The configuration file is not found |
4343
| `ConfigFileIsEmpty` | 31 | The configuration file is empty |
4444
| `CommitMessageLengthLimitExceededError`| 32 | The commit message length exceeds the given limit. |
45+
| `DirtyWorkingTreeError` | 33 | Tracked files have uncommitted changes and `allow_dirty` is disabled |
4546

4647
## Ignoring Exit Codes
4748

‎tests/commands/test_bump_command.py‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
BumpTagFailedError,
1818
CommitizenException,
1919
CurrentVersionNotFoundError,
20+
DirtyWorkingTreeError,
2021
DryRunExit,
2122
ExitCode,
2223
ExpectedExit,
@@ -1708,3 +1709,117 @@ def test_bump_allow_no_commit_issue(
17081709
util.run_cli(
17091710
"bump", "--allow-no-commit", "--prerelease", "rc"
17101711
) # Should not fail when changelog generation runs with no new commits
1712+
1713+
1714+
@pytest.mark.usefixtures("tmp_commitizen_project")
1715+
def test_bump_no_allow_dirty_aborts_with_uncommitted_changes(util: UtilFixture):
1716+
# Arrange
1717+
util.create_file_and_commit("feat: new file", filename="tracked.txt")
1718+
Path("tracked.txt").write_text("work in progress")
1719+
1720+
# Act / Assert
1721+
with pytest.raises(DirtyWorkingTreeError, match=r"\[DIRTY_WORKING_TREE\]"):
1722+
util.run_cli("bump", "--yes", "--no-allow-dirty")
1723+
1724+
assert git.tag_exist("0.2.0") is False
1725+
assert 'version="0.1.0"' in Path("pyproject.toml").read_text()
1726+
assert Path("tracked.txt").read_text() == "work in progress"
1727+
1728+
1729+
@pytest.mark.usefixtures("tmp_commitizen_project")
1730+
def test_bump_no_allow_dirty_lists_dirty_files(util: UtilFixture):
1731+
# Arrange
1732+
util.create_file_and_commit("feat: new file", filename="tracked.txt")
1733+
Path("tracked.txt").write_text("work in progress")
1734+
1735+
# Act
1736+
with pytest.raises(DirtyWorkingTreeError) as exc_info:
1737+
util.run_cli("bump", "--yes", "--no-allow-dirty")
1738+
1739+
# Assert
1740+
assert exc_info.value.exit_code == ExitCode.DIRTY_WORKING_TREE
1741+
assert " tracked.txt\n" in exc_info.value.message
1742+
1743+
1744+
def test_bump_allow_dirty_false_in_config_aborts(
1745+
tmp_commitizen_project, util: UtilFixture
1746+
):
1747+
# Arrange
1748+
with (tmp_commitizen_project / "pyproject.toml").open("a", encoding="utf-8") as f:
1749+
f.write("allow_dirty = false\n")
1750+
util.create_file_and_commit("feat: new file", filename="tracked.txt")
1751+
Path("tracked.txt").write_text("work in progress")
1752+
1753+
# Act / Assert
1754+
with pytest.raises(DirtyWorkingTreeError):
1755+
util.run_cli("bump", "--yes")
1756+
1757+
assert git.tag_exist("0.2.0") is False
1758+
1759+
1760+
def test_bump_allow_dirty_flag_overrides_config(
1761+
tmp_commitizen_project, util: UtilFixture
1762+
):
1763+
# Arrange
1764+
with (tmp_commitizen_project / "pyproject.toml").open("a", encoding="utf-8") as f:
1765+
f.write("allow_dirty = false\n")
1766+
util.create_file_and_commit("feat: new file", filename="tracked.txt")
1767+
Path("tracked.txt").write_text("work in progress")
1768+
1769+
# Act
1770+
util.run_cli("bump", "--yes", "--allow-dirty")
1771+
1772+
# Assert
1773+
assert git.tag_exist("0.2.0") is True
1774+
1775+
1776+
@pytest.mark.usefixtures("tmp_commitizen_project")
1777+
def test_bump_dirty_tree_is_allowed_by_default(util: UtilFixture):
1778+
"""Existing behavior is preserved: pending changes land in the bump commit."""
1779+
# Arrange
1780+
util.create_file_and_commit("feat: new file", filename="tracked.txt")
1781+
Path("tracked.txt").write_text("work in progress")
1782+
1783+
# Act
1784+
util.run_cli("bump", "--yes")
1785+
1786+
# Assert
1787+
assert git.tag_exist("0.2.0") is True
1788+
assert "tracked.txt" in git.get_filenames_in_commit()
1789+
1790+
1791+
@pytest.mark.usefixtures("tmp_commitizen_project")
1792+
def test_bump_no_allow_dirty_ignores_untracked_files(util: UtilFixture):
1793+
# Arrange
1794+
util.create_file_and_commit("feat: new file")
1795+
Path("untracked.txt").write_text("not part of the repo")
1796+
1797+
# Act
1798+
util.run_cli("bump", "--yes", "--no-allow-dirty")
1799+
1800+
# Assert
1801+
assert git.tag_exist("0.2.0") is True
1802+
assert "untracked.txt" not in git.get_filenames_in_commit()
1803+
1804+
1805+
@pytest.mark.parametrize(
1806+
("extra_args", "expected_exception"),
1807+
[
1808+
(["--dry-run"], DryRunExit),
1809+
(["--get-next"], DryRunExit),
1810+
(["--version-files-only"], ExpectedExit),
1811+
],
1812+
)
1813+
@pytest.mark.usefixtures("tmp_commitizen_project")
1814+
def test_bump_no_allow_dirty_skipped_when_no_commit_is_made(
1815+
util: UtilFixture,
1816+
extra_args: list[str],
1817+
expected_exception: type[Exception],
1818+
):
1819+
# Arrange
1820+
util.create_file_and_commit("feat: new file", filename="tracked.txt")
1821+
Path("tracked.txt").write_text("work in progress")
1822+
1823+
# Act / Assert
1824+
with pytest.raises(expected_exception):
1825+
util.run_cli("bump", "--yes", "--no-allow-dirty", *extra_args)

‎tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_bump_.txt‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ usage: cz bump [-h] [--dry-run] [--files-only] [--version-files-only]
1212
[--version-scheme {pep440,semver,semver2}]
1313
[--version-type {pep440,semver,semver2}]
1414
[--build-metadata BUILD_METADATA] [--get-next]
15-
[--allow-no-commit]
15+
[--allow-no-commit] [--allow-dirty | --no-allow-dirty]
1616
[MANUAL_VERSION]
1717

1818
Bump semantic version based on the git log
@@ -85,3 +85,8 @@ options:
8585
Add additional build-metadata to the version-number.
8686
--get-next Determine the next version and write to stdout.
8787
--allow-no-commit Bump version without eligible commits.
88+
--allow-dirty, --no-allow-dirty
89+
Allow bumping when tracked files have uncommitted
90+
changes, which get included in the bump commit
91+
(default: allowed). Use --no-allow-dirty to abort
92+
instead.

0 commit comments

Comments
 (0)