Skip to content

feat(forge): user-level [forge-hosts] map for platform by hostname - #3681

Open
worktrunk-bot wants to merge 3 commits into
mainfrom
feat/issue-3678
Open

feat(forge): user-level [forge-hosts] map for platform by hostname#3681
worktrunk-bot wants to merge 3 commits into
mainfrom
feat/issue-3678

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

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 at git.company.example, a company git server — can't be inferred (ForgeKind::from_host returns None), so forge-aware features (CI status in wt 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 the UserConfig shape, 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:

  1. Project [forge] override (authoritative, unchanged)
  2. User-level [forge-hosts] exact-hostname map
  3. Built-in hostname inference
  4. Unknown
# ~/.config/worktrunk/config.toml (or system config)
[forge-hosts."git.company.example"]
platform = "gitlab"

Hostnames route through the existing normalized_hostname (port, case, trailing DNS dot stripped) so a map keyed git.company.example matches a git.company.example:8443 remote 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, mirroring configured_ci_platform. Per the issue, the map covers platform only — 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.rs after_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 --help snapshots are regenerated by the sync/insta tests.

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.
@max-sixty

Copy link
Copy Markdown
Owner

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).
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Done in 54f8611 — badged the ## Forge hosts config section [experimental], so it renders the experimental badge in the config docs, config --help, and wt config create output, same as the other maturing config surfaces. Regenerated the doc mirrors and help snapshots.

max-sixty added a commit that referenced this pull request Aug 2, 2026
… 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)
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Nightly sweep picked this up as CONFLICTING and started a merge of main, then stopped: I think this PR is superseded and merging it would ship a second config surface for a job main already does.

#3701 merged on 2026-08-02 and added forge to the pattern-keyed [projects."…"] user-config entries. It closes #3678 with the same resolution order this PR implements — repository [forge] → user-level entry → built-in inference → unknown — and the docs it landed name the motivating case directly:

# Every repository on a self-hosted forge whose hostname carries no brand
[projects."git.company.example/*"]
forge.platform = "gitlab"

That's a superset of [forge-hosts]."git.company.example": it takes a glob rather than an exact hostname (so one entry covers nested groups, and a narrower entry can carve out a namespace), it carries forge.hostname as well as forge.platform, and it reuses the [projects] specificity rule instead of introducing a table with its own matching semantics. I couldn't find anything [forge-hosts] resolves that a [projects] pattern doesn't — the exact-hostname case is [projects."git.company.example/*"]. The overlap shows up structurally in the conflict itself: main and this branch have parallel tests for the same scenarios (unbranded host resolves, project [forge] overrides, unmatched host falls through to inference, invalid platform string leaves the host unresolved), differing only in which config surface they exercise.

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 [forge-hosts] covers that a [projects] pattern can't, say so and I'll finish the merge instead.

Conflict detail

Merging origin/main (286004f) into feat/issue-3678 conflicts in 8 files: src/git/ci_platform.rs, src/cli/mod.rs, dev/wt.example.toml, the three generated config.md mirrors, and two help_config_* snapshots. Every one of them is where #3701 landed its version of this feature — ci_platform.rs alone has 9 conflict hunks, including the doc comment on Repository::ci_platform's priority order, the resolution helper, and five of the tests.

Relevant main sources: src/git/ci_platform.rs, and the Forge platform and hostname section of docs/content/config.md.

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