GitLab (multi-forge)

August 4, 2026 · View on GitHub

Added by #419.

gwm talks to a forge (a code-hosting platform) for issue and merge/pull request lookups. Two backends ship today:

ForgeCLITerminology
GitHubghpull request, "PR"
GitLabglabmerge request, "MR"

Everything else is identical. Worktrees, bootstrap, branch naming, the trust ledger, the daemon and the TUI do not know which forge you are on; only the network layer does. In particular, the link storage model is unchanged: a GitLab worktree writes the same branch.<name>.gwm-* git-config keys, so nothing about a repo becomes forge-specific on disk.

selecting the forge

gwm reads the forge off your origin remote only when the host says which one it runs, that is, on the vendors' own domains:

git@github.com:owner/repo.git              → GitHub
https://acme.ghe.com/team/proj.git         → GitHub (GitHub Enterprise Cloud)
https://gitlab.com/group/sub/proj.git      → GitLab
https://gitlab.example.com/team/proj.git   → refused: name the forge
https://code.acme.internal/team/proj.git   → refused: name the forge

A self-hosted instance lives on an arbitrary domain and cannot be detected from the remote URL alone. gwm does not guess there, and the gitlab. label is not a guess it will make either: a hostname is chosen by whoever owns the domain, so it proves nothing about what runs on it. Guessing would mean sending an authenticated gh / glab call, and whatever token is in your environment, to whatever host a cloned repo happens to name.

Two separate questions follow, and it is worth keeping them apart because they have different answers:

  1. Which backend drives this repo? The forge key.
  2. May gwm send an authenticated call to this host at all? Authorisation, and the forge key is not an answer to it.

authorising a self-hosted host

List the hosts you work with in your own ~/.config/gwm/config.toml, each with the backend that drives it:

[forge_hosts]
"gitlab.acme.com" = "gitlab"
"ghe.acme.com"    = "github"

That file never ships with a repo, which is what makes it an answer: nothing you clone can add to it. Per host rather than one blanket key, so a shop running both a self-hosted GitLab and a GitHub Enterprise is describable in one config. Host matching is case-insensitive.

The alternative, for a one-off repo you would rather not add to your global config, is to name the backend in the repo's own .gwm.toml and approve the repo:

# .gwm.toml
forge = "gitlab"
gwm trust add     # in the repo, once

That is the same TOFU ledger [[bootstrap.command]] uses, and the same decision: .gwm.toml ships with the repo, so letting it name the host on its own would hand a hostile clone an authenticated call to its own server from a plain gwm status. Approving covers the file as it is now: editing it changes its hash and revokes the approval. gwm trust list shows what you have approved, gwm trust revoke <origin> takes it back, and GWM_ALLOW_BOOTSTRAP=1 bypasses the check for CI runners with no one to answer.

::: warning A bare forge key authorises nothing forge = "gitlab", wherever you set it (repo or global), states which backend you use. It does not state which hosts may receive your token, and gwm does not read it as though it did. Otherwise the single most ordinary setup there is (one key, set once, at a GitLab shop) would authorise every host in existence, including one an attacker put in a clone's origin. That is not hypothetical: given GITLAB_HOST, glab sends the ambient GITLAB_TOKEN to whatever it names, as a Private-Token header, with no host scoping of its own. :::

on the vendors' own domains

None of this applies on github.com, ghe.com or gitlab.com: the host already states which forge it runs, so no authorisation is needed. The forge key is free there and still wins in both directions: forge = "github" forces the GitHub backend even on a gitlab.com remote. The call reaches a vendor either way, so the worst case is gwm talking to GitLab with gh.

::: tip gwm doctor probes for the forge CLI (gh / glab) only when forge is set explicitly: an explicit key is read as "I talk to this forge", which makes the warning actionable. Repos that never opt in get no new warning. :::

nested groups

GitHub slugs are always owner/repo. A GitLab project can sit any number of subgroups deep, and gwm keeps the whole path:

https://gitlab.com/group/sub/deeper/proj.git   → slug `group/sub/deeper/proj`

When the origin is an SSH remote, gwm cannot know the web endpoint, so gwm open and the TUI use the URL the forge itself reported (web_url) rather than the locally constructed guess: the CLI path fetches it, the TUI reuses an already-cached status and falls back to the guess offline.

URLs follow GitLab's /-/ infix (<origin>/<path>/-/issues/42, <origin>/<path>/-/merge_requests/61) and are rooted at the origin remote's scheme, host and web port, so http://gitlab.acme:8080/g/p.git yields http://gitlab.acme:8080/g/p/-/issues/42 rather than a rebuilt (and dead) https://gitlab.acme/…. An ssh://host:2222 port is the exception: it addresses sshd, not the web UI, so it is dropped.

CI state

GitHub returns a per-check statusCheckRollup array. GitLab hangs a single pipeline off the merge request, so the CI checks overlay shows one row, pipeline, linking to the pipeline page, with its run duration. Per-job granularity would need a second request per MR and is not implemented.

Pipeline statuses map like this:

GitLab statusgwm outcome
success, skippedpassing
failed, canceled, cancelingfailing
created, waiting_for_resource, preparing, pending, running, scheduled, manualrunning
anything elseunknown

The last row is deliberate. A status gwm does not recognise (a new one added upstream) is reported as unknown and never aggregates to a green CI. A catch-all that fell through to "success" would report a passing pipeline that is not passing, and would do it silently.

manual sits with the running states rather than with skipped for the same reason: a pipeline reports manual while it waits on a blocking manual job, so it is suspended and may bar the merge. It is not a pass.

which instance is queried

gh and glab both resolve the instance from their working directory when nothing pins it, and gwm's own cwd is not reliably the repo being queried: in workspace mode it is the workspace root while the row belongs to a child repo. Two guards, in that order:

  1. The child is spawned inside the repo. That makes the CLI read that repo's own remote, which is right for every remote type and respects your existing gh / glab configuration.
  2. $GITLAB_HOST / $GH_HOST are pinned when the origin is an http(s) URL, which states the web endpoint outright. An SSH remote does not: https://<ssh-host> is a guess, and forcing a guess over a working CLI config breaks setups that were fine.
  3. On GitLab, an SSH origin also drops --repo. Passing a slug makes glab resolve it against its default host, which would defeat guard 1 entirely; handing it nothing lets it read the repo's own remote. The REST paths follow the same rule via glab api's :fullpath placeholder.

On top of those, the inherited repository selectors are stripped from the child: $GITLAB_REPO, $REMOTE_ALIAS and $GIT_REMOTE_URL_VAR (and $GH_REPO on the GitHub side) all override which project the CLI acts on, and gwm always knows the project, either as a slug or as "the repo I am spawning you in". The host variables are deliberately left alone: gwm does not always know the host, and on an SSH origin your exported $GITLAB_HOST may be the only correct signal there is.

gh is the exception to guard 3: --repo owner/repo carries no hostname and gh api repos/<slug>/… bakes the slug into the request path, so neither can defer to the working directory. Its host is therefore pinned whenever the slug is known, including github.com and including an SSH origin.

That last point matters even on github.com: the child inherits gwm's environment, so an ambient GH_HOST=github.acme.internal (routine for enterprise users) would otherwise retarget every call at a same-named repo on another tenant.

not supported: installs under a URL prefix

GitLab can be installed under a path (https://example.com/gitlab). gwm does not handle that today: from the remote alone, https://example.com/gitlab/group/proj.git is indistinguishable from a project at gitlab/group/proj on example.com, so gwm reads the slug as gitlab/group/proj and every call misses. Declaring the instance root in .gwm.toml would fix it, but that value comes from a checked-in file and feeds the CLI's host, so a hostile repo could then redirect authenticated calls at its own server. Doing it safely means routing it through the trust ledger, which is tracked separately.

overriding the binary

$GWM_GLAB overrides the glab program, mirroring $GWM_GH on the GitHub side. Both are read once, on the thread that resolves the forge, so the TUI's background fetch never races environment mutation.

milestone due dates are date-only

GitLab stores a milestone due_date as a date, with no time. A due_on declared with a time of day other than end-of-day would be written as the bare date, read back as end-of-day, and so never compare equal, so the milestone would show as changed forever and be rewritten on every push. gwm milestones push refuses such a value with the cause named rather than looping. Declare a bare due_on = "2026-07-15".

group labels are not project labels

gwm labels list / push scopes itself to the project. GitLab's project-labels endpoint returns ancestor group labels too by default, which the diff would read as extras, and --prune would then propose deleting labels the project does not own. The query asks for project labels only, and any group label that slips through (an older self-managed instance ignoring the parameter) is filtered on is_project_label.

a caveat on issue / MR bodies

glab has no --body-file, so a rendered body rides inline in --description. gwm redacts that value from the Command Logs transcript, but it is still visible in the process argv (ps) for the duration of the call. That one is glab's CLI surface, not something gwm can close.

what is not translated yet

The forge exposes the right noun (PR / MR), and the strings built at runtime use it: the gwm status link line, gwm pr / gwm review progress output, and the TUI's "no MR linked" status message.

Still reading "PR" on GitLab:

  • the TUI's static render labels and key hints (sidebar headings, footer, help overlay),
  • the pr: field label in gwm status human output, which stays put deliberately: it is an output key scripts grep for, and --json freezes the same name.

Sweeping the static render strings is a separate change.