feat(config): match [projects."…"] keys by pattern, and carry forge there - #3701
Merged
Conversation
… there
A `[projects]` key containing `*` matches any run of characters, `/`
included, so one entry covers every repository on a host — nested groups
and all. Every matching entry applies, least- to most-specific, so a
narrower key wins where two collide and leaves the rest alone. A literal
key is the most specific of all, and specificity is the count of non-`*`
characters.
`[projects."…"]` also gains a `forge` block, the same shape as the
repository's own. Forge platform was readable only from project config,
so a self-hosted host carrying no `github`/`gitlab`/`gitea` in its name
needed the same `[forge]` block in every repo's `.config/wt.toml`. One
pattern entry now names it for the whole host:
[projects."git.company.example/*".forge]
platform = "gitlab"
Both fields describe the host rather than the repository, which is why
an SSH alias resolved through `~/.ssh/config` — a name local to one
machine — belongs here rather than in a repository's committed config.
Resolution runs through one accessor, `configured_forge_platform`, so
`wt list`, its statusline, `wt switch pr:`, and CI-platform detection
answer the same way. They previously read project config separately,
which meant a configured platform could resolve in one command and read
`unknown` in the next.
`approved-commands` matches by the same rules, so a pattern entry
approves its commands for every repository it covers. Writes and
removals stay exact: `wt config approvals add` records under the exact
identifier, and `clear` empties only that entry, so approving or
clearing in one repository can't reach a pattern others share.
Closes #3678.
worktrunk-bot
left a comment
Collaborator
There was a problem hiding this comment.
Reviewing as a draft — flagging anything that looks worth a quick fix. Mark ready for a full review.
One correctness gap in the specificity ordering: the "literal key is most specific" invariant isn't enforced when a pattern equals a literal key plus a star that matches the empty string. Inline suggestion with a one-line fix. Everything else — the resolver consolidation into configured_forge_platform/forge_hostname, the exact-only write/clear paths for approvals, and the layering tests — reads well.
…eview Findings from an adversarial review of the pattern-key feature, worst first: an approval written for a repository whose identifier itself contains `*` — a starred remote URL, or a no-remote path fallback — was persisted verbatim, and reads then treated the entry as a pattern, approving its commands for every repository the star matched. `Approvals::approve_commands` now refuses such an identifier, so a pattern entry can only ever be hand-written; the interactive flow already degrades to a warning plus a per-run approval. `approve_command` delegates to it, leaving one dedup predicate. A literal key now outranks a pattern that ties it on literal count: `github.com/owner/repo*` matches `github.com/owner/repo` with its star empty and sorts after it lexicographically, so it would have won the fold — "a literal key is the most specific of all" now holds outright. `matches()` is a two-pointer byte glob instead of a per-call regex compile: accessors reach it per pattern key per invocation — a few hundred times per `wt list` — and compilation measured ~0.7 ms per key. A differential test pins it to an exponential reference matcher over every small pattern/identifier pair. Docs now say what the code does: hooks and aliases from every matching entry append rather than "most specific wins"; a repository's `[forge]` beats a user entry field by field; a host-wide key should end in `/*` (a bare `host*` also covers hosts whose names merely start that way). The invalid-platform diagnostics name their two possible homes, and a root `[forge]` in user config now points at `[projects."<id>"].forge`.
`wt config approvals list` reads approvals pattern-aware, while `clear` deliberately touches only the exact entry — so with a hand-written pattern entry, `clear` printed "No approvals to clear for this project" while `list` kept showing the command as APPROVED, with nothing pointing at the entry supplying it. Both `clear` outcomes now end with a hint naming the pattern entries still approving commands for the project, and where to edit them.
The section's own example already writes nested settings as dotted keys under a single entry header (list.full = true, merge.squash = false); the forge examples used a [projects."…".forge] subtable header instead. One header per entry, forge.platform beneath it, reads as what it is: one more setting in the same table.
max-sixty
marked this pull request as ready for review
August 2, 2026 05:57
worktrunk-bot
approved these changes
Aug 2, 2026
This was referenced Aug 2, 2026
max-sixty
pushed a commit
that referenced
this pull request
Aug 5, 2026
… span (#3731) `wt config create --project` writes a comment into the user's `.config/wt.toml` — and `wt config create --help` prints the same text — carrying a raw, unresolvable Zola link: ``` # When many repositories share one self-hosted host, name it once in user config with a [pattern-keyed `[projects]` entry](@/config.md#user-project-specific-settings) instead of repeating this block in each repo. ``` Every other cross-reference in that file is a plain URL (`… see \`wt hook\` (https://worktrunk.dev/hook/) …`), because `transform_config_source_to_toml` converts the `after_long_help` markdown to plain text on the way into `dev/wt.example.toml`. This one link isn't converted: `convert_markdown_links_for_config` matched link text with `[^\]]+`, which stops at the first `]` — here the one closing the nested `` `[projects]` `` span — so the regex failed to match and the markdown survived verbatim. The line arrived with #3701; it's the only link in either generated example file with a bracketed span in its text. ## The fix **One rule for `]` in link text.** `ZOLA_LINK_PATTERN`, earlier in the same file, already solves this problem for the skill mirrors — it alternates a backticked code span with any non-`]`-non-backtick char, which is why `skills/worktrunk/reference/config.md` renders this very sentence with a resolved URL while the TOML example didn't. `convert_markdown_links_for_config` now uses that same class rather than a second, weaker one. Brackets in these link texts always sit inside a code span, so the class fits the shape exactly, and it covers `[[…]]` array-of-tables names as well — these sections already document `[[projects."…".post-start]]` pipelines, so a link naming one is the next form to arrive. Regenerating produces the intended form: ``` # When many repositories share one self-hosted host, name it once in user config with a pattern-keyed `[projects]` entry (https://worktrunk.dev/config/#user-project-specific-settings) instead of repeating this block in each repo. ``` **A shape the regex declines now fails loudly.** Widening the class fixes the shapes we know about; it can't fix the next one. `finalize_skill_content` already handled that risk with a guardrail — after the rewrite it scans for a stray `](@/…md` and panics with the offending line, precisely because "the regex declined on an unexpected character in the link text" is the expected failure mode. `transform_config_source_to_toml` had no equivalent, which is why this one reached `dev/wt.example.toml` and the `--help` output. That check is now extracted into `assert_no_untransformed_zola_links` and called from both surfaces, so the next unsupported shape is a test failure naming the line rather than a raw `@/config.md` target in a user's config file. ## Why nothing caught it `test_project_config_source_generates_example_toml` compares `dev/wt.example.toml` against the output of this same transform, so an unconverted link is "in sync" by construction — the sync test can't see the difference between a link that converted and one the regex declined to match. Two tests close that gap: - `test_config_markdown_links_convert_to_plain_text` asserts the transform's output directly. It fails on `main`'s regex with exactly the reported symptom, and pins the forms already working (Zola page, Zola page + anchor, absolute URL, two links on one line, the `[[…]]` array-of-tables name) plus the case that must *not* convert — a bare `` `[forge]` `` span is not a link and has to survive verbatim. - `test_untransformed_zola_link_fails_the_config_transform` covers the backstop itself: an unbalanced backtick in link text makes the rewrite decline, and the assertion turns that into a panic naming the line. ## Files - `tests/integration_tests/readme_sync.rs` — the shared link-text class, the guardrail extraction and its second call site, and both tests. - `dev/wt.example.toml` — regenerated by the sync test (one line). - `tests/snapshots/…help_config_create.snap` — the same line, as `wt config create --help` renders it. Ran locally on the final state: `readme_sync::` (15), `test_help` (47), `cargo clippy --tests --all-features`, and `cargo fmt --check`. All green; the generated files are byte-identical under the new class, so the sync tests pass without regenerating. The full gate runs in CI. --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Forge platform is readable only from project config (
[forge].platform) or a brand substring in the remote hostname. A self-hosted host carrying none ofgithub/gitlab/gitea— a GitLab atgit.company.example, a company git server — needs the same[forge]block in every repository's.config/wt.toml. Closes #3678.The user-level
[projects."…"]table is where per-repository settings already live without touching each repo, but its keys are exact, so covering a host means one entry per repository.Solution
Pattern keys. A
[projects]key containing*matches any run of characters,/included, so one entry covers every repository on a host, nested groups and all.*is the only metacharacter.Every matching entry applies, least- to most-specific, so a narrower key wins where two set the same field and leaves the rest alone. A literal key is the most specific of all; specificity is the count of non-
*characters. Rules and rationale: theproject_matchmodule docstring.forgeon[projects]. Same shape as the repository's own block, carryingplatformandhostname. Both describe the host rather than the repository — which is why an SSH alias resolved through~/.ssh/config, a name local to one machine, belongs in user config rather than a repository's committed one. A repository's own[forge]still wins field by field, being the more specific of the two: a repository that sets onlyplatformstill takes a matching entry'shostname.One resolver.
wt list, its statusline,wt switch pr:, and CI-platform detection each read project config separately, so a configured platform could resolve in one command and readunknownin the next. They now shareRepository::configured_forge_platform(andforge_hostnamefor the API host).Approvals
approved-commandsmatches by the same rules, so a pattern entry approves its commands for every repository it covers. That widening is the user's to opt into — only a hand-written key is ever a pattern:wt config approvals addand the interactive prompt record under the exact project identifier, so approving in one repository never reaches another. An identifier that itself contains*(a starred remote URL or no-remote path fallback) is refused outright — persisting it verbatim would create an entry reads treat as a pattern; the interactive flow degrades to a warning plus a per-run approval.wt config approvals clearempties only the exact entry, leaving a pattern other repositories share intact — and both its outcomes end with a hint naming any pattern entries still approving commands for the project, so a surviving approval is traceable to the hand-written entry supplying it.--stalejudges only the exact entry, so one repository's config can't revoke approvals the others rely on.Tests
project_matchunit tests cover*spanning/,.staying literal, specificity ordering, and the lexicographic tie-break. Config tests cover a host-wide entry applying to nested groups, exact-over-pattern precedence, field-by-field layering, hooks appending across both entries, and forge platform/hostname. Forge resolution tests cover the unbranded host, nested groups, a narrower entry winning, project config overriding, falling through to inference, and an invalid value leaving the host unresolved. Approvals tests cover pattern lookup plus the two exactness guarantees above.Docs
src/cli/mod.rs(the primary source) gains "Matching several repositories with one entry" and "Forge platform and hostname" under user project-specific settings, plus a pointer from the project-config forge section. Generated mirrors and--helpsnapshots regenerated.Review hardening
An adversarial review pass surfaced eight findings, all fixed:
github.com/owner/repo*) ties the exact key on literal count and sorted after it, so its values won the fold. Literal keys now outrank any pattern outright.matches()is a two-pointer byte glob (was a per-call regex compile, ~0.7 ms per pattern key, a few hundred calls perwt list), pinned by an exhaustive differential test against a reference matcher; the invalid-platform diagnostics name their two possible config homes; a root[forge]in user config now points at[projects."<id>"].forge; docs note a host-wide key should end in/*;approve_commanddelegates toapprove_commands, unifying their dedup predicates.Relationship to #3681
This is an alternative to #3681, which adds a bespoke
[forge-hosts]section for the same issue. Both can't land — they'd be two ways to write one sentence. This one puts the setting in the table that already carries per-repository user config, and the pattern keys are reusable for the workspace-scoped ask in #3654 where repositories share a host or namespace.🤖 Generated with Claude Code