Skip to content

feat(config): match [projects."…"] keys by pattern, and carry forge there - #3701

Merged
max-sixty merged 4 commits into
mainfrom
review-forge-hosts-config
Aug 2, 2026
Merged

feat(config): match [projects."…"] keys by pattern, and carry forge there#3701
max-sixty merged 4 commits into
mainfrom
review-forge-hosts-config

Conversation

@max-sixty

@max-sixty max-sixty commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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 of github/gitlab/gitea — a GitLab at git.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.

[projects."git.company.example/*"]
forge.platform = "gitlab"

[projects."git.company.example/platform/*"]
worktree-path = ".worktrees/{{ branch | sanitize }}"

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: the project_match module docstring.

forge on [projects]. Same shape as the repository's own block, carrying platform and hostname. 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 only platform still takes a matching entry's hostname.

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 read unknown in the next. They now share Repository::configured_forge_platform (and forge_hostname for the API host).

Approvals

approved-commands matches 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 add and 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 clear empties 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.
  • --stale judges only the exact entry, so one repository's config can't revoke approvals the others rely on.

Tests

project_match unit 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 --help snapshots regenerated.

Review hardening

An adversarial review pass surfaced eight findings, all fixed:

  • Approval widening (moderate): the starred-identifier refusal above. Previously such an approval persisted verbatim and silently approved its commands for every repository the star matched.
  • Literal-key tie (moderate): a pattern whose stars all match empty (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.
  • Docs vs behavior (moderate): the layering paragraph claimed "most specific wins" for everything; hooks and aliases actually append across matching entries (all run, least-specific first). Docs now say so, and state the forge field-by-field precedence.
  • Minor: matches() is a two-pointer byte glob (was a per-call regex compile, ~0.7 ms per pattern key, a few hundred calls per wt 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_command delegates to approve_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

… 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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/config/user/project_match.rs Outdated
…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
max-sixty marked this pull request as ready for review August 2, 2026 05:57
@max-sixty
max-sixty merged commit 02a12c7 into main Aug 2, 2026
38 checks passed
@max-sixty
max-sixty deleted the review-forge-hosts-config branch August 2, 2026 06:06
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Support user-level forge platform mappings by hostname

2 participants