# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Declarative registry of all components managed by the `init` subcommand.
Every resource the `init` subcommand can create, sync, or merge is declared
here as a {class}`Component` subclass instance in the {data}`COMPONENTS` tuple.
Each component carries all its metadata: what kind it is, whether it is
selected by default, which files it manages, and any per-file properties like
repo-scope gating or config keys.
All derived constants (`ALL_COMPONENTS`, `REUSABLE_WORKFLOWS`,
`SKILL_PHASES`, etc.) are computed from this single registry at the bottom of
this module.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum, auto
from .bundle import get_data_content
from .config import Config, location_path
from .frontmatter import split_frontmatter
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Sequence
GITHUB_YAML_PATTERNS: tuple[str, ...] = (
".github/workflows/*.yaml",
".github/workflows/*.yml",
".github/actions/**/*.yaml",
".github/actions/**/*.yml",
)
"""Globs matching every workflow and composite-action file of a repository.
Rooted at the repository root rather than at `.github/`, so the same patterns
work against the current directory and against an arbitrary target tree. Both
`.yml` and `.yaml` are listed because GitHub accepts either, whatever this
project's own [long-extension convention](https://repomatic.net)
prefers: a downstream repository is free to have picked the short one.
Shared by `sync_ops._workflow_and_action_files`, which reads the pins to bump,
and `init_project._highest_upstream_pin`, which reads them to floor a new pin.
The two must agree on which files carry a pin, or `init` would floor against a
file `sync-workflow-pins` never bumps.
"""
def _config_enabled(config: object, config_key: str, config_default: bool) -> bool:
"""Resolve a `[tool.repomatic]` gate against a `Config` object.
Returns `True` when *config_key* is empty (unconditionally enabled) or when
the corresponding config field is truthy. Shared by
{meth}`FileEntry.is_enabled` and {meth}`Component.is_enabled`.
A `[tool.repomatic]` key reaches its field one of two ways, and both are
tried because both are in use. `metrics.sync` is a key *on a nested schema*
({class}`~repomatic.config.MetricsConfig`), reached by walking the dotted
path. `notification.unsubscribe` is a scalar whose dotted path is only
metadata, reached by flattening the whole key to one attribute name.
Walking first, since a nested schema is the more specific match: a gate
naming a nested key would otherwise flatten to an attribute nothing
defines and silently resolve to *config_default*, which reads as a feature
switched off rather than as a gate wired wrong.
"""
if not config_key:
return True
parts = config_key.replace("-", "_").split(".")
node: object = config
for part in parts:
if not hasattr(node, part):
break
node = getattr(node, part)
else:
return bool(node)
return bool(getattr(config, "_".join(parts), config_default))
[docs]
class InitDefault(Enum):
"""How `init` treats the component when no explicit CLI args are given."""
INCLUDE = auto()
"""Included by default (like changelog or workflows)."""
EXCLUDE = auto()
"""In default set but excluded unless explicitly included
(e.g., labels, skills)."""
AUTO = auto()
"""Auto-included only for matching repos (e.g., awesome-template)."""
EXPLICIT = auto()
"""Only included when explicitly requested (e.g., tool configs)."""
[docs]
class SyncMode(Enum):
"""How a `ToolConfigComponent` behaves when the section already exists."""
BOOTSTRAP = auto()
"""Insert once, skip if section already exists (e.g., ruff, pytest)."""
ONGOING = auto()
"""Replace template content on every sync, preserving local additions
(e.g., bumpversion)."""
[docs]
class RepoScope(Enum):
"""Which repository types a component or file entry applies to.
The classification has three axes: whether the repo is an `awesome-*` list,
whether it carries a PEP 621 `pyproject.toml`, and whether that project is
a distributable package. The first is mutually exclusive with the other two
(awesome repos are content lists, not Python projects), so a single scope
value suffices.
The Python axis is deliberately split in two. `PYTHON_ONLY` covers anything
that needs Python code to be useful; `PACKAGE_ONLY` covers only what needs
something to publish. A uv virtual project (`[tool.uv] package = false`)
sits between the two: it locks dependencies and runs tests, but never
ships a release. Collapsing the pair would hand every blog and docs site a
PyPI publish action and a release workflow it can never run.
Scope restrictions are defaults: they apply during bare `repomatic init`
but are bypassed when components are explicitly named on the CLI or
covered by `[tool.repomatic] include`.
"""
ALL = auto()
"""Included in every repository type."""
AWESOME_ONLY = auto()
"""Only for `awesome-*` repositories."""
PYTHON_ONLY = auto()
"""Only for Python projects (PEP 621 `[project].name` present).
Use for anything a uv virtual project still wants: dependency locking,
coverage config, test tooling.
"""
PACKAGE_ONLY = auto()
"""Only for Python projects that build a distributable package.
Strictly narrower than {attr}`PYTHON_ONLY`, excluding uv virtual projects.
Use for the release lane: publishing, tagging, changelog upkeep.
"""
[docs]
def matches(self, is_awesome: bool, is_python: bool, is_package: bool) -> bool:
"""Whether this scope applies to the given repository traits.
:param is_awesome: `True` for `awesome-*` repositories.
:param is_python: `True` for repositories whose `pyproject.toml`
declares a PEP 621 `[project].name`, per
{func}`repomatic.pyproject.is_python_project`.
:param is_package: `True` when that project is also distributable, per
{func}`repomatic.pyproject.is_python_package`. Always implies
*is_python*.
"""
if self is RepoScope.ALL:
return True
if self is RepoScope.AWESOME_ONLY:
return is_awesome
if self is RepoScope.PACKAGE_ONLY:
return is_package
return is_python
[docs]
@dataclass(frozen=True)
class FileEntry:
"""A single file managed within a component."""
source: str
"""Filename in `repomatic/data/`, or a directory when {attr}`tree` is set."""
target: str = ""
"""Relative output path in the target repository.
Defaults to `source` (root-level file)."""
file_id: str = ""
"""Identifier for file-level `--include`/`--exclude`.
Defaults to the filename portion of `target`."""
scope: RepoScope = RepoScope.ALL
"""Which repository types get this file."""
config_key: str = ""
"""`[tool.repomatic]` key that gates this entry."""
config_default: bool = False
"""Value assumed when `config_key` is absent from config. `False`
means opt-in (excluded unless enabled), `True` means opt-out
(included unless disabled)."""
reusable: bool = True
"""Workflow-specific: supports `workflow_call` trigger."""
phase: str = ""
"""Skill-specific: lifecycle phase for `list-skills` display."""
tree: bool = False
r"""Whether {attr}`source` and {attr}`target` name directories, not files.
A tree entry is copied wholesale, so a skill can ship `scripts/`,
`references/` and `assets/` alongside its `SKILL.md` exactly as the [Agent
Skills spec](https://agentskills.io/specification) describes, with no
per-file registration.
```{caution}
Under `repomatic/data/` a tree's directories must be **real** and only its
leaves may be symlinks back into the authoritative tree. `uv_build` refuses
a symlinked directory in package data (`Is a directory (os error 21)`) and
fails the whole wheel, while symlinked files are dereferenced into it
normally.
```
"""
[docs]
def is_enabled(self, config: object) -> bool:
"""Whether this entry is enabled by the given `Config` object.
See {func}`_config_enabled` for the resolution rule.
:param config: A {class}`~repomatic.config.Config` instance.
"""
return _config_enabled(config, self.config_key, self.config_default)
def __post_init__(self) -> None:
"""Derive `target` and `file_id` from `source` when omitted."""
if not self.target:
object.__setattr__(self, "target", self.source)
if not self.file_id:
object.__setattr__(self, "file_id", self.target.rsplit("/", 1)[-1])
# ---------------------------------------------------------------------------
# Component hierarchy.
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class Component:
"""Base class for all init components."""
name: str
"""Component name used on the CLI (e.g., `"skills"`)."""
description: str
"""Human-readable description for help text."""
init_default: InitDefault = InitDefault.INCLUDE
"""How `init` treats this component when no explicit CLI selection
is made."""
scope: RepoScope = RepoScope.ALL
"""Which repository types get this component. Checked at the component
level during auto-exclusion, complementing the file-level
{attr}`FileEntry.scope`."""
files: tuple[FileEntry, ...] = ()
"""File entries this component manages."""
config_key: str = ""
"""`[tool.repomatic]` key that gates this component."""
config_default: bool = True
"""Value assumed when `config_key` is absent from config. `True`
means opt-out (included unless disabled)."""
keep_unmodified: bool = False
"""Preserve files on disk even when identical to the bundled default.
When `False`, unmodified copies are flagged for cleanup by
`--delete-unmodified`."""
ephemeral: bool = False
"""Whether this component's files are inputs regenerated on demand rather
than repository content.
Every consumer of an ephemeral component dumps it right before reading it,
so a copy in the working tree is never the one that gets used. Bare
`repomatic init` therefore skips these components, and `[tool.repomatic]
include` cannot opt into materializing them: only naming the component
explicitly on the CLI (`repomatic init labels`) writes its files out, which
is how `sync-labels` stages `labels.toml` into a temporary directory to
hand to `labelmaker`, leaving the working tree untouched.
"""
location_field: str = ""
"""{class}`~repomatic.config.Config` field holding this component's
destination, when the user can move it.
Set for every component whose destination is configurable: the directories
`subagents` and `skills` write into, and the single files `plugin` and
`agent` merge into. Declared targets are built against the *default*
location, so a repo that overrode it needs each target rebased onto the
configured one. {meth}`resolve_target` performs that rebase, and leaving
this empty means the targets are fixed (`.github/workflows/` is GitHub's,
not ours to move).
"""
[docs]
def is_enabled(self, config: object) -> bool:
"""Whether this component is enabled by the given `Config` object.
See {func}`_config_enabled` for the resolution rule.
:param config: A {class}`~repomatic.config.Config` instance.
"""
return _config_enabled(config, self.config_key, self.config_default)
[docs]
def resolve_target(self, target: str, config: object) -> str:
"""Rebase a declared target path onto this component's configured location.
A no-op unless {attr}`location_field` is set and the resolved config
actually moves the destination, so every caller can route every target
through this method instead of testing the component name first.
Handles both shapes a location may take. A directory location rebases
the path under it; a file location (`plugin`, `agent`) *is* the path, so
it is replaced outright. Matching only the directory shape would leave a
moved file reported at its default path, and stale-file detection would
then hunt for an orphan the repository never wrote there.
:param target: A path as declared on a {class}`FileEntry` (or a
{class}`RemovedAsset` tombstone), relative to the repository root and
expressed against the default location.
:param config: A {class}`~repomatic.config.Config` instance, or `None`.
:return: The target rebased onto the configured location, or *target*
unchanged.
"""
if not self.location_field or config is None:
return target
# The Config default carries a "./" prefix the registry targets omit.
# Annotated because `getattr` on a computed name answers `Any`, which
# mypy then carries all the way out of this function's `str` return.
default: str = location_path(getattr(Config, self.location_field))
custom: str = location_path(getattr(config, self.location_field))
if custom == default:
return target
if target == default:
return custom
if not target.startswith(f"{default}/"):
return target
return f"{custom}/{target[len(default) + 1 :]}"
[docs]
@dataclass(frozen=True)
class BundledComponent(Component):
"""Files copied from `repomatic/data/` to a target path."""
[docs]
@dataclass(frozen=True)
class WorkflowComponent(Component):
"""Thin-caller generation and header sync."""
[docs]
@dataclass(frozen=True)
class TemplateComponent(Component):
"""Directory tree (awesome-template)."""
[docs]
@dataclass(frozen=True)
class GeneratedComponent(Component):
"""Produced from code (changelog).
Unlike bundled components, generated components have no `files` tuple.
The `target` field records the output path so the auto-exclusion logic
can detect stale copies on disk.
"""
target: str = ""
"""Relative output path in the target repository."""
[docs]
@dataclass(frozen=True)
class RemovedAsset:
"""An asset repomatic once shipped and has since dropped.
```{note}
Stale-file detection in `init` only inspects files still listed in
{data}`COMPONENTS`. An asset removed from the registry (a renamed or
consolidated skill, a retired workflow) becomes invisible to it, so
downstream repos accumulate one orphan per upstream removal. Each
`RemovedAsset` is a tombstone that lets `init` find and prune those
orphans.
```
`init` finds an on-disk orphan and decides whether to prune it with one of
two gates, depending on the component:
- Content-gated (skills, agents, config files): the file is deleted only
when its normalized content matches one of {attr}`hashes` (a version
repomatic shipped), proving it is an untouched copy.
- Fingerprint-gated (workflows): thin-callers are parameterized per repo
(version pin, `paths:` filters), so they carry no fixed content. The file
is deleted only when it is a repomatic-lineage thin-caller for this
workflow (its `uses:` line references an upstream slug, see
{data}`UPSTREAM_REPO_SLUGS`) with no extra downstream jobs.
Either way, a locally modified orphan is reported for manual review, never
deleted. When {attr}`target` is already gone but the asset shipped as a
folder, an empty {attr}`owned_dir` left behind is pruned on its own: it
carries nothing anyone could lose.
"""
component: str
"""Component the asset belonged to (like `"skills"` or `"workflows"`)."""
target: str
"""Relative output path the asset occupied, in default-location form
(like `.claude/skills/repomatic-release/SKILL.md` or
`.github/workflows/label-sponsors.yaml`).
Build skill and subagent targets with `_skill_target` / `_subagent_target`
so they match the live registry: the `skills.location` and
`subagents.location` overrides are re-applied at detection time. Workflow
targets are literal (`.github/workflows/` is fixed by GitHub)."""
removed_in: str
"""Bare package version that first stopped shipping the asset
(like `6.21.0`). Surfaced in the prune report."""
hashes: tuple[str, ...] = ()
r"""Content gate for skills and agents: the hex SHA-256 of every distinct
normalized content repomatic shipped for this asset (`content.rstrip() +
"\n"`, exactly as `init` writes it to disk). An on-disk file whose content
hashes to any of these is an untouched copy of some released version and is
safe to delete. Listing one hash per distinct released revision (not just
the last) means a downstream repo that synced an older version is still
recognized and pruned rather than flagged for review.
Empty for workflows, which are fingerprint-gated by their `uses:` line
instead (see the class docstring)."""
owned_dir: str = ""
"""Directory the asset had to itself, in default-location form (like
`.claude/skills/repomatic-release`), for an asset shipped as a folder.
A skill is a folder, so deleting its `SKILL.md` by any route other than
`init` (a hand `rm`, a repomatic old enough to unlink the file alone) leaves
the folder behind, empty. {attr}`target` no longer exists, so the tombstone
never fires again and the fossil outlives every later `init`. Declaring the
folder gives detection a second thing to look for. Empty for an asset that
shipped as a lone file in a shared directory (a subagent, a workflow), whose
parent must never be swept."""
successor: str = ""
"""Optional human note describing what replaced the asset, shown in the
report (like `replaced by repomatic-ship`)."""
# ---------------------------------------------------------------------------
# Helpers for computed targets.
# ---------------------------------------------------------------------------
WORKFLOW_TARGET_ROOT = ".github/workflows"
"""Directory GitHub reads workflow files from. Not configurable."""
INSTALL_GUIDE_PATH = "docs/install.md"
"""Install guide the release freeze pins download URLs in.
Shared by {class}`~repomatic.prepare_release.PrepareRelease`, which rewrites
those URLs, and {func}`~repomatic.lint_repo.check_install_guide_downloads`,
which verifies the release they name actually carries the files.
"""
def _subagent_target(agent_id: str) -> str:
"""Build the default target path for a subagent file from the Config default."""
return f"{location_path(Config.subagents_location)}/{agent_id}.md"
def _subagent_entry(agent_id: str) -> FileEntry:
"""Declare the {class}`FileEntry` of one bundled subagent definition.
Every subagent follows the same shape (`agent-{id}.md` in the bundle, one
Markdown file under the subagents directory, `{id}` as its selector), so the
registry names the id once and derives the rest.
"""
return FileEntry(f"agent-{agent_id}.md", _subagent_target(agent_id), agent_id)
SKILL_FILENAME = "SKILL.md"
"""Name the Agent Skills spec reserves for a skill's entry point."""
SKILL_SOURCE_ROOT = "skills"
"""Directory under `repomatic/data/` holding one folder per bundled skill."""
def _skill_dir(skill_id: str) -> str:
"""Build the default target directory for a skill from the Config default."""
return f"{location_path(Config.skills_location)}/{skill_id}"
def _skill_target(skill_id: str) -> str:
"""Build the default target path of a skill's `SKILL.md`.
Kept alongside {func}`_skill_dir` because a {class}`RemovedAsset` tombstone
is gated on an individual file on a downstream repo's disk. It names the
folder too, through {attr}`RemovedAsset.owned_dir`, but only to catch what
an already-deleted file leaves behind.
"""
return f"{_skill_dir(skill_id)}/{SKILL_FILENAME}"
def _skill_source(skill_id: str) -> str:
"""Build the bundled source directory for a skill."""
return f"{SKILL_SOURCE_ROOT}/{skill_id}"
def _skill_entry(
skill_id: str,
phase: str,
scope: RepoScope = RepoScope.ALL,
) -> FileEntry:
"""Declare the {class}`FileEntry` of one bundled skill.
Every skill is a whole folder copied verbatim (`tree=True`) from
`data/skills/{id}/` to `{skills_location}/{id}/`, with `{id}` as its
selector. Only the lifecycle phase and the repo scope vary, so those are the
only arguments.
"""
return FileEntry(
_skill_source(skill_id),
_skill_dir(skill_id),
skill_id,
scope=scope,
phase=phase,
tree=True,
)
def _workflow_entry(
source: str,
*,
target: str = "",
scope: RepoScope = RepoScope.ALL,
reusable: bool = True,
config_key: str = "",
) -> FileEntry:
"""Declare the {class}`FileEntry` of one workflow.
A workflow's downstream path is `.github/workflows/` plus its own filename,
so naming the bundled source is enough. *target* overrides the filename for
the one workflow whose deployed name differs from its source (the release
entry, generated from `_release-engine.yaml`).
"""
filename = target or source
return FileEntry(
source,
f"{WORKFLOW_TARGET_ROOT}/{filename}",
scope=scope,
reusable=reusable,
config_key=config_key,
)
# ---------------------------------------------------------------------------
# The registry.
# ---------------------------------------------------------------------------
COMPONENTS: tuple[Component, ...] = (
# --- Bundled file components ---
BundledComponent(
name="labels",
description="Label definitions for labelmaker (labels.toml)",
init_default=InitDefault.EXCLUDE,
ephemeral=True,
# Only the labelmaker definitions remain: the labelling rules live in
# `repomatic.labels.DEFAULT_*_RULES` and `[tool.repomatic.labels]`,
# with no file staged anywhere since `apply-labels` matches in-process.
files=(FileEntry("labels.toml"),),
),
BundledComponent(
name="publish-pypi-action",
description=(
"Composite action that publishes to PyPI via Trusted Publishing"
" (.github/actions/publish-pypi/)"
),
scope=RepoScope.PACKAGE_ONLY,
# GitHub Actions resolves `uses: ./.github/actions/publish-pypi` and
# `uses: kdeldycke/repomatic/.github/actions/[email protected]`
# directly from the repo path; the file must stay on disk even when
# byte-identical to the bundled default.
keep_unmodified=True,
files=(
FileEntry(
"action-publish-pypi.yaml",
".github/actions/publish-pypi/action.yaml",
),
),
),
BundledComponent(
name="subagents",
description="Agent subagent definitions (.claude/agents/)",
init_default=InitDefault.EXCLUDE,
# Subagents are user-facing documents the runtime auto-invokes by
# description. Keep them on disk even when unmodified so it can always
# discover them.
keep_unmodified=True,
location_field="subagents_location",
files=(
_subagent_entry("grunt-qa"),
_subagent_entry("qa-engineer"),
_subagent_entry("sphinx-docs"),
),
),
BundledComponent(
name="skills",
description="Claude Code skill definitions (.claude/skills/)",
init_default=InitDefault.EXCLUDE,
# Skills are user-facing documents, not machine configs. Keep them
# on disk even when unmodified so Claude Code can always find them.
keep_unmodified=True,
location_field="skills_location",
files=(
_skill_entry("av-false-positive", "Release"),
_skill_entry("awesome-triage", "Maintenance", RepoScope.AWESOME_ONLY),
_skill_entry("babysit-ci", "Quality"),
_skill_entry("benchmark-update", "Development"),
_skill_entry("brand-assets", "Development"),
_skill_entry("file-bug-report", "Maintenance"),
_skill_entry("github-housekeeping", "Maintenance"),
_skill_entry("repomatic-audit", "Maintenance"),
_skill_entry("repomatic-changelog", "Release"),
_skill_entry("repomatic-deps", "Development"),
_skill_entry("repomatic-init", "Setup"),
_skill_entry("repomatic-ship", "Release"),
_skill_entry("repomatic-test-matrix", "Quality"),
_skill_entry("repomatic-topics", "Development"),
_skill_entry("sphinx-docs-sync", "Maintenance"),
_skill_entry("translation-sync", "Maintenance", RepoScope.AWESOME_ONLY),
_skill_entry("upstream-audit", "Maintenance"),
),
),
# --- Workflow component ---
WorkflowComponent(
name="workflows",
description="Thin-caller workflow files",
files=(
_workflow_entry("autofix.yaml"),
_workflow_entry("autolock.yaml"),
_workflow_entry("cancel-runs.yaml"),
_workflow_entry("changelog.yaml", scope=RepoScope.PACKAGE_ONLY),
_workflow_entry("debug.yaml", scope=RepoScope.PYTHON_ONLY),
_workflow_entry("docs.yaml"),
_workflow_entry("labels.yaml"),
_workflow_entry("lint.yaml"),
_workflow_entry("metrics.yaml", config_key="metrics.sync"),
# Downstream artifact is release.yaml (the entry), generated by
# workflow_sync._generate_release_caller and pointing its `uses:` at
# the RELEASE_ENGINE_WORKFLOWS lanes. This source records the
# representative backing reusable (_release-engine.yaml); see
# WORKFLOW_SOURCES and RELEASE_ENGINE_WORKFLOWS.
_workflow_entry(
"_release-engine.yaml",
target="release.yaml",
scope=RepoScope.PACKAGE_ONLY,
),
_workflow_entry("tests.yaml", reusable=False),
_workflow_entry("unsubscribe.yaml", config_key="notification.unsubscribe"),
),
),
# --- Special components ---
TemplateComponent(
name="awesome-template",
description="Boilerplate for awesome-* repositories",
init_default=InitDefault.AUTO,
config_key="awesome-template.sync",
),
GeneratedComponent(
name="changelog",
description="Minimal changelog.md",
scope=RepoScope.PACKAGE_ONLY,
target=location_path(Config.changelog_location),
),
GeneratedComponent(
name="plugin",
description="Claude Code plugin marketplace wiring (.claude/settings.json)",
# Opt-in like its `skills` and `subagents` siblings, and for a stronger
# reason: this one asks every collaborator to install something. A bare
# `repomatic init` never touches it.
init_default=InitDefault.EXCLUDE,
# The wiring is merged into a file the repository owns, so an unchanged
# document is the steady state rather than a stale copy to clean up.
keep_unmodified=True,
location_field="settings_location",
target=location_path(Config.settings_location),
),
GeneratedComponent(
name="agent",
description="Audience-tagged sections of the agent instructions file",
# Opt-in, for the reason `plugin` above is: this one rewrites a document
# the repository already maintains, and its first run moves that
# document's own sections below the managed block. Handing the
# instructions file over to the sync is a decision a maintainer makes
# once, in a reviewable diff, rather than something a bare
# `repomatic init` does to them.
init_default=InitDefault.EXCLUDE,
# Sections are merged into a file the repository owns, so a document
# matching upstream is the steady state, not a stale copy to clean up.
keep_unmodified=True,
# Follows `[tool.repomatic.flavor] agent` through `AgentLayout`, with
# `agent.location` overriding it: the filename an agent runtime reads
# is per-runtime (`claude.md`, `AGENTS.md`), and a repository may keep
# it outside the root entirely.
location_field="agent_location",
target=location_path(Config.agent_location),
),
# --- Tool config components (merged into pyproject.toml) ---
ToolConfigComponent(
name="uv",
description="uv resolver pin and dependency cooldown policy",
init_default=InitDefault.EXPLICIT,
source_file="uv.toml",
tool_section="tool.uv",
# `[tool.uv]` is mostly project-owned (dependencies, sources,
# exclude-newer-package, build-backend); repomatic owns only the two
# policy pins in the template. Overlay updates those in place and
# leaves everything else, so a re-sync stays a `pyproject-fmt`
# fixpoint and a no-op when the pins already match.
sync_mode=SyncMode.ONGOING,
overlay=True,
),
ToolConfigComponent(
name="lychee",
description="Lychee link checker configuration",
scope=RepoScope.AWESOME_ONLY,
source_file="lychee.toml",
tool_section="tool.lychee",
sync_mode=SyncMode.ONGOING,
),
ToolConfigComponent(
name="ruff",
description="Ruff linter/formatter configuration",
init_default=InitDefault.EXPLICIT,
source_file="ruff.toml",
tool_section="tool.ruff",
),
ToolConfigComponent(
name="pytest",
description="Pytest test configuration",
init_default=InitDefault.EXPLICIT,
source_file="pytest.toml",
tool_section="tool.pytest",
),
ToolConfigComponent(
name="coverage",
description="Coverage.py measurement and reporting configuration",
init_default=InitDefault.EXPLICIT,
source_file="coverage.toml",
tool_section="tool.coverage",
),
ToolConfigComponent(
name="mypy",
description="Mypy type checking configuration",
init_default=InitDefault.EXPLICIT,
source_file="mypy.toml",
tool_section="tool.mypy",
),
ToolConfigComponent(
name="mdformat",
description="mdformat Markdown formatter configuration",
init_default=InitDefault.EXPLICIT,
source_file="mdformat.toml",
tool_section="tool.mdformat",
),
ToolConfigComponent(
name="bumpversion",
description="bump-my-version configuration",
init_default=InitDefault.EXPLICIT,
source_file="bumpversion.toml",
tool_section="tool.bumpversion",
sync_mode=SyncMode.ONGOING,
preserved_keys=("current_version",),
graft_identity_keys=("filename", "glob", "key_path", "replace"),
),
ToolConfigComponent(
name="typos",
description="Typos spell checker configuration",
init_default=InitDefault.EXPLICIT,
source_file="typos.toml",
tool_section="tool.typos",
# Re-merge on every sync so the canonical proper-noun identifiers reach
# repos that already carry a project-specific `[tool.typos]`. typos has
# `reads_pyproject=True`, so the bundled defaults only take effect once
# they physically live in `[tool.typos]`; a BOOTSTRAP insert would skip
# the existing section and leave them inactive. Local additions
# (extra excludes, identifiers, words) survive via the merge.
sync_mode=SyncMode.ONGOING,
),
)
"""The component registry.
Single source of truth for all resources managed by the `init` subcommand.
Every component declares its kind, selection default, file entries, and
behavioral flags. All derived constants are computed from this tuple.
"""
COMPONENTS_BY_NAME: dict[str, Component] = {c.name: c for c in COMPONENTS}
"""Index for O(1) component lookup by name."""
# ---------------------------------------------------------------------------
# Removed assets (tombstones).
# ---------------------------------------------------------------------------
# Successors shared by several tombstones: the 2026 reorganization retired one
# skill per CI concern in favor of the workflow that now runs on every push, and
# each rename generation (gha-* β repokit-* β repomatic-*) left a tombstone
# pointing at the same replacement.
_NOW_IN_AUTOFIX = "now handled by autofix.yaml on every push"
_NOW_IN_LINT = "now handled by lint.yaml on every push"
_NOW_IN_METADATA_CMD = "now handled by the repomatic metadata CLI command"
_NOW_IN_TESTS = "now handled by tests.yaml on every push"
_REPLACED_BY_SHIP = "replaced by repomatic-ship"
def _removed_skill(
skill_id: str,
removed_in: str,
*hashes: str,
successor: str = "",
) -> RemovedAsset:
"""Declare a tombstone for a skill repomatic no longer ships.
Skills are content-gated: the *hashes* are the normalized contents the skill
shipped across its released lifetime (see {attr}`RemovedAsset.hashes` for the
recipe that collects them). A skill owns its whole folder, so the tombstone
carries that too (see {attr}`RemovedAsset.owned_dir`).
"""
return RemovedAsset(
"skills",
_skill_target(skill_id),
removed_in,
hashes,
owned_dir=_skill_dir(skill_id),
successor=successor,
)
def _removed_workflow(
filename: str, removed_in: str, *, successor: str = ""
) -> RemovedAsset:
"""Declare a tombstone for a workflow repomatic no longer ships.
Workflows are fingerprint-gated by their `uses:` line rather than hashed, so
they carry no *hashes* (see the {class}`RemovedAsset` docstring).
"""
return RemovedAsset(
"workflows",
f"{WORKFLOW_TARGET_ROOT}/{filename}",
removed_in,
successor=successor,
)
REMOVED_ASSETS: tuple[RemovedAsset, ...] = (
RemovedAsset(
"codecov",
".github/codecov.yaml",
"7.8.0.dev0",
("e8e96bfead62334599f4ec4c0448f2376352629789a70a76ae6fc3746ff7057b",),
successor="coverage is now gated by pytest --cov-fail-under",
),
RemovedAsset(
"labels",
".github/labeller-content-based.yaml",
"7.11.0.dev0",
(
"1f3e670c0b4c6687a8920fb3738a15fb82b8639b7825d81f76c55bc5784cdb08",
"adf62c78c539229d34d4d2518a9af7f39df44d599c60852784b0faa47a6defa9",
"5cf481b4aec2bf98a4056757f41ef5fc50f808dbd7c8a43f1dea0b224ecb7f1f",
"8a047d53d5449ea0b53517e2f63e126360050127342084b7a705f34fb735d818",
),
successor="rules now live in repomatic.labels.DEFAULT_CONTENT_RULES",
),
RemovedAsset(
"labels",
".github/labeller-file-based.yaml",
"7.11.0.dev0",
(
"9dc0948e23a3a83d2cec5f11e400c75992fb1ce326eb6c5811c1fc3bfe258b31",
"b216d370e4d2c6118f46d9bb2eacaf91392e6a6267a4f4857f44a698422cc860",
"9a4feeb49c37ee7eba1d13957d26aaaa867c791ec12be8cd4197e7526bfbf963",
),
successor="rules now live in repomatic.labels.DEFAULT_FILE_RULES",
),
_removed_skill(
"gha-changelog",
"6.0.0",
"2c178a58e1106f08aa6e540cd022eff12c4e954942ec5d794282c7b640adf768",
successor="renamed to repomatic-changelog",
),
_removed_skill(
"gha-deps",
"6.0.0",
"d0bcb44f81335f4aabcadb82085f5048be12db252fc0a1f8c6bda8d9e5292efd",
successor="renamed to repomatic-deps",
),
_removed_skill(
"gha-init",
"6.0.0",
"0f4f23f424c73774dd6253d9cb547e7a1d52ed64266c93b5b7271f4bee492a25",
successor="renamed to repomatic-init",
),
_removed_skill(
"gha-lint",
"6.0.0",
"7079f4d79c6347b03b4788de97db2e1839006b606e9dbacbfeb51e9cca04db20",
successor=_NOW_IN_LINT,
),
_removed_skill(
"gha-metadata",
"6.0.0",
"74c6f7d3574236d20aa7011b92f174abd2f8fdda162131e7f61851dfee7145fa",
successor=_NOW_IN_METADATA_CMD,
),
_removed_skill(
"gha-release",
"6.0.0",
"99a466bc4d377bb056c5696de8f0eae2b025b34505ac951d504bee55a42bdd1c",
successor=_REPLACED_BY_SHIP,
),
_removed_skill(
"gha-sync",
"6.0.0",
"f856f143db3f0ad37adb6c80b89c33efa5112e1307927ff3331f82857a71fef4",
successor=_NOW_IN_AUTOFIX,
),
_removed_skill(
"gha-test",
"6.0.0",
"4a00dac78e0ca3c598c2a3ae6e649f354f73e754c5aaea531d8409f1eff23434",
successor=_NOW_IN_TESTS,
),
_removed_skill(
"repokit-changelog",
"6.0.1",
"6e176d9d0090afb9d9a10035e4c6721fff8fac4a1c313010fc04a7ab631be399",
successor="renamed to repomatic-changelog",
),
_removed_skill(
"repokit-deps",
"6.0.1",
"577687ae8481cc67b992497ee0de9fb38c0f26cd20a9b907a4bf78f834803cc0",
successor="renamed to repomatic-deps",
),
_removed_skill(
"repokit-init",
"6.0.1",
"c68a9108ead81c4bb5b33912770155f6a587188ca72c8ba8d08f7283fdcad281",
successor="renamed to repomatic-init",
),
_removed_skill(
"repokit-lint",
"6.0.1",
"1c05f0fb8c5ff8eed38ac02af2fff016e931fdf8866fd93a3fc6c61f84d4df52",
successor=_NOW_IN_LINT,
),
_removed_skill(
"repokit-metadata",
"6.0.1",
"0322f70cdd8e53d03fce2befbf904be1f0dc5596b79e41557ce8ec788a202cff",
successor=_NOW_IN_METADATA_CMD,
),
_removed_skill(
"repokit-release",
"6.0.1",
"a6ceb0394f084f481765bb834f275af0cb1cf58a9383059358ceec50ea87b93a",
successor=_REPLACED_BY_SHIP,
),
_removed_skill(
"repokit-sync",
"6.0.1",
"412811337a541b6c4518e588240ce2cb13f3f476bcd311f32edcf04394e17ade",
successor=_NOW_IN_AUTOFIX,
),
_removed_skill(
"repokit-test",
"6.0.1",
"63f0b532f379aa4400eea5a6284c3004ddc09749c8f476f4ea5a5e8ce3c4716f",
successor=_NOW_IN_TESTS,
),
_removed_skill(
"repomatic-lint",
"6.21.0",
"11131553c99adb7daf880b6b19b84e4d4573eedbe7b951092aa7d4a1f9357aab",
"d72cada008b46db93eff0b7a167f1f57346c528ec317fca73857205895fb1395",
"058b9cc3248cd1d537d8fbf7a0c1133e3107c6ed405859457e88625b9301d3d8",
"7ec6520cba0a14af07ed1bb4e2f0388109ac8db0509ca92ffa0829cf2967bd11",
successor=_NOW_IN_LINT,
),
_removed_skill(
"repomatic-metadata",
"6.3.0",
"e94ba4246c0bf56b8dfb6a7e4d3ea2e9521c000e8322130b1746e7a54d3f260b",
"58c6eec756177f445893366960464c2d5872de994a692399440df0eb30b11e35",
successor=_NOW_IN_METADATA_CMD,
),
_removed_skill(
"repomatic-release",
"6.21.0",
"0ecfa8ff5d55b33394d83bce76d39015450403124ff63e131fea14adf685c00b",
"8546a42c1ea44b2a4fa0ed1bc49f71eaf8be3b5656a323ee93957ea1fdb0bb38",
"778783f3ef6093d9892a4772fc312747155b399e18ba33f416fa9b138897b43d",
"b076cae374b3104f50996cf8b92eae6f53ec9546d3b0fab2c033c90cb1e8a107",
"8e93d723827042e90acbe22d038516400bcd743bf39f3fb45a65c115008a97d0",
successor=_REPLACED_BY_SHIP,
),
_removed_skill(
"repomatic-sync",
"6.21.0",
"3b36a8b4fc76282c280f6cc19fdc24aa826db8a81ee91a66737b24cb921c84d9",
"1460738708f7e878c17ef578a7fad14710962a5fd7e7789f3bc08ae6bc49247b",
"54a2b2aa40799c05d666295ee0a1f4d65946605c5397a006185123e4c2e9f1d0",
"771d4e15efab4739fb00a7c1ba20495e063025842beb2e54d84207e1410f40a1",
"687c7f9cae7271ee56f4d35b754325ba7a2c3b13537eee057679cc160e39471e",
"ceaf3141599850847ee51b2e4f85c76a4cae130a01b2a4fd820dd3b5c0dd0dc0",
"91add2c0b7686f64f810bb86fa70c3ac99d3940b37ba6fbe57c01a4d427cc902",
successor=_NOW_IN_AUTOFIX,
),
_removed_skill(
"repomatic-test",
"6.21.0",
"8bc5f054507b369f9be34dd4a34183e00b6a8e0186c34d4deb385032e6682e1a",
"cb987bfe342c2d00ea1a6226585238f19bc5a351a678124f7e6225d5c6122c2c",
"17bae80a4b98518b6037518ad340a60d117d35a4fa26725fa2ab685ebd23e8dd",
successor=_NOW_IN_TESTS,
),
_removed_workflow(
"label-sponsors.yaml",
"4.25.0",
successor="merged into labels.yaml",
),
_removed_workflow(
"labeller-content-based.yaml",
"4.25.0",
successor="merged into labels.yaml",
),
_removed_workflow(
"labeller-file-based.yaml",
"4.25.0",
successor="merged into labels.yaml",
),
_removed_workflow(
"renovate.yaml",
"7.0.0.dev0",
successor="replaced by self-hosted sync-tool-versions, sync-action-pins,"
" and sync-workflow-pins",
),
)
r"""Tombstones for assets repomatic has dropped (see {class}`RemovedAsset`).
`init` prunes orphaned copies of these from downstream repos. Ordered by
`(component, target)`.
When you drop a bundled asset from {data}`COMPONENTS`, add an entry here so
the removal propagates downstream on the next `init` instead of leaving an
orphan. List one hash per distinct content the asset shipped across its
released lifetime, collected from the release tags where its data file
existed:
```python
import hashlib, subprocess
src = "repomatic/data/skill-repomatic-release.md" # the dropped data file
tags = subprocess.run(
["git", "tag", "--list", "v*"], capture_output=True, text=True, check=True
).stdout.split()
hashes = {}
for tag in tags:
blob = subprocess.run(
["git", "show", f"{tag}:{src}"],
capture_output=True, text=True, encoding="UTF-8",
)
if blob.returncode == 0:
normalized = blob.stdout.rstrip() + "\n"
hashes.setdefault(hashlib.sha256(normalized.encode("UTF-8")).hexdigest(), tag)
print(tuple(hashes)) # distinct contents, in first-shipped order
```
Removed *workflows* are fingerprint-gated, not hashed: omit `hashes` and give
the workflow's downstream path as `target` (`.github/workflows/{name}`).
"""
DEFAULT_REPO: str = "kdeldycke/repomatic"
"""Default upstream repository for reusable workflows."""
UPSTREAM_PACKAGE: str = DEFAULT_REPO.rsplit("/", 1)[-1]
"""Distribution name of the upstream toolkit, derived from {data}`DEFAULT_REPO`.
The freeze, cooldown-exemption, and lint code that handles the `uses:` refs
and the inline self-pin all key on this name: deriving it here keeps the
writer/checker pairs in lockstep and makes a rename a one-line change.
"""
UPSTREAM_REPO_SLUGS: tuple[str, ...] = (
"kdeldycke/repomatic",
"kdeldycke/repokit",
"kdeldycke/workflows",
)
"""Upstream repository slugs across the project's renames, current first.
A downstream thin-caller's `uses:` line references whichever slug was current
when it was generated. Workflow-tombstone detection matches against all of
them (current first, since most callers are recent) so an orphaned thin-caller
is recognized regardless of which era set it up."""
UPSTREAM_SOURCE_GLOB: str = "repomatic/**"
"""Path glob for the upstream source directory in canonical workflows.
Canonical workflow `paths:` filters use this glob to match source code
changes. In downstream repos, this is replaced with the project's own source
directory.
"""
UPSTREAM_SOURCE_PREFIX: str = "repomatic/"
"""Path prefix for upstream-specific files in canonical workflows.
Paths starting with this prefix (but not matching
{data}`UPSTREAM_SOURCE_GLOB`) are dropped in downstream thin callers because
they reference files that only exist in the upstream repository (like
`repomatic/data/labels.toml`).
"""
SKILL_PHASE_ORDER: tuple[str, ...] = (
"Setup",
"Development",
"Quality",
"Maintenance",
"Release",
)
"""Canonical display order for lifecycle phases in `list-skills` output."""
# ---------------------------------------------------------------------------
# Registry queries.
# ---------------------------------------------------------------------------
ALL_COMPONENTS: dict[str, str] = {c.name: c.description for c in COMPONENTS}
"""All available init components."""
EPHEMERAL_TARGETS: frozenset[str] = frozenset(
entry.target
for component in COMPONENTS
if component.ephemeral
for entry in component.files
)
"""Target paths belonging to {attr}`Component.ephemeral` components.
Written only when the component is named explicitly on the CLI, and never worth
committing: whatever reads them regenerates them first. `init` uses this to keep
its closing "commit the generated files" advice off a run that produced nothing
but scratch output.
"""
BUNDLED_VERBATIM_TARGETS: frozenset[str] = frozenset(
entry.target
for component in COMPONENTS
if isinstance(component, BundledComponent)
for entry in component.files
)
"""Target paths `repomatic init` writes verbatim from a `repomatic/data/` template.
Every {class}`BundledComponent` copies its bundled source byte-for-byte to the
target, so downstream the file's content (including any SHA-pinned `uses:` ref) is
owned by `repomatic init`. `sync-action-pins` and `sync-workflow-pins` skip these
paths for the same reason they skip {data}`UPSTREAM_REPO_SLUGS`: a pin the next
`sync-repomatic` overwrites turns the two pull requests into a ping-pong, the bump
PR and the init-revert PR chasing each other. The skip lifts inside the source
repo, where each bundled source is a symlink to its in-tree target and the pin is
a normal source-of-truth ref (see `repomatic.sync_ops._pinnable_files`). Generated
workflows ({class}`WorkflowComponent`) are deliberately absent: they carry only
upstream-slug refs (already skipped) and may host downstream-authored extra jobs
whose third-party pins the bumpers should keep current.
"""
REUSABLE_WORKFLOWS: tuple[str, ...] = tuple(
f.file_id for f in COMPONENTS_BY_NAME["workflows"].files if f.reusable
)
"""Workflow filenames that support `workflow_call` triggers."""
NON_REUSABLE_WORKFLOWS: frozenset[str] = frozenset(
f.file_id for f in COMPONENTS_BY_NAME["workflows"].files if not f.reusable
)
"""Workflows without `workflow_call` that cannot be used as thin callers."""
ALL_WORKFLOW_FILES: tuple[str, ...] = tuple(
sorted(f.file_id for f in COMPONENTS_BY_NAME["workflows"].files)
)
"""All workflow filenames (reusable and non-reusable)."""
WORKFLOW_SOURCES: dict[str, str] = {
f.file_id: f.source for f in COMPONENTS_BY_NAME["workflows"].files
}
"""Maps each workflow's downstream file_id to its bundled source filename.
For most workflows source == file_id. The release entry is the exception: its
downstream artifact is `release.yaml`, whose backing reusable engine is
`_release-engine.yaml` (the lane the generic "is this a reusable workflow" tests
inspect). The full set of reusable lanes the generated `release.yaml` calls is
{data}`RELEASE_ENGINE_WORKFLOWS`.
"""
RELEASE_ENGINE_WORKFLOWS: tuple[str, ...] = (
"_release-build.yaml",
"_release-engine.yaml",
)
"""Reusable workflows the generated `release.yaml` references but that
`repomatic init` never materializes downstream.
The `workflows` component deploys a *generated* `release.yaml` (not a thin
delegation): its `build` job calls `_release-build.yaml` and its `release` job
calls `_release-engine.yaml`, each via `{repo}/.github/workflows/<lane>@<tag>`
resolved from this repo at the release tag rather than copied into the
downstream tree. These lanes live in `.github/workflows/` here (and at every
release tag) but are not `FileEntry` targets and never appear in
{data}`ALL_WORKFLOW_FILES`.
The release entry's `FileEntry` still records `_release-engine.yaml` as its
`source` (see {data}`WORKFLOW_SOURCES`) so the generic backing-reusable tests
and a downstream `repomatic lint` can read it via `get_data_content` to check
the engine lane forwards its secrets; `_release-build.yaml` is not bundled
because nothing reads it at runtime (the build lane declares no secrets). Naming
both lanes here lets stale-file detection and the data-symlink rules treat them
as a group instead of special-casing each by hand.
"""
SELF_MAINTENANCE_WORKFLOWS: frozenset[str] = frozenset(("self-maintenance.yaml",))
"""Workflows that maintain this package's own source and never ship downstream.
Unlike {data}`RELEASE_ENGINE_WORKFLOWS`, which downstream repos still reach
remotely through a `uses:` ref at a release tag, these are invisible outside this
repository: they are not `FileEntry` targets, carry no `repomatic/data/` symlink,
and nothing resolves them at runtime. That is what lets their jobs drop the
`github.repository == 'kdeldycke/repomatic'` guard every in-`autofix.yaml`
upstream-only step needs, and pick a schedule without spending downstream CI.
A workflow belongs here when its write domain is a path that exists only in this
repository (`repomatic/tool_registry.py` and friends). A workflow that merely
*behaves* differently upstream does not: it still ships, so it still needs the
runtime guard.
"""
SKILL_PHASES: dict[str, str] = {
f.file_id: f.phase for f in COMPONENTS_BY_NAME["skills"].files if f.phase
}
"""Maps skill names to lifecycle phases for display grouping."""
[docs]
def skill_catalog() -> list[tuple[str, str, str]]:
"""Read every bundled skill's display metadata off its frontmatter.
:return: One `(phase, name, description)` tuple per bundled skill, in
registry order, with the description's trailing period stripped for
table display. Phases are keyed by the registry `file_id`, not the
frontmatter name, so a skill renamed in frontmatter still lands in
its phase.
"""
skills = []
for entry in COMPONENTS_BY_NAME["skills"].files:
# Each skill is a bundled folder, so reach past it for the entry point.
content = get_data_content(f"{entry.source}/{SKILL_FILENAME}")
meta, _body = split_frontmatter(content)
name = str(meta.get("name", entry.file_id))
description = str(meta.get("description", "")).removesuffix(".")
skills.append((SKILL_PHASES[entry.file_id], name, description))
return skills
FILE_SELECTOR_COMPONENTS: tuple[str, ...] = tuple(c.name for c in COMPONENTS if c.files)
"""Components that support file-level `component/file` selectors."""
_MAX_NAME = max(len(c.name) for c in COMPONENTS)
COMPONENT_HELP_TABLE: str = "\n".join(
f" {c.name:<{_MAX_NAME + 4}s}{c.description}" for c in COMPONENTS
)
"""Formatted component table for CLI help text."""
[docs]
def valid_file_ids(component: str) -> frozenset[str]:
"""Return valid file identifiers for a component.
Components with file entries report their declared `file_id` values.
Returns an empty set for components without file-level selection
(e.g., changelog, tool configs).
"""
comp = COMPONENTS_BY_NAME.get(component)
if comp is None:
return frozenset()
return frozenset(entry.file_id for entry in comp.files)
[docs]
def excluded_rel_path(component: str, file_id: str) -> str | None:
"""Map a component and file identifier to its relative output path.
Returns `None` when the identifier cannot be resolved (e.g., for tool
config components that have no file-level exclusion support).
"""
comp = COMPONENTS_BY_NAME.get(component)
if comp is None:
return None
for entry in comp.files:
if entry.file_id == file_id:
return entry.target
return None
[docs]
def parse_component_entries(
entries: Sequence[str],
*,
context: str = "entry",
) -> tuple[set[str], dict[str, set[str]]]:
"""Parse component entries into full-component and file-level sets.
Bare names (no `/`) must be component names from
{data}`ALL_COMPONENTS`. Qualified `component/identifier` entries
target individual files. Raises `ValueError` on unknown entries.
Used by both the `exclude` config path and the CLI positional
selection, with *context* controlling error message wording.
:param context: Label for error messages (e.g., `"exclude"`,
`"selection"`).
:return: `(full_components, file_selections)` where
`file_selections` maps component names to sets of file
identifiers.
"""
full_components: set[str] = set()
file_selections: dict[str, set[str]] = {}
for entry in entries:
if "/" in entry:
component, file_id = entry.split("/", 1)
if component not in ALL_COMPONENTS:
msg = (
f"Unknown component {component!r} in {context}"
f" {entry!r}. Valid components:"
f" {', '.join(sorted(ALL_COMPONENTS))}"
)
raise ValueError(msg)
valid = valid_file_ids(component)
if not valid:
msg = (
f"Component {component!r} does not support"
f" file-level selection in {context} {entry!r}."
f" Use the bare component name {component!r}"
" instead."
)
raise ValueError(msg)
if file_id not in valid:
msg = (
f"Unknown file {file_id!r} in {context}"
f" {entry!r}. Valid identifiers for"
f" {component!r}:"
f" {', '.join(sorted(valid))}"
)
raise ValueError(msg)
file_selections.setdefault(component, set()).add(file_id)
elif entry in ALL_COMPONENTS:
full_components.add(entry)
else:
msg = (
f"Unknown {context} {entry!r}. Use a component name"
f" ({', '.join(sorted(ALL_COMPONENTS))}) or a"
" qualified component/file entry"
" (e.g., 'workflows/debug.yaml')."
)
raise ValueError(msg)
return full_components, file_selections