Skip to content

Commit 1da871e

Browse files
fix(config): ensure the actually used config file is correct, better test coverage (#1784)
1 parent 7e59a05 commit 1da871e

File tree

4 files changed

+151
-102
lines changed

4 files changed

+151
-102
lines changed

commitizen/config/__init__.py

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,64 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4-
from typing import TYPE_CHECKING
54

65
from commitizen import defaults, git, out
76
from commitizen.config.factory import create_config
87
from commitizen.exceptions import ConfigFileIsEmpty, ConfigFileNotFound
98

109
from .base_config import BaseConfig
1110

12-
if TYPE_CHECKING:
13-
from collections.abc import Generator
14-
15-
16-
def _resolve_config_paths(filepath: str | None = None) -> Generator[Path, None, None]:
17-
if filepath is not None:
18-
out_path = Path(filepath)
19-
if not out_path.exists():
20-
raise ConfigFileNotFound()
21-
22-
yield out_path
23-
return
2411

12+
def _resolve_config_paths() -> list[Path]:
2513
git_project_root = git.find_git_project_root()
2614
cfg_search_paths = [Path(".")]
27-
if git_project_root:
15+
16+
if git_project_root and not cfg_search_paths[0].samefile(git_project_root):
2817
cfg_search_paths.append(git_project_root)
2918

30-
for path in cfg_search_paths:
19+
# The following algorithm is ugly, but we need to ensure that the order of the candidates are preserved before v5.
20+
# Also, the number of possible config files is limited, so the complexity is not a problem.
21+
candidates: list[Path] = []
22+
for dir in cfg_search_paths:
3123
for filename in defaults.CONFIG_FILES:
32-
out_path = path / Path(filename)
33-
if out_path.exists():
34-
yield out_path
24+
out_path = dir / Path(filename)
25+
if out_path.exists() and all(not out_path.samefile(p) for p in candidates):
26+
candidates.append(out_path)
27+
return candidates
28+
29+
30+
def _create_config_from_path(path: Path) -> BaseConfig:
31+
with open(path, "rb") as f:
32+
data: bytes = f.read()
33+
34+
return create_config(data=data, path=path)
3535

3636

3737
def read_cfg(filepath: str | None = None) -> BaseConfig:
38-
config_candidates = list(_resolve_config_paths(filepath))
38+
if filepath is not None:
39+
conf_path = Path(filepath)
40+
if not conf_path.exists():
41+
raise ConfigFileNotFound()
42+
conf = _create_config_from_path(conf_path)
43+
if conf.is_empty_config:
44+
raise ConfigFileIsEmpty()
45+
return conf
46+
47+
config_candidate_paths = _resolve_config_paths()
3948

4049
# Check for multiple config files and warn the user
4150
config_candidates_exclude_pyproject = [
42-
path for path in config_candidates if path.name != "pyproject.toml"
51+
path for path in config_candidate_paths if path.name != "pyproject.toml"
4352
]
44-
if len(config_candidates_exclude_pyproject) > 1:
45-
filenames = [path.name for path in config_candidates_exclude_pyproject]
46-
out.warn(
47-
f"Multiple config files detected: {', '.join(filenames)}. "
48-
f"Using config file: '{filenames[0]}'."
49-
)
50-
51-
for filename in config_candidates:
52-
with open(filename, "rb") as f:
53-
data: bytes = f.read()
54-
55-
conf = create_config(data=data, path=filename)
53+
54+
for config_candidate_path in config_candidate_paths:
55+
conf = _create_config_from_path(config_candidate_path)
5656
if not conf.is_empty_config:
57+
if len(config_candidates_exclude_pyproject) > 1:
58+
out.warn(
59+
f"Multiple config files detected: {', '.join(map(str, config_candidates_exclude_pyproject))}. "
60+
f"Using config file: '{config_candidate_path}'."
61+
)
5762
return conf
5863

59-
if filepath is not None:
60-
raise ConfigFileIsEmpty()
61-
6264
return BaseConfig()

commitizen/defaults.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,15 @@ class Settings(TypedDict, total=False):
6767
breaking_change_exclamation_in_title: bool
6868

6969

70-
CONFIG_FILES: list[str] = [
71-
"pyproject.toml",
70+
CONFIG_FILES: tuple[str, ...] = (
7271
".cz.toml",
72+
"cz.toml",
7373
".cz.json",
7474
"cz.json",
7575
".cz.yaml",
7676
"cz.yaml",
77-
"cz.toml",
78-
]
77+
"pyproject.toml",
78+
)
7979
ENCODING = "utf-8"
8080

8181
DEFAULT_SETTINGS: Settings = {

docs/config/configuration_file.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ It is recommended to create a configuration file via our [`cz init`](../commands
1010

1111
Configuration files are typically located in the root of your project directory. Commitizen searches for configuration files in the following order:
1212

13-
1. `pyproject.toml` (in the `[tool.commitizen]` section)
14-
2. `.cz.toml`
13+
<!-- DEPENDENCY: commitizen/defaults.py CONFIG_FILES -->
14+
15+
1. `.cz.toml`
16+
2. `cz.toml`
1517
3. `.cz.json`
1618
4. `cz.json`
1719
5. `.cz.yaml`
1820
6. `cz.yaml`
19-
7. `cz.toml`
21+
7. `pyproject.toml` (in the `[tool.commitizen]` section)
2022

2123
The first valid configuration file found will be used. If no configuration file is found, Commitizen will use its default settings.
2224

@@ -29,7 +31,7 @@ The first valid configuration file found will be used. If no configuration file
2931
```
3032

3133
!!! tip
32-
For Python projects, it's recommended to add your Commitizen configuration to `pyproject.toml` to keep all project configuration in one place.
34+
For Python projects, you can add your Commitizen configuration to `pyproject.toml` to keep all project configuration in one place.
3335

3436
!!! warning "Multiple Configuration Files"
3537
If Commitizen detects more than one configuration file in your project directory (excluding `pyproject.toml`), it will display a warning message and identify which file is being used. To avoid confusion, ensure you have only one Commitizen configuration file in your project.

tests/test_conf.py

Lines changed: 104 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99
import yaml
1010

11-
from commitizen import config, defaults, git
11+
from commitizen import cmd, config, defaults, git
1212
from commitizen.config.json_config import JsonConfig
1313
from commitizen.config.toml_config import TomlConfig
1414
from commitizen.config.yaml_config import YAMLConfig
@@ -172,9 +172,7 @@ def test_find_git_project_root(tmpdir):
172172
assert git.find_git_project_root() is None
173173

174174

175-
@pytest.mark.parametrize(
176-
"config_files_manager", defaults.CONFIG_FILES.copy(), indirect=True
177-
)
175+
@pytest.mark.parametrize("config_files_manager", defaults.CONFIG_FILES, indirect=True)
178176
def test_set_key(config_files_manager):
179177
_conf = config.read_cfg()
180178
_conf.set_key("version", "2.0.0")
@@ -184,7 +182,7 @@ def test_set_key(config_files_manager):
184182

185183
class TestReadCfg:
186184
@pytest.mark.parametrize(
187-
"config_files_manager", defaults.CONFIG_FILES.copy(), indirect=True
185+
"config_files_manager", defaults.CONFIG_FILES, indirect=True
188186
)
189187
def test_load_conf(_, config_files_manager):
190188
cfg = config.read_cfg()
@@ -239,71 +237,85 @@ def test_load_empty_pyproject_toml_from_config_argument(_, tmpdir):
239237
with pytest.raises(ConfigFileIsEmpty):
240238
config.read_cfg(filepath="./not_in_root/pyproject.toml")
241239

242-
def test_warn_multiple_config_files(_, tmpdir, capsys):
243-
"""Test that a warning is issued when multiple config files exist."""
244-
with tmpdir.as_cwd():
245-
# Create multiple config files
246-
tmpdir.join(".cz.toml").write(PYPROJECT)
247-
tmpdir.join(".cz.json").write(JSON_STR)
248240

249-
# Read config
250-
cfg = config.read_cfg()
251-
252-
# Check that the warning was issued
253-
captured = capsys.readouterr()
254-
assert "Multiple config files detected" in captured.err
255-
assert ".cz.toml" in captured.err
256-
assert ".cz.json" in captured.err
257-
assert "Using" in captured.err
258-
259-
# Verify the correct config is loaded (first in priority order)
260-
assert cfg.settings == _settings
261-
262-
def test_warn_multiple_config_files_with_pyproject(_, tmpdir, capsys):
263-
"""Test warning excludes pyproject.toml from the warning message."""
241+
class TestWarnMultipleConfigFiles:
242+
@pytest.mark.parametrize(
243+
"files,expected_path,should_warn",
244+
[
245+
# Same directory, different file types
246+
([(".cz.toml", PYPROJECT), (".cz.json", JSON_STR)], ".cz.toml", True),
247+
([(".cz.json", JSON_STR), (".cz.yaml", YAML_STR)], ".cz.json", True),
248+
([(".cz.toml", PYPROJECT), (".cz.yaml", YAML_STR)], ".cz.toml", True),
249+
# With pyproject.toml (excluded from warning)
250+
(
251+
[("pyproject.toml", PYPROJECT), (".cz.json", JSON_STR)],
252+
".cz.json",
253+
False,
254+
),
255+
(
256+
[("pyproject.toml", PYPROJECT), (".cz.toml", PYPROJECT)],
257+
".cz.toml",
258+
False,
259+
),
260+
],
261+
)
262+
def test_warn_multiple_config_files_same_dir(
263+
_, tmpdir, capsys, files, expected_path, should_warn
264+
):
265+
"""Test warning when multiple config files exist in same directory."""
264266
with tmpdir.as_cwd():
265-
# Create multiple config files including pyproject.toml
266-
tmpdir.join("pyproject.toml").write(PYPROJECT)
267-
tmpdir.join(".cz.json").write(JSON_STR)
267+
for filename, content in files:
268+
tmpdir.join(filename).write(content)
268269

269-
# Read config - should use pyproject.toml (first in priority)
270270
cfg = config.read_cfg()
271-
272-
# No warning should be issued as only one non-pyproject config exists
273271
captured = capsys.readouterr()
274-
assert "Multiple config files detected" not in captured.err
275272

276-
# Verify the correct config is loaded
277-
assert cfg.settings == _settings
273+
if should_warn:
274+
assert "Multiple config files detected" in captured.err
275+
assert "Using" in captured.err
276+
for filename, _ in files:
277+
if filename != "pyproject.toml":
278+
assert filename in captured.err
279+
else:
280+
assert "Multiple config files detected" not in captured.err
281+
282+
assert cfg.path == Path(expected_path)
283+
# Verify config loaded correctly (name and version match expected)
284+
assert cfg.settings["name"] == "cz_jira"
285+
assert cfg.settings["version"] == "1.0.0"
278286

279-
def test_warn_multiple_config_files_uses_correct_one(_, tmpdir, capsys):
280-
"""Test that the correct config file is used when multiple exist."""
287+
@pytest.mark.parametrize(
288+
"config_file,content",
289+
[
290+
(".cz.json", JSON_STR),
291+
(".cz.toml", PYPROJECT),
292+
(".cz.yaml", YAML_STR),
293+
("cz.toml", PYPROJECT),
294+
("cz.json", JSON_STR),
295+
("cz.yaml", YAML_STR),
296+
],
297+
)
298+
def test_warn_same_filename_different_directories_with_git(
299+
_, tmpdir, capsys, config_file, content
300+
):
301+
"""Test warning when same config filename exists in the current directory and in the git root."""
281302
with tmpdir.as_cwd():
282-
# Create .cz.json with different settings
283-
json_different = """
284-
{
285-
"commitizen": {
286-
"name": "cz_conventional_commits",
287-
"version": "2.0.0"
288-
}
289-
}
290-
"""
291-
tmpdir.join(".cz.json").write(json_different)
292-
tmpdir.join(".cz.toml").write(PYPROJECT)
303+
cmd.run("git init")
293304

294-
# Read config - should use pyproject.toml (first in defaults.CONFIG_FILES)
295-
# But since pyproject.toml doesn't exist, .cz.toml is second in priority
296-
cfg = config.read_cfg()
305+
# Create config in git root
306+
tmpdir.join(config_file).write(content)
297307

298-
# Check that warning mentions both files
299-
captured = capsys.readouterr()
300-
assert "Multiple config files detected" in captured.err
301-
assert ".cz.toml" in captured.err
302-
assert ".cz.json" in captured.err
308+
# Create same filename in subdirectory
309+
subdir = tmpdir.mkdir("subdir")
310+
subdir.join(config_file).write(content)
303311

304-
# Verify .cz.toml was used (second in priority after pyproject.toml)
305-
assert cfg.settings["name"] == "cz_jira" # from PYPROJECT
306-
assert cfg.settings["version"] == "1.0.0"
312+
with subdir.as_cwd():
313+
cfg = config.read_cfg()
314+
captured = capsys.readouterr()
315+
316+
assert "Multiple config files detected" in captured.err
317+
assert f"Using config file: '{config_file}'" in captured.err
318+
assert cfg.path == Path(config_file)
307319

308320
def test_no_warn_with_explicit_config_path(_, tmpdir, capsys):
309321
"""Test that no warning is issued when user explicitly specifies config."""
@@ -323,6 +335,39 @@ def test_no_warn_with_explicit_config_path(_, tmpdir, capsys):
323335
json_cfg_expected = JsonConfig(data=JSON_STR, path=Path(".cz.json"))
324336
assert cfg.settings == json_cfg_expected.settings
325337

338+
@pytest.mark.parametrize(
339+
"config_file, content, with_git",
340+
[
341+
(file, content, with_git)
342+
for file, content in [
343+
(".cz.toml", PYPROJECT),
344+
(".cz.json", JSON_STR),
345+
(".cz.yaml", YAML_STR),
346+
("pyproject.toml", PYPROJECT),
347+
("cz.toml", PYPROJECT),
348+
("cz.json", JSON_STR),
349+
("cz.yaml", YAML_STR),
350+
]
351+
for with_git in [True, False]
352+
],
353+
)
354+
def test_no_warn_with_single_config_file(
355+
_, tmpdir, capsys, config_file, content, with_git
356+
):
357+
"""Test that no warning is issued when user explicitly specifies config."""
358+
with tmpdir.as_cwd():
359+
if with_git:
360+
cmd.run("git init")
361+
362+
tmpdir.join(config_file).write(content)
363+
364+
cfg = config.read_cfg()
365+
captured = capsys.readouterr()
366+
367+
# No warning should be issued
368+
assert "Multiple config files detected" not in captured.err
369+
assert cfg.path == Path(config_file)
370+
326371

327372
@pytest.mark.parametrize(
328373
"config_file, exception_string",

0 commit comments

Comments
 (0)