Skip to content

Commit 80b1027

Browse files
committed
A bare repomatic init now re-syncs a tool config
1 parent 48d82c8 commit 80b1027

4 files changed

Lines changed: 113 additions & 1 deletion

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- `lint-repo` now fails when a workflow asks `repomatic metadata` for a key that no longer exists. `init` syncs a header-only workflow's header and pins but never its job bodies, so a retired key sits in a `run:` line nothing sweeps until the command rejects it and every job gated on it through `needs:` dies with it. `coverage_cells` took a downstream test workflow down this way.
99
- `lint-repo` now warns when a Sphinx project's GitHub website field differs from the documentation URL declared in `[project.urls]`.
1010
- `runner-images` now watches every image the workflows name literally, not just the curated test axes. An off-axis runner is the one nothing tracks, so its retirement was the one announcement never flagged.
11+
- A bare `repomatic init` now re-syncs a tool config the repository already carries, so `[tool.typos]`, `[tool.uv]` and `[tool.bumpversion]` follow the bundled template once adopted. Naming them explicitly governs adoption, not upkeep, and the only sync that runs unattended is the bare `init` behind the `sync-repomatic` job: a section written by hand therefore never picked up a single canonical rule. An `awesome-*` list had been spell-checked without the proper-noun map since it migrated.
1112
- New `ci-status` command reporting each workflow's latest run and which of its failing jobs actually gate a merge. Reads jobs rather than runs, so an allowed-failure probe cannot hide inside a green run conclusion.
1213
- New `repomatic run <tool> --verify` reporting which files a formatter would rewrite, without touching the working tree.
1314
- `repomatic run` now resolves a tool's targets itself when given no arguments, running the invocation CI performs. A tool with no matching file is skipped instead of invoked pathless.

docs/configuration.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ Scope is a default, not a rule. Naming a component on the command line, or listi
184184
$ repomatic init changelog
185185
```
186186

187+
### Adopting a tool config
188+
189+
Tool configs are the one group a bare `repomatic init` never introduces. `[tool.typos]`, `[tool.ruff]`, `[tool.pytest]` and their siblings land only when named:
190+
191+
```shell-session
192+
$ repomatic init typos
193+
```
194+
195+
Adoption is one-way. Once the section exists, a bare `init` picks it back up on every run, so the `sync-repomatic` job keeps it aligned with the bundled template from then on: new canonical rules arrive, local additions survive. The section is a managed file like any other after that, which makes it subject to [§ Diverging from a managed file](#diverging-from-a-managed-file): the sync rebuilds it from the template and grafts local content back, so hand-written comments inside it do not survive.
196+
197+
Only the configs repomatic keeps syncing behave this way, `typos`, `uv` and `bumpversion`. The rest (`ruff`, `pytest`, `coverage`, `mypy`, `mdformat`) are starting points the repository owns outright after the first write, and `init` never revisits them.
198+
187199
## `[tool.X]` bridge and tool runner
188200

189201
`repomatic run` also bridges the gap for tools that can't read `pyproject.toml` natively: write your config in `[tool.<name>]` and repomatic translates it to the tool's native format at invocation time. See the [tool runner](tool-runner.md) page for the full list of supported tools, config resolution precedence, binary caching, and a tutorial.

repomatic/init_project.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@
6363
from .metadata import Metadata, all_metadata_keys
6464
from .plugin import merge_plugin_settings
6565
from .prepare_release import SELF_PIN_COOLDOWN_EXEMPTION
66-
from .pyproject import is_python_package, is_python_project, resolve_source_paths
66+
from .pyproject import (
67+
is_python_package,
68+
is_python_project,
69+
read_pyproject_toml,
70+
resolve_source_paths,
71+
)
6772
from .registry import (
6873
COMPONENTS,
6974
COMPONENTS_BY_NAME,
@@ -834,6 +839,42 @@ def prune_paths(
834839
target.unlink()
835840

836841

842+
def adopted_ongoing_configs(output_dir: Path) -> set[str]:
843+
"""Return the ongoing tool configs whose section `pyproject.toml` already carries.
844+
845+
{attr}`~repomatic.registry.InitDefault.EXPLICIT` governs *adoption*, not
846+
upkeep: it keeps a bare `init` from pushing `[tool.typos]` onto a repository
847+
that never asked for one. Once the section is there the repository has
848+
asked, so an {attr}`~repomatic.registry.SyncMode.ONGOING` component rejoins
849+
the bare-init set and resumes tracking the bundled template.
850+
851+
Without this the two flags cancel out. The only sync that ever runs
852+
unattended is the bare `init` the `sync-repomatic` job calls, so an ONGOING
853+
section is otherwise re-derived only when a human types its component name,
854+
and a `[tool.typos]` written by hand sits indefinitely beside a bundled
855+
template it never adopts a single rule from.
856+
857+
{attr}`~repomatic.registry.SyncMode.BOOTSTRAP` components stay out: their
858+
template is a starting point the repository owns outright after the first
859+
write, and re-selecting one would revert deliberate local edits.
860+
861+
:param output_dir: Repository root holding `pyproject.toml`.
862+
:return: Component names to add to a bare `init` selection. Empty when the
863+
file is absent, unparseable, or carries no `[tool]` table.
864+
"""
865+
tool_table = read_pyproject_toml(output_dir).get("tool", {})
866+
if not tool_table:
867+
return set()
868+
return {
869+
comp.name
870+
for comp in COMPONENTS
871+
if isinstance(comp, ToolConfigComponent)
872+
and comp.init_default is InitDefault.EXPLICIT
873+
and comp.sync_mode is SyncMode.ONGOING
874+
and comp.tool_name in tool_table
875+
}
876+
877+
837878
def run_init(
838879
output_dir: Path,
839880
components: Sequence[str] = (),
@@ -899,6 +940,7 @@ def run_init(
899940
for c in COMPONENTS
900941
if c.init_default in (InitDefault.INCLUDE, InitDefault.EXCLUDE)
901942
}
943+
selected |= adopted_ongoing_configs(output_dir)
902944
result = InitResult()
903945

904946
# Auto-include awesome-template for awesome-* repositories.

tests/test_init_project.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
_highest_upstream_pin,
4141
_select_cooldown_pin,
4242
_update_tool_config,
43+
adopted_ongoing_configs,
4344
default_version_pin,
4445
export_content,
4546
get_data_content,
@@ -515,6 +516,62 @@ def test_init_config_lychee_preserves_other_sections(toml_file) -> None:
515516
assert "lychee" in parsed["tool"]
516517

517518

519+
@pytest.mark.parametrize(
520+
("content", "expected"),
521+
(
522+
pytest.param("", set(), id="no-tool-table"),
523+
pytest.param('[project]\nname = "papaya"\n', set(), id="project-only"),
524+
pytest.param("[tool.typos]\n", {"typos"}, id="ongoing-adopted"),
525+
pytest.param("[tool.ruff]\n", set(), id="bootstrap-stays-out"),
526+
pytest.param("[tool.gitleaks]\n", set(), id="unmanaged-section"),
527+
pytest.param(
528+
"[tool.typos]\n[tool.uv]\n[tool.bumpversion]\n[tool.mypy]\n",
529+
{"typos", "uv", "bumpversion"},
530+
id="every-ongoing-config",
531+
),
532+
),
533+
)
534+
def test_adopted_ongoing_configs(
535+
tmp_path: Path, content: str, expected: set[str]
536+
) -> None:
537+
"""A section already on disk re-enters the bare-init set, if it is ONGOING.
538+
539+
`EXPLICIT` keeps `init` from pushing a tool config onto a repository that
540+
never asked for one; it must not also stop the ongoing sync of a section the
541+
repository already carries. `BOOTSTRAP` templates stay out either way: the
542+
repository owns them outright after the first write.
543+
"""
544+
(tmp_path / "pyproject.toml").write_text(content, encoding="UTF-8")
545+
assert adopted_ongoing_configs(tmp_path) == expected
546+
547+
548+
def test_adopted_ongoing_configs_without_pyproject(tmp_path: Path) -> None:
549+
"""A repository with no pyproject.toml adopts nothing."""
550+
assert adopted_ongoing_configs(tmp_path) == set()
551+
552+
553+
def test_adopted_ongoing_configs_are_explicit_and_ongoing(tmp_path: Path) -> None:
554+
"""Whatever the helper returns is an EXPLICIT, ONGOING tool config.
555+
556+
Guards the invariant against a registry edit that flips a component's
557+
`init_default` or `sync_mode` without revisiting this selection path.
558+
"""
559+
sections = "\n".join(
560+
f"[{comp.tool_section}]"
561+
for comp in COMPONENTS
562+
if isinstance(comp, ToolConfigComponent)
563+
)
564+
(tmp_path / "pyproject.toml").write_text(sections + "\n", encoding="UTF-8")
565+
566+
adopted = adopted_ongoing_configs(tmp_path)
567+
assert adopted
568+
for name in adopted:
569+
comp = COMPONENTS_BY_NAME[name]
570+
assert isinstance(comp, ToolConfigComponent)
571+
assert comp.init_default is InitDefault.EXPLICIT
572+
assert comp.sync_mode is SyncMode.ONGOING
573+
574+
518575
def test_uv_component_uses_overlay_ongoing() -> None:
519576
"""The uv tool config is an ongoing overlay, not a full-section rebuild."""
520577
comp = COMPONENTS_BY_NAME["uv"]

0 commit comments

Comments
 (0)