Skip to content

Commit f6a2481

Browse files
committed
feat(cmd/version): add support for --next USE_GIT_COMMITS
1 parent 61b1c58 commit f6a2481

7 files changed

Lines changed: 256 additions & 13 deletions

commitizen/commands/version.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@
44

55
from packaging.version import InvalidVersion
66

7-
from commitizen import out
7+
from commitizen import bump, factory, git, out
88
from commitizen.__version__ import __version__
99
from commitizen.config import BaseConfig
10-
from commitizen.exceptions import NoVersionSpecifiedError, VersionSchemeUnknown
10+
from commitizen.exceptions import (
11+
NoCommitsFoundError,
12+
NoPatternMapError,
13+
NotAGitProjectError,
14+
NoVersionSpecifiedError,
15+
VersionSchemeUnknown,
16+
)
1117
from commitizen.providers import get_provider
1218
from commitizen.tags import TagRules
1319
from commitizen.version_increment import VersionIncrement
14-
from commitizen.version_schemes import Increment, get_version_scheme
20+
from commitizen.version_schemes import Increment, VersionProtocol, get_version_scheme
1521

1622

1723
class VersionArgs(TypedDict, total=False):
@@ -63,6 +69,9 @@ def __call__(self) -> None:
6369
or self.arguments.get("next")
6470
or self.arguments.get("manual_version")
6571
):
72+
# TODO: once `cz --version` is implemented, move `self.cz` back to __init__
73+
self.cz = factory.committer_factory(self.config)
74+
6675
version_str = self.arguments.get("manual_version")
6776
if version_str is None:
6877
try:
@@ -84,11 +93,15 @@ def __call__(self) -> None:
8493

8594
if next_increment_str := self.arguments.get("next"):
8695
if next_increment_str == "USE_GIT_COMMITS":
96+
# Only check under `git` to allow the user to do stuff like
97+
# `cz version 1.2.3 --major`
98+
if not git.is_git_project():
99+
raise NotAGitProjectError()
100+
87101
# TODO: implement USE_GIT_COMMITS by deriving the increment from
88102
# git history. This requires refactoring the bump logic out of
89103
# `commitizen/commands/bump.py` so it can be reused here. See #1678.
90-
out.error("--next USE_GIT_COMMITS is not implemented yet.")
91-
return
104+
version = self._get_next_git_version(version)
92105

93106
next_increment = VersionIncrement.from_value(next_increment_str)
94107
increment: Increment | None
@@ -100,6 +113,9 @@ def __call__(self) -> None:
100113
increment = "MINOR"
101114
else:
102115
increment = "MAJOR"
116+
117+
# TODO: Consider adding all the parameters `.bump` supports:
118+
# prerelease, prerelease_offset,exact_increment, etc...
103119
version = version.bump(increment=increment)
104120

105121
if self.arguments.get("major"):
@@ -139,3 +155,39 @@ def __call__(self) -> None:
139155

140156
# If no arguments are provided, just show the installed commitizen version
141157
out.write(__version__)
158+
159+
def _get_next_git_version(
160+
self, current_version: VersionProtocol
161+
) -> VersionProtocol:
162+
"""Calculate the next version based on commits."""
163+
rules = TagRules.from_settings(self.config.settings)
164+
current_tag = rules.find_tag_for(git.get_tags(), current_version)
165+
commits = git.get_commits(current_tag.name if current_tag else None)
166+
167+
# No commits, there is no need to create an empty tag.
168+
# Unless we previously had a prerelease.
169+
if not commits and not current_version.is_prerelease:
170+
raise NoCommitsFoundError("[NO_COMMITS_FOUND]\nNo new commits found.")
171+
172+
bump_map = (
173+
self.cz.bump_map_major_version_zero
174+
if self.config.settings["major_version_zero"]
175+
else self.cz.bump_map
176+
)
177+
bump_pattern = self.cz.bump_pattern
178+
179+
if not bump_map or not bump_pattern:
180+
raise NoPatternMapError(
181+
f"'{self.config.settings['name']}' rule does not support bump"
182+
)
183+
increment = bump.find_increment(
184+
commits, regex=bump_pattern, increments_map=bump_map
185+
)
186+
187+
# TODO: Consider adding all the parameters `.bump` supports:
188+
# prerelease, prerelease_offset,exact_increment, etc..
189+
new_version = current_version.bump(
190+
increment,
191+
)
192+
193+
return new_version

docs/commands/version.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,25 @@ Get the version of the installed Commitizen or the current project (default: ins
1313

1414
- **`--major`**, **`--minor`**, **`--patch`**: print only that component of the (possibly manual) project version. Requires `--project`, `--verbose`, or a manual version.
1515
- **`--next` `[MAJOR|MINOR|PATCH|NONE]`**: print the version after applying that bump to the current project or manual version. `NONE` leaves the version unchanged.
16+
- **`--next USE_GIT_COMMITS`**: derive the bump automatically from the commits since the tag matching the current version, using your commit rules' bump map (the same logic as `cz bump`). Passing `--next` with no value defaults to `USE_GIT_COMMITS`. If no tag matches the current version, all commits are considered. When there are no new commits (and the current version is not a pre-release), the command errors out. The functionality does not yet include advanced versioning like prereleases or exact_increment.
1617
- **`--tag`**: print the version formatted with your `tag_format` (requires `--project` or `--verbose`).
1718

18-
`--next USE_GIT_COMMITS` is reserved for a future feature (derive the bump from git history) and is not implemented yet.
19-
2019
## Examples
2120

2221
```bash
2322
cz version --project
2423
cz version 2.0.0 --next MAJOR
2524
cz version --project --major
2625
cz version --verbose
26+
27+
# Derive the next version from the commits since the current version's tag
28+
cz version --project --next USE_GIT_COMMITS
29+
# Equivalent shorthand (--next defaults to USE_GIT_COMMITS)
30+
cz version --project --next
31+
# Retrieve just the next major part of the version
32+
cz version --project --next --major
33+
# Works with a manual version too
34+
cz version 1.4.0 --next
35+
# Combine with --tag to format the derived version using your tag_format
36+
cz version --project --next --tag
2737
```

tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_version_.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
usage: cz version [-h] [-r | -p | -c | -v]
2-
[--major | --minor | --tag | --patch | --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]]
2+
[--major | --minor | --tag | --patch]
3+
[--next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]]
34
[MANUAL_VERSION]
45

56
Get the version of the installed commitizen or the current project (default:

tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_version_.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
usage: cz version [-h] [-r | -p | -c | -v]
2-
[--major | --minor | --tag | --patch | --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]]
2+
[--major | --minor | --tag | --patch]
3+
[--next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]]
34
[MANUAL_VERSION]
45

56
Get the version of the installed commitizen or the current project (default:

tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_version_.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
usage: cz version [-h] [-r | -p | -c | -v] [--major | --minor | --tag |
2-
--patch | --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]]
2+
--patch] [--next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]]
33
[MANUAL_VERSION]
44

55
Get the version of the installed commitizen or the current project (default:

tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_version_.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
usage: cz version [-h] [-r | -p | -c | -v] [--major | --minor | --tag |
2-
--patch | --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]]
2+
--patch] [--next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]]
33
[MANUAL_VERSION]
44

55
Get the version of the installed commitizen or the current project (default:

tests/commands/test_version_command.py

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
from commitizen import commands
88
from commitizen.__version__ import __version__
99
from commitizen.config.base_config import BaseConfig
10+
from commitizen.cz.base import BaseCommitizen
11+
from commitizen.exceptions import (
12+
NoCommitsFoundError,
13+
NoPatternMapError,
14+
NotAGitProjectError,
15+
)
16+
from tests.utils import UtilFixture
1017

1118

1219
def test_version_for_showing_project_version_error(config, capsys):
@@ -282,14 +289,186 @@ def test_version_unknown_scheme(config, capsys):
282289
assert "Unknown version scheme." in captured.err
283290

284291

285-
def test_version_use_git_commits_not_implemented(config, capsys):
292+
@pytest.mark.parametrize(
293+
("commit_message", "expected_version"),
294+
[
295+
("feat: new feature", "1.1.0"),
296+
("fix: a bug", "1.0.1"),
297+
("feat!: breaking change", "2.0.0"),
298+
("docs: update readme", "1.0.0"),
299+
],
300+
)
301+
@pytest.mark.usefixtures("tmp_git_project")
302+
def test_version_next_use_git_commits(
303+
config: BaseConfig,
304+
capsys: pytest.CaptureFixture,
305+
util: UtilFixture,
306+
commit_message: str,
307+
expected_version: str,
308+
):
309+
"""USE_GIT_COMMITS derives the next version from commits since the last tag."""
286310
config.settings["version"] = "1.0.0"
311+
util.create_file_and_commit("feat: initial commit")
312+
util.create_tag("1.0.0")
313+
util.create_file_and_commit(commit_message)
314+
315+
commands.Version(
316+
config,
317+
{"project": True, "next": "USE_GIT_COMMITS"},
318+
)()
319+
320+
captured = capsys.readouterr()
321+
assert captured.out == f"{expected_version}\n"
322+
323+
324+
@pytest.mark.usefixtures("tmp_git_project")
325+
def test_version_next_use_git_commits_manual_version(
326+
config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture
327+
):
328+
"""USE_GIT_COMMITS also works with an explicit MANUAL_VERSION."""
329+
util.create_file_and_commit("feat: initial commit")
330+
util.create_tag("1.0.0")
331+
util.create_file_and_commit("feat: new feature")
332+
333+
commands.Version(
334+
config,
335+
{"manual_version": "1.0.0", "next": "USE_GIT_COMMITS"},
336+
)()
337+
338+
captured = capsys.readouterr()
339+
assert captured.out == "1.1.0\n"
340+
341+
342+
@pytest.mark.usefixtures("tmp_git_project")
343+
def test_version_next_use_git_commits_without_matching_tag(
344+
config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture
345+
):
346+
"""When no tag matches the current version, all commits are considered."""
347+
config.settings["version"] = "2.0.0"
348+
util.create_file_and_commit("feat: initial commit")
349+
util.create_file_and_commit("feat: new feature")
350+
287351
commands.Version(
288352
config,
289353
{"project": True, "next": "USE_GIT_COMMITS"},
290354
)()
355+
291356
captured = capsys.readouterr()
292-
assert "USE_GIT_COMMITS is not implemented" in captured.err
357+
assert captured.out == "2.1.0\n"
358+
359+
360+
@pytest.mark.usefixtures("tmp_git_project")
361+
def test_version_next_use_git_commits_major_version_zero(
362+
config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture
363+
):
364+
"""major_version_zero uses the zero bump map so no major bump is emitted."""
365+
config.settings["version"] = "0.1.0"
366+
config.settings["major_version_zero"] = True
367+
util.create_file_and_commit("feat: initial commit")
368+
util.create_tag("0.1.0")
369+
util.create_file_and_commit("feat!: breaking change")
370+
371+
commands.Version(
372+
config,
373+
{"project": True, "next": "USE_GIT_COMMITS"},
374+
)()
375+
376+
captured = capsys.readouterr()
377+
assert captured.out == "0.2.0\n"
378+
379+
380+
@pytest.mark.usefixtures("tmp_git_project")
381+
def test_version_next_use_git_commits_prerelease_without_commits(
382+
config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture
383+
):
384+
"""A prerelease with no new commits finalizes into its release version."""
385+
config.settings["version"] = "1.0.0rc1"
386+
util.create_file_and_commit("feat: initial commit")
387+
util.create_tag("1.0.0rc1")
388+
389+
commands.Version(
390+
config,
391+
{"project": True, "next": "USE_GIT_COMMITS"},
392+
)()
393+
394+
captured = capsys.readouterr()
395+
assert captured.out == "1.0.0\n"
396+
397+
398+
@pytest.mark.usefixtures("tmp_git_project")
399+
def test_version_next_use_git_commits_no_commits_raises(
400+
config: BaseConfig, util: UtilFixture
401+
):
402+
"""No new commits since the last (non-prerelease) tag raises an error."""
403+
config.settings["version"] = "1.0.0"
404+
util.create_file_and_commit("feat: initial commit")
405+
util.create_tag("1.0.0")
406+
407+
with pytest.raises(NoCommitsFoundError):
408+
commands.Version(
409+
config,
410+
{"project": True, "next": "USE_GIT_COMMITS"},
411+
)()
412+
413+
414+
@pytest.mark.usefixtures("chdir")
415+
def test_version_next_use_git_commits_outside_git_project_raises(
416+
config: BaseConfig,
417+
):
418+
"""USE_GIT_COMMITS outside a git repository raises NotAGitProjectError."""
419+
config.settings["version"] = "1.0.0"
420+
421+
with pytest.raises(NotAGitProjectError):
422+
commands.Version(
423+
config,
424+
{"project": True, "next": "USE_GIT_COMMITS"},
425+
)()
426+
427+
428+
class _NoBumpRulesCz(BaseCommitizen):
429+
"""A commitizen rule without bump pattern or map to trigger NoPatternMapError."""
430+
431+
bump_pattern = None
432+
bump_map = None
433+
434+
def questions(self):
435+
return []
436+
437+
def message(self, answers):
438+
return ""
439+
440+
def example(self) -> str:
441+
return ""
442+
443+
def schema(self) -> str:
444+
return ""
445+
446+
def schema_pattern(self) -> str:
447+
return ""
448+
449+
def info(self) -> str:
450+
return ""
451+
452+
453+
@pytest.mark.usefixtures("tmp_git_project")
454+
def test_version_next_use_git_commits_no_pattern_map_raises(
455+
config: BaseConfig, util: UtilFixture, mocker: MockerFixture
456+
):
457+
"""A rule that does not support bumping raises NoPatternMapError."""
458+
config.settings["version"] = "1.0.0"
459+
mocker.patch(
460+
"commitizen.factory.committer_factory",
461+
return_value=_NoBumpRulesCz(config),
462+
)
463+
util.create_file_and_commit("feat: initial commit")
464+
util.create_tag("1.0.0")
465+
util.create_file_and_commit("feat: new feature")
466+
467+
with pytest.raises(NoPatternMapError):
468+
commands.Version(
469+
config,
470+
{"project": True, "next": "USE_GIT_COMMITS"},
471+
)()
293472

294473

295474
def test_version_no_arguments_shows_commitizen_version(config, capsys):

0 commit comments

Comments
 (0)