feat(forge): user-level [forge-hosts] map for platform by hostname - #3681
feat(forge): user-level [forge-hosts] map for platform by hostname#3681worktrunk-bot wants to merge 3 commits into
Conversation
Detection reads the forge platform only from project config or the remote host's brand. A self-hosted host carrying no forge brand (e.g. git.company.example) needs a [forge] block repeated in every repo on that host. Add a user-level (and system-level, via the shared UserConfig shape) [forge-hosts."<host>"] map keying an exact hostname to its platform. Repository::ci_platform now resolves, per remote host: project [forge] override -> [forge-hosts] exact-hostname map -> built-in inference. Hostnames normalize through the existing normalized_hostname (port, case, trailing dot) so lookups match regardless of transport syntax; an unrecognized platform string is dropped with a single warning. Closes #3678
The [`Repository::ci_platform`] link in UserConfig's forge_hosts docstring couldn't resolve — Repository isn't in scope in src/config/user/mod.rs — so rustdoc failed the fast-checks doc build. Use the explicit crate::git path, matching the ForgeKind::from_host link in sections.rs.
|
please mark as experimental |
Per maintainer request, badge the user-level forge-hosts config section experimental, matching the convention used for other maturing config surfaces (LLM summaries, custom columns, commit-message append).
|
Done in 54f8611 — badged the |
… there (#3701) ## 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. ```toml [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](https://claude.com/claude-code)
|
Nightly sweep picked this up as #3701 merged on 2026-08-02 and added # Every repository on a self-hosted forge whose hostname carries no brand
[projects."git.company.example/*"]
forge.platform = "gitlab"That's a superset of Given the project's stance on config growth, my read is that this should be closed rather than merged up — but it's your call, and the merge is cheap to redo if you want it. I aborted rather than resolving, so nothing has been pushed and the branch is untouched. If I've missed a case Conflict detailMerging Relevant |
Problem
Forge platform is read only from project config (
[forge].platform) or the remote host's brand. A self-hosted host whose name carries no forge brand — a GitLab atgit.company.example, a company git server — can't be inferred (ForgeKind::from_hostreturnsNone), so forge-aware features (CI status inwt list --full, MR discovery) require repeating the same[forge]block in every repository's.config/wt.toml. Closes #3678.Solution
Add a user-level
[forge-hosts."<host>"]map that keys an exact hostname to its platform. Because the system-config layer shares theUserConfigshape, an organization can ship the same map in system config for every user to inherit.Resolution order in
Repository::ci_platform, per remote host, matches the issue's proposal:[forge]override (authoritative, unchanged)[forge-hosts]exact-hostname mapHostnames route through the existing
normalized_hostname(port, case, trailing DNS dot stripped) so a map keyedgit.company.examplematches agit.company.example:8443remote and vice versa. The validated map is built once per repository handle and cached; an unrecognized platform string is dropped with a single deduplicated warning, mirroringconfigured_ci_platform. Per the issue, the map coversplatformonly — a host whose SSH alias differs from its API host still needs a per-repository[forge].hostname.Tests
New unit tests in
ci_platform.rs: unbranded host resolves via the map; project[forge]still overrides it; port/case/trailing-dot normalization matches; a host absent from the map falls through to inference; an invalid platform string is dropped (leaving the host unresolved rather than mis-mapped).Docs
[forge-hosts]is documented as a new user-config Forge hosts section (src/cli/mod.rsafter_long_help, the primary source), with a pointer from the project-config Forge platform section. Generated mirrors (docs/content/config.md, skill references,dev/*.example.toml) and the--helpsnapshots are regenerated by the sync/insta tests.