Skip to content

Commit 600da2d

Browse files
committed
fix(config): ensure the actually used config file is correct, better test coverage
1 parent aa82b98 commit 600da2d

File tree

2 files changed

+139
-90
lines changed

2 files changed

+139
-90
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()

tests/test_conf.py

Lines changed: 102 additions & 55 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
@@ -239,71 +239,85 @@ def test_load_empty_pyproject_toml_from_config_argument(_, tmpdir):
239239
with pytest.raises(ConfigFileIsEmpty):
240240
config.read_cfg(filepath="./not_in_root/pyproject.toml")
241241

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)
248-
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
261242

262-
def test_warn_multiple_config_files_with_pyproject(_, tmpdir, capsys):
263-
"""Test warning excludes pyproject.toml from the warning message."""
243+
class TestWarnMultipleConfigFiles:
244+
@pytest.mark.parametrize(
245+
"files,expected_path,should_warn",
246+
[
247+
# Same directory, different file types
248+
([(".cz.toml", PYPROJECT), (".cz.json", JSON_STR)], ".cz.toml", True),
249+
([(".cz.json", JSON_STR), (".cz.yaml", YAML_STR)], ".cz.json", True),
250+
([(".cz.toml", PYPROJECT), (".cz.yaml", YAML_STR)], ".cz.toml", True),
251+
# With pyproject.toml (excluded from warning)
252+
(
253+
[("pyproject.toml", PYPROJECT), (".cz.json", JSON_STR)],
254+
"pyproject.toml",
255+
False,
256+
),
257+
(
258+
[("pyproject.toml", PYPROJECT), (".cz.toml", PYPROJECT)],
259+
"pyproject.toml",
260+
False,
261+
),
262+
],
263+
)
264+
def test_warn_multiple_config_files_same_dir(
265+
_, tmpdir, capsys, files, expected_path, should_warn
266+
):
267+
"""Test warning when multiple config files exist in same directory."""
264268
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)
269+
for filename, content in files:
270+
tmpdir.join(filename).write(content)
268271

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

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

279-
def test_warn_multiple_config_files_uses_correct_one(_, tmpdir, capsys):
280-
"""Test that the correct config file is used when multiple exist."""
289+
@pytest.mark.parametrize(
290+
"config_file,content",
291+
[
292+
(".cz.json", JSON_STR),
293+
(".cz.toml", PYPROJECT),
294+
(".cz.yaml", YAML_STR),
295+
("cz.toml", PYPROJECT),
296+
("cz.json", JSON_STR),
297+
("cz.yaml", YAML_STR),
298+
],
299+
)
300+
def test_warn_same_filename_different_directories_with_git(
301+
_, tmpdir, capsys, config_file, content
302+
):
303+
"""Test warning when same config filename exists in the current directory and in the git root."""
281304
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)
305+
cmd.run("git init")
293306

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()
307+
# Create config in git root
308+
tmpdir.join(config_file).write(content)
297309

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
310+
# Create same filename in subdirectory
311+
subdir = tmpdir.mkdir("subdir")
312+
subdir.join(config_file).write(content)
303313

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"
314+
with subdir.as_cwd():
315+
cfg = config.read_cfg()
316+
captured = capsys.readouterr()
317+
318+
assert "Multiple config files detected" in captured.err
319+
assert f"Using config file: '{config_file}'" in captured.err
320+
assert cfg.path == Path(config_file)
307321

308322
def test_no_warn_with_explicit_config_path(_, tmpdir, capsys):
309323
"""Test that no warning is issued when user explicitly specifies config."""
@@ -323,6 +337,39 @@ def test_no_warn_with_explicit_config_path(_, tmpdir, capsys):
323337
json_cfg_expected = JsonConfig(data=JSON_STR, path=Path(".cz.json"))
324338
assert cfg.settings == json_cfg_expected.settings
325339

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

327374
@pytest.mark.parametrize(
328375
"config_file, exception_string",

0 commit comments

Comments
 (0)