gwm-cli 1.6.0

git worktree manager — TUI + CLI, native libgit2, per-repo bootstrap
Documentation
---
title: GitLab (multi-forge)
description: Point gwm at GitLab instead of GitHub — the `forge` key, the `glab` CLI, and what differs from the GitHub backend.
---

# GitLab (multi-forge)

Added by [#419](https://github.com/kbrdn1/gwm-cli/issues/419).

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

| Forge | CLI | Terminology |
|---|---|---|
| GitHub | [`gh`](https://cli.github.com) | pull request, "PR" |
| GitLab | [`glab`](https://gitlab.com/gitlab-org/cli) | merge 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](/integrations/github-linking#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, 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:

```toml
[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:

```toml
# .gwm.toml
forge = "gitlab"
```

```bash
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 status | gwm outcome |
|---|---|
| `success`, `skipped` | passing |
| `failed`, `canceled`, `canceling` | failing |
| `created`, `waiting_for_resource`, `preparing`, `pending`, `running`, `scheduled`, `manual` | running |
| anything else | **unknown** |

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 — a hostile repo could then redirect authenticated calls at its own server. Doing it safely means routing it through the [trust ledger](/configuration/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 — 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 — `--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.