Skip to content

Commit 12214ab

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

File tree

2 files changed

+154
-93
lines changed

2 files changed

+154
-93
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: 117 additions & 58 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,92 @@ 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()
251242

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."""
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",
291+
[".cz.json", ".cz.toml", ".cz.yaml"],
292+
)
293+
def test_warn_same_filename_different_directories(_, tmpdir, capsys, config_file):
294+
"""Test warning when same config filename exists in different directories."""
281295
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)
293-
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()
297-
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
303-
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"
296+
cmd.run("git init")
297+
298+
# Create config in git root
299+
if config_file.endswith(".json"):
300+
root_content = '{"commitizen": {"name": "cz_jira", "version": "1.0.0"}}'
301+
elif config_file.endswith(".yaml"):
302+
root_content = "commitizen:\n name: cz_jira\n version: 1.0.0"
303+
else:
304+
root_content = PYPROJECT
305+
tmpdir.join(config_file).write(root_content)
306+
307+
# Create same filename in subdirectory
308+
subdir = tmpdir.mkdir("subdir")
309+
if config_file.endswith(".json"):
310+
subdir_content = '{"commitizen": {"name": "cz_conventional_commits", "version": "2.0.0"}}'
311+
elif config_file.endswith(".yaml"):
312+
subdir_content = (
313+
"commitizen:\n name: cz_conventional_commits\n version: 2.0.0"
314+
)
315+
else:
316+
subdir_content = PYPROJECT.replace(
317+
'name = "cz_jira"', 'name = "cz_conventional_commits"'
318+
).replace('version = "1.0.0"', 'version = "2.0.0"')
319+
subdir.join(config_file).write(subdir_content)
320+
321+
with subdir.as_cwd():
322+
cfg = config.read_cfg()
323+
captured = capsys.readouterr()
324+
325+
assert "Multiple config files detected" in captured.err
326+
assert f"Using config file: '{config_file}'" in captured.err
327+
assert cfg.path == Path(config_file)
307328

308329
def test_no_warn_with_explicit_config_path(_, tmpdir, capsys):
309330
"""Test that no warning is issued when user explicitly specifies config."""
@@ -323,6 +344,44 @@ def test_no_warn_with_explicit_config_path(_, tmpdir, capsys):
323344
json_cfg_expected = JsonConfig(data=JSON_STR, path=Path(".cz.json"))
324345
assert cfg.settings == json_cfg_expected.settings
325346

347+
@pytest.mark.parametrize(
348+
"config_file, content, with_git",
349+
[
350+
(".cz.toml", PYPROJECT, True),
351+
(".cz.json", JSON_STR, True),
352+
(".cz.yaml", YAML_STR, True),
353+
("pyproject.toml", PYPROJECT, True),
354+
("cz.toml", PYPROJECT, True),
355+
("cz.json", JSON_STR, True),
356+
("cz.yaml", YAML_STR, True),
357+
(".cz.toml", PYPROJECT, False),
358+
(".cz.json", JSON_STR, False),
359+
(".cz.yaml", YAML_STR, False),
360+
("pyproject.toml", PYPROJECT, False),
361+
("cz.toml", PYPROJECT, False),
362+
("cz.json", JSON_STR, False),
363+
("cz.yaml", YAML_STR, False),
364+
],
365+
)
366+
def test_no_warn_with_single_config_file(
367+
_, tmpdir, capsys, config_file, content, with_git
368+
):
369+
"""Test that no warning is issued when user explicitly specifies config."""
370+
with tmpdir.as_cwd():
371+
if with_git:
372+
cmd.run("git init")
373+
# Create multiple config files
374+
tmpdir.join(config_file).write(content)
375+
376+
# Read config with explicit path
377+
cfg = config.read_cfg()
378+
captured = capsys.readouterr()
379+
380+
# No warning should be issued
381+
assert "Multiple config files detected" not in captured.err
382+
383+
assert cfg.path == Path(config_file)
384+
326385

327386
@pytest.mark.parametrize(
328387
"config_file, exception_string",

0 commit comments

Comments
 (0)