Skip to content

Commit 92c3743

Browse files
committed
feat(cmd/version): add support for --next USE_GIT_COMMITS
1 parent 2b0b74c commit 92c3743

3 files changed

Lines changed: 224 additions & 9 deletions

File tree

commitizen/commands/version.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@
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+
NoVersionSpecifiedError,
14+
VersionSchemeUnknown,
15+
)
1116
from commitizen.providers import get_provider
1217
from commitizen.tags import TagRules
1318
from commitizen.version_increment import VersionIncrement
14-
from commitizen.version_schemes import Increment, get_version_scheme
19+
from commitizen.version_schemes import Increment, VersionProtocol, get_version_scheme
1520

1621

1722
class VersionArgs(TypedDict, total=False):
@@ -42,6 +47,7 @@ class Version:
4247
def __init__(self, config: BaseConfig, arguments: VersionArgs) -> None:
4348
self.config: BaseConfig = config
4449
self.arguments = arguments
50+
self.cz = factory.committer_factory(self.config)
4551

4652
def __call__(self) -> None:
4753
if self.arguments.get("report"):
@@ -87,8 +93,7 @@ def __call__(self) -> None:
8793
# TODO: implement USE_GIT_COMMITS by deriving the increment from
8894
# git history. This requires refactoring the bump logic out of
8995
# `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
96+
version = self._get_next_git_version(version)
9297

9398
next_increment = VersionIncrement.from_value(next_increment_str)
9499
increment: Increment | None
@@ -100,6 +105,9 @@ def __call__(self) -> None:
100105
increment = "MINOR"
101106
else:
102107
increment = "MAJOR"
108+
109+
# TODO: Consider adding all the parameters `.bump` supports:
110+
# prerelease, prerelease_offset,exact_increment, etc...
103111
version = version.bump(increment=increment)
104112

105113
if self.arguments.get("major"):
@@ -139,3 +147,39 @@ def __call__(self) -> None:
139147

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

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
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 NoCommitsFoundError, NoPatternMapError
12+
from tests.utils import UtilFixture
1013

1114

1215
def test_version_for_showing_project_version_error(config, capsys):
@@ -282,14 +285,172 @@ def test_version_unknown_scheme(config, capsys):
282285
assert "Unknown version scheme." in captured.err
283286

284287

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

294455

295456
def test_version_no_arguments_shows_commitizen_version(config, capsys):

0 commit comments

Comments
 (0)