diff --git a/commitizen/cli.py b/commitizen/cli.py index 3ee03d99ce..398fd90272 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -382,6 +382,16 @@ def __call__( "help": "Bump version without eligible commits.", "action": "store_true", }, + { + "name": ["--allow-dirty"], + "action": argparse.BooleanOptionalAction, + "default": None, + "help": ( + "Allow bumping when tracked files have uncommitted changes, " + "which get included in the bump commit (default: allowed). " + "Use --no-allow-dirty to abort instead." + ), + }, ], }, { diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 3e92775e28..9862c29abf 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -13,6 +13,7 @@ from commitizen.exceptions import ( BumpCommitFailedError, BumpTagFailedError, + DirtyWorkingTreeError, DryRunExit, ExpectedExit, InvalidManualVersion, @@ -79,6 +80,7 @@ def __init__(self, config: BaseConfig, arguments: BumpArgs) -> None: **{ k: v for k in ( + "allow_dirty", "annotated_tag_message", "annotated_tag", "bump_message", @@ -196,6 +198,32 @@ def _validate_arguments(self, current_version: VersionProtocol) -> None: if errors: raise NotAllowed("\n".join(errors)) + def _ensure_clean_working_tree(self) -> None: + """Abort the bump if tracked files have uncommitted changes. + + The bump commit is created with `git commit -a`, so any pending edit to + a tracked file silently ends up in the release. When `allow_dirty` is + disabled we refuse to continue instead. + + This runs before Commitizen touches any file (changelog, version files, + provider), so every reported change is one the user made. That is why + no exclusion list for `version_files` or the changelog is needed. + """ + if self.bump_settings.get("allow_dirty", True): + return + + dirty_files = git.get_uncommitted_tracked_files() + if not dirty_files: + return + + raise DirtyWorkingTreeError( + "[DIRTY_WORKING_TREE]\n" + "Tracked files have uncommitted changes that would be included " + "in the bump commit:\n" + + "\n".join(f" {path}" for path in dirty_files) + + "\nCommit or stash them, or use --allow-dirty to bump anyway." + ) + def _resolve_increment_and_new_version( self, current_version: VersionProtocol, current_tag: git.GitTag | None ) -> tuple[Increment | None, VersionProtocol]: @@ -257,6 +285,17 @@ def __call__(self) -> None: self._validate_arguments(current_version) next_version_to_stdout = self.arguments["get_next"] + + # Only guard runs that will create a commit, and do it before any + # interactive prompt so the user isn't asked questions for nothing. + if not ( + next_version_to_stdout + or self.arguments["dry_run"] + or self.arguments.get("files_only") + or self.arguments.get("version_files_only") + ): + self._ensure_clean_working_tree() + if next_version_to_stdout: for value, option in ( (self.changelog_flag, "--changelog"), diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 40dcee5620..4d128fc91a 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -31,6 +31,7 @@ class CzSettings(TypedDict, total=False): class Settings(TypedDict, total=False): allow_abort: bool + allow_dirty: bool allowed_prefixes: list[str] always_signoff: bool annotated_tag: bool @@ -91,6 +92,7 @@ class Settings(TypedDict, total=False): "bump_message": None, # bumped v$current_version to $new_version "retry_after_failure": False, "allow_abort": False, + "allow_dirty": True, "allowed_prefixes": [ "Merge", "Revert", diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py index d4f0d8f2de..976f8a6001 100644 --- a/commitizen/exceptions.py +++ b/commitizen/exceptions.py @@ -40,6 +40,7 @@ class ExitCode(IntEnum): CONFIG_FILE_NOT_FOUND = 30 CONFIG_FILE_IS_EMPTY = 31 COMMIT_MESSAGE_LENGTH_LIMIT_EXCEEDED = 32 + DIRTY_WORKING_TREE = 33 @classmethod def from_str(cls, value: str) -> ExitCode: @@ -284,4 +285,10 @@ class CommitMessageLengthExceededError(CommitizenException): message = "Length of commit message exceeds the given limit." +class DirtyWorkingTreeError(CommitizenException): + """Tracked files have uncommitted changes and `allow_dirty` is disabled.""" + + exit_code = ExitCode.DIRTY_WORKING_TREE + + # When adding / updating a new exit code, please update the documentation of the exit codes in docs/exit_codes.md diff --git a/commitizen/git.py b/commitizen/git.py index ce9f440c95..8a22f2eed8 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -311,6 +311,27 @@ def is_staging_clean() -> bool: return not bool(c.out) +def get_uncommitted_tracked_files() -> list[str]: + """Return the tracked files that have staged or unstaged modifications. + + `cz bump` commits with `git commit -a`, which sweeps every modified tracked + file into the release commit. This lists exactly those files so callers can + refuse to bump from a dirty working tree. + + Untracked files are deliberately excluded: `git commit -a` never picks them + up, so they cannot leak into the release commit. + + Returns: + The paths reported by `git status`, relative to the repository root. + Empty when the working tree is clean. + """ + c = cmd.run(["git", "status", "--porcelain", "--untracked-files=no"]) + if c.return_code != 0: + raise GitCommandError(c.err) + # Each porcelain line is "XY " (or "XY -> " for renames). + return [line[3:] for line in c.out.splitlines() if line.strip()] + + def is_git_project() -> bool: c = cmd.run(["git", "rev-parse", "--is-inside-work-tree"]) return c.out.strip() == "true" diff --git a/docs/commands/bump.md b/docs/commands/bump.md index 84666cbc66..b51299a475 100644 --- a/docs/commands/bump.md +++ b/docs/commands/bump.md @@ -390,6 +390,26 @@ cz bump --allow-no-commit 2.0.0 This makes the new release visible in the changelog while still showing that no commit-based changes were included. +### `--allow-dirty` + +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. +This is convenient in CI, but on a local machine it can let unrelated work-in-progress end up in a release. + +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. +`--allow-dirty` overrides the configuration for a single run. + +```sh +# Refuse to bump if tracked files have pending changes +cz bump --no-allow-dirty + +# The configuration sets `allow_dirty = false`, but bump anyway this time +cz bump --allow-dirty +``` + +!!! note + Untracked files never block the bump, because `git commit -a` does not include them. + The check is skipped for `--dry-run`, `--get-next`, and `--version-files-only`, since those do not create a commit. + ### `--tag-format` `tag_format` and [version_scheme][version_scheme] are combined to make Git tag names from versions. diff --git a/docs/config/bump.md b/docs/config/bump.md index 7263f8066e..1fb74347f7 100644 --- a/docs/config/bump.md +++ b/docs/config/bump.md @@ -2,6 +2,19 @@ +## `allow_dirty` + +- Type: `bool` +- Default: `true` + +When set to `false`, `cz bump` aborts if any tracked file has uncommitted changes, instead of silently including them in the bump commit. +Equivalent to `cz bump --no-allow-dirty`. See [`--allow-dirty`](../commands/bump.md#-allow-dirty). + +```toml title="pyproject.toml" +[tool.commitizen] +allow_dirty = false +``` + ## `annotated_tag` When set to `true`, `cz bump` is equivalent to `cz bump --annotated-tag`. diff --git a/docs/exit_codes.md b/docs/exit_codes.md index f99115ed31..8ecd1a160e 100644 --- a/docs/exit_codes.md +++ b/docs/exit_codes.md @@ -42,6 +42,7 @@ All exit codes are defined in [commitizen/exceptions.py](https://github.com/comm | `ConfigFileNotFound` | 30 | The configuration file is not found | | `ConfigFileIsEmpty` | 31 | The configuration file is empty | | `CommitMessageLengthLimitExceededError`| 32 | The commit message length exceeds the given limit. | +| `DirtyWorkingTreeError` | 33 | Tracked files have uncommitted changes and `allow_dirty` is disabled | ## Ignoring Exit Codes diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 2e3921deab..4ba7b27f0f 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -17,6 +17,7 @@ BumpTagFailedError, CommitizenException, CurrentVersionNotFoundError, + DirtyWorkingTreeError, DryRunExit, ExitCode, ExpectedExit, @@ -1708,3 +1709,117 @@ def test_bump_allow_no_commit_issue( util.run_cli( "bump", "--allow-no-commit", "--prerelease", "rc" ) # Should not fail when changelog generation runs with no new commits + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_no_allow_dirty_aborts_with_uncommitted_changes(util: UtilFixture): + # Arrange + util.create_file_and_commit("feat: new file", filename="tracked.txt") + Path("tracked.txt").write_text("work in progress") + + # Act / Assert + with pytest.raises(DirtyWorkingTreeError, match=r"\[DIRTY_WORKING_TREE\]"): + util.run_cli("bump", "--yes", "--no-allow-dirty") + + assert git.tag_exist("0.2.0") is False + assert 'version="0.1.0"' in Path("pyproject.toml").read_text() + assert Path("tracked.txt").read_text() == "work in progress" + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_no_allow_dirty_lists_dirty_files(util: UtilFixture): + # Arrange + util.create_file_and_commit("feat: new file", filename="tracked.txt") + Path("tracked.txt").write_text("work in progress") + + # Act + with pytest.raises(DirtyWorkingTreeError) as exc_info: + util.run_cli("bump", "--yes", "--no-allow-dirty") + + # Assert + assert exc_info.value.exit_code == ExitCode.DIRTY_WORKING_TREE + assert " tracked.txt\n" in exc_info.value.message + + +def test_bump_allow_dirty_false_in_config_aborts( + tmp_commitizen_project, util: UtilFixture +): + # Arrange + with (tmp_commitizen_project / "pyproject.toml").open("a", encoding="utf-8") as f: + f.write("allow_dirty = false\n") + util.create_file_and_commit("feat: new file", filename="tracked.txt") + Path("tracked.txt").write_text("work in progress") + + # Act / Assert + with pytest.raises(DirtyWorkingTreeError): + util.run_cli("bump", "--yes") + + assert git.tag_exist("0.2.0") is False + + +def test_bump_allow_dirty_flag_overrides_config( + tmp_commitizen_project, util: UtilFixture +): + # Arrange + with (tmp_commitizen_project / "pyproject.toml").open("a", encoding="utf-8") as f: + f.write("allow_dirty = false\n") + util.create_file_and_commit("feat: new file", filename="tracked.txt") + Path("tracked.txt").write_text("work in progress") + + # Act + util.run_cli("bump", "--yes", "--allow-dirty") + + # Assert + assert git.tag_exist("0.2.0") is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_dirty_tree_is_allowed_by_default(util: UtilFixture): + """Existing behavior is preserved: pending changes land in the bump commit.""" + # Arrange + util.create_file_and_commit("feat: new file", filename="tracked.txt") + Path("tracked.txt").write_text("work in progress") + + # Act + util.run_cli("bump", "--yes") + + # Assert + assert git.tag_exist("0.2.0") is True + assert "tracked.txt" in git.get_filenames_in_commit() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_no_allow_dirty_ignores_untracked_files(util: UtilFixture): + # Arrange + util.create_file_and_commit("feat: new file") + Path("untracked.txt").write_text("not part of the repo") + + # Act + util.run_cli("bump", "--yes", "--no-allow-dirty") + + # Assert + assert git.tag_exist("0.2.0") is True + assert "untracked.txt" not in git.get_filenames_in_commit() + + +@pytest.mark.parametrize( + ("extra_args", "expected_exception"), + [ + (["--dry-run"], DryRunExit), + (["--get-next"], DryRunExit), + (["--version-files-only"], ExpectedExit), + ], +) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_no_allow_dirty_skipped_when_no_commit_is_made( + util: UtilFixture, + extra_args: list[str], + expected_exception: type[Exception], +): + # Arrange + util.create_file_and_commit("feat: new file", filename="tracked.txt") + Path("tracked.txt").write_text("work in progress") + + # Act / Assert + with pytest.raises(expected_exception): + util.run_cli("bump", "--yes", "--no-allow-dirty", *extra_args) diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_bump_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_bump_.txt index fa696d063d..a3e40c9c01 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_bump_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_bump_.txt @@ -12,7 +12,7 @@ usage: cz bump [-h] [--dry-run] [--files-only] [--version-files-only] [--version-scheme {pep440,semver,semver2}] [--version-type {pep440,semver,semver2}] [--build-metadata BUILD_METADATA] [--get-next] - [--allow-no-commit] + [--allow-no-commit] [--allow-dirty | --no-allow-dirty] [MANUAL_VERSION] Bump semantic version based on the git log @@ -85,3 +85,8 @@ options: Add additional build-metadata to the version-number. --get-next Determine the next version and write to stdout. --allow-no-commit Bump version without eligible commits. + --allow-dirty, --no-allow-dirty + Allow bumping when tracked files have uncommitted + changes, which get included in the bump commit + (default: allowed). Use --no-allow-dirty to abort + instead. diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_bump_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_bump_.txt index fa696d063d..a3e40c9c01 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_bump_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_bump_.txt @@ -12,7 +12,7 @@ usage: cz bump [-h] [--dry-run] [--files-only] [--version-files-only] [--version-scheme {pep440,semver,semver2}] [--version-type {pep440,semver,semver2}] [--build-metadata BUILD_METADATA] [--get-next] - [--allow-no-commit] + [--allow-no-commit] [--allow-dirty | --no-allow-dirty] [MANUAL_VERSION] Bump semantic version based on the git log @@ -85,3 +85,8 @@ options: Add additional build-metadata to the version-number. --get-next Determine the next version and write to stdout. --allow-no-commit Bump version without eligible commits. + --allow-dirty, --no-allow-dirty + Allow bumping when tracked files have uncommitted + changes, which get included in the bump commit + (default: allowed). Use --no-allow-dirty to abort + instead. diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_bump_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_bump_.txt index fa696d063d..a3e40c9c01 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_bump_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_bump_.txt @@ -12,7 +12,7 @@ usage: cz bump [-h] [--dry-run] [--files-only] [--version-files-only] [--version-scheme {pep440,semver,semver2}] [--version-type {pep440,semver,semver2}] [--build-metadata BUILD_METADATA] [--get-next] - [--allow-no-commit] + [--allow-no-commit] [--allow-dirty | --no-allow-dirty] [MANUAL_VERSION] Bump semantic version based on the git log @@ -85,3 +85,8 @@ options: Add additional build-metadata to the version-number. --get-next Determine the next version and write to stdout. --allow-no-commit Bump version without eligible commits. + --allow-dirty, --no-allow-dirty + Allow bumping when tracked files have uncommitted + changes, which get included in the bump commit + (default: allowed). Use --no-allow-dirty to abort + instead. diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_bump_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_bump_.txt index 8e4c863585..fec4a39175 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_bump_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_bump_.txt @@ -12,7 +12,7 @@ usage: cz bump [-h] [--dry-run] [--files-only] [--version-files-only] [--version-scheme {pep440,semver,semver2}] [--version-type {pep440,semver,semver2}] [--build-metadata BUILD_METADATA] [--get-next] - [--allow-no-commit] + [--allow-no-commit] [--allow-dirty | --no-allow-dirty] [MANUAL_VERSION] Bump semantic version based on the git log @@ -84,3 +84,8 @@ options: Add additional build-metadata to the version-number. --get-next Determine the next version and write to stdout. --allow-no-commit Bump version without eligible commits. + --allow-dirty, --no-allow-dirty + Allow bumping when tracked files have uncommitted + changes, which get included in the bump commit + (default: allowed). Use --no-allow-dirty to abort + instead. diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_bump_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_bump_.txt index 8e4c863585..fec4a39175 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_bump_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_bump_.txt @@ -12,7 +12,7 @@ usage: cz bump [-h] [--dry-run] [--files-only] [--version-files-only] [--version-scheme {pep440,semver,semver2}] [--version-type {pep440,semver,semver2}] [--build-metadata BUILD_METADATA] [--get-next] - [--allow-no-commit] + [--allow-no-commit] [--allow-dirty | --no-allow-dirty] [MANUAL_VERSION] Bump semantic version based on the git log @@ -84,3 +84,8 @@ options: Add additional build-metadata to the version-number. --get-next Determine the next version and write to stdout. --allow-no-commit Bump version without eligible commits. + --allow-dirty, --no-allow-dirty + Allow bumping when tracked files have uncommitted + changes, which get included in the bump commit + (default: allowed). Use --no-allow-dirty to abort + instead. diff --git a/tests/test_conf.py b/tests/test_conf.py index 15be0630aa..2d0ec3e33f 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -86,6 +86,7 @@ "bump_message": None, "retry_after_failure": False, "allow_abort": False, + "allow_dirty": True, "allowed_prefixes": [ "Merge", "Revert", @@ -127,6 +128,7 @@ "bump_message": None, "retry_after_failure": False, "allow_abort": False, + "allow_dirty": True, "allowed_prefixes": [ "Merge", "Revert", diff --git a/tests/test_git.py b/tests/test_git.py index db0ce4039b..c59d2ac732 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -516,3 +516,25 @@ def test_get_default_branch_error(util: UtilFixture): ) with pytest.raises(GitCommandError): git.get_default_branch() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_get_uncommitted_tracked_files(): + # Arrange + Path("committed.txt").touch() + Path("staged.txt").touch() + cmd.run(["git", "add", "committed.txt", "staged.txt"]) + cmd.run(["git", "commit", "-m", "add files"]) + assert git.get_uncommitted_tracked_files() == [] + + # Act + Path("committed.txt").write_text("unstaged change") + Path("staged.txt").write_text("staged change") + cmd.run(["git", "add", "staged.txt"]) + Path("untracked.txt").touch() + + # Assert + assert sorted(git.get_uncommitted_tracked_files()) == [ + "committed.txt", + "staged.txt", + ]