` deep link (F5/T5) are
+ not built. Nothing is unreachable as a result.
+- **Sessions already bound to a worktree keep their recorded paths** across a
+ re-point. For the case D4′ exists for, worktrees live under the worktree root and
+ are unaffected; a session whose worktree was the repo directory itself still
+ points at the old location. Stated in the code, not only here.
+- **Interaction on the real app is still verified by test, not by clicking.**
+ `cua-driver`'s synthesized clicks do not land in this Flutter app
+ (`"effect":"unverifiable"` on three builds), so the macOS integration test is the
+ substitute — it is a real build, driven by the Flutter harness rather than by the
+ window server.
diff --git a/mockups/forge-detection-display.html b/mockups/forge-detection-display.html
new file mode 100644
index 00000000..07683d28
--- /dev/null
+++ b/mockups/forge-detection-display.html
@@ -0,0 +1,263 @@
+
+
+
+
+
+makit — Forge detection display
+
+
+
+
+
+
Forge detection in makit
+
+
+
What we're shipping
+
+ No UI needed yet. The app automatically detects the forge provider by probing the git remote host. Most users have one provider (GitHub, Forgejo, or Gitea). Multi-provider repos get GitHub's full feature set; other providers get the essential PR features (state, branch, merge).
+
+
+
+
Detection logic
+
+
+
+
+
🔍 GitHub repo (most common)
+
Remote: github.com/user/repo
+
+
+ ✓ Full feature set: PR checks, rate-limit tracking, merge state, thread resolution.
+ ✓ Budget panel shows quota health (core, graphql buckets).
+
+
+
+
+
+
🔍 Forgejo repo (your instance)
+
Remote: forgejo.internal.xdent.ai/le/repo
+
+
⚙
+
+
Forgejo
+
forgejo.internal.xdent.ai
+
+
+
+ ✓ Essential PR features: state, branch, merge.
+ ✗ No quota (no rate-limit endpoint). Poll at fast cadence (5s).
+ ✗ Merge state: no "behind" detection (use commit comparison).
+ ✗ Unresolved threads: requires N+1 queries.
+
+
+
+
+
+
🔍 Gitea repo (public)
+
Remote: gitea.com/user/repo
+
+
+ ✓ Same surface as Forgejo (both share the Gitea API).
+ ✗ No quota. Needs GITEA_TOKEN env var if the repo is private.
+
+
+
+
+
+
❓ Unknown or unsupported forge
+
Remote: git.company.com/team/repo (probes return 404/403/timeout)
+
+
?
+
+
Unknown
+
git.company.com
+
+
+
+ ⚠ No PR support. The PR widget is hidden. Multi-repo features work (sessions, chat), just not PR automation.
+
+
+
+
+
How it works: detection probes
+
+
+
+
1. probe /api/forgejo/v1/version
+
+ 200 OK → Forgejo
+ 404 Not Found → try next
+
+
+
+
+
2. probe /api/v1/version
+
+ 200 OK → Gitea (or Forgejo on older versions)
+ 404 Not Found → try next
+
+
+
+
+
3. probe /api/v4/version
+
+ 401 Unauthorized → GitLab
+ 404 Not Found → Unknown
+
+
+
+
+
+ Probes run in SEQUENCE, each with its own timeout. classify tries them in order and returns on the first decisive answer, so an instance that identifies itself on the first probe costs one request. If none is decisive the forge is Unknown and PR features are hidden — and because unknown is cached only briefly, a host that was merely unreachable is re-probed rather than written off. (This page described parallel probes with a single 10s budget; the shipped detector in server/src/forge/detect.ts does not.)
+
+
+
UI affordances (future)
+
+
+ Superseded: there is a settings UI now. This page recorded the design before per-repo settings landed. Each repository has a Git provider row (Auto | None | Forgejo | Gitea | GitHub) in its own Settings section, and a non-Auto choice picks the provider without probing — which is the recourse for exactly the case named here, a repo whose host does not advertise what it runs. MAKIT_FORGEJO_BASE_URL still supplies the instance URL and scopes the token to it. See docs/specs/2026-08-10-SPEC-48-per-repo-settings.md.
+
+
+
Performance
+
+
+
+
Local instance
+
~50–80ms
+
+
+
Cloud instance
+
~600–800ms
+
+
+
Caching
+
Per host; unknown re-probed
+
+
+
+
+ Why cache? A decisive answer does not change mid-session, and it is keyed by the instance's normalised base URL, so every repo on one host shares a single probe. An unknown answer is cached for only 60s and then re-probed, so a briefly unreachable instance is not written off until restart — and re-pointing a project discards its routing decision outright.
+
+
+
+
+
+
diff --git a/mockups/forge-provider-per-repo.html b/mockups/forge-provider-per-repo.html
new file mode 100644
index 00000000..f572bd1f
--- /dev/null
+++ b/mockups/forge-provider-per-repo.html
@@ -0,0 +1,511 @@
+
+
+
+
+
+makit — Which forge is this repo on?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+makit — Which forge is this repo on?
+
+ The server can now identify a forge instead of guessing: it probes
+ /api/forgejo/v1/version, /api/v1/version and /api/v4/version,
+ once per host, cached. Verified live against Forgejo (self-hosted + Codeberg), Gitea, GitLab and an
+ unrelated host. Nothing in the UI surfaces any of it. These are the three places it
+ should, and one shape the config should not take.
+
+
+ Status: this page is a PROPOSAL, and part of it has since shipped. Read it as the
+ argument that led to the design, not as a description of current behaviour. What is built now: each
+ repository has its own Settings section with a Git provider row
+ (Auto | None | Forgejo | Gitea | GitHub) that drives routing, plus worktree root,
+ default branch and logo. Tokens remain per instance , exactly as argued below. The current
+ behaviour is documented in docs/specs/2026-08-10-SPEC-48-per-repo-settings.md and
+ docs/DEVELOPMENT.md §1b.
+
+
+ The premise needs one correction. The ask was “set up a git provider per repo ”.
+ But an API token authenticates an instance , not a repository — the server already enforces that
+ (forgejoRefFromRemote withholds a token from any host other than the configured one, so an
+ internal token cannot leak to codeberg.org). So credentials belong to the instance, and the
+ only genuinely per-repo thing is an override for when detection is wrong. Designing this as
+ per-repo credentials would mean asking for the same token once per repository.
+
+
+
+
+
+
+
+
+
iOS — repo card
+
+
+
9:41
+
+
+
+
+ Forgejo
+ main
+ +248 −31
+ 2 PRs
+
+
+
+ GitHub
+ main
+ 1 PR
+
+
+
+ GitLab — unsupported
+ master
+
+
+
+
+
+
+
+
macOS — sidebar + repo pane
+
+
+
+
+
+
+
+
Diana
+ forgejo.internal.xdent.ai signed in
+
+ Forgejo 16.0.0
+ main
+ 2 PRs
+
+
+
feat/attachments — #142
Open on Forgejo
+
+
fix/lfs-message — #139
Open on Forgejo
+
+
+
+
+
+
+
+
why
+
+ Zero-setup is the default. Detection is right for every forge tested, so the
+ common path must not involve a form. The chip reports ; it does not ask.
+ It joins an existing family. repo_chips.dart already has
+ BranchChip, DiffChip, PrStatusChip, TagChip at
+ kPillIconSize = 11. A ForgeChip costs a row of that file, not a screen.
+ The unsupported case finally speaks. Today a GitLab remote polls a Forgejo API
+ that is not there and reports unknown — pixel-identical to “instance down”. The chip
+ says GitLab — unsupported , and the server logs the host once.
+ The glyph must come from the server. forge_glyph.dart currently
+ re-derives the forge from the PR URL in Dart. That is a third copy of a guess the server now
+ answers properly; it should read the DTO and delete its own rule.
+
+
shown only when it earns the space
+
GitHub is the overwhelming majority for most users, so a “GitHub” chip on every card is noise.
+ Render the chip when the forge is not GitHub, or when it is unsupported. A one-forge user
+ sees no new chrome at all.
+
+
+
+
+
+
+
+
+
+
+
+
macOS — Settings ▸ Forges (new section)
+
+
+
+
+
+
+
+
Forges
+
+
detected instances
+
+
+
forgejo.internal.xdent.ai
Forgejo 16.0.0 · 3 repos
+
token set
+
+
gitea.example.org
Gitea 1.27 · 1 repo
+
no token
+
+
git.legacy.example
GitLab — no provider yet
+
read-only
+
+
Tokens are stored per instance and never sent to another host.
+
+
+
+
+
+
+
iOS — instance detail
+
+
+
9:41
+
+
+
+
forgejo.internal.xdent.ai
+
+
+
+
access token
+
••••••••••••••••••••••••••••••••••••c0e1
+
Settings ▸ Applications on your instance. Needs
+ read:repository; PR actions also need write:repository.
+
+
+
+
+
+
+
+
+
why the instance, not the repo
+
+ It is what the token actually is. The server withholds a token from any host
+ other than the one it was configured for — that is a deliberate security property, added after a
+ bug that would have sent an internal token to codeberg.org. Per-repo credentials
+ would fight it.
+ It de-duplicates. Three repos on one Forgejo means one token, entered once.
+ Detection fills the list. Rows appear because a repo was added, not because
+ someone registered a server. The only editable field is the token.
+
+
what this replaces
+
Today the only way to authenticate is exporting FORGEJO_ACCESS_TOKEN before the
+ daemon starts — invisible to the app and impossible from a phone. Env vars should keep working and
+ show as from environment , read-only.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
iOS — the override sheet
+
+
+
9:41
+
+
+
Diana · forgejo.internal.xdent.ai
+
+
Detected automatically
Forgejo 16.0.0
+
+
or choose manually
+
+
+
+
+
+
Only change this if detection is wrong — a proxy hiding
+ /api/forgejo, for example.
+
+
+
+
+
+
+
why a disclosure, not a required step
+
+ “Detected automatically” is pre-selected and named. The user sees what was
+ found, so the override is a correction rather than a question.
+ The unsupported option is visible but disabled. Listing GitLab greyed out
+ answers “can I use GitLab?” without a support round trip. Hiding it invites the question.
+ It reuses the existing menu. repo_card.dart already has
+ themedMenuItem entries for New session / Resume session / Remove from makit; this is
+ a fourth entry before the divider, plus one sheet.
+ An override must survive a restart , so it is server-side state keyed by repo
+ path — which is the part that needs a protocol addition, not a widget.
+
+
+
+
+
+
+
+
+
+
+
+ server value provider used chip PR button mutations
+
+ github gh gateway none (majority case) Open #42 on GitHub all
+ forgejo forgejo REST ForgejoOpen #42 on Forgejo all; “ready” rewrites the title
+ gitea forgejo REST GiteaOpen #42 on Gitea all; same API
+ gitlab unsupported GitLab — unsupportedOpen #42 (no forge named) refused, with the reason
+ unknown unsupported Unrecognised forgeOpen #42 refused
+ unknown retried in 60s Checking…Open #42 refused meanwhile
+ no readable remote gh gateway none hidden (no PR) n/a
+
+
+
+ Two unknown rows on purpose: a failed probe is cached for 60s, not forever, so an instance
+ that was briefly down is not pinned as unsupported until the daemon restarts. The UI should distinguish
+ “we could not tell yet” from “we asked and it is not supported”.
+
+
+
+
+
+
+
5 · Shapes rejected REJECTED
+
+
+ shape why not
+
+ A required “choose your provider” step when adding a repo
+ Asks the user to answer what the server already knows, and gets it wrong more often than
+ detection does. Setup friction on the majority path to serve a rare one.
+ Per-repo token fields
+ A token authenticates an instance. Three repos on one Forgejo would mean pasting the same
+ secret three times and three places to rotate it — and it contradicts the host-scoping the
+ server enforces.
+ A free-text “API base URL” per repo
+ Derivable from the remote in every case tested. Keep it as an instance-level override for
+ sub-path installs (today's FORGEJO_BASE_URL), not a per-repo field.
+ Silently treating unknown forges as Forgejo AS BUILT (before)
+ What shipped until detection landed: a GitLab remote polled a nonexistent API and read as
+ “unknown”, indistinguishable from an outage.
+ A “GitHub” chip on every repo card
+ Chrome for the majority case to serve the minority. Show the chip only when the forge is not
+ GitHub, or is unsupported.
+
+
+
+
+
+
+
+
+
+
+ # change file size
+
+ 1 Carry the detected forge on the repo DTO: forge?: {software, host, authed, source}. Optional, so an older app renders no chip rather than a fabricated one. server/src/protocol.ts ~14
+ 2 Expose softwareFor(repoPath) from the router and populate the DTO in the repo snapshot. server/src/forge/router.ts server/src/repo_service.ts ~25
+ 3 ForgeChip beside the existing chips; render only for non-GitHub or unsupported.app/lib/ui/home/repo_chips.dart ~45
+ 4 Delete the Dart-side guess: forgeKindForUrl reads the DTO instead of re-deriving the forge from the PR URL. Removes the third copy of one rule. app/lib/ui/widgets/forge_glyph.dart −25
+ 5 Instance list + token field, with env-provided values shown read-only. app/lib/desktop/settings/sections/ forges_section.dart (new) ~190
+ 6 Persist and apply a per-repo override; a forge.setOverride command plus storage keyed by repo path. server/src/ws/commands/forge.ts (new) server/src/forge/router.ts ~120
+ 7 “Forge…” entry in the repo overflow menu + the override sheet. app/lib/ui/home/repo_card.dart app/lib/ui/home/forge_sheet.dart (new) ~150
+
+
+
not changed, and why
+
+ No GitLab provider. That is a third implementation (merge_requests,
+ different auth), not a config entry. Rows 1–7 make makit honest about GitLab; they do not
+ support it.
+ The budget footer stays GitHub-only. Forgejo has no rate limiting to display —
+ no /rate_limit, no headers, no config knob.
+ Detection stays server-side. The app is told; it never probes. One answer, one
+ place, no drift.
+
+
+
+
+
+
diff --git a/mockups/repo-settings.html b/mockups/repo-settings.html
new file mode 100644
index 00000000..416c41e6
--- /dev/null
+++ b/mockups/repo-settings.html
@@ -0,0 +1,495 @@
+
+
+
+
+
+makit — Per-repo settings (matched to the built app)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+makit — Per-repo settings
+
+ A repository needs more than a forge: a logo, a default branch, a worktree root that may or may not follow
+ the global one, and lifecycle scripts. That is a settings surface , not a field — so the question is
+ the pattern it grows into, not the first row.
+
+
+ Card 1 tracks the built section , screenshotted from the real macOS app. All four
+ identity rows are editable, the provider is a segmented control in the Endpoint idiom, and
+ the provenance badges are gone : from name beside a monogram,
+ from remote beside main, and detected beside a subtitle already
+ reading “Auto: Forgejo · …” are each the same sentence twice — and once every row became editable, where
+ a value came from stopped being actionable. The copy button went with them: copying a path is not a
+ configuration task. What survives is inherited / overridden on Worktree root,
+ the one row with no subtitle, where the distinction is the whole point.
+
+ The provider selector also offers None : makit talks to no forge and stops checking
+ pull requests. That is an instruction , distinct from Auto failing to identify one — and it
+ gives the two states that previously read identically their own words: “Auto: no remote, so no forge”
+ is a conclusion, “Auto: not identified yet” is a probe still pending.
+
+ Provider auto-detection is already built and verified (server/src/forge/detect.ts).
+ It probes /api/forgejo/v1/version, /api/v1/version and /api/v4/version,
+ once per host, cached — correctly identifying Forgejo (self-hosted + Codeberg), Gitea, GitLab and an unrelated
+ host against live servers. So the forge row below is a read-out with an override , never a required
+ choice. Nothing else on this page is built.
+
+
+ Lifecycle scripts are the one genuinely dangerous item here , and they change what makit is.
+ They run on the host as the daemon user, which holds your gh token, your
+ FORGEJO_ACCESS_TOKEN and your SSH keys. Two decisions have to be made before any of this
+ is built — see card 4. Both are easy to get wrong in a way that is not recoverable by a later patch.
+
+
+
+
+
+
+
+
+
macOS — one sidebar section per repository
+
+
+
+
+
Settings
+
Search settings
+
General
+
Appearance
+
Agents & Chat
+
Server & Devices
+
Notifications
+
Shortcuts
+
Advanced
+
About
+
repositories
+
D Diana
+
m makit
+
p piano
+
+
+
Diana
+
identity
+
+
+
+
Root path
+
~/Work/XDent/Diana
+
+
+
Git provider
Auto: Forgejo · forgejo.internal.xdent.ai · token set
+
+
+
Auto None Forgejo Gitea GitHub
+
+
+
Default branch
+
main
+
+
+
+
worktrees
+
+
Worktree root
+
~/.worktrees inherited
+
+
Worktree root (overridden)
+
~/.worktrees/makit overridden
+
+
+
+
+
+
+
+
+
+
iOS — same page, one column
+
+
+
9:41
+
+
+
identity
+
+
+
Git provider
Auto: Forgejo
+
+
+
worktrees
+
+
Worktree root
~/.worktrees inherited
+
+
Worktree root is editable on the machine running makit; a paired phone shows
+ the same values read-only (D16). Lifecycle scripts are P3 and deliberately not advertised here.
+
+
+
+
+
+
+
why a section per repo, not one “Repositories” page
+
+ It is bounded by what you added. Sections come from projects — three on this
+ install (makit, Diana, piano). Repos makit merely noticed are
+ pinned:false (manager.ts:287 vs :215) and stay out of the
+ sidebar, so it cannot turn into a file browser.
+ Grouped, so the taxonomy stays honest. Fixed app sections are a closed set;
+ repos are data. Interleaving them in one flat list is a smell — under a
+ REPOSITORIES header it is just the Mail/Finder sidebar idiom, and the monogram makes
+ the group scannable at a glance.
+ One click, and the page gets simpler. No list-then-detail drill, and the green
+ page title becomes the repo name — the breadcrumb an “All repositories” page would have needed
+ disappears entirely.
+ Search still reaches everything. The existing “Search settings” field is what
+ makes a longer sidebar safe: “worktree root” finds the row whichever section owns it.
+
+
the risk worth naming
+
The sidebar becomes a second place that lists repositories, alongside the repo-centric home.
+ Two lists drift — different order, different names, different logos. Mitigation is not a rule but a
+ shared source: both render from the same RepoDTO and the same monogram widget, which is
+ row 6 of the deltas below.
+
if it ever grows
+
Past roughly ten repos, add a single All repositories… entry at the end of the group and
+ keep only pinned ones inline. Not worth building now — three sections is not a scrolling problem, and
+ the overflow rule is cheap to add later precisely because pinned already exists.
+
+
+
+
+
+
+
+
2 · Inheritance: show the effective value and where it came from RECOMMENDED
+
+
+ setting resolution order (first hit wins) badge shown
+
+ Worktree root
+ repo override → global setting → MAKIT_WORKTREE_DIR → ~/.worktrees
+ inherited / overridden
+ Git provider
+ repo override → detection (detect.ts) → unsupported
+ none — the subtitle says it
+ Default branch
+ repo override → origin/HEAD → main
+ none — main is the fact
+ Logo
+ repo override (logoHue, a palette index) → deterministic monogram from the name
+ none — the monogram is the value
+ Access token
+ per instance , never per repo — see forge-provider-per-repo.html
+ —
+
+
+
+
three rules that keep this honest
+
+ Always render the effective value, never an empty field. A blank “Worktree root”
+ box that silently means ~/.worktrees is how people end up with worktrees somewhere they
+ did not expect. Show ~/.worktrees with inherited beside it.
+ The badge names the source, and it is the undo target.
+ overridden is the only state with a visible
+ — reverting means “inherit again”,
+ not “set to the current global value”, so a later change to the global still propagates.
+ Env vars are a source, not a competitor. MAKIT_WORKTREE_DIR already
+ exists and must keep working; it slots into the chain above the built-in default and renders as
+ from environment , read-only, because the app cannot change the daemon's env.
+
+
+
+
+
+
+
+
3 · The logo, without a file-picker problem RECOMMENDED
+
+
+
+
default: deterministic monogram
+
+
+
+
+
+
infra-prod
custom image — not built
+
+
Hue is derived from the repo name, so it is stable across devices without syncing anything. What persists is logoHue, an index into a fixed six-colour palette (RepoSettings in server/src/repo_settings.ts) — there is no image field, and the “custom image” state above is not built.
+
+
+
+
why a monogram first
+
+ Nothing to configure, and it already distinguishes. The list needs to be scannable
+ more than it needs to be branded. Deriving hue from the name means every device shows the same colour
+ with zero state.
+ A custom image is a file that must reach the daemon. Picking one on a phone means
+ uploading bytes to the host — makit has a media route, so it is possible, but it is a real transfer
+ path with size and type validation, not a settings row. Ship the monogram, add image upload later.
+ Do not auto-scrape the repo. Reading a favicon or the first README image is a
+ surprise: the logo would change when someone edits a file. An explicit override is predictable.
+
+
+
+
+
+
+
+
+
+
+
+ decision recommended what goes wrong otherwise
+
+
+ Where does the script text live?
+ RECOMMENDED In makit's own config (projects.json), authored
+ on the host. Never read from the repository working tree.
+ If makit runs .makit/hooks/post-worktree-create.sh from the repo, then
+ cloning a repository and adding it to makit is arbitrary code execution . Reviewing a
+ stranger's PR branch would run their script. This is the direnv / workspace-trust trap.
+
+
+ Who may set one?
+ RECOMMENDED The host only. A paired phone can read the row but not edit
+ it; the iOS page shows “Editable on the host only”.
+ makit pairs with phones. If any paired device can write a script the daemon executes, pairing
+ stops meaning “chat with an agent” and starts meaning “remote shell on my laptop”. A lost phone
+ becomes a host compromise.
+
+
+ If repo-provided hooks are ever wanted
+ Explicit per-repo trust, granted on the host, with the script's full text shown before the first
+ run and re-prompted whenever it changes.
+ Silent execution of file contents that change under version control.
+
+
+
+
+
and the boring parts that still matter
+
+
+
macOS — editing a hook (host only)
+
+
+
+
+
Diana
+
Lifecycle scripts
+ › After worktree create
+
#!/bin/sh
+pnpm install --frozen-lockfile
+cp "$MAKIT_REPO_ROOT/.env.local" .
+
+
Working directory
the new worktree
+
Environment
MAKIT_* only allowlist
+
+
On failure
keep the worktree, show a notice
+
+
Output is captured to the session log, so a failing hook is diagnosable rather than a worktree that “just did not work”.
+
+
+
+
+
+
+ Do not hand the hook the daemon's environment. It holds
+ FORGEJO_ACCESS_TOKEN and whatever else launched the daemon. Pass a documented
+ allowlist (MAKIT_REPO_ROOT, MAKIT_WORKTREE, MAKIT_BRANCH)
+ so a hook that leaks its env leaks nothing that matters.
+ Failure must not destroy work. A failed post -create hook leaves the
+ worktree and reports; a failed pre -prune hook cancels the prune. Prune is the destructive
+ verb, so its hook is the one with veto power.
+ Timeout, always. A hook that waits on stdin would otherwise hang worktree
+ creation forever — the same non-tty hazard the server already guards for
+ gh pr merge.
+
+
+
+
+
+
+
+
+
5 · Storage: extend what already persists RECOMMENDED
+
+
+
server/src/project-store.ts already persists one record per project to
+ $MAKIT_HOME/projects.json, server-side, with a documented rule that a corrupt or missing
+ file degrades to an empty list so the daemon always starts. That is the right home: the daemon itself
+ needs these values (worktree root, hooks), and it is keyed by a stable id that survives restarts.
+
Not SharedPreferences. Today's settings live in app-side prefs, which are
+ per-device: a worktree root set on the phone would not reach the daemon that creates the worktree, and
+ two paired devices would disagree. Per-repo settings must be server-owned, with the app as an editor.
+
+
+ # change file size
+
+ 1 PersistedProject.settings?: RepoSettings — every field optional, so absent means “inherit”. Unknown keys preserved on save so an older daemon does not silently drop a newer app's field.server/src/project-store.ts ~60
+ 2 Resolver: effective value + its source, one pure function per setting. This is what the badges render, and it is the whole inheritance model. server/src/repo_settings.ts (new) ~120
+ 3 Wire the worktree root through it — replaces the direct MAKIT_WORKTREE_DIR read at git.ts:33, which is the only consumer today. server/src/git.ts ~15
+ 4 repo.settings.get / repo.settings.set; set rejects hook fields unless the caller is the host.server/src/ws/commands/repo_settings.ts (new) ~110
+ 5 Settings ▸ Repositories: list + detail, built from SettingsGroup / SettingsSectionHeader / SettingsResetButton. app/lib/desktop/settings/sections/ repositories_section.dart (new) ~260
+ 6 Monogram logo widget + “Settings…” entry in the repo card menu. app/lib/ui/home/repo_logo.dart (new) app/lib/ui/home/repo_card.dart ~70
+ 7 Hook runner: allowlisted env, cwd, timeout, output to the session log, pre-prune veto. server/src/worktree_hooks.ts (new) ~180
+
+
+
order I would build it
+
+ Rows 1–3 first and alone: they make the inheritance model real and immediately fix a live wart
+ (worktree root is global-only today), with no new UI and no security surface. Row 5 next, read-only, so
+ the page exists and shows detected values. Rows 4 and 6 make it editable. Row 7 last, and only
+ after card 4's two decisions are settled — it is the only irreversible one.
+
+
+
+
+
+
diff --git a/scripts/forge b/scripts/forge
new file mode 100755
index 00000000..9dcfca54
--- /dev/null
+++ b/scripts/forge
@@ -0,0 +1,140 @@
+#!/usr/bin/env bash
+#
+# forge — a provider-neutral shim over `gh` (GitHub) and `tea` (Forgejo/Gitea).
+#
+# WHY THIS EXISTS
+# The agent-facing prompts in app/lib/ui/widgets/pr_actions.dart tell the model
+# which command to run. Without this shim every prompt needs a per-provider
+# variant. With it they name one verb and the shim picks the tool from the
+# repo's own remote.
+#
+# WHAT THIS IS NOT
+# Not a data source. It never parses JSON and the makit daemon never calls it:
+# the daemon talks to each forge over its own typed provider
+# (server/src/forge/**). This file only translates verbs and passes output
+# through verbatim for a human or a model to read. Keep it that way — the moment
+# it starts extracting fields, the logic belongs in TypeScript where the test
+# suite can reach it.
+#
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+forge — provider-neutral PR commands (gh for GitHub, tea for Forgejo/Gitea).
+
+ forge pr create [args...] create a pull request
+ forge pr list [args...] list pull requests
+ forge pr view [N] [args...] show a PR with its comments
+ forge pr checks [N] show a PR's CI status
+ forge provider print the detected provider
+
+The provider is read from the `origin` remote: github.com means gh, anything
+else is treated as a Forgejo/Gitea instance.
+EOF
+}
+
+die() {
+ printf 'forge: %s\n' "$1" >&2
+ exit 1
+}
+
+need() {
+ command -v "$1" >/dev/null 2>&1 || die "$1 is not installed (try: brew install $1)"
+}
+
+# The forge hosting `origin`. Anything that is not github.com is treated as
+# Forgejo/Gitea, because those are self-hosted on arbitrary hostnames — there is
+# no domain to match against.
+detect_provider() {
+ local url host
+ url="$(git remote get-url origin 2>/dev/null || true)"
+ [ -n "$url" ] || die "no 'origin' remote here; run this inside a repo checkout"
+ # Strip scheme then userinfo, then cut at the first ':' or '/' — handles both
+ # the scp-like (git@host:owner/repo) and URL (https://host/owner/repo) forms.
+ host="${url#*://}"
+ host="${host#*@}"
+ host="${host%%[:/]*}"
+ case "$host" in
+ github.com | *.github.com) printf 'github\n' ;;
+ '') die "could not read a host out of origin ($url)" ;;
+ *) printf 'forgejo\n' ;;
+ esac
+}
+
+# `tea pr ` shows PR detail, including the CI status list; with no index it
+# lists. Callers below always pass --comments explicitly because tea PROMPTS for
+# it when the flag is absent and stdin is a tty, which would hang an agent's turn
+# — the same reason the server passes `--squash` to `gh pr merge` rather than
+# letting it open a prompt.
+tea_detail() {
+ local want_comments="$1"
+ shift
+ local index=""
+ local rest=()
+ local arg
+ for arg in "$@"; do
+ case "$arg" in
+ # We set this ourselves; drop any caller-supplied copy so it cannot conflict.
+ --comments | --comments=*) ;;
+ -*) rest+=("$arg") ;;
+ *)
+ if [ -z "$index" ]; then index="$arg"; else rest+=("$arg"); fi
+ ;;
+ esac
+ done
+ if [ -z "$index" ]; then
+ exec tea pr list ${rest+"${rest[@]}"}
+ fi
+ exec tea pr "$index" "--comments=${want_comments}" ${rest+"${rest[@]}"}
+}
+
+main() {
+ case "${1:-}" in
+ -h | --help | help | '')
+ usage
+ exit 0
+ ;;
+ provider)
+ detect_provider
+ exit 0
+ ;;
+ pr) ;;
+ *) die "unknown command '${1}' (try: forge --help)" ;;
+ esac
+ shift
+
+ local verb="${1:-}"
+ [ -n "$verb" ] || die "missing verb after 'pr' (try: forge --help)"
+ shift
+
+ local provider
+ provider="$(detect_provider)"
+
+ if [ "$provider" = github ]; then
+ need gh
+ exec gh pr "$verb" "$@"
+ fi
+
+ need tea
+ case "$verb" in
+ create) exec tea pr create "$@" ;;
+ list) exec tea pr list "$@" ;;
+ # Comments are the point of `gh pr view --comments`, so they are always on.
+ view) tea_detail true "$@" ;;
+ # `gh pr checks` is asking for CI state, which the detail view carries.
+ # Comments are suppressed to keep that output focused.
+ checks) tea_detail false "$@" ;;
+ ready)
+ die "Forgejo has no 'pr ready'. Draft state is derived from a WIP: title prefix, so
+leaving draft means rewriting the title:
+ tea pr edit --title ''
+The makit server does this for you via the Forgejo provider — prefer the app's action."
+ ;;
+ *)
+ die "'$verb' has no tea equivalent. Reach the API directly, e.g.:
+ tea api repos///pulls/"
+ ;;
+ esac
+}
+
+main "$@"
diff --git a/scripts/sync-icons.sh b/scripts/sync-icons.sh
new file mode 100755
index 00000000..6ee71c11
--- /dev/null
+++ b/scripts/sync-icons.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+#
+# sync-icons.sh — vendor the glyphs from the phosphor_extras source repo.
+#
+# WHY VENDOR RATHER THAN DEPEND
+# phosphor_extras is the source of truth: geometry is generated there and its
+# invariants are checked there. It is not yet a git dependency because it has no
+# published remote, and a `path:` dependency pointing outside this repo would
+# break CI and the cloud VM, which both run `flutter pub get` on a fresh clone.
+# So the built SVGs are committed here and this script keeps them honest.
+# Once the source repo is pushed, this becomes a `git:` dependency in
+# app/pubspec.yaml and this script goes away.
+#
+# USAGE
+# scripts/sync-icons.sh # copy glyphs in
+# scripts/sync-icons.sh --check # fail if the vendored copies have drifted
+#
+# PHOSPHOR_EXTRAS_DIR=/path/to/repo scripts/sync-icons.sh
+#
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SRC="${PHOSPHOR_EXTRAS_DIR:-$ROOT/../phosphor_extras}"
+DEST="$ROOT/app/assets/icons"
+
+# Only the glyphs this app actually renders. Pulling the whole set would ship
+# weights nothing references, and a Flutter asset directory is bundled wholesale.
+GLYPHS=(
+ git-pull-request-closed-thin
+ git-pull-request-closed-light
+ git-pull-request-closed-regular
+ git-pull-request-closed-bold
+ git-pull-request-closed-fill
+ forgejo-light
+ gitea-light
+)
+
+die() {
+ printf 'sync-icons: %s\n' "$1" >&2
+ exit 1
+}
+
+# Arguments are validated before anything is copied: `[ "$1" = --check ]` alone left a
+# misspelled `--chek` as check_only=false, so a command meant to VERIFY vendored assets
+# silently overwrote them instead.
+check_only=false
+case "${1:-}" in
+ --check) check_only=true ;;
+ "") ;;
+ *) die "unknown argument: $1 (usage: sync-icons.sh [--check])" ;;
+esac
+[ "$#" -le 1 ] || die "too many arguments (usage: sync-icons.sh [--check])"
+
+[ -d "$SRC/icons" ] || die "no glyphs at $SRC/icons — set PHOSPHOR_EXTRAS_DIR to the phosphor_extras checkout"
+
+
+drifted=0
+for name in "${GLYPHS[@]}"; do
+ from="$SRC/icons/$name.svg"
+ to="$DEST/$name.svg"
+ [ -f "$from" ] || die "missing source glyph: $from"
+ if [ ! -f "$to" ] || ! cmp -s "$from" "$to"; then
+ if $check_only; then
+ printf ' drifted: %s\n' "$name.svg"
+ drifted=$((drifted + 1))
+ else
+ cp "$from" "$to"
+ printf ' updated: %s\n' "$name.svg"
+ fi
+ fi
+done
+
+if $check_only; then
+ [ "$drifted" -eq 0 ] || die "$drifted vendored glyph(s) differ from $SRC/icons — run scripts/sync-icons.sh"
+ printf 'sync-icons: %d glyphs match the source repo\n' "${#GLYPHS[@]}"
+else
+ printf 'sync-icons: %d glyphs in sync\n' "${#GLYPHS[@]}"
+fi
diff --git a/server/src/forge/cadence.test.ts b/server/src/forge/cadence.test.ts
new file mode 100644
index 00000000..b2d7bf98
--- /dev/null
+++ b/server/src/forge/cadence.test.ts
@@ -0,0 +1,49 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { forgePollIntervalMs } from "./cadence.js";
+import { POLL_FAST_MS } from "../github/policy.js";
+import type { GithubGateway } from "../github/gateway.js";
+
+/** A gateway stub exposing just what the cadence helper reads. */
+function stub(opts: { level?: string; providers?: string[] }): GithubGateway {
+ const g: Record = {
+ // The real BudgetLike shape: hourly buckets plus a level and retry window.
+ budget: () => ({
+ level: opts.level ?? "unknown",
+ retryAfterMs: null,
+ buckets: { core: { remaining: 5000 }, graphql: { remaining: 5000 } },
+ }),
+ };
+ if (opts.providers !== undefined) g.providersInUse = () => new Set(opts.providers);
+ return g as unknown as GithubGateway;
+}
+
+test("a Forgejo-only setup polls at the fast rung, ignoring GitHub's ladder", () => {
+ // The bug this fixes: with no GitHub repo, the rate_limit read never succeeds,
+ // the level stays `unknown`, and the ladder treated that as the warm rung --
+ // throttling Forgejo polling to 30s against a quota that does not apply.
+ assert.equal(forgePollIntervalMs(stub({ level: "unknown", providers: ["forgejo"] })), POLL_FAST_MS);
+ assert.equal(forgePollIntervalMs(stub({ level: "critical", providers: ["forgejo"] })), POLL_FAST_MS);
+});
+
+test("any GitHub repo in play hands the cadence back to the GitHub ladder", () => {
+ const mixed = forgePollIntervalMs(stub({ level: "unknown", providers: ["forgejo", "github"] }));
+ assert.ok(mixed > POLL_FAST_MS, `mixed setups must stay conservative, got ${mixed}`);
+});
+
+test("before anything is routed the cadence stays conservative", () => {
+ // An empty mix is "not known yet", not "no GitHub" -- guessing fast here would
+ // burn GitHub quota for the first few ticks after startup.
+ const idle = forgePollIntervalMs(stub({ level: "unknown", providers: [] }));
+ assert.ok(idle > POLL_FAST_MS);
+});
+
+test("a gateway that cannot report a mix falls back to the ladder", () => {
+ const legacy = forgePollIntervalMs(stub({ level: "unknown" }));
+ assert.ok(legacy > POLL_FAST_MS);
+});
+
+test("a healthy GitHub budget still yields the fast rung", () => {
+ assert.equal(forgePollIntervalMs(stub({ level: "healthy", providers: ["github"] })), POLL_FAST_MS);
+});
diff --git a/server/src/forge/cadence.ts b/server/src/forge/cadence.ts
new file mode 100644
index 00000000..e3b6db8b
--- /dev/null
+++ b/server/src/forge/cadence.ts
@@ -0,0 +1,39 @@
+/**
+ * cadence.ts — how often to re-poll pull requests, across providers.
+ *
+ * GitHub's degradation ladder (`github/policy.ts`) exists to ration a quota.
+ * Forgejo has no quota: no `/api/v1/rate_limit` endpoint, no rate-limit response
+ * headers, and no request limiter anywhere in its configuration. So a
+ * Forgejo-only setup must not be governed by that ladder.
+ *
+ * It previously was, and the failure was silent: with no GitHub repo the
+ * `rate_limit` read never succeeds, the level stays `unknown`, and the ladder
+ * treats unknown as the warm rung — 30s polling with unresolved counts shed.
+ * Forgejo repos were therefore polled 6x slower than needed against a quota that
+ * provably does not exist.
+ *
+ * KNOWN LIMITATION: `pr_watcher` runs one global timer, so a MIXED setup (GitHub
+ * and Forgejo repos together) still takes the GitHub cadence for everything.
+ * Fixing that properly means per-repo cadence in the watcher; conflating it here
+ * would be worse, because taking the fast rung in a mixed setup would burn the
+ * GitHub quota the ladder is protecting.
+ */
+
+import type { GithubGateway } from "../github/gateway.js";
+import { POLL_FAST_MS, decide } from "../github/policy.js";
+import { hasProviderMix } from "./types.js";
+
+/**
+ * The poll interval to use now.
+ *
+ * Takes the unthrottled rung only when the providers in play are KNOWN and
+ * exclude GitHub. An empty mix means "nothing routed yet", not "no GitHub" —
+ * guessing fast there would spend GitHub quota for the first ticks after startup.
+ */
+export function forgePollIntervalMs(gateway: GithubGateway): number {
+ if (hasProviderMix(gateway)) {
+ const inUse = gateway.providersInUse();
+ if (inUse.size > 0 && !inUse.has("github")) return POLL_FAST_MS;
+ }
+ return decide(gateway.budget()).pollIntervalMs;
+}
diff --git a/server/src/forge/detect.test.ts b/server/src/forge/detect.test.ts
new file mode 100644
index 00000000..03e86672
--- /dev/null
+++ b/server/src/forge/detect.test.ts
@@ -0,0 +1,233 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ createForgeDetector,
+ forgejoProbeUrl,
+ giteaProbeUrl,
+ gitlabProbeUrl,
+ isGiteaFamilyVersion,
+ isGitHubHost,
+ NEGATIVE_TTL_MS,
+ type ForgeSoftware,
+} from "./detect.js";
+import type { Http, HttpRequest } from "./forgejo/gateway.js";
+
+/**
+ * Scripted HTTP, matched by URL substring. Records every request so the tests can
+ * assert on probe COUNT — detection runs once per host, and a detector that
+ * re-probes on every lookup would add a round trip to the hot path.
+ */
+function harness(routes: Array<[string, { status: number; body?: string }]>) {
+ const calls: HttpRequest[] = [];
+ let nowMs = 1_000;
+ const http: Http = async (req) => {
+ calls.push(req);
+ for (const [needle, res] of routes) {
+ if (req.url.includes(needle)) return { status: res.status, body: res.body ?? "", headers: {} };
+ }
+ return { status: 404, body: "not found", headers: {} };
+ };
+ const detector = createForgeDetector({ http, now: () => nowMs });
+ return { detector, calls, tick: (ms: number) => (nowMs += ms) };
+}
+
+const VERSION = (v: string) => JSON.stringify({ version: v });
+/** Real payloads, copied from live instances. */
+const FORGEJO_V = VERSION("16.0.0+gitea-1.22.0");
+const GITEA_V = VERSION("1.27.0+dev-652-g0571722545");
+
+// ---------------------------------------------------------------------------
+// Host classification (GitHub needs no probe)
+// ---------------------------------------------------------------------------
+
+test("isGitHubHost accepts github.com and subdomains, and no lookalikes", () => {
+ assert.equal(isGitHubHost("github.com"), true);
+ assert.equal(isGitHubHost("WWW.GitHub.com"), true);
+ assert.equal(isGitHubHost("github.com.evil.test"), false);
+ assert.equal(isGitHubHost("notgithub.com"), false);
+ assert.equal(isGitHubHost("codeberg.org"), false);
+});
+
+// ---------------------------------------------------------------------------
+// Probe URLs
+// ---------------------------------------------------------------------------
+
+test("probe URLs are built off the instance base, trailing slash tolerated", () => {
+ assert.equal(forgejoProbeUrl("https://x.test/"), "https://x.test/api/forgejo/v1/version");
+ assert.equal(giteaProbeUrl("https://x.test"), "https://x.test/api/v1/version");
+ assert.equal(gitlabProbeUrl("https://x.test"), "https://x.test/api/v4/version");
+});
+
+test("probe URLs survive a sub-path install", () => {
+ assert.equal(forgejoProbeUrl("https://x.test/forge"), "https://x.test/forge/api/forgejo/v1/version");
+});
+
+// ---------------------------------------------------------------------------
+// Payload classification
+// ---------------------------------------------------------------------------
+
+test("isGiteaFamilyVersion accepts a version payload and rejects anything else", () => {
+ assert.equal(isGiteaFamilyVersion(FORGEJO_V), true);
+ assert.equal(isGiteaFamilyVersion(GITEA_V), true);
+ assert.equal(isGiteaFamilyVersion('{"version":""}'), false);
+ assert.equal(isGiteaFamilyVersion("{}"), false);
+ // GitLab answers /api/v1/version with an HTML redirect to its sign-in page.
+ assert.equal(isGiteaFamilyVersion("redirected"), false);
+ assert.equal(isGiteaFamilyVersion(""), false);
+});
+
+// ---------------------------------------------------------------------------
+// Detection, against the responses real servers actually give
+// ---------------------------------------------------------------------------
+
+const detect = async (
+ routes: Array<[string, { status: number; body?: string }]>,
+): Promise<{ got: ForgeSoftware; probes: number }> => {
+ const { detector, calls } = harness(routes);
+ const got = await detector.detect("https://git.test");
+ return { got, probes: calls.length };
+};
+
+test("Forgejo is identified by its own API namespace, in one probe", async () => {
+ const { got, probes } = await detect([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]);
+ assert.equal(got, "forgejo");
+ assert.equal(probes, 1, "the Forgejo namespace is decisive; do not probe further");
+});
+
+test("Gitea is identified by serving /api/v1/version WITHOUT the Forgejo namespace", async () => {
+ const { got } = await detect([
+ ["/api/forgejo/v1/version", { status: 404 }],
+ ["/api/v1/version", { status: 200, body: GITEA_V }],
+ ]);
+ assert.equal(got, "gitea");
+});
+
+test("a Forgejo version string is not mistaken for Gitea when the namespace 404s", async () => {
+ // Belt and braces: if a proxy hides /api/forgejo, the `+gitea-` suffix still
+ // marks it as Forgejo rather than Gitea.
+ const { got } = await detect([
+ ["/api/forgejo/v1/version", { status: 404 }],
+ ["/api/v1/version", { status: 200, body: FORGEJO_V }],
+ ]);
+ assert.equal(got, "forgejo");
+});
+
+test("GitLab is identified by /api/v4/version answering at all", async () => {
+ // 401 unauthenticated is what gitlab.com actually returns, and it is still
+ // proof the server is GitLab.
+ const { got } = await detect([
+ ["/api/forgejo/v1/version", { status: 404 }],
+ ["/api/v1/version", { status: 302, body: "redirected" }],
+ ["/api/v4/version", { status: 401, body: '{"message":"401 Unauthorized"}' }],
+ ]);
+ assert.equal(got, "gitlab");
+});
+
+test("a server that answers nothing recognisable is `unknown`, not guessed", async () => {
+ const { got } = await detect([]);
+ assert.equal(got, "unknown");
+});
+
+test("an unreachable host is `unknown` rather than throwing", async () => {
+ const { got } = await detect([
+ ["/api", { status: 0 }],
+ ]);
+ assert.equal(got, "unknown");
+});
+
+// ---------------------------------------------------------------------------
+// Caching. Detection is on the hot path's critical section, so it must happen
+// once per host -- but a transient failure must NOT pin a host as unsupported.
+// ---------------------------------------------------------------------------
+
+test("a positive detection is cached and never re-probed", async () => {
+ const { detector, calls } = harness([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]);
+ assert.equal(await detector.detect("https://git.test"), "forgejo");
+ const n = calls.length;
+ assert.equal(await detector.detect("https://git.test"), "forgejo");
+ assert.equal(await detector.detect("https://git.test"), "forgejo");
+ assert.equal(calls.length, n);
+});
+
+test("a failed detection is retried after a short TTL", async () => {
+ // The hazard: an instance down during the first probe would otherwise be pinned
+ // as unsupported until the server restarts.
+ const routes: Array<[string, { status: number; body?: string }]> = [["/api", { status: 0 }]];
+ const { detector, calls, tick } = harness(routes);
+ assert.equal(await detector.detect("https://git.test"), "unknown");
+ const n = calls.length;
+ await detector.detect("https://git.test");
+ assert.equal(calls.length, n, "not immediately -- that would hammer a down host");
+ tick(NEGATIVE_TTL_MS + 1);
+ routes[0] = ["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }];
+ assert.equal(await detector.detect("https://git.test"), "forgejo", "recovery must be possible");
+});
+
+test("concurrent first detections for one host share a single probe", async () => {
+ const { detector, calls } = harness([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]);
+ const [a, b, c] = await Promise.all([
+ detector.detect("https://git.test"),
+ detector.detect("https://git.test"),
+ detector.detect("https://git.test"),
+ ]);
+ assert.deepEqual([a, b, c], ["forgejo", "forgejo", "forgejo"]);
+ assert.equal(calls.length, 1, "an in-flight probe must be shared");
+});
+
+test("detection is keyed per instance, not shared across hosts", async () => {
+ const { detector } = harness([
+ ["a.test/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }],
+ ["b.test/api/forgejo/v1/version", { status: 404 }],
+ ["b.test/api/v1/version", { status: 200, body: GITEA_V }],
+ ]);
+ assert.equal(await detector.detect("https://a.test"), "forgejo");
+ assert.equal(await detector.detect("https://b.test"), "gitea");
+});
+
+test("a token is sent with the probe, since a private instance 401s without one", async () => {
+ const { detector, calls } = harness([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]);
+ await detector.detect("https://git.test", "t0k");
+ assert.equal(calls[0].headers.Authorization, "token t0k");
+});
+
+test("no Authorization header is sent when there is no token", async () => {
+ const { detector, calls } = harness([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]);
+ await detector.detect("https://git.test");
+ assert.equal("Authorization" in calls[0].headers, false);
+});
+
+test("a gate that 401s every path is NOT reported as GitLab", async () => {
+ // Review finding: step 3 treated 401/403 on the GitLab path as proof of GitLab,
+ // because "only GitLab serves that path". That holds for the status only if the
+ // earlier probes were answered by the APPLICATION. An instance behind SSO or an
+ // authenticating reverse proxy answers 401 on every path, including both Forgejo
+ // probes — so a perfectly ordinary Forgejo instance behind a gate was classified
+ // `gitlab`, routed to the unsupported provider, and the log told the user it
+ // "looks like gitlab". When every probe returns the same auth status the responses
+ // carry no information about the software, so the honest answer is `unknown` —
+ // which is also re-probed later and can be overridden per repo.
+ const d = createForgeDetector({
+ http: async () => ({ status: 401, body: "", headers: {} }),
+ });
+ assert.equal(await d.detect("https://gated.example"), "unknown");
+});
+
+test("403 on every path is likewise unknown, not GitLab", async () => {
+ const d = createForgeDetector({
+ http: async () => ({ status: 403, body: "", headers: {} }),
+ });
+ assert.equal(await d.detect("https://gated.example"), "unknown");
+});
+
+test("401 on the GitLab path alone is still GitLab", async () => {
+ // The real gitlab.com case: the Forgejo/Gitea probes are ANSWERED (404), so the
+ // 401 on /api/v4/version carries information.
+ const d = createForgeDetector({
+ http: async (req) => {
+ if (req.url.includes("/api/v4/version")) return { status: 401, body: "", headers: {} };
+ return { status: 404, body: "", headers: {} };
+ },
+ });
+ assert.equal(await d.detect("https://gitlab.example"), "gitlab");
+});
diff --git a/server/src/forge/detect.ts b/server/src/forge/detect.ts
new file mode 100644
index 00000000..b489eb13
--- /dev/null
+++ b/server/src/forge/detect.ts
@@ -0,0 +1,206 @@
+/**
+ * detect.ts — identify which forge software an instance runs.
+ *
+ * Replaces a guess. Routing previously keyed off the hostname alone — github.com
+ * meant GitHub, everything else was ASSUMED to be Forgejo — which sent GitLab and
+ * Bitbucket remotes to the Forgejo provider, where they failed as `unknown`,
+ * indistinguishable from "your instance is down". A hostname cannot tell you what
+ * software a server runs; asking the server can.
+ *
+ * The discriminators are endpoints, not version-string sniffing, and each was
+ * verified against a live instance:
+ *
+ * GET /api/forgejo/v1/version 200 on Forgejo (codeberg.org, and a self-hosted
+ * 16.0.0), 404 on Gitea (gitea.com)
+ * GET /api/v1/version 200 + {"version":...} on both Forgejo and Gitea;
+ * GitLab answers a 302 to its sign-in page
+ * GET /api/v4/version 401 unauthenticated on gitlab.com — which is
+ * still proof it is GitLab
+ *
+ * Forgejo is probed first because it is decisive in ONE call, and it is the case
+ * we care about; Gitea costs two, and anything else three. Results are cached per
+ * instance, so this never touches the PR hot path more than once.
+ */
+
+import type { Http } from "./forgejo/gateway.js";
+import type { ForgeSoftwareName } from "./types.js";
+
+/** Which software an instance runs. `unknown` means we could not tell. */
+/**
+ * Re-exported from `types.ts` rather than declared again.
+ *
+ * Two identical unions in two files drift: this one and `ForgeSoftwareName` were
+ * already the same list in two places, and nothing would have failed if one had
+ * gained a member.
+ */
+export type ForgeSoftware = ForgeSoftwareName;
+
+/** Probe timeout. A version endpoint is trivial; a slow answer is a bad sign. */
+const PROBE_TIMEOUT_MS = 8_000;
+
+/**
+ * How long a FAILED detection is remembered.
+ *
+ * Short on purpose. Caching a failure forever would pin an instance that happened
+ * to be down during the first probe as unsupported until the server restarts —
+ * the user would see "unsupported forge" on a perfectly good Forgejo. Short
+ * enough to recover quickly, long enough not to re-probe a down host every tick.
+ */
+export const NEGATIVE_TTL_MS = 60_000;
+
+const trim = (base: string): string => base.replace(/\/+$/, "");
+
+/** Forgejo's own API namespace — absent on Gitea. */
+export function forgejoProbeUrl(baseUrl: string): string {
+ return `${trim(baseUrl)}/api/forgejo/v1/version`;
+}
+
+/** The Gitea-compatible version endpoint, served by both Forgejo and Gitea. */
+export function giteaProbeUrl(baseUrl: string): string {
+ return `${trim(baseUrl)}/api/v1/version`;
+}
+
+/** GitLab's version endpoint. */
+export function gitlabProbeUrl(baseUrl: string): string {
+ return `${trim(baseUrl)}/api/v4/version`;
+}
+
+/**
+ * Whether a host is GitHub. Matches the apex and its subdomains and nothing else:
+ * a bare suffix test would classify `github.com.evil.test` as GitHub and hand it
+ * whatever credentials that path carries.
+ */
+export function isGitHubHost(host: string): boolean {
+ const h = host.toLowerCase().split(":")[0];
+ return h === "github.com" || h.endsWith(".github.com");
+}
+
+/** Whether a body is a Gitea-family `{"version": "..."}` payload. */
+export function isGiteaFamilyVersion(body: string): boolean {
+ try {
+ const parsed = JSON.parse(body) as { version?: unknown };
+ return typeof parsed.version === "string" && parsed.version.length > 0;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Whether a Gitea-family version string is actually Forgejo. Forgejo reports its
+ * own version with a `+gitea-x.y.z` API-compatibility suffix (`16.0.0+gitea-1.22.0`)
+ * where Gitea reports a bare `1.27.0`. Only used as a fallback for an instance
+ * whose `/api/forgejo` namespace is hidden by a proxy.
+ */
+function looksLikeForgejoVersion(body: string): boolean {
+ try {
+ const v = (JSON.parse(body) as { version?: unknown }).version;
+ return typeof v === "string" && /\+gitea-/i.test(v);
+ } catch {
+ return false;
+ }
+}
+
+export interface ForgeDetectorDeps {
+ http: Http;
+ now?: () => number;
+}
+
+export interface ForgeDetector {
+ /**
+ * Identify the software at `baseUrl`. `token` is used for the probe because a
+ * private instance (`REQUIRE_SIGNIN_VIEW`) answers 401 to anonymous callers,
+ * which would otherwise read as "not a forge".
+ */
+ detect(baseUrl: string, token?: string): Promise;
+ /** Forget everything learned (tests, and gateway close). */
+ clear(): void;
+}
+
+interface CachedDetection {
+ value: ForgeSoftware;
+ /** null = never expires (a positive result); a number = epoch ms. */
+ expiresAt: number | null;
+}
+
+export function createForgeDetector(deps: ForgeDetectorDeps): ForgeDetector {
+ const now = deps.now ?? (() => Date.now());
+ const settled = new Map();
+ /** In-flight probes, so a fan-out across worktrees shares one round trip. */
+ const inFlight = new Map>();
+
+ async function probe(url: string, token: string | undefined) {
+ const headers: Record = { Accept: "application/json" };
+ if (token !== undefined && token.length > 0) headers.Authorization = `token ${token}`;
+ try {
+ return await deps.http({ url, method: "GET", headers, timeoutMs: PROBE_TIMEOUT_MS });
+ } catch {
+ return { status: 0, body: "" };
+ }
+ }
+
+ const ok = (status: number): boolean => status >= 200 && status < 300;
+
+ /** A status that a gate in front of the app could have produced for any path. */
+ const isAuthStatus = (status: number): boolean => status === 401 || status === 403;
+
+ async function classify(baseUrl: string, token: string | undefined): Promise {
+ // 1. Forgejo's own namespace: decisive, and one call.
+ const fj = await probe(forgejoProbeUrl(baseUrl), token);
+ if (ok(fj.status) && isGiteaFamilyVersion(fj.body)) return "forgejo";
+
+ // 2. Gitea-compatible version endpoint: Forgejo and Gitea both serve it.
+ const gt = await probe(giteaProbeUrl(baseUrl), token);
+ if (ok(gt.status) && isGiteaFamilyVersion(gt.body)) {
+ return looksLikeForgejoVersion(gt.body) ? "forgejo" : "gitea";
+ }
+
+ // 3. GitLab. `401`/`403` counts, because that is what gitlab.com returns
+ // unauthenticated and only GitLab serves that path -- but ONLY when an earlier
+ // probe was answered by the application rather than by a gate.
+ //
+ // An instance behind SSO or an authenticating reverse proxy answers the same auth
+ // status on every path, including both probes above. Treating that as proof of
+ // GitLab classified an ordinary gated Forgejo instance as unsupported and told the
+ // user it "looks like gitlab". When every probe returns the same auth status the
+ // responses carry no information about the software, so the honest answer is
+ // `unknown` -- which is re-probed later, and which the per-repo provider setting
+ // can override.
+ const gl = await probe(gitlabProbeUrl(baseUrl), token);
+ if (ok(gl.status)) return "gitlab";
+ if (gl.status === 401 || gl.status === 403) {
+ const gatedEarlier = isAuthStatus(fj.status) && isAuthStatus(gt.status);
+ if (!gatedEarlier) return "gitlab";
+ }
+
+ return "unknown";
+ }
+
+ return {
+ async detect(baseUrl: string, token?: string): Promise {
+ const key = trim(baseUrl).toLowerCase();
+ const hit = settled.get(key);
+ if (hit !== undefined && (hit.expiresAt === null || hit.expiresAt > now())) return hit.value;
+
+ const running = inFlight.get(key);
+ if (running !== undefined) return running;
+
+ const p = classify(baseUrl, token)
+ .then((value) => {
+ settled.set(key, {
+ value,
+ // Only a failure expires; a server does not change software often
+ // enough to be worth re-probing, and a wrong positive is loud.
+ expiresAt: value === "unknown" ? now() + NEGATIVE_TTL_MS : null,
+ });
+ return value;
+ })
+ .finally(() => inFlight.delete(key));
+ inFlight.set(key, p);
+ return p;
+ },
+ clear(): void {
+ settled.clear();
+ inFlight.clear();
+ },
+ };
+}
diff --git a/server/src/forge/forgejo/gateway.test.ts b/server/src/forge/forgejo/gateway.test.ts
new file mode 100644
index 00000000..20d75ae9
--- /dev/null
+++ b/server/src/forge/forgejo/gateway.test.ts
@@ -0,0 +1,564 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { createForgejoGateway, type Http, type HttpRequest, type ForgejoRepoRef } from "./gateway.js";
+
+const REF: ForgejoRepoRef = {
+ baseUrl: "https://git.example.com",
+ owner: "acme",
+ repo: "app",
+ token: "t0ken",
+};
+
+interface Call {
+ url: string;
+ method: string;
+ headers: Record;
+ body?: string;
+ timeoutMs: number;
+}
+
+/** Build a gateway over a scripted HTTP seam. Routes are matched by substring. */
+function harness(
+ routes: Array<[string, { status?: number; json?: unknown; body?: string; headers?: Record }]>,
+ ref = REF,
+) {
+ const calls: Call[] = [];
+ const http: Http = async (req: HttpRequest) => {
+ calls.push({ url: req.url, method: req.method, headers: req.headers, body: req.body, timeoutMs: req.timeoutMs });
+ for (const [needle, res] of routes) {
+ if (req.url.includes(needle)) {
+ return {
+ status: res.status ?? 200,
+ body: res.body ?? JSON.stringify(res.json ?? null),
+ headers: res.headers ?? {},
+ };
+ }
+ }
+ return { status: 404, body: '{"message":"not found"}', headers: {} };
+ };
+ let nowMs = 1_000;
+ const gateway = createForgejoGateway({
+ http,
+ resolveRepo: async () => ref,
+ now: () => nowMs,
+ });
+ return { gateway, calls, tick: (ms: number) => (nowMs += ms) };
+}
+
+const openPrRow = {
+ number: 42,
+ state: "open",
+ merged: false,
+ title: "feat: thing",
+ draft: false,
+ mergeable: true,
+ html_url: "https://git.example.com/acme/app/pulls/42",
+ base: { ref: "main" },
+ head: { ref: "feat/x", sha: "cafebabe" },
+};
+
+// ---------------------------------------------------------------------------
+// prForBranch
+// ---------------------------------------------------------------------------
+
+test("prForBranch returns the PR with checks composed from the combined status", async () => {
+ const { gateway, calls } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { json: { state: "failure", statuses: [{ context: "test", status: "failure" }] } }],
+ ]);
+ const got = await gateway.prForBranch("/repo", "feat/x");
+ assert.equal(got.kind, "pr");
+ if (got.kind !== "pr") return;
+ assert.equal(got.pr.number, 42);
+ assert.equal(got.pr.state, "OPEN");
+ assert.equal(got.pr.mergeable, "MERGEABLE");
+ assert.equal(got.pr.mergeStateStatus, null);
+ assert.equal(got.pr.checkRollup, "fail");
+ assert.deepEqual(
+ got.pr.checks.map((c) => c.name),
+ ["test"],
+ );
+ // The head filter must be the bare branch name.
+ assert.ok(calls[0].url.includes("head=feat%2Fx"));
+ assert.ok(!calls[0].url.includes("acme%3Afeat"));
+});
+
+test("prForBranch sends the token as a Forgejo `token` credential", async () => {
+ const { gateway, calls } = harness([["/pulls?", { json: [] }]]);
+ await gateway.prForBranch("/repo", "b");
+ assert.equal(calls[0].headers.Authorization, "token t0ken");
+});
+
+test("prForBranch omits Authorization entirely when there is no token", async () => {
+ const { gateway, calls } = harness([["/pulls?", { json: [] }]], { ...REF, token: undefined });
+ await gateway.prForBranch("/repo", "b");
+ assert.equal("Authorization" in calls[0].headers, false);
+});
+
+test("prForBranch returns none only for a genuinely empty result", async () => {
+ const { gateway } = harness([["/pulls?", { json: [] }]]);
+ assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "none" });
+});
+
+test("prForBranch reports unknown -- never none -- on a transport failure", async () => {
+ const { gateway } = harness([["/pulls?", { status: 0, body: "" }]]);
+ assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "error" });
+});
+
+test("prForBranch reports unknown on a 5xx and on unparseable JSON", async () => {
+ const a = harness([["/pulls?", { status: 500, body: "boom" }]]);
+ assert.deepEqual(await a.gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "error" });
+ const b = harness([["/pulls?", { body: "not json" }]]);
+ assert.deepEqual(await b.gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "error" });
+});
+
+test("prForBranch reports unknown when the repo has no Forgejo remote", async () => {
+ const http: Http = async () => ({ status: 200, body: "[]" });
+ const gateway = createForgejoGateway({ http, resolveRepo: async () => null });
+ assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "error" });
+});
+
+test("prForBranch picks the highest-numbered PR, not the first row", async () => {
+ const { gateway } = harness([
+ [
+ "/pulls?",
+ {
+ json: [
+ { ...openPrRow, number: 7, state: "closed", merged: false, head: { ref: "b", sha: "aa" } },
+ { ...openPrRow, number: 99, state: "open", head: { ref: "b", sha: "bb" } },
+ ],
+ },
+ ],
+ ["/commits/bb/status", { json: { statuses: [] } }],
+ ]);
+ const got = await gateway.prForBranch("/repo", "b");
+ assert.equal(got.kind === "pr" && got.pr.number, 99);
+ assert.equal(got.kind === "pr" && got.pr.state, "OPEN");
+});
+
+test("prForBranch still returns the PR when the status call fails, with checks unmeasured", async () => {
+ const { gateway } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { status: 500, body: "nope" }],
+ ]);
+ const got = await gateway.prForBranch("/repo", "b");
+ assert.equal(got.kind, "pr");
+ if (got.kind !== "pr") return;
+ // A PR we found must not vanish because CI could not be read.
+ assert.deepEqual(got.pr.checks, []);
+ assert.equal(got.pr.checkRollup, "none");
+});
+
+test("prForBranch marks unresolved comments as unmeasured rather than reporting zero", async () => {
+ const { gateway } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { json: { statuses: [] } }],
+ ]);
+ const got = await gateway.prForBranch("/repo", "b");
+ assert.equal(got.kind === "pr" && got.pr.unresolvedUnknown, true);
+ assert.equal(got.kind === "pr" && got.pr.unresolvedComments, 0);
+});
+
+test("prForBranch skips the status call when the PR reports no head sha", async () => {
+ const { gateway, calls } = harness([["/pulls?", { json: [{ ...openPrRow, head: { ref: "b" } }] }]]);
+ const got = await gateway.prForBranch("/repo", "b");
+ assert.equal(got.kind, "pr");
+ assert.equal(calls.length, 1);
+});
+
+// Forgejo's `head` filter is an unindexed scan: measured against codeberg.org
+// (~9.5k PRs) `state=all&head=X` is bimodal at 1.5s or 20-30s, while the combined
+// status read stays sub-second. A single timeout for both would either abandon
+// good lookups or hold a fast call open far too long -- and abandoning a lookup
+// reports `unknown`, which flickers the pill.
+test("the branch lookup gets a longer timeout than the sub-second status read", async () => {
+ const { gateway, calls } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { json: { statuses: [] } }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ const list = calls.find((c) => c.url.includes("/pulls?"));
+ const status = calls.find((c) => c.url.includes("/status"));
+ assert.ok(list && status);
+ assert.ok(
+ list.timeoutMs >= 15_000,
+ `branch lookup timeout ${list.timeoutMs}ms is below the observed p90 of the unindexed scan`,
+ );
+ assert.ok(status.timeoutMs < list.timeoutMs);
+});
+
+// ---------------------------------------------------------------------------
+// Caching
+// ---------------------------------------------------------------------------
+
+test("prForBranch serves a repeat poll from cache and counts the hit", async () => {
+ const { gateway, calls } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { json: { statuses: [] } }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ const n = calls.length;
+ await gateway.prForBranch("/repo", "b");
+ assert.equal(calls.length, n, "second poll should not hit the network");
+ assert.equal(gateway.stats().cacheHits, 1);
+});
+
+test("an interactive call bypasses the cache", async () => {
+ const { gateway, calls } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { json: { statuses: [] } }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ const n = calls.length;
+ await gateway.prForBranch("/repo", "b", { interactive: true });
+ assert.ok(calls.length > n);
+});
+
+test("a failed lookup is not cached, so the next poll retries", async () => {
+ const { gateway, calls } = harness([["/pulls?", { status: 500, body: "x" }]]);
+ await gateway.prForBranch("/repo", "b");
+ await gateway.prForBranch("/repo", "b");
+ assert.equal(calls.length, 2);
+});
+
+test("the cache expires", async () => {
+ const { gateway, calls, tick } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { json: { statuses: [] } }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ const n = calls.length;
+ tick(120_000);
+ await gateway.prForBranch("/repo", "b");
+ assert.ok(calls.length > n);
+});
+
+// ---------------------------------------------------------------------------
+// openPrs
+// ---------------------------------------------------------------------------
+
+test("openPrs maps rows and orders them newest-first regardless of server order", async () => {
+ const { gateway } = harness([
+ [
+ "/pulls?",
+ {
+ json: [
+ { number: 3, title: "c", draft: false, html_url: "u3", head: { ref: "b3" } },
+ { number: 51, title: "a", draft: true, html_url: "u51", head: { ref: "b51" } },
+ { number: 12, title: "b", draft: false, html_url: "u12", head: { ref: "b12" } },
+ ],
+ },
+ ],
+ ]);
+ const prs = await gateway.openPrs("/repo", 30);
+ assert.deepEqual(
+ prs.map((p) => p.number),
+ [51, 12, 3],
+ );
+ assert.deepEqual(prs[0], { number: 51, title: "a", headRefName: "b51", isDraft: true, url: "u51" });
+});
+
+test("openPrs returns an empty list on failure rather than throwing", async () => {
+ const { gateway } = harness([["/pulls?", { status: 500, body: "x" }]]);
+ assert.deepEqual(await gateway.openPrs("/repo", 30), []);
+});
+
+// ---------------------------------------------------------------------------
+// mutatePr — `ready` is a title rewrite on Forgejo, not a flag flip.
+// ---------------------------------------------------------------------------
+
+test("mutatePr ready re-reads the title and PATCHes it with the prefix stripped", async () => {
+ const { gateway, calls } = harness([
+ ["/pulls/42", { json: { number: 42, title: "WIP: feat: thing", state: "open" } }],
+ ]);
+ const res = await gateway.mutatePr("/repo", "b", 42, "ready");
+ assert.equal(res.ok, true);
+ const patch = calls.find((c) => c.method === "PATCH");
+ assert.ok(patch, "expected a PATCH");
+ assert.deepEqual(JSON.parse(patch.body ?? "{}"), { title: "feat: thing" });
+});
+
+test("mutatePr ready refuses when the title carries no known draft prefix", async () => {
+ const { gateway, calls } = harness([["/pulls/42", { json: { number: 42, title: "feat: thing" } }]]);
+ const res = await gateway.mutatePr("/repo", "b", 42, "ready");
+ assert.equal(res.ok, false);
+ assert.match(res.error ?? "", /draft/i);
+ assert.equal(
+ calls.some((c) => c.method === "PATCH"),
+ false,
+ "must not rewrite a title it did not recognise",
+ );
+});
+
+test("mutatePr ready honours a server's configured WIP prefixes", async () => {
+ const http: Http = async (req) => {
+ if (req.method === "GET") return { status: 200, body: JSON.stringify({ number: 1, title: "Draft: x" }) };
+ return { status: 200, body: "{}" };
+ };
+ const gateway = createForgejoGateway({
+ http,
+ resolveRepo: async () => REF,
+ wipPrefixes: ["Draft:"],
+ });
+ assert.equal((await gateway.mutatePr("/repo", "b", 1, "ready")).ok, true);
+});
+
+test("mutatePr update-branch POSTs to /update with an explicit style", async () => {
+ const { gateway, calls } = harness([["/update", { json: {} }]]);
+ const res = await gateway.mutatePr("/repo", "b", 42, "update-branch");
+ assert.equal(res.ok, true);
+ assert.equal(calls[0].method, "POST");
+ assert.ok(calls[0].url.includes("/pulls/42/update"));
+ assert.ok(calls[0].url.includes("style=merge"));
+});
+
+test("mutatePr merge-squash POSTs the PascalCase Do field Forgejo requires", async () => {
+ const { gateway, calls } = harness([["/merge", { json: {} }]]);
+ const res = await gateway.mutatePr("/repo", "b", 42, "merge-squash");
+ assert.equal(res.ok, true);
+ assert.deepEqual(JSON.parse(calls[0].body ?? "{}"), { Do: "squash" });
+});
+
+test("mutatePr surfaces the server's own error message", async () => {
+ const { gateway } = harness([["/update", { status: 409, json: { message: "merge conflict" } }]]);
+ const res = await gateway.mutatePr("/repo", "b", 42, "update-branch");
+ assert.equal(res.ok, false);
+ assert.match(res.error ?? "", /merge conflict/);
+});
+
+test("a successful mutation invalidates the cached lookup for that branch", async () => {
+ const { gateway, calls } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { json: { statuses: [] } }],
+ ["/update", { json: {} }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ await gateway.mutatePr("/repo", "b", 42, "update-branch");
+ const n = calls.length;
+ await gateway.prForBranch("/repo", "b");
+ assert.ok(calls.length > n, "post-mutation poll must refetch, not serve stale state");
+});
+
+test("a failed mutation leaves the cache intact", async () => {
+ const { gateway, calls } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { json: { statuses: [] } }],
+ ["/update", { status: 500, body: "x" }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ await gateway.mutatePr("/repo", "b", 42, "update-branch");
+ const n = calls.length;
+ await gateway.prForBranch("/repo", "b");
+ assert.equal(calls.length, n);
+});
+
+// ---------------------------------------------------------------------------
+// Contract: no budget facet, because Forgejo has no quota to report.
+// ---------------------------------------------------------------------------
+
+test("the Forgejo gateway does not pretend to report a budget", async () => {
+ const { gateway } = harness([]);
+ const { hasBudgetReporting } = await import("../types.js");
+ assert.equal(hasBudgetReporting(gateway), false);
+});
+
+test("stats counts network calls and close() is safe to call twice", async () => {
+ const { gateway } = harness([
+ ["/pulls?", { json: [openPrRow] }],
+ ["/commits/cafebabe/status", { json: { statuses: [] } }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ assert.equal(gateway.stats().execs, 2);
+ gateway.close();
+ gateway.close();
+});
+
+// ---------------------------------------------------------------------------
+// Throttling. Forgejo itself has no rate limiter -- no /rate_limit endpoint, no
+// rate-limit headers, no config knob -- but an instance behind nginx `limit_req`,
+// Cloudflare or an anti-scraper gate certainly does, and a slow query can shed
+// load with a 503. Answering those by polling at the same cadence leans on a
+// server that just asked us to stop.
+// ---------------------------------------------------------------------------
+
+test("a 429 puts background lookups into backoff without further requests", async () => {
+ const { gateway, calls } = harness([["/pulls?", { status: 429, body: "slow down" }]]);
+ assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "throttled" });
+ const n = calls.length;
+ // A second background poll must not spend a request while told to wait.
+ assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "throttled" });
+ assert.equal(calls.length, n, "must not re-request while in backoff");
+});
+
+test("the backoff is reported as throttled, never as `none`", async () => {
+ // `none` would erase the PR pill on a server that merely asked us to wait.
+ const { gateway } = harness([["/pulls?", { status: 503, body: "" }]]);
+ const first = await gateway.prForBranch("/repo", "b");
+ assert.equal(first.kind, "unknown");
+ assert.equal(first.kind === "unknown" && first.reason, "throttled");
+});
+
+test("Retry-After in seconds is honoured, and the window then expires", async () => {
+ const { gateway, calls, tick } = harness([
+ ["/pulls?", { status: 429, headers: { "retry-after": "30" } }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ const n = calls.length;
+ tick(29_000);
+ await gateway.prForBranch("/repo", "b");
+ assert.equal(calls.length, n, "still inside the Retry-After window");
+ tick(2_000);
+ await gateway.prForBranch("/repo", "b");
+ assert.ok(calls.length > n, "the window must expire");
+});
+
+test("an absurd Retry-After is capped rather than parking the poller for a day", async () => {
+ const { gateway, calls, tick } = harness([
+ ["/pulls?", { status: 429, headers: { "retry-after": "86400" } }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ const n = calls.length;
+ tick(10 * 60_000);
+ await gateway.prForBranch("/repo", "b");
+ assert.ok(calls.length > n, "a hostile or buggy header must not disable polling");
+});
+
+test("a garbage Retry-After falls back to the default backoff", async () => {
+ const { gateway, calls, tick } = harness([
+ ["/pulls?", { status: 429, headers: { "retry-after": "next tuesday" } }],
+ ]);
+ await gateway.prForBranch("/repo", "b");
+ const n = calls.length;
+ tick(1_000);
+ await gateway.prForBranch("/repo", "b");
+ assert.equal(calls.length, n, "a default backoff still applies");
+});
+
+test("an interactive call is still attempted during backoff", async () => {
+ // A button press must reach the server and surface its real answer; silently
+ // returning a cached refusal would read as a dead button.
+ const { gateway, calls } = harness([["/pulls?", { status: 429, body: "" }]]);
+ await gateway.prForBranch("/repo", "b");
+ const n = calls.length;
+ await gateway.prForBranch("/repo", "b", { interactive: true });
+ assert.ok(calls.length > n);
+});
+
+test("a successful response clears the backoff", async () => {
+ // Review finding: this test used to advance the clock 31s past a 30s Retry-After, so
+ // the window had expired by time ALONE and the later poll reached the network whether
+ // or not `call` reset `backoffUntil`. It passed with the reset deleted.
+ //
+ // Now the clock stays INSIDE the window, and the success is forced through an
+ // interactive call (which is exempt from the backoff). A background poll afterwards
+ // can only reach the network because the reset ran.
+ const routes: Array<[string, { status?: number; json?: unknown; headers?: Record }]> = [
+ ["/pulls?", { status: 429, headers: { "retry-after": "30" } }],
+ ];
+ const { gateway, calls, tick } = harness(routes);
+ await gateway.prForBranch("/repo", "b");
+ tick(5_000); // still well within the 30s window
+ routes[0] = ["/pulls?", { json: [] }];
+ await gateway.prForBranch("/repo", "b", { interactive: true });
+ const n = calls.length;
+ // A BACKGROUND poll, on a branch with no cache entry: only the reset lets it out.
+ await gateway.prForBranch("/repo", "x");
+ assert.ok(calls.length > n, "no residual backoff after a success");
+});
+
+test("a short-circuited poll is not counted as a network call", async () => {
+ const { gateway } = harness([["/pulls?", { status: 429, body: "" }]]);
+ await gateway.prForBranch("/repo", "b");
+ const after = gateway.stats().execs;
+ await gateway.prForBranch("/repo", "b");
+ assert.equal(gateway.stats().execs, after, "backoff must not inflate the exec count");
+});
+
+test("openPrs also respects the backoff and returns an empty list", async () => {
+ const { gateway, calls } = harness([["/pulls?", { status: 429, body: "" }]]);
+ await gateway.openPrs("/repo", 30);
+ const n = calls.length;
+ assert.deepEqual(await gateway.openPrs("/repo", 30), []);
+ assert.equal(calls.length, n);
+});
+
+// ---------------------------------------------------------------------------
+// Review findings on the Forgejo gateway.
+// ---------------------------------------------------------------------------
+
+/** A gateway over a scripted `http`, so concurrency and call counts are visible. */
+function counting(handler: (url: string) => { status?: number; body?: string }) {
+ const urls: string[] = [];
+ let inflight = 0;
+ let peak = 0;
+ const http: Http = async (req: HttpRequest) => {
+ urls.push(req.url);
+ inflight += 1;
+ peak = Math.max(peak, inflight);
+ await new Promise((r) => setTimeout(r, 5));
+ inflight -= 1;
+ const res = handler(req.url);
+ return { status: res.status ?? 200, body: res.body ?? "[]", headers: {} };
+ };
+ const gateway = createForgejoGateway({ http, resolveRepo: async () => REF, now: () => 1_000 });
+ return { gateway, urls, peak: () => peak, lists: () => urls.filter((u) => u.includes("/pulls?")).length };
+}
+
+test("a successful mutation drops the cached open-PR list too", async () => {
+ // Review finding: only `prKey` was invalidated, so `open::` survived its
+ // full TTL. That list backs the "New worktree from PR" picker, so a squash-merged PR
+ // stayed listed and the checkout that followed failed, and a PR just marked ready
+ // still read as a draft. The GitHub gateway drops both, and both feed one picker.
+ const h = counting(() => ({ body: "[]" }));
+ await h.gateway.openPrs("/r", 30);
+ assert.equal(h.lists(), 1);
+ await h.gateway.openPrs("/r", 30);
+ assert.equal(h.lists(), 1, "served from cache");
+ await h.gateway.mutatePr("/r", "b", 7, "merge-squash");
+ await h.gateway.openPrs("/r", 30);
+ assert.equal(h.lists(), 2, "re-fetched after the mutation");
+});
+
+test("every cached limit for the repo is dropped, not only one", async () => {
+ // The key carries the limit, and the picker and the home screen ask for different
+ // ones, so a single delete leaves the other stale.
+ const h = counting(() => ({ body: "[]" }));
+ await h.gateway.openPrs("/r", 30);
+ await h.gateway.openPrs("/r", 5);
+ assert.equal(h.lists(), 2);
+ // The precondition is asserted, not assumed: invalidation only runs on SUCCESS, so a
+ // verb that failed in the stub would make this test pass for the wrong reason.
+ const r = await h.gateway.mutatePr("/r", "b", 7, "merge-squash");
+ assert.equal(r.ok, true, "the mutation must succeed for invalidation to be in play");
+ await h.gateway.openPrs("/r", 30);
+ await h.gateway.openPrs("/r", 5);
+ assert.equal(h.lists(), 4);
+});
+
+test("concurrent lookups for one branch share a single in-flight request", async () => {
+ // Review finding: results were cached but in-flight requests were not shared, so on
+ // a cold cache N worktrees of one repo each issued their own copy of a query this
+ // module measures at 1.5-30s against a real instance. The GitHub gateway dedupes for
+ // exactly this reason.
+ const h = counting(() => ({ body: "[]" }));
+ await Promise.all([
+ h.gateway.prForBranch("/r", "same"),
+ h.gateway.prForBranch("/r", "same"),
+ h.gateway.prForBranch("/r", "same"),
+ ]);
+ assert.equal(h.peak(), 1, "one request served all three callers");
+});
+
+test("different branches are NOT collapsed into one request", async () => {
+ // The key must include the branch, or one worktree's question gets another's answer.
+ const h = counting(() => ({ body: "[]" }));
+ await Promise.all([h.gateway.prForBranch("/r", "a"), h.gateway.prForBranch("/r", "b")]);
+ assert.equal(h.urls.length, 2);
+});
+
+test("concurrent openPrs for one repo and limit share a request too", async () => {
+ const h = counting(() => ({ body: "[]" }));
+ await Promise.all([h.gateway.openPrs("/r", 30), h.gateway.openPrs("/r", 30)]);
+ assert.equal(h.peak(), 1);
+});
diff --git a/server/src/forge/forgejo/gateway.ts b/server/src/forge/forgejo/gateway.ts
new file mode 100644
index 00000000..7bca7050
--- /dev/null
+++ b/server/src/forge/forgejo/gateway.ts
@@ -0,0 +1,531 @@
+/**
+ * gateway.ts — the Forgejo implementation of {@link ForgeGateway}, over REST.
+ *
+ * No subprocess. Forgejo's API is plain REST with a token, so a request is a
+ * `fetch`, which removes three whole classes of problem the `gh`-backed GitHub
+ * gateway has to manage: process fan-out (the reason `concurrency.ts` exists),
+ * CLI discovery and version skew, and stdout parsing.
+ *
+ * It also implements NO budget facet on purpose. Forgejo exposes no `rate_limit`
+ * endpoint and sends no rate-limit response headers, so there is no quota to
+ * ration — the GitHub gateway's router/policy/budget machinery has no counterpart
+ * here, and faking one would put a number on screen that means nothing. See
+ * `../types.ts`.
+ *
+ * What remains is a cache (to keep the home-screen fan-out cheap) and strict
+ * discipline about the difference between "no PR" and "could not tell", which is
+ * SPEC-32 §6.5 and the reason every failure path below returns `unknown`.
+ */
+
+import type { OpenPr, PullRequestInfo } from "../../git.js";
+import { rollupChecks } from "../../git.js";
+import type { PrCheckDTO } from "../../protocol.js";
+import type { ForgeGateway, GatewayStats, PrLookup, PrMutation } from "../types.js";
+import {
+ DEFAULT_WIP_PREFIXES,
+ combinedStatusUrl,
+ forgejoChecks,
+ mapForgejoPr,
+ mergeUrl,
+ openPrsUrl,
+ pickLatestPr,
+ prDetailUrl,
+ prForBranchUrl,
+ readyTitle,
+ updateBranchUrl,
+} from "./map.js";
+
+/** One HTTP request. `timeoutMs` is advisory to the adapter. */
+export interface HttpRequest {
+ url: string;
+ method: string;
+ headers: Record;
+ body?: string;
+ timeoutMs: number;
+}
+
+/**
+ * An HTTP response. `status: 0` means the request never completed (DNS, TLS,
+ * timeout, connection refused).
+ */
+export interface HttpResponse {
+ status: number;
+ body: string;
+ /**
+ * Response headers, keys lower-cased. Only `retry-after` is read today, but a
+ * throttled response is useless without it: guessing a backoff either ignores
+ * the server's instruction or parks the poller far longer than it asked for.
+ */
+ headers?: Record;
+}
+
+/**
+ * The injectable HTTP seam. Mirrors {@link import("../../github/gateway.js").Exec}
+ * in one important respect: it MUST NOT reject. A transport failure is data
+ * (`status: 0`), not an exception, so a single unreachable instance can never
+ * take down the poller that fans out across every worktree.
+ */
+export type Http = (req: HttpRequest) => Promise;
+
+/** Where a local repo path lives on a Forgejo instance. */
+export interface ForgejoRepoRef {
+ /** Instance origin, e.g. `https://git.example.com` (no trailing slash needed). */
+ baseUrl: string;
+ owner: string;
+ repo: string;
+ /** API token. Absent means unauthenticated — fine for public reads. */
+ token?: string;
+}
+
+/** Resolve a local repo path to its Forgejo coordinates, or null if it isn't one. */
+export type ResolveRepo = (repoPath: string) => Promise;
+
+export interface ForgejoGatewayDeps {
+ http: Http;
+ resolveRepo: ResolveRepo;
+ /** Clock, injectable so cache expiry is testable without real time. */
+ now?: () => number;
+ /**
+ * The instance's `WORK_IN_PROGRESS_PREFIXES`. Defaults to Forgejo's own
+ * defaults; pass the server's real value when it can be read, because "mark
+ * ready for review" strips one of these from the title.
+ */
+ wipPrefixes?: readonly string[];
+}
+
+/** Read timeout for cheap, indexed reads (combined status, PR detail). */
+const READ_TIMEOUT_MS = 5_000;
+/**
+ * Timeout for the branch->PR lookup specifically.
+ *
+ * Far above {@link READ_TIMEOUT_MS} because Forgejo's `head` filter is an
+ * unindexed scan: measured against codeberg.org (Forgejo 16, ~9.5k PRs) the same
+ * `state=all&head=X&limit=5` query returned in 1.5s on some attempts and 20-30s
+ * on others. A tight cap turns that variance into a stream of `unknown` results,
+ * which flickers the PR pill to "unmeasured" on a repo that is merely busy.
+ *
+ * A self-hosted instance with a normal repo is expected to be far quicker; this
+ * cap exists for the pathological end. If a real instance proves slow enough for
+ * this to hurt, the known optimisation is the dedicated
+ * `/pulls/{base}/{head}` endpoint (~1.9s, single object) once the base ref is
+ * known -- deliberately not built yet, since it trades a guess about `base` for
+ * speed we may not need.
+ */
+const BRANCH_LOOKUP_TIMEOUT_MS = 20_000;
+/** The picker's list is larger, so it gets the same slack `gh` got. */
+const OPEN_PRS_TIMEOUT_MS = 8_000;
+/**
+ * Write timeout — deliberately far above the read timeout. Abandoning a read
+ * costs a stale pill; abandoning a write costs correctness, because the server
+ * may apply it anyway while the caller, told it failed, skips its cache
+ * invalidation and reports pre-mutation state until the TTL runs out.
+ */
+const MUTATION_TIMEOUT_MS = 60_000;
+
+const TTL_PR_MS = 20_000;
+const TTL_OPEN_PRS_MS = 60_000;
+
+/**
+ * Statuses that mean "stop asking": 429 from a rate limiter, 503 from a server
+ * shedding load. Forgejo core has no rate limiter, but instances routinely sit
+ * behind nginx `limit_req`, Cloudflare or an anti-scraper gate.
+ */
+const THROTTLE_STATUSES = new Set([429, 503]);
+/** Backoff when the server throttles us without saying for how long. */
+const DEFAULT_BACKOFF_MS = 60_000;
+/**
+ * Ceiling on an honoured `Retry-After`. A misconfigured proxy (or a hostile one)
+ * can answer `86400`, and obeying that literally would silently disable PR
+ * polling for a day with no way for the user to tell why.
+ */
+const MAX_BACKOFF_MS = 5 * 60_000;
+
+/** Parse `Retry-After`: delta-seconds or an HTTP date. Null when unusable. */
+function parseRetryAfter(value: string | undefined, now: number): number | null {
+ if (value === undefined) return null;
+ const trimmed = value.trim();
+ if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1000;
+ const at = Date.parse(trimmed);
+ return Number.isFinite(at) ? Math.max(0, at - now) : null;
+}
+
+interface CacheEntry {
+ value: unknown;
+ expiresAt: number;
+}
+
+function isOk(res: HttpResponse): boolean {
+ return res.status >= 200 && res.status < 300;
+}
+
+/** Parse a JSON body, returning `undefined` rather than throwing. */
+function parseJson(body: string): unknown {
+ try {
+ return JSON.parse(body) as unknown;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * The most useful error text available: Forgejo's own `message` when it sent
+ * one, else the raw body, else the status. Surfacing the server's wording keeps
+ * the diagnosis accurate (a branch-protection refusal reads as such).
+ */
+function errorText(res: HttpResponse): string {
+ const parsed = parseJson(res.body);
+ if (typeof parsed === "object" && parsed !== null) {
+ const msg = (parsed as { message?: unknown }).message;
+ if (typeof msg === "string" && msg.trim().length > 0) return msg.trim();
+ }
+ const raw = res.body.trim();
+ if (raw.length > 0 && raw.length < 300) return raw;
+ return res.status === 0 ? "request failed" : `HTTP ${res.status}`;
+}
+
+export function createForgejoGateway(deps: ForgejoGatewayDeps): ForgeGateway {
+ const now = deps.now ?? (() => Date.now());
+ const wipPrefixes = deps.wipPrefixes ?? DEFAULT_WIP_PREFIXES;
+ const cache = new Map();
+ const stats: GatewayStats = { execs: 0, exemptExecs: 0, cacheHits: 0 };
+ /** Epoch ms until which background requests are withheld. 0 = not throttled. */
+ let backoffUntil = 0;
+
+ /** True while a server-requested pause is in force. */
+ const throttled = (): boolean => backoffUntil > now();
+
+ /**
+ * Record a throttling response. Interactive callers are still allowed through
+ * (a button press must reach the server), so this only gates polling.
+ */
+ function noteThrottle(res: HttpResponse): void {
+ const asked = parseRetryAfter(res.headers?.["retry-after"], now());
+ const wait = Math.min(asked ?? DEFAULT_BACKOFF_MS, MAX_BACKOFF_MS);
+ backoffUntil = now() + Math.max(wait, 1);
+ }
+
+ function cacheGet(key: string): T | undefined {
+ const hit = cache.get(key);
+ if (hit === undefined) return undefined;
+ if (hit.expiresAt <= now()) {
+ cache.delete(key);
+ return undefined;
+ }
+ return hit.value as T;
+ }
+
+ function cacheSet(key: string, value: unknown, ttlMs: number): void {
+ cache.set(key, { value, expiresAt: now() + ttlMs });
+ }
+
+ /**
+ * In-flight requests, so N callers asking the same question issue ONE request.
+ *
+ * The cache alone is not enough: it is only populated once a response arrives, so on
+ * a cold cache the home-screen fan-out -- every worktree of a repo at once -- issued
+ * one copy per worktree of a query this module measures at 1.5-30s against a real
+ * instance (see BRANCH_LOOKUP_TIMEOUT_MS). The GitHub gateway shares in-flight work
+ * for the same reason.
+ *
+ * Keyed exactly like the cache entry it will produce, so a branch never receives
+ * another branch's answer.
+ */
+ const inflight = new Map>();
+
+ function share(key: string, run: () => Promise): Promise {
+ const hit = inflight.get(key) as Promise | undefined;
+ if (hit !== undefined) return hit;
+ // `finally` rather than `then`: a rejection must also release the slot, or one
+ // failure would wedge that key for the process lifetime.
+ const p = run().finally(() => {
+ if (inflight.get(key) === p) inflight.delete(key);
+ });
+ inflight.set(key, p);
+ return p;
+ }
+
+ /** Drop every cached open-PR list for a repo, whatever limit it was asked with. */
+ function dropOpenPrLists(repoPath: string): void {
+ const prefix = `open:${repoPath}:`;
+ for (const key of cache.keys()) if (key.startsWith(prefix)) cache.delete(key);
+ }
+
+ function headers(ref: ForgejoRepoRef, withBody: boolean): Record {
+ const h: Record = { Accept: "application/json" };
+ // Forgejo/Gitea's own credential form. Omitted entirely when absent, so an
+ // unauthenticated read of a public repo is not sent a bogus header.
+ if (ref.token !== undefined && ref.token.length > 0) h.Authorization = `token ${ref.token}`;
+ if (withBody) h["Content-Type"] = "application/json";
+ return h;
+ }
+
+ /**
+ * Issue one request. Defensive against an adapter that rejects despite the
+ * {@link Http} contract — a throwing adapter must degrade to `unknown`, not
+ * take down the caller.
+ */
+ async function call(
+ ref: ForgejoRepoRef,
+ url: string,
+ opts: { method?: string; body?: unknown; timeoutMs?: number } = {},
+ ): Promise {
+ const method = opts.method ?? "GET";
+ const body = opts.body === undefined ? undefined : JSON.stringify(opts.body);
+ stats.execs += 1;
+ let res: HttpResponse;
+ try {
+ res = await deps.http({
+ url,
+ method,
+ headers: headers(ref, body !== undefined),
+ body,
+ timeoutMs: opts.timeoutMs ?? READ_TIMEOUT_MS,
+ });
+ } catch {
+ res = { status: 0, body: "" };
+ }
+ if (THROTTLE_STATUSES.has(res.status)) noteThrottle(res);
+ // Any completed, non-throttled answer means the server is talking to us
+ // again -- holding the backoff after that would throttle us on our own.
+ else if (res.status !== 0) backoffUntil = 0;
+ return res;
+ }
+
+ const prKey = (repoPath: string, branch: string) => `pr:${repoPath}:${branch}`;
+
+ async function prForBranch(
+ repoPath: string,
+ branch: string,
+ opts?: { interactive?: boolean },
+ ): Promise {
+ const ref = await deps.resolveRepo(repoPath);
+ // Not a Forgejo repo (or the remote could not be read): we never queried, so
+ // the answer is unmeasured. Returning `none` here would erase the pill and
+ // read as "this branch has no PR" -- a fact we do not have.
+ if (ref === null) return { kind: "unknown", reason: "error" };
+
+ const key = prKey(repoPath, branch);
+ if (opts?.interactive !== true) {
+ const hit = cacheGet(key);
+ if (hit !== undefined) {
+ stats.cacheHits += 1;
+ return hit;
+ }
+ // The server asked us to wait. `throttled`, not `none`: a pause is not
+ // evidence that the branch has no PR (SPEC-32 §6.5).
+ if (throttled()) return { kind: "unknown", reason: "throttled" };
+ }
+
+ // Shared, so the fan-out across a repo's worktrees issues one request per
+ // (repo, branch) rather than one per caller.
+ const listed = await share(`req:${key}`, () =>
+ call(ref, prForBranchUrl(ref.baseUrl, ref.owner, ref.repo, branch), {
+ timeoutMs: BRANCH_LOOKUP_TIMEOUT_MS,
+ }),
+ );
+ if (!isOk(listed)) {
+ return {
+ kind: "unknown",
+ reason: THROTTLE_STATUSES.has(listed.status) ? "throttled" : "error",
+ };
+ }
+ const rows = parseJson(listed.body);
+ // A non-array body is a malformed or error response, not an empty repo.
+ if (!Array.isArray(rows)) return { kind: "unknown", reason: "error" };
+
+ const raw = pickLatestPr(rows as Array | null>);
+ if (raw === null) {
+ const miss: PrLookup = { kind: "none" };
+ cacheSet(key, miss, TTL_PR_MS);
+ return miss;
+ }
+ const core = mapForgejoPr(raw);
+ // We found a row but could not read it — again unmeasured, not absent.
+ if (core === null) return { kind: "unknown", reason: "error" };
+
+ let checks: PrCheckDTO[] = [];
+ if (core.headSha !== null) {
+ const status = await call(ref, combinedStatusUrl(ref.baseUrl, ref.owner, ref.repo, core.headSha));
+ // A PR we already found must not disappear because CI could not be read;
+ // an empty check list renders as "no checks", which is the honest fallback.
+ if (isOk(status)) checks = forgejoChecks(parseJson(status.body));
+ }
+
+ const pr: PullRequestInfo = {
+ number: core.number,
+ url: core.url,
+ state: core.state,
+ title: core.title,
+ isDraft: core.isDraft,
+ mergeable: core.mergeable,
+ mergeStateStatus: core.mergeStateStatus,
+ baseRefName: core.baseRefName,
+ checks,
+ checkRollup: rollupChecks(checks),
+ // Forgejo exposes resolution per review COMMENT (`resolver`), not per
+ // thread, and only via a reviews -> comments walk. Until that walk is
+ // verified against an instance with real review threads, the count is
+ // declared unmeasured rather than reported as 0 -- a plain 0 would render
+ // as "no unresolved comments" and be believed (SPEC-32 §6.5).
+ unresolvedComments: 0,
+ unresolvedUnknown: true,
+ };
+ const found: PrLookup = { kind: "pr", pr };
+ cacheSet(key, found, TTL_PR_MS);
+ return found;
+ }
+
+ async function openPrs(repoPath: string, limit: number, opts?: { interactive?: boolean }): Promise {
+ const ref = await deps.resolveRepo(repoPath);
+ if (ref === null) return [];
+
+ const key = `open:${repoPath}:${limit}`;
+ if (opts?.interactive !== true) {
+ const hit = cacheGet(key);
+ if (hit !== undefined) {
+ stats.cacheHits += 1;
+ return hit;
+ }
+ if (throttled()) return [];
+ }
+
+ const res = await share(`req:${key}`, () =>
+ call(ref, openPrsUrl(ref.baseUrl, ref.owner, ref.repo, limit), {
+ timeoutMs: OPEN_PRS_TIMEOUT_MS,
+ }),
+ );
+ if (!isOk(res)) return [];
+ const rows = parseJson(res.body);
+ if (!Array.isArray(rows)) return [];
+
+ const out: OpenPr[] = [];
+ for (const raw of rows) {
+ if (typeof raw !== "object" || raw === null) continue;
+ const r = raw as Record;
+ if (typeof r.number !== "number") continue;
+ const head = r.head as { ref?: unknown } | undefined;
+ out.push({
+ number: r.number,
+ title: typeof r.title === "string" ? r.title : "",
+ headRefName: typeof head?.ref === "string" ? head.ref : "",
+ isDraft: r.draft === true,
+ url: typeof r.html_url === "string" ? r.html_url : "",
+ });
+ }
+ // Newest first. Sorted here rather than requested from the server because
+ // Forgejo's `sort` enum has no created-desc member: the default order is
+ // newest-first in practice but is not part of the contract, and the picker
+ // relies on it.
+ out.sort((a, b) => b.number - a.number);
+ cacheSet(key, out, TTL_OPEN_PRS_MS);
+ return out;
+ }
+
+ /**
+ * Take a PR out of draft. On Forgejo this is a TITLE REWRITE, not a flag flip:
+ * `draft` is a read-only projection of the title's WIP prefix and
+ * `EditPullRequestOption` has no `draft` field.
+ *
+ * The title is re-read immediately before the write instead of being taken from
+ * the cached lookup, because a PATCH sends the whole title: acting on a stale
+ * copy would silently revert an edit made in the web UI since the last poll.
+ */
+ async function markReady(ref: ForgejoRepoRef, number: number): Promise<{ ok: boolean; error?: string }> {
+ const url = prDetailUrl(ref.baseUrl, ref.owner, ref.repo, number);
+ const res = await call(ref, url);
+ if (!isOk(res)) return { ok: false, error: errorText(res) };
+ const parsed = parseJson(res.body);
+ const title = typeof parsed === "object" && parsed !== null ? (parsed as { title?: unknown }).title : undefined;
+ if (typeof title !== "string") return { ok: false, error: "could not read the pull request title" };
+
+ const next = readyTitle(title, wipPrefixes);
+ if (next === null) {
+ return {
+ ok: false,
+ error: `#${number} is not a draft: its title carries none of the instance's work-in-progress prefixes (${wipPrefixes.join(", ")})`,
+ };
+ }
+ const patched = await call(ref, url, { method: "PATCH", body: { title: next }, timeoutMs: MUTATION_TIMEOUT_MS });
+ return isOk(patched) ? { ok: true } : { ok: false, error: errorText(patched) };
+ }
+
+ async function mutatePr(
+ repoPath: string,
+ branch: string,
+ number: number,
+ verb: PrMutation,
+ ): Promise<{ ok: boolean; error?: string }> {
+ const ref = await deps.resolveRepo(repoPath);
+ if (ref === null) return { ok: false, error: "not a Forgejo repository" };
+
+ let result: { ok: boolean; error?: string };
+ if (verb === "ready") {
+ result = await markReady(ref, number);
+ } else if (verb === "update-branch") {
+ const res = await call(ref, updateBranchUrl(ref.baseUrl, ref.owner, ref.repo, number), {
+ method: "POST",
+ timeoutMs: MUTATION_TIMEOUT_MS,
+ });
+ result = isOk(res) ? { ok: true } : { ok: false, error: errorText(res) };
+ } else {
+ // `Do` is PascalCase and required by MergePullRequestOption. Naming the
+ // strategy explicitly also avoids inheriting the instance's configurable
+ // default, which would make the same button squash on one server and
+ // rebase on another.
+ const res = await call(ref, mergeUrl(ref.baseUrl, ref.owner, ref.repo, number), {
+ method: "POST",
+ body: { Do: "squash" },
+ timeoutMs: MUTATION_TIMEOUT_MS,
+ });
+ result = isOk(res) ? { ok: true } : { ok: false, error: errorText(res) };
+ }
+
+ // Only a success invalidates: dropping the entry after a failed mutation
+ // would spend a fresh round trip to re-learn the state we already hold.
+ //
+ // BOTH the branch lookup and every open-PR list go: that list backs the "New
+ // worktree from PR" picker, so a squash-merged PR left in it leads to a checkout
+ // that fails, and a PR just marked ready still reads as a draft. The key carries
+ // the limit, and the picker and the home screen ask with different ones, so one
+ // delete is not enough.
+ if (result.ok) {
+ cache.delete(prKey(repoPath, branch));
+ dropOpenPrLists(repoPath);
+ }
+ return result;
+ }
+
+ return {
+ prForBranch,
+ openPrs,
+ mutatePr,
+ stats: () => ({ ...stats }),
+ close: () => {
+ cache.clear();
+ inflight.clear();
+ },
+ };
+}
+
+/**
+ * Production {@link Http} over global `fetch`, upholding the never-reject
+ * contract: every failure mode becomes `status: 0`.
+ */
+export function createFetchHttp(): Http {
+ return async (req: HttpRequest): Promise => {
+ try {
+ const res = await fetch(req.url, {
+ method: req.method,
+ headers: req.headers,
+ body: req.body,
+ signal: AbortSignal.timeout(req.timeoutMs),
+ });
+ const headers: Record = {};
+ const retryAfter = res.headers.get("retry-after");
+ if (retryAfter !== null) headers["retry-after"] = retryAfter;
+ return { status: res.status, body: await res.text(), headers };
+ } catch {
+ return { status: 0, body: "" };
+ }
+ };
+}
diff --git a/server/src/forge/forgejo/map.test.ts b/server/src/forge/forgejo/map.test.ts
new file mode 100644
index 00000000..5603639d
--- /dev/null
+++ b/server/src/forge/forgejo/map.test.ts
@@ -0,0 +1,268 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ DEFAULT_WIP_PREFIXES,
+ PR_PAGE_SIZE,
+ forgejoChecks,
+ isEpochTimestamp,
+ mapForgejoPr,
+ parseForgejoRemote,
+ pickLatestPr,
+ prForBranchUrl,
+ openPrsUrl,
+ readyTitle,
+ updateBranchUrl,
+} from "./map.js";
+
+// ---------------------------------------------------------------------------
+// URL building. Forgejo's `head` filter takes a BARE branch name -- passing
+// GitHub's `owner:branch` returns an empty list, which the gateway would map to
+// `none` and erase the pill (the very defect SPEC-32 §6.5 exists to prevent).
+// ---------------------------------------------------------------------------
+
+test("prForBranchUrl filters by bare branch name, never owner:branch", () => {
+ const u = new URL(prForBranchUrl("https://git.example.com", "acme", "app", "feat/x"));
+ assert.equal(u.pathname, "/api/v1/repos/acme/app/pulls");
+ assert.equal(u.searchParams.get("head"), "feat/x");
+ assert.equal(u.searchParams.get("state"), "all");
+});
+
+test("prForBranchUrl percent-encodes refs that would otherwise inject query params", () => {
+ const u = new URL(prForBranchUrl("https://git.example.com", "acme", "app", "a&state=closed"));
+ assert.equal(u.searchParams.get("head"), "a&state=closed");
+ assert.equal(u.searchParams.get("state"), "all");
+});
+
+test("prForBranchUrl requests a page, not limit=1, because order is not guaranteed", () => {
+ const u = new URL(prForBranchUrl("https://git.example.com", "acme", "app", "b"));
+ assert.ok(Number(u.searchParams.get("limit")) > 1);
+ // Forgejo's sort enum has no created-desc, so we must not pretend to ask for one.
+ assert.equal(u.searchParams.get("sort"), null);
+ assert.equal(u.searchParams.get("direction"), null);
+});
+
+// Measured against codeberg.org (Forgejo 16, ~9.5k PRs): `state=all` combined with
+// a `head` filter costs ~1.3s at limit=5 but 17-19s at limit=30, and 504s outright
+// often enough to matter. The scan is unindexed, so this page size is a latency
+// cliff on the hot path, not a tuning preference.
+test("prForBranchUrl keeps the page small -- the head-filtered scan is unindexed", () => {
+ assert.ok(PR_PAGE_SIZE >= 3, "too small to survive imperfect default ordering");
+ assert.ok(PR_PAGE_SIZE <= 10, `page size ${PR_PAGE_SIZE} risks a multi-second or 504 hot path`);
+ const u = new URL(prForBranchUrl("https://git.example.com", "acme", "app", "b"));
+ assert.equal(u.searchParams.get("limit"), String(PR_PAGE_SIZE));
+});
+
+test("openPrsUrl asks only for open PRs and honours the caller's limit", () => {
+ const u = new URL(openPrsUrl("https://git.example.com", "acme", "app", 30));
+ assert.equal(u.searchParams.get("state"), "open");
+ assert.equal(u.searchParams.get("limit"), "30");
+});
+
+test("updateBranchUrl targets the PR index with an explicit merge style", () => {
+ const u = new URL(updateBranchUrl("https://git.example.com", "acme", "app", 7));
+ assert.equal(u.pathname, "/api/v1/repos/acme/app/pulls/7/update");
+ assert.equal(u.searchParams.get("style"), "merge");
+});
+
+// ---------------------------------------------------------------------------
+// Hazard 3: no created-desc sort. `limit=1` is unsafe -- an older CLOSED PR
+// could win the slot over a newer OPEN one and flip the glyph.
+// ---------------------------------------------------------------------------
+
+test("pickLatestPr takes the highest number regardless of arrival order", () => {
+ const rows = [{ number: 3 }, { number: 91 }, { number: 12 }];
+ assert.equal(pickLatestPr(rows)?.number, 91);
+});
+
+test("pickLatestPr returns null for an empty list", () => {
+ assert.equal(pickLatestPr([]), null);
+});
+
+test("pickLatestPr ignores rows without a usable number", () => {
+ const rows = [{ number: "nope" }, { number: 4 }, {}, null];
+ assert.equal(pickLatestPr(rows)?.number, 4);
+});
+
+// ---------------------------------------------------------------------------
+// PR mapping. Forgejo has no mergeStateStatus, and reports MERGED via a bool.
+// ---------------------------------------------------------------------------
+
+test("mapForgejoPr distinguishes MERGED from CLOSED via the merged flag", () => {
+ const closed = mapForgejoPr({ number: 1, state: "closed", merged: false });
+ const merged = mapForgejoPr({ number: 2, state: "closed", merged: true });
+ assert.equal(closed?.state, "CLOSED");
+ assert.equal(merged?.state, "MERGED");
+});
+
+test("mapForgejoPr upper-cases the open state", () => {
+ assert.equal(mapForgejoPr({ number: 1, state: "open", merged: false })?.state, "OPEN");
+});
+
+test("mapForgejoPr maps mergeable onto the GraphQL vocabulary the DTO speaks", () => {
+ assert.equal(mapForgejoPr({ number: 1, state: "open", mergeable: true })?.mergeable, "MERGEABLE");
+ assert.equal(mapForgejoPr({ number: 1, state: "open", mergeable: false })?.mergeable, "CONFLICTING");
+ assert.equal(mapForgejoPr({ number: 1, state: "open" })?.mergeable, "UNKNOWN");
+});
+
+test("mapForgejoPr reports mergeStateStatus as null -- Forgejo has no such concept", () => {
+ const pr = mapForgejoPr({ number: 1, state: "open", mergeable: true });
+ assert.equal(pr?.mergeStateStatus, null);
+});
+
+test("mapForgejoPr reads url from html_url, not the API url", () => {
+ const pr = mapForgejoPr({
+ number: 5,
+ state: "open",
+ html_url: "https://git.example.com/acme/app/pulls/5",
+ url: "https://git.example.com/api/v1/repos/acme/app/pulls/5",
+ });
+ assert.equal(pr?.url, "https://git.example.com/acme/app/pulls/5");
+});
+
+test("mapForgejoPr carries draft, base ref and head sha", () => {
+ const pr = mapForgejoPr({
+ number: 5,
+ state: "open",
+ draft: true,
+ base: { ref: "main" },
+ head: { ref: "feat/x", sha: "deadbeef" },
+ });
+ assert.equal(pr?.isDraft, true);
+ assert.equal(pr?.baseRefName, "main");
+ assert.equal(pr?.headSha, "deadbeef");
+});
+
+test("mapForgejoPr rejects a row with no usable number", () => {
+ assert.equal(mapForgejoPr({ state: "open" }), null);
+ assert.equal(mapForgejoPr(null), null);
+});
+
+// ---------------------------------------------------------------------------
+// Hazard 1: `draft` is derived from a title prefix, and the prefix list is
+// server-configurable. "Mark ready" is a title rewrite, not a flag flip.
+// ---------------------------------------------------------------------------
+
+test("readyTitle strips a configured WIP prefix", () => {
+ assert.equal(readyTitle("WIP: add thing", DEFAULT_WIP_PREFIXES), "add thing");
+ assert.equal(readyTitle("[WIP] add thing", DEFAULT_WIP_PREFIXES), "add thing");
+});
+
+test("readyTitle matches prefixes case-insensitively, as Forgejo does", () => {
+ assert.equal(readyTitle("wip: add thing", DEFAULT_WIP_PREFIXES), "add thing");
+ assert.equal(readyTitle("Wip: add thing", DEFAULT_WIP_PREFIXES), "add thing");
+});
+
+test("readyTitle returns null when no prefix matches, so we never rewrite blindly", () => {
+ assert.equal(readyTitle("add thing", DEFAULT_WIP_PREFIXES), null);
+ // "WIPE" is not the "WIP:" prefix -- a substring match would corrupt the title.
+ assert.equal(readyTitle("WIPE the cache", DEFAULT_WIP_PREFIXES), null);
+});
+
+test("readyTitle honours a server's custom prefix list", () => {
+ assert.equal(readyTitle("Draft: x", ["Draft:"]), "x");
+ assert.equal(readyTitle("WIP: x", ["Draft:"]), null);
+});
+
+test("readyTitle never yields an empty title", () => {
+ assert.equal(readyTitle("WIP:", DEFAULT_WIP_PREFIXES), null);
+ assert.equal(readyTitle("WIP: ", DEFAULT_WIP_PREFIXES), null);
+});
+
+// ---------------------------------------------------------------------------
+// Check rollup. Forgejo's per-status field is `status` (GitHub REST uses
+// `state`), and its enum includes `skipped`, which GitHub's status vocabulary
+// has no member for.
+// ---------------------------------------------------------------------------
+
+test("forgejoChecks reads the `status` field and maps the full enum", () => {
+ const checks = forgejoChecks({
+ statuses: [
+ { context: "a", status: "success" },
+ { context: "b", status: "failure" },
+ { context: "c", status: "error" },
+ { context: "d", status: "pending" },
+ { context: "e", status: "skipped" },
+ { context: "f", status: "warning" },
+ ],
+ });
+ assert.deepEqual(
+ checks.map((c) => [c.name, c.bucket]),
+ [
+ ["a", "pass"],
+ ["b", "fail"],
+ ["c", "fail"],
+ ["d", "pending"],
+ ["e", "skipping"],
+ ["f", "skipping"],
+ ],
+ );
+});
+
+test("forgejoChecks does not silently bucket an unknown state as passing", () => {
+ const [c] = forgejoChecks({ statuses: [{ context: "x", status: "something-new" }] });
+ assert.equal(c.bucket, "pending");
+});
+
+test("forgejoChecks carries target_url as detailsUrl and leaves workflowName null", () => {
+ const [c] = forgejoChecks({
+ statuses: [{ context: "x", status: "success", target_url: "https://ci/1" }],
+ });
+ assert.equal(c.detailsUrl, "https://ci/1");
+ assert.equal(c.workflowName, null);
+});
+
+test("forgejoChecks tolerates a missing or malformed statuses array", () => {
+ assert.deepEqual(forgejoChecks({}), []);
+ assert.deepEqual(forgejoChecks(null), []);
+ assert.deepEqual(forgejoChecks({ statuses: "nope" }), []);
+});
+
+// ---------------------------------------------------------------------------
+// Hazard: Forgejo returns epoch for unset timestamps instead of null. Feeding
+// that into a duration renders as ~56 years (SPEC-47's timings).
+// ---------------------------------------------------------------------------
+
+test("isEpochTimestamp recognises Forgejo's unset-time sentinel", () => {
+ assert.equal(isEpochTimestamp("1970-01-01T01:00:00+01:00"), true);
+ assert.equal(isEpochTimestamp("1970-01-01T00:00:00Z"), true);
+ assert.equal(isEpochTimestamp("2026-07-24T18:20:47+02:00"), false);
+ assert.equal(isEpochTimestamp(undefined), false);
+ assert.equal(isEpochTimestamp("not a date"), false);
+});
+
+// ---------------------------------------------------------------------------
+// Remote parsing: any host, since a Forgejo instance is self-hosted.
+// ---------------------------------------------------------------------------
+
+test("parseForgejoRemote handles ssh and https remotes on an arbitrary host", () => {
+ assert.deepEqual(parseForgejoRemote("git@git.example.com:acme/app.git"), {
+ host: "git.example.com",
+ owner: "acme",
+ repo: "app",
+ });
+ assert.deepEqual(parseForgejoRemote("https://git.example.com/acme/app.git"), {
+ host: "git.example.com",
+ owner: "acme",
+ repo: "app",
+ });
+ assert.deepEqual(parseForgejoRemote("https://git.example.com/acme/app"), {
+ host: "git.example.com",
+ owner: "acme",
+ repo: "app",
+ });
+});
+
+test("parseForgejoRemote keeps an explicit port and strips ssh:// and userinfo", () => {
+ assert.deepEqual(parseForgejoRemote("ssh://git@git.example.com:2222/acme/app.git"), {
+ host: "git.example.com:2222",
+ owner: "acme",
+ repo: "app",
+ });
+});
+
+test("parseForgejoRemote returns null for a remote it cannot read", () => {
+ assert.equal(parseForgejoRemote(""), null);
+ assert.equal(parseForgejoRemote("not-a-remote"), null);
+ assert.equal(parseForgejoRemote("https://git.example.com/acme"), null);
+});
diff --git a/server/src/forge/forgejo/map.ts b/server/src/forge/forgejo/map.ts
new file mode 100644
index 00000000..0eb415f0
--- /dev/null
+++ b/server/src/forge/forgejo/map.ts
@@ -0,0 +1,353 @@
+/**
+ * map.ts — pure Forgejo REST mapping: URL builders and payload adapters.
+ *
+ * Pure by design (no I/O, no clock), because every hazard in Forgejo's API that
+ * can silently corrupt a PR signal lives here and must be unit-testable:
+ *
+ * 1. `draft` is a READ-only projection of the title: Forgejo marks a PR draft
+ * when its title starts with a `WORK_IN_PROGRESS_PREFIXES` entry (default
+ * `WIP:,[WIP]`, matched case-insensitively, configurable per instance).
+ * `EditPullRequestOption` has no `draft` field, so "mark ready for review"
+ * is a TITLE REWRITE -- see {@link readyTitle}.
+ * 2. There is no `mergeStateStatus`. GitHub's BEHIND/BLOCKED/CLEAN vocabulary
+ * has no Forgejo counterpart, so it is reported as `null` (unknown) rather
+ * than guessed -- a wrong CLEAN would tell the user a blocked PR is ready.
+ * 3. The `sort` enum has no created-desc member and there is no `direction`
+ * param, so "the newest PR on this branch" is NOT expressible as a query.
+ * Default order is newest-first in practice but is not part of the contract,
+ * so we page and pick -- see {@link pickLatestPr}.
+ * 4. Each combined-status entry keys its state as `status` (GitHub REST uses
+ * `state`) and the enum includes `skipped`, which GitHub's status vocabulary
+ * cannot express -- see {@link forgejoChecks}.
+ * 5. Unset timestamps come back as the zero time (`1970-01-01T01:00:00+01:00`)
+ * rather than null -- see {@link isEpochTimestamp}.
+ *
+ * The `head` filter takes a BARE branch name. GitHub's `owner:branch` form
+ * returns an empty list here, which the gateway would map to `none` and erase the
+ * pill -- exactly the null-versus-zero defect SPEC-32 §6.5 exists to prevent.
+ */
+
+import type { PrCheckBucket, PrCheckDTO } from "../../protocol.js";
+
+/**
+ * Page size for a branch->PR lookup.
+ *
+ * Two forces set this. It must be >1 because hazard 3 means we cannot ask the
+ * server for "the newest" and must choose locally. It must be SMALL because the
+ * `head` filter is unindexed: measured against codeberg.org (Forgejo 16, ~9.5k
+ * PRs), `state=all` with a `head` filter costs ~1.3s at limit=5 but 17-19s at
+ * limit=30 -- and returns 504 often enough to matter. This is the hot path,
+ * polled per worktree, so a deep page would stall the whole home screen.
+ *
+ * 5 is enough to absorb the default ordering being merely "roughly newest-first"
+ * while staying an order of magnitude inside the read timeout.
+ */
+export const PR_PAGE_SIZE = 5;
+
+/**
+ * Forgejo's default `[repository.pull-request] WORK_IN_PROGRESS_PREFIXES`.
+ *
+ * A default, not a constant: an instance can redefine this list, so any caller
+ * that can read the server's config should pass its real value through rather
+ * than assume this one.
+ */
+export const DEFAULT_WIP_PREFIXES: readonly string[] = ["WIP:", "[WIP]"];
+
+/** Forgejo's API root for an instance base URL (`https://git.example.com`). */
+function apiRoot(baseUrl: string): string {
+ return `${baseUrl.replace(/\/+$/, "")}/api/v1`;
+}
+
+/** Percent-encode one path segment; owner/repo may contain URL-significant bytes. */
+function seg(value: string | number): string {
+ return encodeURIComponent(String(value));
+}
+
+/** `.../pulls` for a repo. */
+function pullsPath(baseUrl: string, owner: string, repo: string): string {
+ return `${apiRoot(baseUrl)}/repos/${seg(owner)}/${seg(repo)}/pulls`;
+}
+
+/**
+ * The PRs whose head is `branch`, newest LAST-resort-sorted by us (hazard 3).
+ *
+ * `state=all` (not `open`) so a merged or closed PR keeps rendering with its own
+ * glyph instead of vanishing to the bare-branch icon. Note the absence of
+ * `sort`/`direction`: Forgejo offers no created-desc, and sending a bogus value
+ * would be a silent no-op that reads as if ordering were guaranteed.
+ *
+ * `state=all` is also what makes this query expensive -- see {@link PR_PAGE_SIZE}.
+ * `state=open` is ~15x faster, but would erase the pill on a merged PR, which is
+ * the regression this whole lookup exists to avoid.
+ */
+export function prForBranchUrl(baseUrl: string, owner: string, repo: string, branch: string): string {
+ const u = new URL(pullsPath(baseUrl, owner, repo));
+ u.searchParams.set("state", "all");
+ // Bare branch name -- NOT `owner:branch`. See the module note.
+ u.searchParams.set("head", branch);
+ u.searchParams.set("limit", String(PR_PAGE_SIZE));
+ return u.toString();
+}
+
+/** All open PRs for the repo (the "New worktree from PR" picker). */
+export function openPrsUrl(baseUrl: string, owner: string, repo: string, limit: number): string {
+ const u = new URL(pullsPath(baseUrl, owner, repo));
+ u.searchParams.set("state", "open");
+ u.searchParams.set("limit", String(limit));
+ return u.toString();
+}
+
+/** Combined commit status for a head sha — Forgejo's `statusCheckRollup`. */
+export function combinedStatusUrl(baseUrl: string, owner: string, repo: string, ref: string): string {
+ return `${apiRoot(baseUrl)}/repos/${seg(owner)}/${seg(repo)}/commits/${seg(ref)}/status`;
+}
+
+/** A single PR, by index. Used to re-read a title before rewriting it. */
+export function prDetailUrl(baseUrl: string, owner: string, repo: string, index: number): string {
+ return `${pullsPath(baseUrl, owner, repo)}/${seg(index)}`;
+}
+
+/**
+ * Merge the base branch into the PR head — Forgejo's `gh pr update-branch`.
+ *
+ * `style` is explicit: the instance default (`DEFAULT_UPDATE_STYLE`) is
+ * configurable, and silently inheriting it would make the same button rebase on
+ * one server and merge on another.
+ */
+export function updateBranchUrl(
+ baseUrl: string,
+ owner: string,
+ repo: string,
+ index: number,
+ style: "merge" | "rebase" = "merge",
+): string {
+ const u = new URL(`${pullsPath(baseUrl, owner, repo)}/${seg(index)}/update`);
+ u.searchParams.set("style", style);
+ return u.toString();
+}
+
+/** Squash-merge a PR. */
+export function mergeUrl(baseUrl: string, owner: string, repo: string, index: number): string {
+ return `${pullsPath(baseUrl, owner, repo)}/${seg(index)}/merge`;
+}
+
+/**
+ * The newest PR in a page, by index — hazard 3's mitigation.
+ *
+ * Highest `number` wins rather than first-returned, so an older CLOSED PR can
+ * never take the slot from a newer OPEN one on the same branch and flip the
+ * glyph. Rows without a numeric index are skipped rather than coerced.
+ */
+export function pickLatestPr(rows: readonly (T | null)[]): T | null {
+ let best: T | null = null;
+ for (const row of rows) {
+ if (row === null || typeof row !== "object") continue;
+ if (typeof row.number !== "number" || !Number.isFinite(row.number)) continue;
+ if (best === null || row.number > (best.number as number)) best = row;
+ }
+ return best;
+}
+
+/** Identity + mergeability of a Forgejo PR, in the vocabulary the DTO speaks. */
+export interface ForgejoPrCore {
+ number: number;
+ url: string;
+ /** OPEN | CLOSED | MERGED */
+ state: string;
+ title: string;
+ isDraft: boolean;
+ /** MERGEABLE | CONFLICTING | UNKNOWN */
+ mergeable: string | null;
+ /** Always null: Forgejo has no equivalent concept (hazard 2). */
+ mergeStateStatus: null;
+ baseRefName: string | null;
+ /** Head commit, needed to fetch the check rollup. Null when unreported. */
+ headSha: string | null;
+}
+
+function str(v: unknown): string | null {
+ return typeof v === "string" && v.length > 0 ? v : null;
+}
+
+/**
+ * Map one Forgejo PR row onto {@link ForgejoPrCore}.
+ *
+ * `mergeable` is taken verbatim from the API rather than being ANDed with the
+ * open state: conflating the two is a presentation choice, and the pill already
+ * derives its own tint from `state`. Absent means "not computed yet" — Forgejo
+ * resolves conflicts asynchronously — which is `UNKNOWN`, not "conflicting".
+ */
+export function mapForgejoPr(raw: unknown): ForgejoPrCore | null {
+ if (typeof raw !== "object" || raw === null) return null;
+ const r = raw as Record;
+ if (typeof r.number !== "number" || !Number.isFinite(r.number)) return null;
+
+ const merged = r.merged === true;
+ const rawState = typeof r.state === "string" ? r.state.toUpperCase() : "";
+ // REST has no distinct "merged" state; the flag disambiguates it from a plain
+ // close, which is what keeps a merged PR from rendering with the closed glyph.
+ const state = merged ? "MERGED" : rawState === "OPEN" ? "OPEN" : "CLOSED";
+
+ let mergeable: string;
+ if (r.mergeable === true) mergeable = "MERGEABLE";
+ else if (r.mergeable === false) mergeable = "CONFLICTING";
+ else mergeable = "UNKNOWN";
+
+ const base = r.base as { ref?: unknown } | undefined;
+ const head = r.head as { ref?: unknown; sha?: unknown } | undefined;
+
+ return {
+ number: r.number,
+ // `html_url` is the human page; `url` is the API resource. The UI links out.
+ url: str(r.html_url) ?? "",
+ state,
+ title: typeof r.title === "string" ? r.title : "",
+ isDraft: r.draft === true,
+ mergeable,
+ mergeStateStatus: null,
+ baseRefName: str(base?.ref),
+ headSha: str(head?.sha),
+ };
+}
+
+/**
+ * The title a PR must be given to leave draft — hazard 1's mitigation.
+ *
+ * Returns null when no configured prefix matches, so a caller can never blindly
+ * rewrite a title it did not recognise as a draft marker. Matching is anchored
+ * and case-insensitive (mirroring Forgejo), so `WIPE the cache` is untouched
+ * while `wip: x` is not missed. Also returns null when stripping would leave an
+ * empty title, which Forgejo would reject anyway.
+ */
+export function readyTitle(title: string, prefixes: readonly string[] = DEFAULT_WIP_PREFIXES): string | null {
+ const lower = title.toLowerCase();
+ for (const prefix of prefixes) {
+ if (prefix.length === 0) continue;
+ if (!lower.startsWith(prefix.toLowerCase())) continue;
+ const stripped = title.slice(prefix.length).trim();
+ return stripped.length > 0 ? stripped : null;
+ }
+ return null;
+}
+
+/**
+ * Map Forgejo's `CommitStatusState` onto a {@link PrCheckBucket}.
+ *
+ * Forgejo's enum is `pending | success | error | failure | warning | skipped`.
+ * Two notes on the edges:
+ * - `skipped` exists here but not in GitHub's status vocabulary, which is why
+ * these entries are classified locally instead of being reshaped into
+ * GitHub's `statusCheckRollup` form and run through `normalizeChecks`.
+ * - An unrecognised value buckets as `pending`, never `pass`: a check we cannot
+ * classify must not be reported as green.
+ */
+function bucketForForgejoStatus(status: string): PrCheckBucket {
+ switch (status.toLowerCase()) {
+ case "success":
+ return "pass";
+ case "failure":
+ case "error":
+ return "fail";
+ case "pending":
+ return "pending";
+ case "skipped":
+ // `warning` is Forgejo's advisory state — the closest honest bucket is the
+ // one already used for GitHub's NEUTRAL: present, but not a pass or a fail.
+ case "warning":
+ return "skipping";
+ default:
+ return "pending";
+ }
+}
+
+/**
+ * Adapt a Forgejo combined-status payload into flat {@link PrCheckDTO}s.
+ *
+ * `workflowName` is left null: Forgejo's commit status carries only a `context`
+ * string, and splitting it on `/` to invent a workflow name would be a guess
+ * (`testing / test-unit` and `a/b` are indistinguishable).
+ */
+export function forgejoChecks(combined: unknown): PrCheckDTO[] {
+ if (typeof combined !== "object" || combined === null) return [];
+ const statuses = (combined as { statuses?: unknown }).statuses;
+ if (!Array.isArray(statuses)) return [];
+ const out: PrCheckDTO[] = [];
+ for (const raw of statuses) {
+ if (typeof raw !== "object" || raw === null) continue;
+ const s = raw as Record;
+ out.push({
+ // Forgejo keys the state as `status`; GitHub REST uses `state`.
+ name: str(s.context) ?? "check",
+ bucket: bucketForForgejoStatus(typeof s.status === "string" ? s.status : ""),
+ workflowName: null,
+ detailsUrl: str(s.target_url),
+ });
+ }
+ return out;
+}
+
+/**
+ * True when a Forgejo timestamp is the zero-time sentinel (hazard 5).
+ *
+ * Forgejo serialises an unset time as the zero `time.Time` in the server's local
+ * zone (`1970-01-01T01:00:00+01:00`), not as null. Fed into a duration that
+ * renders as ~56 years, so callers must treat these as "absent" rather than
+ * "epoch". `<= 0` rather than `=== 0` so a negative offset zone is caught too.
+ */
+export function isEpochTimestamp(value: unknown): boolean {
+ if (typeof value !== "string" || value.length === 0) return false;
+ const t = Date.parse(value);
+ return Number.isFinite(t) && t <= 0;
+}
+
+/** Host + slug of a git remote, for any self-hosted instance. */
+export interface ForgejoRemote {
+ /** Host including a non-default port, e.g. `git.example.com:2222`. */
+ host: string;
+ owner: string;
+ repo: string;
+}
+
+/**
+ * Parse `owner/repo` and the host out of a git remote URL.
+ *
+ * Unlike the GitHub parser this cannot anchor on a known hostname — a Forgejo
+ * instance is self-hosted, so the host is data. Both git forms are accepted: the
+ * scp-like `git@host:owner/repo.git` and any explicit scheme. Exactly two path
+ * segments are required, so a group URL or a bare owner returns null rather than
+ * a half-parsed slug that would 404 on every call.
+ */
+export function parseForgejoRemote(url: string): ForgejoRemote | null {
+ const trimmed = url.trim();
+ if (trimmed.length === 0) return null;
+
+ let host: string;
+ let path: string;
+
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed);
+ } catch {
+ return null;
+ }
+ // `host` keeps an explicit port; userinfo (`git@`) is dropped by the parser.
+ host = parsed.host;
+ path = parsed.pathname;
+ } else {
+ // scp-like syntax: [user@]host:path — the colon separates host from path,
+ // so a port cannot be expressed and any digits after it are part of the path.
+ const m = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(trimmed);
+ if (!m) return null;
+ host = m[1];
+ path = m[2];
+ }
+
+ if (host.length === 0) return null;
+ const parts = path
+ .replace(/\.git$/i, "")
+ .split("/")
+ .filter((p) => p.length > 0);
+ if (parts.length !== 2) return null;
+ return { host, owner: parts[0], repo: parts[1] };
+}
diff --git a/server/src/forge/none.test.ts b/server/src/forge/none.test.ts
new file mode 100644
index 00000000..f808c33c
--- /dev/null
+++ b/server/src/forge/none.test.ts
@@ -0,0 +1,47 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { createNoForgeGateway } from "./none.js";
+import { createUnsupportedGateway } from "./unsupported.js";
+import { hasBudgetReporting } from "./types.js";
+
+test("a lookup is none, not unknown — there is nothing to look for, by instruction", async () => {
+ // The distinction from `unsupported` is the reason this gateway exists. `unknown`
+ // makes the app hold a stale PR pill and keep retrying (SPEC-32 §6.5), which is
+ // precisely the chatter setting the provider to None is meant to stop.
+ const g = createNoForgeGateway();
+ assert.deepEqual(await g.prForBranch("/r", "b"), { kind: "none" });
+});
+
+test("None and unsupported do NOT report the same thing", async () => {
+ // Pinned as a comparison rather than two separate assertions: if these ever
+ // collapse into one value, a deliberate choice starts reading as a defect.
+ const chosen = await createNoForgeGateway().prForBranch("/r", "b");
+ const cannot = await createUnsupportedGateway().prForBranch("/r", "b");
+ assert.notDeepEqual(chosen, cannot);
+});
+
+test("it makes no requests at all, so a None repo costs nothing to poll", async () => {
+ const g = createNoForgeGateway();
+ await g.prForBranch("/r", "b");
+ await g.openPrs("/r", 30);
+ assert.deepEqual(g.stats(), { execs: 0, exemptExecs: 0, cacheHits: 0 });
+});
+
+test("there are no open PRs to offer, so the picker is empty rather than wrong", async () => {
+ assert.deepEqual(await createNoForgeGateway().openPrs("/r", 30), []);
+});
+
+test("a mutation names the setting, so the fix is one hop away", async () => {
+ // "It failed" is useless here: the cause is a choice the user made, and the
+ // message has to say where to unmake it.
+ const r = await createNoForgeGateway().mutatePr("/r", "b", 1, "merge-squash");
+ assert.equal(r.ok, false);
+ assert.match(r.error ?? "", /None/);
+ assert.match(r.error ?? "", /Settings/);
+ assert.match(r.error ?? "", /merge-squash/);
+});
+
+test("it claims no budget", () => {
+ assert.equal(hasBudgetReporting(createNoForgeGateway()), false);
+});
diff --git a/server/src/forge/none.ts b/server/src/forge/none.ts
new file mode 100644
index 00000000..9e00a2a1
--- /dev/null
+++ b/server/src/forge/none.ts
@@ -0,0 +1,45 @@
+/**
+ * none.ts — the provider for a repository the user told makit to leave alone.
+ *
+ * Distinct from `unsupported.ts`, and the distinction is the entire reason this
+ * file exists: unsupported means *we cannot talk to this forge*, which is a
+ * failure worth investigating; `none` means *do not talk to any forge for this
+ * repository*, which is an instruction and settled. Collapsing them would make
+ * a deliberate choice read as a defect in the UI, and would leave the user with
+ * no way to silence PR chatter on a mirror or a vendored copy (SPEC-48 rev 3.2).
+ *
+ * The observable difference is `prForBranch`:
+ *
+ * unsupported → `unknown` — we did not look, so we cannot claim there is no PR
+ * none → `none` — there is nothing to look for, by instruction
+ *
+ * `unknown` would be wrong here: it makes the app hold a stale PR pill and keep
+ * retrying (SPEC-32 §6.5), which is the chatter the user just asked to stop.
+ *
+ * Makes no requests, spawns no processes, and reads no remote.
+ */
+
+import type { OpenPr } from "../git.js";
+import type { ForgeGateway, GatewayStats, PrLookup, PrMutation } from "./types.js";
+
+export function createNoForgeGateway(): ForgeGateway {
+ return {
+ // `none`, not `unknown` — see the module note. This is a conclusion.
+ prForBranch: async (): Promise => ({ kind: "none" }),
+ openPrs: async (): Promise => [],
+ mutatePr: async (
+ _repoPath: string,
+ _branch: string,
+ _number: number,
+ verb: PrMutation,
+ ): Promise<{ ok: boolean; error?: string }> => ({
+ ok: false,
+ // Names the setting, so the fix is one hop away rather than a mystery.
+ error: `This repository's Git provider is set to None, so makit cannot run "${verb}" on it. Choose a provider in its Settings section first.`,
+ }),
+ // Always zero: nothing here spends quota or touches the network, and reporting
+ // otherwise would corrupt the call-reduction figure the stats feed.
+ stats: (): GatewayStats => ({ execs: 0, exemptExecs: 0, cacheHits: 0 }),
+ close: () => {},
+ };
+}
diff --git a/server/src/forge/router.test.ts b/server/src/forge/router.test.ts
new file mode 100644
index 00000000..db8a2d3f
--- /dev/null
+++ b/server/src/forge/router.test.ts
@@ -0,0 +1,838 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ createDefaultForgeGateway,
+ createForgeRouter,
+ forgejoRefFromRemote,
+ isGitHubHost,
+ ORIGIN_REMOTE_ARGV,
+} from "./router.js";
+import { createUnsupportedGateway } from "./unsupported.js";
+import type { ForgeGateway, ForgeSoftwareName, GatewayStats, PrLookup } from "./types.js";
+import type { ProviderChoice } from "../repo_settings.js";
+import type { GithubGateway } from "../github/gateway.js";
+
+/** A recording stand-in for either provider. */
+function fake(name: string, calls: string[]): ForgeGateway {
+ return {
+ prForBranch: async (repoPath, branch) => {
+ calls.push(`${name}.prForBranch(${repoPath},${branch})`);
+ return { kind: "none" } as PrLookup;
+ },
+ openPrs: async (repoPath) => {
+ calls.push(`${name}.openPrs(${repoPath})`);
+ return [];
+ },
+ mutatePr: async (repoPath, _branch, _number, verb) => {
+ calls.push(`${name}.mutatePr(${repoPath},${verb})`);
+ return { ok: true };
+ },
+ stats: () => ({ execs: name === "github" ? 3 : 5, exemptExecs: 1, cacheHits: 2 }) as GatewayStats,
+ close: () => calls.push(`${name}.close`),
+ };
+}
+
+function githubFake(calls: string[]): GithubGateway {
+ const base = fake("github", calls);
+ return {
+ ...base,
+ budget: () => {
+ calls.push("github.budget");
+ return { level: "high" } as never;
+ },
+ history: () => {
+ calls.push("github.history");
+ return [];
+ },
+ refresh: async () => {
+ calls.push("github.refresh");
+ return { level: "high" } as never;
+ },
+ setPaused: (p: boolean) => calls.push(`github.setPaused(${p})`),
+ onBudgetChange: (fn) => {
+ calls.push("github.onBudgetChange");
+ void fn;
+ return () => {};
+ },
+ } as GithubGateway;
+}
+
+/**
+ * [hosts] maps a repo path to its origin host (null = unreadable remote), and
+ * [software] maps a host to what detection reports for it. A host with no entry
+ * defaults to "forgejo", which keeps the routing tests focused on routing.
+ */
+function harness(hosts: Record, software: Record = {}) {
+ const calls: string[] = [];
+ const lookups: string[] = [];
+ const probes: string[] = [];
+ /**
+ * Per-repo provider overrides, MUTABLE on purpose: the setting is changed while
+ * the daemon runs, so a test that could only set it before the first route
+ * would never catch the routing cache serving a stale decision.
+ */
+ const choices = new Map();
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: fake("unsupported", calls),
+ none: fake("none", calls),
+ providerFor: (repoPath: string) => choices.get(repoPath) ?? "auto",
+ resolveInstance: async (repoPath: string) => {
+ lookups.push(repoPath);
+ const host = hosts[repoPath];
+ if (host === undefined || host === null) return null;
+ return { host, baseUrl: `https://${host}`, token: "t" };
+ },
+ detect: async (baseUrl: string) => {
+ probes.push(baseUrl);
+ const host = baseUrl.replace("https://", "");
+ return software[host] ?? "forgejo";
+ },
+ onUnsupported: (host, sw) => calls.push(`warn(${host},${sw})`),
+ });
+ return { router, calls, lookups, probes, choices };
+}
+
+// ---------------------------------------------------------------------------
+// Host classification
+// ---------------------------------------------------------------------------
+
+test("isGitHubHost accepts github.com and its subdomains only", () => {
+ assert.equal(isGitHubHost("github.com"), true);
+ assert.equal(isGitHubHost("GitHub.com"), true);
+ assert.equal(isGitHubHost("www.github.com"), true);
+ assert.equal(isGitHubHost("git.example.com"), false);
+ assert.equal(isGitHubHost("codeberg.org"), false);
+ // Must not be fooled by a lookalike host.
+ assert.equal(isGitHubHost("github.com.evil.test"), false);
+ assert.equal(isGitHubHost("notgithub.com"), false);
+});
+
+// ---------------------------------------------------------------------------
+// Routing
+// ---------------------------------------------------------------------------
+
+test("a github.com repo goes to the gh-backed gateway", async () => {
+ const { router, calls } = harness({ "/gh": "github.com" });
+ await router.prForBranch("/gh", "b");
+ assert.deepEqual(calls, ["github.prForBranch(/gh,b)"]);
+});
+
+test("a self-hosted repo goes to the Forgejo gateway", async () => {
+ const { router, calls } = harness({ "/fj": "git.example.com" });
+ await router.prForBranch("/fj", "b");
+ assert.deepEqual(calls, ["forgejo.prForBranch(/fj,b)"]);
+});
+
+test("openPrs and mutatePr route the same way as prForBranch", async () => {
+ const { router, calls } = harness({ "/fj": "git.example.com", "/gh": "github.com" });
+ await router.openPrs("/fj", 30);
+ await router.mutatePr("/fj", "b", 1, "ready");
+ await router.openPrs("/gh", 30);
+ assert.deepEqual(calls, [
+ "forgejo.openPrs(/fj)",
+ "forgejo.mutatePr(/fj,ready)",
+ "github.openPrs(/gh)",
+ ]);
+});
+
+test("an unreadable remote falls back to GitHub, preserving today's behaviour", async () => {
+ // Routing elsewhere would change the failure mode for every non-git directory;
+ // the gh gateway already degrades such a repo to `unknown`.
+ const { router, calls } = harness({ "/mystery": null });
+ await router.prForBranch("/mystery", "b");
+ assert.deepEqual(calls, ["github.prForBranch(/mystery,b)"]);
+});
+
+test("the host is resolved once per repo, not once per call", async () => {
+ const { router, lookups } = harness({ "/fj": "git.example.com" });
+ await router.prForBranch("/fj", "a");
+ await router.prForBranch("/fj", "b");
+ await router.openPrs("/fj", 30);
+ assert.deepEqual(lookups, ["/fj"]);
+});
+
+test("concurrent first calls for one repo still resolve the host once", async () => {
+ const { router, lookups } = harness({ "/fj": "git.example.com" });
+ await Promise.all([router.prForBranch("/fj", "a"), router.prForBranch("/fj", "b")]);
+ assert.deepEqual(lookups, ["/fj"], "an in-flight lookup must be shared, not duplicated");
+});
+
+// ---------------------------------------------------------------------------
+// Budget: GitHub-only, so it delegates rather than being averaged or faked.
+// ---------------------------------------------------------------------------
+
+test("budget reporting delegates to GitHub, the only provider with a quota", async () => {
+ const { router, calls } = harness({});
+ router.budget();
+ router.history();
+ await router.refresh();
+ router.setPaused(true);
+ router.onBudgetChange(() => {});
+ assert.deepEqual(calls, [
+ "github.budget",
+ "github.history",
+ "github.refresh",
+ "github.setPaused(true)",
+ "github.onBudgetChange",
+ ]);
+});
+
+test("stats sums the providers so the call-reduction figure stays whole", () => {
+ // Four gateways now, all read: github 3, and forgejo/unsupported/none 5 each from
+ // `fake` (which reports 5 for anything not named "github").
+ const { router } = harness({});
+ assert.deepEqual(router.stats(), { execs: 18, exemptExecs: 4, cacheHits: 8 });
+});
+
+test("close closes every provider", () => {
+ const { router, calls } = harness({});
+ router.close();
+ assert.deepEqual(calls.sort(), [
+ "forgejo.close",
+ "github.close",
+ "none.close",
+ "unsupported.close",
+ ]);
+});
+
+// ---------------------------------------------------------------------------
+// Turning a git remote into Forgejo coordinates
+// ---------------------------------------------------------------------------
+
+test("ORIGIN_REMOTE_ARGV reads the origin URL without touching the network", () => {
+ assert.deepEqual(ORIGIN_REMOTE_ARGV, ["remote", "get-url", "origin"]);
+});
+
+test("forgejoRefFromRemote derives base URL, slug and token", () => {
+ const ref = forgejoRefFromRemote("git@git.example.com:acme/app.git", {
+ MAKIT_FORGEJO_TOKEN: "t0k",
+ });
+ assert.deepEqual(ref, {
+ baseUrl: "https://git.example.com",
+ owner: "acme",
+ repo: "app",
+ token: "t0k",
+ });
+});
+
+test("forgejoRefFromRemote accepts the common token env names in priority order", () => {
+ const pick = (env: Record) =>
+ forgejoRefFromRemote("https://git.example.com/a/b", env)?.token;
+ assert.equal(
+ pick({
+ MAKIT_FORGEJO_TOKEN: "m",
+ FORGEJO_ACCESS_TOKEN: "a",
+ FORGEJO_TOKEN: "f",
+ GITEA_TOKEN: "g",
+ }),
+ "m",
+ );
+ assert.equal(pick({ FORGEJO_ACCESS_TOKEN: "a", FORGEJO_TOKEN: "f" }), "a");
+ assert.equal(pick({ FORGEJO_TOKEN: "f", GITEA_TOKEN: "g" }), "f");
+ assert.equal(pick({ GITEA_TOKEN: "g" }), "g");
+ assert.equal(pick({}), undefined);
+});
+
+test("forgejoRefFromRemote honours a base-URL override for the host it names", () => {
+ for (const key of ["MAKIT_FORGEJO_BASE_URL", "FORGEJO_BASE_URL"]) {
+ const ref = forgejoRefFromRemote("https://git.example.com/a/b", {
+ [key]: "https://git.example.com/forge",
+ });
+ assert.equal(ref?.baseUrl, "https://git.example.com/forge", key);
+ }
+});
+
+// A configured instance URL scopes the credentials to THAT host. Without this a
+// single global FORGEJO_ACCESS_TOKEN -- the normal way to configure one instance
+// -- would be attached to every non-GitHub remote, so cloning any public Gitea
+// repo would ship the user's internal token to a third party.
+test("a configured instance never lends its token to a different host", () => {
+ const env = {
+ FORGEJO_BASE_URL: "https://forgejo.internal.example",
+ FORGEJO_ACCESS_TOKEN: "secret",
+ };
+ const own = forgejoRefFromRemote("https://forgejo.internal.example/a/b", env);
+ assert.equal(own?.token, "secret");
+ assert.equal(own?.baseUrl, "https://forgejo.internal.example");
+
+ const foreign = forgejoRefFromRemote("https://codeberg.org/a/b", env);
+ assert.equal(foreign?.token, undefined, "the internal token must not leave its host");
+ // Still usable unauthenticated against its own host, not the configured one.
+ assert.equal(foreign?.baseUrl, "https://codeberg.org");
+});
+
+test("the base-URL override matches on host, ignoring scheme, port and path", () => {
+ const env = { FORGEJO_BASE_URL: "http://git.example.com:3000/forge", FORGEJO_TOKEN: "t" };
+ const ref = forgejoRefFromRemote("git@git.example.com:a/b.git", env);
+ assert.equal(ref?.baseUrl, "http://git.example.com:3000/forge");
+ assert.equal(ref?.token, "t");
+});
+
+test("with no instance configured the token applies to the remote's own host", () => {
+ // The single-instance case: there is nothing to scope against, so the token is
+ // attached to whatever host the remote names.
+ const ref = forgejoRefFromRemote("https://git.example.com/a/b", { FORGEJO_TOKEN: "t" });
+ assert.equal(ref?.token, "t");
+ assert.equal(ref?.baseUrl, "https://git.example.com");
+});
+
+test("forgejoRefFromRemote returns null for a remote it cannot read", () => {
+ assert.equal(forgejoRefFromRemote("", {}), null);
+ assert.equal(forgejoRefFromRemote("https://git.example.com/only-owner", {}), null);
+});
+
+// ---------------------------------------------------------------------------
+// Which providers are actually in play. The poll cadence needs this: GitHub's
+// degradation ladder exists to ration GitHub quota, and must not throttle a
+// Forgejo-only setup where there is no quota to ration.
+// ---------------------------------------------------------------------------
+
+test("providersInUse is empty until a repo has been routed", () => {
+ const { router } = harness({ "/fj": "git.example.com" });
+ assert.deepEqual([...router.providersInUse()], []);
+});
+
+test("providersInUse learns each provider as repos are routed", async () => {
+ const { router } = harness({ "/fj": "git.example.com", "/gh": "github.com" });
+ await router.prForBranch("/fj", "b");
+ assert.deepEqual([...router.providersInUse()], ["forgejo"]);
+ await router.prForBranch("/gh", "b");
+ assert.deepEqual([...router.providersInUse()].sort(), ["forgejo", "github"]);
+});
+
+test("close() forgets the provider mix along with the routing cache", async () => {
+ const { router } = harness({ "/fj": "git.example.com" });
+ await router.prForBranch("/fj", "b");
+ router.close();
+ assert.deepEqual([...router.providersInUse()], []);
+});
+
+// ---------------------------------------------------------------------------
+// Detection-driven routing. Before this, EVERY non-GitHub host was assumed to be
+// Forgejo, so a GitLab remote was polled against an API that does not exist there
+// and reported `unknown` -- identical to the instance being down.
+// ---------------------------------------------------------------------------
+
+test("a Gitea instance routes to the Forgejo provider (same REST API)", async () => {
+ const { router, calls } = harness({ "/gt": "gitea.example" }, { "gitea.example": "gitea" });
+ await router.prForBranch("/gt", "b");
+ assert.deepEqual(calls, ["forgejo.prForBranch(/gt,b)"]);
+});
+
+test("a GitLab instance routes to the unsupported provider, not Forgejo", async () => {
+ const { router, calls } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" });
+ await router.prForBranch("/gl", "b");
+ assert.ok(calls.includes("unsupported.prForBranch(/gl,b)"), calls.join(","));
+ assert.ok(!calls.some((c) => c.startsWith("forgejo.")), "must not query a Forgejo API that is not there");
+});
+
+test("an unidentifiable forge routes to the unsupported provider", async () => {
+ const { router, calls } = harness({ "/x": "mystery.example" }, { "mystery.example": "unknown" });
+ await router.prForBranch("/x", "b");
+ assert.ok(calls.includes("unsupported.prForBranch(/x,b)"), calls.join(","));
+});
+
+test("an unsupported host is reported once, not once per poll", async () => {
+ const { router, calls } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" });
+ await router.prForBranch("/gl", "a");
+ await router.prForBranch("/gl", "b");
+ await router.openPrs("/gl", 30);
+ assert.equal(calls.filter((c) => c.startsWith("warn(")).length, 1, calls.join(","));
+ assert.deepEqual(
+ calls.filter((c) => c.startsWith("warn(")),
+ ["warn(gitlab.example,gitlab)"],
+ );
+});
+
+test("GitHub is never probed -- the host is decisive", async () => {
+ const { router, probes } = harness({ "/gh": "github.com" });
+ await router.prForBranch("/gh", "b");
+ assert.deepEqual(probes, [], "no round trip should be spent identifying github.com");
+});
+
+test("detection runs once per repo, like the rest of the routing decision", async () => {
+ const { router, probes } = harness({ "/fj": "git.example" });
+ await router.prForBranch("/fj", "a");
+ await router.prForBranch("/fj", "b");
+ await router.openPrs("/fj", 30);
+ assert.deepEqual(probes, ["https://git.example"]);
+});
+
+test("an unsupported forge counts as its own provider in the mix", async () => {
+ const { router } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" });
+ await router.prForBranch("/gl", "b");
+ assert.deepEqual([...router.providersInUse()], ["unsupported"]);
+});
+
+test("a detection failure falls back to GitHub rather than breaking the poll", async () => {
+ const calls: string[] = [];
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: fake("unsupported", calls),
+ none: fake("none", calls),
+ resolveInstance: async () => ({ host: "git.example", baseUrl: "https://git.example" }),
+ detect: async () => {
+ throw new Error("probe exploded");
+ },
+ });
+ await router.prForBranch("/fj", "b");
+ assert.deepEqual(calls, ["github.prForBranch(/fj,b)"]);
+});
+
+// ---------------------------------------------------------------------------
+// F1/F2 — the router records what it decided, because nothing else retains it:
+// `chosen` holds only the gateway promise.
+// ---------------------------------------------------------------------------
+
+test("forgeFor is undefined until a repo has been routed", () => {
+ const { router } = harness({ "/fj": "git.example" });
+ assert.equal(router.forgeFor("/fj"), undefined);
+});
+
+test("forgeFor reports the software, host and whether a credential exists", async () => {
+ const { router } = harness({ "/gt": "gitea.example" }, { "gitea.example": "gitea" });
+ await router.prForBranch("/gt", "b");
+ assert.deepEqual(router.forgeFor("/gt"), {
+ software: "gitea",
+ host: "gitea.example",
+ authed: true,
+ source: "detected",
+ });
+});
+
+test("a GitHub repo reports no authed flag — gh's budget is not host auth", async () => {
+ const { router } = harness({ "/gh": "github.com" });
+ await router.prForBranch("/gh", "b");
+ assert.deepEqual(router.forgeFor("/gh"), {
+ software: "github",
+ host: "github.com",
+ source: "detected",
+ });
+});
+
+test("an unsupported forge is still recorded, so the UI can name it", async () => {
+ const { router } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" });
+ await router.prForBranch("/gl", "b");
+ assert.equal(router.forgeFor("/gl")?.software, "gitlab");
+});
+
+test("a repo with no readable remote records nothing rather than guessing github.com", async () => {
+ // `forge: undefined` on the DTO means "not measured"; inventing a host here
+ // would make a local-only repo claim to be on GitHub.
+ const { router } = harness({ "/mystery": null });
+ await router.prForBranch("/mystery", "b");
+ assert.equal(router.forgeFor("/mystery"), undefined);
+});
+
+test("close() forgets the decisions", async () => {
+ const { router } = harness({ "/fj": "git.example" });
+ await router.prForBranch("/fj", "b");
+ router.close();
+ assert.equal(router.forgeFor("/fj"), undefined);
+});
+
+// ---------------------------------------------------------------------------
+// P2 / D3" — the provider override DRIVES ROUTING.
+//
+// The whole point of the control: detection returns `unknown` for a private
+// instance that answers 401 to an anonymous probe, and for one behind a proxy
+// that hides `/api/forgejo/v1/version`. Both route to the *unsupported* provider,
+// where the repo is unusable with no recourse. The override is the recourse — so
+// it must pick the gateway, not merely be displayed.
+// ---------------------------------------------------------------------------
+
+test("an override to Forgejo rescues a repo detection could not identify", async () => {
+ // Detection says `unknown`, which today lands on `unsupported` — unusable.
+ const { router, calls, choices } = harness({ "/fj": "private.example" }, { "private.example": "unknown" });
+ choices.set("/fj", "forgejo");
+ await router.prForBranch("/fj", "b");
+ assert.deepEqual(calls, ["forgejo.prForBranch(/fj,b)"]);
+});
+
+test("an override skips the probe entirely — the probe is what failed", async () => {
+ // Not an optimisation. A proxy that hides the version endpoint makes the probe
+ // useless; spending it anyway would delay every poll for no information.
+ const { router, probes, choices } = harness({ "/fj": "private.example" }, { "private.example": "unknown" });
+ choices.set("/fj", "forgejo");
+ await router.prForBranch("/fj", "b");
+ assert.deepEqual(probes, []);
+});
+
+test("an override to Gitea routes to the Forgejo provider and records gitea", async () => {
+ const { router, calls, choices } = harness({ "/gt": "gt.example" }, { "gt.example": "unknown" });
+ choices.set("/gt", "gitea");
+ await router.prForBranch("/gt", "b");
+ assert.deepEqual(calls, ["forgejo.prForBranch(/gt,b)"]);
+ assert.equal(router.forgeFor("/gt")?.software, "gitea");
+});
+
+test("an override to GitHub sends a non-github.com host to the gh gateway", async () => {
+ // A GitHub Enterprise host is not github.com, so the host rule alone sends it to
+ // Forgejo, where it fails. This is the only way to reach `gh` for such a repo.
+ const { router, calls, choices } = harness({ "/ghe": "github.acme.test" });
+ choices.set("/ghe", "github");
+ await router.prForBranch("/ghe", "b");
+ assert.deepEqual(calls, ["github.prForBranch(/ghe,b)"]);
+});
+
+test("an override to None talks to no forge at all", async () => {
+ // "Stops checking pull requests" has to mean no provider call and no remote read,
+ // otherwise it is a label rather than an instruction.
+ const { router, calls, lookups, probes, choices } = harness({ "/mirror": "gt.example" });
+ choices.set("/mirror", "none");
+ const lookup = await router.prForBranch("/mirror", "b");
+ assert.deepEqual(calls, ["none.prForBranch(/mirror,b)"]);
+ assert.deepEqual(lookups, [], "None must not even read the origin remote");
+ assert.deepEqual(probes, []);
+ // `none`, not `unknown`: we are not failing to look, we were told not to.
+ assert.deepEqual(lookup, { kind: "none" });
+});
+
+test("None counts as its own provider in the mix, so cadence can ignore it", async () => {
+ const { router, choices } = harness({ "/mirror": "gt.example" });
+ choices.set("/mirror", "none");
+ await router.prForBranch("/mirror", "b");
+ assert.deepEqual([...router.providersInUse()], ["none"]);
+});
+
+test("changing the override re-routes WITHOUT a restart", async () => {
+ // The routing cache keys on the repo path alone, so without re-checking the
+ // choice the setting would appear to do nothing until the daemon restarted —
+ // which is indistinguishable from the feature being broken.
+ const { router, calls, choices } = harness({ "/r": "git.example" });
+ await router.prForBranch("/r", "b");
+ assert.deepEqual(calls, ["forgejo.prForBranch(/r,b)"]);
+ choices.set("/r", "github");
+ await router.prForBranch("/r", "b");
+ assert.deepEqual(calls, ["forgejo.prForBranch(/r,b)", "github.prForBranch(/r,b)"]);
+});
+
+test("an unchanged override still resolves the host only once", async () => {
+ // Re-checking the choice must not throw away the cache that makes the home-screen
+ // fan-out cheap.
+ const { router, lookups, choices } = harness({ "/r": "git.example" });
+ choices.set("/r", "forgejo");
+ await router.prForBranch("/r", "b");
+ await router.prForBranch("/r", "c");
+ await router.openPrs("/r", 10);
+ assert.deepEqual(lookups, ["/r"]);
+});
+
+test("forgeFor says the decision came from the override, not from detection", async () => {
+ // The UI must not caption an override "detected": that is the one thing the
+ // reader would use to decide whether to trust it.
+ const { router, choices } = harness({ "/fj": "private.example" }, { "private.example": "unknown" });
+ choices.set("/fj", "forgejo");
+ await router.prForBranch("/fj", "b");
+ assert.deepEqual(router.forgeFor("/fj"), {
+ software: "forgejo",
+ host: "private.example",
+ authed: true,
+ source: "override",
+ });
+});
+
+test("a detected decision is labelled detected", async () => {
+ const { router } = harness({ "/gt": "gitea.example" }, { "gitea.example": "gitea" });
+ await router.prForBranch("/gt", "b");
+ assert.equal(router.forgeFor("/gt")?.source, "detected");
+});
+
+test("Auto is unchanged: detection still decides", async () => {
+ const { router, calls, probes } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" });
+ await router.prForBranch("/gl", "b");
+ assert.deepEqual(probes, ["https://gitlab.example"]);
+ assert.equal(calls[0], "warn(gitlab.example,gitlab)");
+ assert.equal(calls[1], "unsupported.prForBranch(/gl,b)");
+});
+
+// ---------------------------------------------------------------------------
+// P2 — "no remote" and "not measured yet" must be separable.
+//
+// `settingsDtoFor` derived hasRemote from `forge !== undefined`, which made the
+// app's "Auto: not identified yet" branch UNREACHABLE: every repo that had not
+// been polled yet claimed to have no remote. rev 3.2 pinned that these two read
+// differently, so the router has to record the remote as its own fact.
+// ---------------------------------------------------------------------------
+
+test("hasRemoteFor is undefined until the repo has been routed", () => {
+ const { router } = harness({ "/r": "git.example" });
+ assert.equal(router.hasRemoteFor("/r"), undefined);
+});
+
+test("hasRemoteFor is true once a readable remote has been routed", async () => {
+ const { router } = harness({ "/r": "git.example" });
+ await router.prForBranch("/r", "b");
+ assert.equal(router.hasRemoteFor("/r"), true);
+});
+
+test("hasRemoteFor is false for a repo whose origin cannot be read", async () => {
+ // The local-only repo. `forgeFor` is undefined here too — which is exactly why
+ // one field cannot carry both facts.
+ const { router } = harness({ "/local": null });
+ await router.prForBranch("/local", "b");
+ assert.equal(router.hasRemoteFor("/local"), false);
+ assert.equal(router.forgeFor("/local"), undefined);
+});
+
+test("close() forgets the remote facts along with the decisions", async () => {
+ const { router } = harness({ "/r": "git.example" });
+ await router.prForBranch("/r", "b");
+ router.close();
+ assert.equal(router.hasRemoteFor("/r"), undefined);
+});
+
+test("a transient lookup failure does NOT discard an explicit override", async () => {
+ // Found while reviewing the override work. The router falls back to GitHub when
+ // routing throws, which was right when nothing could contradict it. With an
+ // override it is wrong twice over: it ignores an explicit instruction, and `gh`
+ // cannot talk to the host anyway — so every call fails.
+ //
+ // Worse, the fallback is CACHED against the choice that produced it, so one
+ // transient error pins the repo to the wrong provider until the setting changes
+ // or the daemon restarts.
+ const calls: string[] = [];
+ const choices = new Map([["/fj", "forgejo"]]);
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: fake("unsupported", calls),
+ none: fake("none", calls),
+ providerFor: (p) => choices.get(p) ?? "auto",
+ resolveInstance: async () => {
+ throw new Error("git remote read exploded");
+ },
+ detect: async () => "forgejo",
+ });
+ await router.prForBranch("/fj", "b");
+ assert.deepEqual(calls, ["forgejo.prForBranch(/fj,b)"]);
+ // And it is not pinned to a wrong answer by that one failure.
+ await router.openPrs("/fj", 10);
+ assert.deepEqual(calls, ["forgejo.prForBranch(/fj,b)", "forgejo.openPrs(/fj)"]);
+});
+
+test("Auto still falls back to GitHub when routing throws", async () => {
+ // The status quo for a repo with no opinion attached, unchanged.
+ const calls: string[] = [];
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: fake("unsupported", calls),
+ none: fake("none", calls),
+ resolveInstance: async () => {
+ throw new Error("boom");
+ },
+ detect: async () => "forgejo",
+ });
+ await router.prForBranch("/x", "b");
+ assert.deepEqual(calls, ["github.prForBranch(/x,b)"]);
+});
+
+test("a failed remote read is RECORDED as no-remote, not left as 'unmeasured'", async () => {
+ // Review finding: the fallback path recorded nothing, so `hasRemoteFor` stayed
+ // `undefined` — indistinguishable from a repo that has not been routed yet, which
+ // is the exact three-states-in-one-boolean confusion this pair of methods exists to
+ // stop.
+ const calls: string[] = [];
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: fake("unsupported", calls),
+ none: fake("none", calls),
+ resolveInstance: async () => {
+ throw new Error("git remote read exploded");
+ },
+ detect: async () => "forgejo",
+ });
+ await router.prForBranch("/x", "b");
+ assert.equal(router.hasRemoteFor("/x"), false);
+});
+
+test("a failure AFTER the remote was read keeps the remote fact true", async () => {
+ // The trap in the obvious fix. Two different failures reach the same catch: the
+ // remote read failing (no remote) and DETECTION failing (a perfectly good remote we
+ // could not classify). Recording `false` unconditionally would turn the second into
+ // a claim that the repo has no origin — a fact we already measured as true.
+ const calls: string[] = [];
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: fake("unsupported", calls),
+ none: fake("none", calls),
+ resolveInstance: async () => ({ host: "git.example", baseUrl: "https://git.example" }),
+ detect: async () => {
+ throw new Error("probe exploded");
+ },
+ });
+ await router.prForBranch("/y", "b");
+ assert.equal(router.hasRemoteFor("/y"), true, "the remote WAS read; only detection failed");
+});
+
+test("stats sums EVERY provider, matching what the doc comment claims", async () => {
+ // Review finding: the sum covered github and forgejo only, while the comment said
+ // it "covers every provider in play". Both omitted gateways return zeros today, so
+ // the number was right by accident — and would drift silently the moment either
+ // started counting a call.
+ const calls: string[] = [];
+ const counting = (n: number): ForgeGateway => ({
+ ...fake(`c${n}`, calls),
+ stats: () => ({ execs: n, exemptExecs: n, cacheHits: n }),
+ });
+ const router = createForgeRouter({
+ github: { ...githubFake(calls), stats: () => ({ execs: 1, exemptExecs: 1, cacheHits: 1 }) } as GithubGateway,
+ forgejo: counting(2),
+ unsupported: counting(4),
+ none: counting(8),
+ resolveInstance: async () => ({ host: "git.example", baseUrl: "https://git.example" }),
+ detect: async () => "forgejo",
+ });
+ assert.deepEqual(router.stats(), { execs: 15, exemptExecs: 15, cacheHits: 15 });
+});
+
+test("close closes EVERY provider, not just the two with caches", async () => {
+ const calls: string[] = [];
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: fake("unsupported", calls),
+ none: fake("none", calls),
+ resolveInstance: async () => ({ host: "git.example", baseUrl: "https://git.example" }),
+ detect: async () => "forgejo",
+ });
+ router.close();
+ assert.deepEqual(calls.sort(), ["forgejo.close", "github.close", "none.close", "unsupported.close"]);
+});
+
+test("a fallback route is NOT cached, so the repo recovers when the read works", async () => {
+ // Review finding: `pick` cached whatever `route` resolved, including the
+ // catch-block fallback, and nothing evicted it. One failed `git remote` read at
+ // startup therefore sent every later poll for a Forgejo repo to `gh` for the
+ // lifetime of the daemon, where it failed and the PR pill read `unknown` forever.
+ const calls: string[] = [];
+ let attempts = 0;
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: fake("unsupported", calls),
+ none: fake("none", calls),
+ resolveInstance: async () => {
+ attempts += 1;
+ if (attempts === 1) throw new Error("transient git failure");
+ return { host: "git.example", baseUrl: "https://git.example" };
+ },
+ detect: async () => "forgejo",
+ });
+ await router.prForBranch("/r", "b");
+ assert.deepEqual(calls, ["github.prForBranch(/r,b)"], "first call falls back");
+ await router.prForBranch("/r", "b");
+ assert.deepEqual(
+ calls,
+ ["github.prForBranch(/r,b)", "forgejo.prForBranch(/r,b)"],
+ "the second call retries and reaches the real provider",
+ );
+});
+
+test("a successful route is still cached, so the fan-out stays cheap", async () => {
+ // The fix must not turn every call into a fresh `git remote` read.
+ const calls: string[] = [];
+ let lookups = 0;
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: fake("unsupported", calls),
+ none: fake("none", calls),
+ resolveInstance: async () => {
+ lookups += 1;
+ return { host: "git.example", baseUrl: "https://git.example" };
+ },
+ detect: async () => "forgejo",
+ });
+ await router.prForBranch("/r", "b");
+ await router.prForBranch("/r", "c");
+ await router.openPrs("/r", 5);
+ assert.equal(lookups, 1);
+});
+
+test("the unsupported gateway names THIS repo's forge, not the last one detected", async () => {
+ // `currentSoftware` was a single shared variable set by whichever repo was detected
+ // most recently, so a mutation on a GitLab repo could report Bitbucket's name after
+ // another repo was probed. The decision is per repo; the message must be too.
+ // The REAL unsupported gateway, since the message is what is under test.
+ const calls: string[] = [];
+ const software: Record = {
+ "gitlab.example": "gitlab",
+ "weird.example": "unknown",
+ };
+ let inspector: { forgeFor(p: string): { software: ForgeSoftwareName } | undefined } | undefined;
+ const router = createForgeRouter({
+ github: githubFake(calls),
+ forgejo: fake("forgejo", calls),
+ unsupported: createUnsupportedGateway({
+ softwareFor: (repoPath) => inspector?.forgeFor(repoPath)?.software,
+ }),
+ none: fake("none", calls),
+ resolveInstance: async (repoPath: string) => {
+ const host = repoPath === "/gl" ? "gitlab.example" : "weird.example";
+ return { host, baseUrl: `https://${host}` };
+ },
+ detect: async (baseUrl: string) => software[baseUrl.replace("https://", "")] ?? "unknown",
+ });
+ inspector = router;
+ await router.prForBranch("/gl", "b");
+ // Probing the second repo used to overwrite the shared `currentSoftware`.
+ await router.prForBranch("/mystery", "b");
+ const r = await router.mutatePr("/gl", "b", 1, "merge-squash");
+ assert.equal(r.ok, false);
+ assert.match(r.error ?? "", /GitLab/);
+});
+
+// ---------------------------------------------------------------------------
+// The production wiring's own `git remote` reads.
+// ---------------------------------------------------------------------------
+
+test("the origin remote is read ONCE per repo, not once per gateway call", async () => {
+ // Review finding: the router caches its routing promise, but the Forgejo gateway
+ // calls `resolveRepo` -- and therefore `git remote get-url origin` -- at the top of
+ // prForBranch, openPrs and mutatePr. The gateway's own cache is consulted AFTER
+ // that, so even a cache HIT paid for a subprocess, and N worktrees of one repo
+ // spawned N processes per poll tick.
+ // A NON-GitHub remote on purpose. With `github.com` the router picks the gh gateway,
+ // the Forgejo gateway's `resolveRepo` never runs, and one read for three calls is
+ // guaranteed by the routing cache alone -- so the test would pass with the memo
+ // deleted, which is exactly the finding it is meant to cover.
+ const execs: string[] = [];
+ const gateway = createDefaultForgeGateway({
+ exec: async (cmd: string, args: readonly string[], cwd?: string) => {
+ execs.push(`${cmd} ${args.join(" ")} @${cwd ?? ""}`);
+ return { code: 0, stdout: "https://git.example.com/acme/app.git", stderr: "" };
+ },
+ env: {},
+ });
+ // Detection and the REST calls both fail closed (no network in a unit test), which is
+ // fine: what is counted is the subprocess, not the outcome.
+ await gateway.prForBranch("/r", "a");
+ await gateway.prForBranch("/r", "b");
+ await gateway.openPrs("/r", 30);
+ const remoteReads = execs.filter((e) => e.includes("remote get-url origin")).length;
+ assert.equal(remoteReads, 1, `one read for three calls, got ${remoteReads}`);
+ gateway.close();
+});
+
+test("two different repos still get their own read", async () => {
+ const execs: string[] = [];
+ const gateway = createDefaultForgeGateway({
+ exec: async (_cmd: string, _args: readonly string[], cwd?: string) => {
+ execs.push(`${cwd ?? ""}`);
+ return { code: 0, stdout: "https://git.example.com/acme/app.git", stderr: "" };
+ },
+ env: {},
+ });
+ await gateway.prForBranch("/one", "a");
+ await gateway.prForBranch("/two", "a");
+ assert.equal(new Set(execs).size, 2);
+ gateway.close();
+});
diff --git a/server/src/forge/router.ts b/server/src/forge/router.ts
new file mode 100644
index 00000000..b9a704d9
--- /dev/null
+++ b/server/src/forge/router.ts
@@ -0,0 +1,548 @@
+/**
+ * router.ts — picks a forge provider per repository.
+ *
+ * The decision has three inputs, in this order:
+ *
+ * 1. **The repo's own provider setting** (SPEC-48 D3"). `forgejo`/`gitea` go to the
+ * REST gateway, `github` to the `gh` one, and `none` to a gateway that talks to
+ * no forge at all. An override is honoured WITHOUT probing, because the cases it
+ * exists for are the ones where the probe cannot answer.
+ * 2. **`github.com` (or a subdomain)** → the `gh`-backed gateway. The host is
+ * decisive here, so no probe is spent.
+ * 3. **Detection** — the instance is asked what software it runs (see `detect.ts`).
+ * `forgejo`/`gitea` reach the REST gateway; GitLab or anything unidentifiable
+ * reaches the `unsupported` gateway, which makes no requests and says so.
+ *
+ * An unreadable remote stays on `gh`: that is the status quo for every directory that
+ * is not a git checkout, and changing where such repos fail would be a behaviour
+ * change with no upside. That answer is deliberately NOT cached, so a transient
+ * failure does not pin the repo to the wrong provider.
+ *
+ * Deliberately implements {@link GithubGateway} rather than a narrower type, so
+ * `server.ts` and `manager.ts` need no changes: the budget surface they depend on
+ * is forwarded to the gh-backed gateway, which is the only provider that HAS a
+ * quota (Forgejo exposes no `rate_limit` endpoint and no rate-limit headers). A
+ * Forgejo repo therefore contributes nothing to the budget panel, which is
+ * accurate rather than a stub.
+ *
+ */
+
+import type { Exec } from "../github/gateway.js";
+import { createGithubGateway, type GithubGateway } from "../github/gateway.js";
+import type { OpenPr } from "../git.js";
+import type {
+ ForgeGateway,
+ ForgeProviderId,
+ ForgeSoftwareName,
+ GatewayStats,
+ PrLookup,
+ PrMutation,
+ ProviderMix,
+} from "./types.js";
+import { createFetchHttp, createForgejoGateway, type ForgejoRepoRef } from "./forgejo/gateway.js";
+import { parseForgejoRemote } from "./forgejo/map.js";
+import { createForgeDetector, isGitHubHost } from "./detect.js";
+import { createUnsupportedGateway } from "./unsupported.js";
+import { createNoForgeGateway } from "./none.js";
+import type { ProviderChoice } from "../repo_settings.js";
+
+// Re-exported: routing is where callers reach for it, detection is where it lives.
+export { isGitHubHost };
+
+/**
+ * Reads the origin URL. Declared here rather than imported from
+ * `github/queries.ts` so the neutral router does not depend on a provider.
+ */
+export const ORIGIN_REMOTE_ARGV = ["remote", "get-url", "origin"] as const;
+
+/** Where a repo lives, and how to reach its API. */
+export interface ForgeInstance {
+ /** Host of the `origin` remote, including a non-default port. */
+ host: string;
+ /** API base, e.g. `https://git.example.com` or a sub-path install. */
+ baseUrl: string;
+ /** Token for this instance, if one is configured for it. */
+ token?: string;
+}
+
+/** Timeout for the local `git remote` read. Local, so this is generous. */
+const REMOTE_TIMEOUT_MS = 5_000;
+
+
+/**
+ * Turn a git remote URL into Forgejo coordinates, or null when it cannot be read.
+ *
+ * Configuration comes from the environment:
+ *
+ * `MAKIT_FORGEJO_BASE_URL` / `FORGEJO_BASE_URL`
+ * The instance's URL. Needed only when `https://` is not right --
+ * an instance behind a sub-path, or plain HTTP on a private network.
+ * `MAKIT_FORGEJO_TOKEN` / `FORGEJO_ACCESS_TOKEN` / `FORGEJO_TOKEN` /
+ * `GITEA_TOKEN`
+ * API token, most specific name first.
+ *
+ * **A configured instance URL scopes the credentials to that host.** This is a
+ * security property, not a convenience: configuring one instance means setting a
+ * single global token, and without scoping that token would be attached to every
+ * non-GitHub remote — so opening any public Gitea/Forgejo repo would send the
+ * user's internal token to a third party. A foreign host is still queried, just
+ * unauthenticated, which is the correct outcome for a public repo.
+ *
+ * With no instance configured there is nothing to scope against (the
+ * single-instance case), so the token applies to the remote's own host.
+ */
+export function forgejoRefFromRemote(
+ remoteUrl: string,
+ env: Record,
+): ForgejoRepoRef | null {
+ const parsed = parseForgejoRemote(remoteUrl);
+ if (parsed === null) return null;
+
+ const configured = firstSet(env, ["MAKIT_FORGEJO_BASE_URL", "FORGEJO_BASE_URL"]);
+ const token = firstSet(env, [
+ "MAKIT_FORGEJO_TOKEN",
+ "FORGEJO_ACCESS_TOKEN",
+ "FORGEJO_TOKEN",
+ "GITEA_TOKEN",
+ ]);
+
+ // Compared on HOSTNAME alone -- no scheme, no port, no path. The override
+ // exists precisely to supply those, and an scp-form remote
+ // (`git@host:owner/repo`) cannot express a port at all, so an instance whose
+ // API is on :3000 would never match if the port counted.
+ const configuredHost = configured === undefined ? undefined : hostnameOf(configured);
+ const isConfiguredInstance =
+ configuredHost !== undefined && configuredHost.length > 0 && configuredHost === hostnameOnly(parsed.host);
+
+ return {
+ baseUrl: isConfiguredInstance && configured !== undefined ? configured : `https://${parsed.host}`,
+ owner: parsed.owner,
+ repo: parsed.repo,
+ // Withheld from any host other than the configured one -- see the note above.
+ token: configuredHost === undefined || isConfiguredInstance ? token : undefined,
+ };
+}
+
+/** First env var of [names] that is set and non-empty. */
+function firstSet(env: Record, names: string[]): string | undefined {
+ for (const name of names) {
+ const v = env[name];
+ if (v !== undefined && v.length > 0) return v;
+ }
+ return undefined;
+}
+
+/** Hostname of a URL (no port), lower-cased; empty string when unparseable. */
+function hostnameOf(url: string): string {
+ try {
+ return new URL(url).hostname.toLowerCase();
+ } catch {
+ return "";
+ }
+}
+
+/** Strip a `:port` suffix from a bare host. */
+function hostnameOnly(host: string): string {
+ return host.toLowerCase().split(":")[0];
+}
+
+/**
+ * What routing concluded about one repo. Recorded because nothing else retains it:
+ * `chosen` holds only the gateway promise, so without this a caller asking "which
+ * forge is this repo on?" would have to re-probe.
+ */
+export interface RepoForge {
+ software: ForgeSoftwareName;
+ host: string;
+ /**
+ * Whether a credential is configured **for that host**. Never the token itself,
+ * and omitted for GitHub, where `gh`'s budget is not host-specific
+ * authentication and reporting it would be a guess dressed as a fact.
+ */
+ authed?: boolean;
+ /**
+ * Whether this repo's provider came from probing the instance or from the user's
+ * override (SPEC-48 D3").
+ *
+ * Recorded because the UI must not caption an override "detected": whether the
+ * answer was measured or asserted is the one thing a reader would use to decide
+ * how much to trust it — and when an override is in force, detection's answer is
+ * deliberately never asked for.
+ */
+ source: "detected" | "override";
+}
+
+/**
+ * The narrow port `repo_service` needs. Deliberately not part of the gateway:
+ * `listRepos` receives a `GithubGateway`, and widening that contract to carry
+ * inspection would put two responsibilities on one interface.
+ */
+export interface ForgeInspector {
+ forgeFor(repoPath: string): RepoForge | undefined;
+ /**
+ * Whether `origin` could be read, or `undefined` when this repo has not been
+ * routed yet.
+ *
+ * Its own fact rather than `forgeFor(p) !== undefined`, because those two
+ * questions have three answers between them and one boolean cannot hold them:
+ * *not measured yet*, *no remote so no forge is possible*, and *a forge we
+ * identified*. Deriving "has a remote" from the forge decision collapsed the
+ * first two, which made the app's "not identified yet" wording unreachable and
+ * had every un-polled repo claim to have no remote.
+ */
+ hasRemoteFor(repoPath: string): boolean | undefined;
+}
+
+/**
+ * Invalidation, kept apart from {@link ForgeInspector} on purpose: inspection is a
+ * read and this is a write, and the consumers are different components. Only the
+ * one place that re-points a project needs it (SPEC-48 D4′), so putting it on the
+ * read port would hand every reader a way to clear the cache.
+ */
+export interface ForgeForgetful {
+ forgetRepo(repoPath: string): void;
+}
+
+export interface ForgeRouterDeps {
+ github: GithubGateway;
+ forgejo: ForgeGateway;
+ /** Used for a forge makit cannot talk to (GitLab, or unidentifiable). */
+ unsupported: ForgeGateway;
+ /** Used for a repo whose provider the user set to `none`. See `none.ts`. */
+ none: ForgeGateway;
+ /**
+ * The user's per-repo provider override (SPEC-48 D3"), or `auto` to believe
+ * detection. Read at routing time rather than injected once, because the setting
+ * changes while the daemon runs.
+ */
+ providerFor?: (repoPath: string) => ProviderChoice;
+ /** Where a repo lives, or null when the remote cannot be read. */
+ resolveInstance: (repoPath: string) => Promise;
+ /** Ask the instance what software it runs. See `detect.ts`. */
+ detect: (baseUrl: string, token?: string) => Promise;
+ /** Called once per host that turns out to be unsupported, for logging. */
+ onUnsupported?: (host: string, software: ForgeSoftwareName) => void;
+}
+
+export function createForgeRouter(
+ deps: ForgeRouterDeps,
+): GithubGateway & ProviderMix & ForgeInspector & ForgeForgetful {
+ /**
+ * Cache of the chosen provider per repo. Stores the PROMISE, not the resolved
+ * value, so the home-screen fan-out — which hits every worktree of a repo at
+ * once — shares one `git remote` read instead of spawning one per worktree.
+ *
+ * The CHOICE that produced it is stored alongside, so a changed override
+ * re-routes on the next call. Without that, setting a provider would appear to do
+ * nothing until the daemon restarted, which is indistinguishable from the feature
+ * being broken.
+ */
+ const chosen = new Map }>();
+ /**
+ * Providers actually reached. Recorded rather than inferred from config because
+ * only routing knows the truth, and the poll cadence depends on it.
+ */
+ const inUse = new Set();
+ /** Hosts already reported as unsupported, so the log says it once, not per tick. */
+ const warned = new Set();
+ /** What routing concluded, per repo. See {@link RepoForge}. */
+ const decided = new Map();
+ /** Whether `origin` was readable, per repo. See {@link ForgeInspector.hasRemoteFor}. */
+ const remotes = new Map();
+
+ function pick(repoPath: string): Promise {
+ const choice = deps.providerFor?.(repoPath) ?? "auto";
+ const hit = chosen.get(repoPath);
+ // Re-check the choice, but keep the cache when it has not changed: re-resolving
+ // on every call would throw away the read-sharing the fan-out depends on.
+ if (hit !== undefined && hit.choice === choice) return hit.gateway;
+ const routed = route(repoPath, choice);
+ const p = routed.then((r) => r.gateway);
+ // Cache only a route that was actually DECIDED. A fallback produced by a failed
+ // `git remote` read or a failed probe must not be cached: nothing evicts these
+ // entries, so one transient failure at startup used to pin a Forgejo repo to the
+ // `gh` gateway for the lifetime of the daemon -- every later poll failing, the PR
+ // pill reading `unknown` forever, and no way back short of a restart.
+ chosen.set(repoPath, { choice, gateway: p });
+ void routed.then((r) => {
+ if (!r.cacheable && chosen.get(repoPath)?.gateway === p) chosen.delete(repoPath);
+ });
+ return p;
+ }
+
+ /** A routing answer, plus whether it was decided (cacheable) or fallen back to. */
+ interface Routed {
+ gateway: ForgeGateway;
+ cacheable: boolean;
+ }
+
+ function route(repoPath: string, choice: ProviderChoice): Promise {
+ return (async (): Promise => {
+ // `none` short-circuits before the remote is even read. "Talks to no forge"
+ // has to include not looking one up, or it is a label rather than an
+ // instruction — and the decision is the user's, so there is nothing to learn.
+ if (choice === "none") {
+ inUse.add("none");
+ decided.delete(repoPath);
+ remotes.delete(repoPath);
+ return { gateway: deps.none, cacheable: true };
+ }
+
+ const inst = await deps.resolveInstance(repoPath);
+ remotes.set(repoPath, inst !== null);
+
+ // An override is honoured WITHOUT probing. That is the point: the cases it
+ // exists for are exactly the ones where the probe cannot answer — a private
+ // instance that 401s an anonymous request, or one behind a proxy that hides
+ // the version endpoint. Spending the probe anyway would delay every poll to
+ // learn nothing.
+ if (choice !== "auto") {
+ if (inst !== null) {
+ decided.set(repoPath, {
+ software: choice,
+ host: inst.host,
+ // Omitted for GitHub for the same reason detection omits it.
+ ...(choice === "github"
+ ? {}
+ : { authed: inst.token !== undefined && inst.token.length > 0 }),
+ source: "override",
+ });
+ }
+ if (choice === "github") {
+ inUse.add("github");
+ return { gateway: deps.github, cacheable: true };
+ }
+ inUse.add("forgejo");
+ return { gateway: deps.forgejo, cacheable: true };
+ }
+
+ // No readable remote: stay on gh, which is the status quo for anything that
+ // is not a checkout. Routing it elsewhere would change where such a
+ // directory fails, for no gain.
+ if (inst === null || isGitHubHost(inst.host)) {
+ inUse.add("github");
+ if (inst !== null) {
+ decided.set(repoPath, { software: "github", host: inst.host, source: "detected" });
+ }
+ // An unreadable remote is not a decision -- it is the absence of one, and it
+ // is exactly the transient case that must be retried rather than pinned.
+ return { gateway: deps.github, cacheable: inst !== null };
+ }
+ const software = await deps.detect(inst.baseUrl, inst.token);
+ decided.set(repoPath, {
+ software,
+ host: inst.host,
+ authed: inst.token !== undefined && inst.token.length > 0,
+ source: "detected",
+ });
+ if (software === "forgejo" || software === "gitea") {
+ inUse.add("forgejo");
+ return { gateway: deps.forgejo, cacheable: true };
+ }
+ inUse.add("unsupported");
+ if (!warned.has(inst.host)) {
+ warned.add(inst.host);
+ deps.onUnsupported?.(inst.host, software);
+ }
+ // `unknown` means the probe could not classify it; re-probe next time rather
+ // than concluding forever. A named-but-unsupported forge IS a decision.
+ return { gateway: deps.unsupported, cacheable: software !== "unknown" };
+ })().catch(() => {
+ // A transient failure must not discard an EXPLICIT choice. Falling back to gh
+ // here would ignore an instruction the user gave and send the repo to a
+ // provider that cannot talk to its host — and because the result is cached
+ // against the choice that produced it, one failed `git remote` read would pin
+ // the repo to the wrong provider until the setting changed or the daemon
+ // restarted.
+ //
+ // Record the remote as unreadable ONLY if nothing was recorded, because two
+ // different failures land here: the remote read failing (no remote) and
+ // DETECTION failing (a good remote we could not classify, already recorded as
+ // `true`). Setting `false` unconditionally would turn the second into a claim
+ // that the repo has no origin — a fact we just measured otherwise.
+ if (!remotes.has(repoPath)) remotes.set(repoPath, false);
+ // `auto` keeps the original behaviour: with no opinion attached, gh is the
+ // status quo for a repo we could not read.
+ if (choice === "forgejo" || choice === "gitea") {
+ inUse.add("forgejo");
+ return { gateway: deps.forgejo, cacheable: false };
+ }
+ if (choice === "none") {
+ inUse.add("none");
+ return { gateway: deps.none, cacheable: false };
+ }
+ inUse.add("github");
+ return { gateway: deps.github, cacheable: false };
+ });
+ }
+
+ return {
+ async prForBranch(repoPath: string, branch: string, opts?: { interactive?: boolean }): Promise {
+ return (await pick(repoPath)).prForBranch(repoPath, branch, opts);
+ },
+ async openPrs(repoPath: string, limit: number, opts?: { interactive?: boolean }): Promise {
+ return (await pick(repoPath)).openPrs(repoPath, limit, opts);
+ },
+ async mutatePr(
+ repoPath: string,
+ branch: string,
+ number: number,
+ verb: PrMutation,
+ ): Promise<{ ok: boolean; error?: string }> {
+ return (await pick(repoPath)).mutatePr(repoPath, branch, number, verb);
+ },
+
+ // Budget: forwarded verbatim. See the module note on why this is not merged.
+ budget: () => deps.github.budget(),
+ history: () => deps.github.history(),
+ refresh: () => deps.github.refresh(),
+ setPaused: (paused: boolean) => deps.github.setPaused(paused),
+ onBudgetChange: (fn) => deps.github.onBudgetChange(fn),
+
+ /**
+ * Summed across EVERY provider, so the ≥80% call-reduction figure covers every
+ * one in play.
+ *
+ * `unsupported` and `none` report zeros today, so omitting them was right by
+ * accident — and would have drifted silently the moment either started counting a
+ * call. Reading all four costs nothing and keeps the number honest by
+ * construction rather than by coincidence.
+ */
+ stats(): GatewayStats {
+ const all = [deps.github, deps.forgejo, deps.unsupported, deps.none].map((g) => g.stats());
+ return {
+ execs: all.reduce((n, s) => n + s.execs, 0),
+ exemptExecs: all.reduce((n, s) => n + s.exemptExecs, 0),
+ cacheHits: all.reduce((n, s) => n + s.cacheHits, 0),
+ };
+ },
+ providersInUse: () => new Set(inUse),
+ forgeFor: (repoPath: string) => decided.get(repoPath),
+ hasRemoteFor: (repoPath: string) => remotes.get(repoPath),
+ /**
+ * Discard everything routing learned about [repoPath].
+ *
+ * Called when a project is re-pointed (SPEC-48 D4′): the repo at the old path is
+ * no longer the project's repo, so its cached gateway, forge decision and remote
+ * fact must not be reported — and detection has to run again for the new path,
+ * because the forge may have changed with the move.
+ */
+ forgetRepo(repoPath: string): void {
+ chosen.delete(repoPath);
+ decided.delete(repoPath);
+ remotes.delete(repoPath);
+ },
+ close(): void {
+ chosen.clear();
+ inUse.clear();
+ warned.clear();
+ decided.clear();
+ remotes.clear();
+ // All four, for the same reason `stats` reads all four: the two no-op gateways
+ // close to nothing today, and a provider that later acquires a timer or socket
+ // must not depend on someone remembering to add it here.
+ for (const g of [deps.github, deps.forgejo, deps.unsupported, deps.none]) g.close();
+ },
+ };
+}
+
+/**
+ * The production wiring: a gh-backed GitHub gateway, a REST-backed Forgejo
+ * gateway, and the router over both. `exec` is git.ts's `run`, so `gh` still
+ * resolves through PATH and the test PATH-shim keeps working.
+ */
+export function createDefaultForgeGateway(opts: {
+ exec: Exec;
+ env?: Record;
+ /** See {@link ForgeRouterDeps.providerFor}. */
+ providerFor?: (repoPath: string) => ProviderChoice;
+}): GithubGateway {
+ const env = opts.env ?? process.env;
+ /**
+ * `origin`'s URL, memoised per repo and SHARED while in flight.
+ *
+ * Both the router and the Forgejo gateway need it, and the gateway asks at the top of
+ * `prForBranch`, `openPrs` and `mutatePr` -- BEFORE consulting its own cache. So even
+ * a cache hit paid for a `git remote get-url origin` subprocess, and the home-screen
+ * fan-out across a repo's worktrees spawned one process per worktree per poll tick.
+ *
+ * Cached for the process lifetime rather than with a TTL: a repo's `origin` does not
+ * change under a running daemon, and the two cases that DO change it both clear the
+ * entry -- re-pointing a project (`forgetRemote`) and shutdown (`close`).
+ */
+ const remoteUrls = new Map>();
+
+ const readRemote = (repoPath: string): Promise => {
+ const hit = remoteUrls.get(repoPath);
+ if (hit !== undefined) return hit;
+ const p = (async (): Promise => {
+ const r = await opts.exec("git", [...ORIGIN_REMOTE_ARGV], repoPath, REMOTE_TIMEOUT_MS);
+ if (r.code !== 0) return null;
+ const url = r.stdout.trim();
+ return url.length > 0 ? url : null;
+ })().catch(() => null);
+ // A failed read is NOT retained: it is the transient case, and pinning it would
+ // repeat the bug the routing cache had.
+ void p.then((url) => {
+ if (url === null && remoteUrls.get(repoPath) === p) remoteUrls.delete(repoPath);
+ });
+ remoteUrls.set(repoPath, p);
+ return p;
+ };
+ const http = createFetchHttp();
+ const detector = createForgeDetector({ http });
+ // Late-bound so the unsupported gateway can ask the router what THIS repo turned
+ // out to be. A single shared "most recently detected" value named the wrong forge
+ // as soon as a second repo was probed.
+ let inspector: ForgeInspector | undefined;
+ const router = createForgeRouter({
+ github: createGithubGateway({ exec: opts.exec }),
+ forgejo: createForgejoGateway({
+ http,
+ resolveRepo: async (repoPath) => {
+ const url = await readRemote(repoPath);
+ return url === null ? null : forgejoRefFromRemote(url, env);
+ },
+ }),
+ unsupported: createUnsupportedGateway({
+ softwareFor: (repoPath) => inspector?.forgeFor(repoPath)?.software,
+ }),
+ none: createNoForgeGateway(),
+ providerFor: opts.providerFor,
+ resolveInstance: async (repoPath) => {
+ const url = await readRemote(repoPath);
+ if (url === null) return null;
+ const ref = forgejoRefFromRemote(url, env);
+ if (ref === null) return null;
+ const host = parseForgejoRemote(url)?.host;
+ return host === undefined ? null : { host, baseUrl: ref.baseUrl, token: ref.token };
+ },
+ detect: (baseUrl, token) => detector.detect(baseUrl, token),
+ onUnsupported: (host, software) => {
+ const what = software === "unknown" ? "an unrecognised forge" : software;
+ // Once per host. Silent failure here is what made this class of bug
+ // indistinguishable from an outage.
+ console.warn(
+ `[makit] ${host} looks like ${what}; makit has no provider for it, so pull-request status is unavailable for repositories there.`,
+ );
+ },
+ });
+ inspector = router;
+ // Positive detections are cached with `expiresAt: null`, so the router's own
+ // `close()` -- which clears its per-repo maps -- would leave them behind. A closed
+ // gateway must not answer from a probe made before it was closed.
+ const close = router.close.bind(router);
+ router.close = (): void => {
+ detector.clear();
+ remoteUrls.clear();
+ close();
+ };
+ const forget = router.forgetRepo.bind(router);
+ router.forgetRepo = (repoPath: string): void => {
+ // A re-pointed project is a different directory: its remembered `origin` is now
+ // another repo's, which is exactly the value that must not be reused.
+ remoteUrls.delete(repoPath);
+ forget(repoPath);
+ };
+ return router;
+}
diff --git a/server/src/forge/types.ts b/server/src/forge/types.ts
new file mode 100644
index 00000000..3c69c224
--- /dev/null
+++ b/server/src/forge/types.ts
@@ -0,0 +1,114 @@
+/**
+ * types.ts — the provider-neutral forge contract.
+ *
+ * Split into two interfaces on purpose (interface segregation). Everything the
+ * app actually needs from a forge — "is there a PR on this branch", "list open
+ * PRs", "run this PR action" — is in {@link ForgeGateway}. The quota accounting
+ * in {@link BudgetReporting} is a GitHub-only concern: self-hosted Forgejo
+ * exposes no `rate_limit` endpoint and sends no rate-limit response headers, so
+ * there is nothing for it to report.
+ *
+ * Keeping them separate is what stops a Forgejo provider from having to fake a
+ * budget it cannot measure. A stub returning "unlimited" would be a lie the
+ * footer would render as fact, and a stub throwing would turn a UI affordance
+ * into a crash — {@link hasBudgetReporting} lets the caller ask instead.
+ */
+
+import type { OpenPr, PullRequestInfo } from "../git.js";
+
+/** Three-way PR lookup result — a failed lookup is never `none` (SPEC-32 §6.5). */
+export type PrLookup =
+ | { kind: "pr"; pr: PullRequestInfo }
+ | { kind: "none" }
+ | { kind: "unknown"; reason: "throttled" | "error" };
+
+/** A state-changing PR action the app can run on the user's behalf. */
+export type PrMutation = "ready" | "update-branch" | "merge-squash";
+
+/** Call/cache counters — how the ≥80% call-reduction claim is measured. */
+export interface GatewayStats {
+ /** Provider calls that spent quota (GitHub) or hit the network (Forgejo). */
+ execs: number;
+ /** Quota-exempt reads. Free, but still round trips. */
+ exemptExecs: number;
+ /** Reads served from cache without a round trip. */
+ cacheHits: number;
+}
+
+/**
+ * The forge operations the app depends on. Implemented by both providers —
+ * `gh`-backed for GitHub, REST-backed for Forgejo.
+ */
+export interface ForgeGateway {
+ /**
+ * The latest PR whose head is `branch`, or `none` when there is genuinely no
+ * PR. A lookup that could not be completed returns `unknown`, never `none`:
+ * reporting "no PR" for a failed call erases the pill and reads as fact.
+ */
+ prForBranch(repoPath: string, branch: string, opts?: { interactive?: boolean }): Promise;
+ /**
+ * All open PRs for a repo (the "New worktree from PR" picker).
+ *
+ * `interactive: true` marks a user-initiated call — a click, not a poll — so a
+ * provider that sheds load must still serve it rather than return an empty
+ * list the user would read as "this repo has no open PRs".
+ */
+ openPrs(repoPath: string, limit: number, opts?: { interactive?: boolean }): Promise;
+ /**
+ * Run a state-changing PR verb. Always interactive (a button press), and must
+ * invalidate any cached lookup for `branch` on success — otherwise the UI keeps
+ * reporting the state the mutation just changed until the TTL expires.
+ */
+ mutatePr(
+ repoPath: string,
+ branch: string,
+ number: number,
+ verb: PrMutation,
+ ): Promise<{ ok: boolean; error?: string }>;
+ stats(): GatewayStats;
+ close(): void;
+}
+
+/**
+ * Quota accounting. GitHub-only — see the module note.
+ *
+ * `BudgetSnapshot` is deliberately loose here (`unknown`) so this module does not
+ * drag GitHub's budget vocabulary into the neutral contract; the GitHub gateway
+ * re-declares it with the precise type.
+ */
+export interface BudgetReporting {
+ budget(): unknown;
+ history(): Array<{ mine: number; others: number }>;
+ refresh(): Promise;
+ setPaused(paused: boolean): void;
+ onBudgetChange(fn: (s: never) => void): () => void;
+}
+
+/** The providers this build can route to. */
+export type ForgeProviderId = "github" | "forgejo" | "unsupported" | "none";
+
+/** Forge software an instance may run, as reported by detection. */
+export type ForgeSoftwareName = "github" | "forgejo" | "gitea" | "gitlab" | "unknown";
+
+/**
+ * Reports which providers are actually in play, learned from the repos routed so
+ * far. Consumed by the poll cadence: GitHub's degradation ladder must not
+ * throttle a setup that contains no GitHub repos (see `cadence.ts`).
+ */
+export interface ProviderMix {
+ providersInUse(): ReadonlySet;
+}
+
+/** Whether a gateway can report which providers it is routing to. */
+export function hasProviderMix(gateway: G): gateway is G & ProviderMix {
+ return typeof (gateway as Partial).providersInUse === "function";
+}
+
+/**
+ * Whether a gateway can report quota. Use this before wiring budget events or
+ * the budget UI, rather than assuming every provider has a quota to report.
+ */
+export function hasBudgetReporting(gateway: G): gateway is G & BudgetReporting {
+ const g = gateway as unknown as Partial;
+ return typeof g.budget === "function" && typeof g.onBudgetChange === "function";
+}
diff --git a/server/src/forge/unsupported.test.ts b/server/src/forge/unsupported.test.ts
new file mode 100644
index 00000000..acf5bc6b
--- /dev/null
+++ b/server/src/forge/unsupported.test.ts
@@ -0,0 +1,35 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { createUnsupportedGateway } from "./unsupported.js";
+import { hasBudgetReporting } from "./types.js";
+
+test("a lookup is unknown, never none -- we never asked", async () => {
+ const g = createUnsupportedGateway();
+ assert.deepEqual(await g.prForBranch("/r", "b"), { kind: "unknown", reason: "error" });
+});
+
+test("it makes no requests, so polling an unsupported forge costs nothing", async () => {
+ const g = createUnsupportedGateway();
+ await g.prForBranch("/r", "b");
+ await g.openPrs("/r", 30);
+ assert.deepEqual(g.stats(), { execs: 0, exemptExecs: 0, cacheHits: 0 });
+});
+
+test("a mutation names the forge, so the user learns why the button did nothing", async () => {
+ const g = createUnsupportedGateway({ software: () => "gitlab" });
+ const r = await g.mutatePr("/r", "b", 1, "merge-squash");
+ assert.equal(r.ok, false);
+ assert.match(r.error ?? "", /GitLab/);
+ assert.match(r.error ?? "", /merge-squash/);
+});
+
+test("an unidentified forge gets a vaguer but still honest message", async () => {
+ const g = createUnsupportedGateway({ software: () => "unknown" });
+ const r = await g.mutatePr("/r", "b", 1, "ready");
+ assert.match(r.error ?? "", /this forge/);
+});
+
+test("it claims no budget", () => {
+ assert.equal(hasBudgetReporting(createUnsupportedGateway()), false);
+});
diff --git a/server/src/forge/unsupported.ts b/server/src/forge/unsupported.ts
new file mode 100644
index 00000000..d4cbfa8f
--- /dev/null
+++ b/server/src/forge/unsupported.ts
@@ -0,0 +1,64 @@
+/**
+ * unsupported.ts — the provider for a forge makit cannot talk to.
+ *
+ * Exists so an unsupported forge fails HONESTLY and CHEAPLY. Before detection,
+ * a GitLab or Bitbucket remote was routed to the Forgejo provider, where every
+ * poll spent a real HTTP request to an API that does not exist there and came
+ * back as `unknown` — the same result as a Forgejo instance being down, so the
+ * user had no way to tell "makit doesn't support this" from "the network is
+ * broken".
+ *
+ * This makes no requests at all, and a mutation says what is actually wrong.
+ */
+
+import type { OpenPr } from "../git.js";
+import type { ForgeGateway, ForgeSoftwareName, GatewayStats, PrLookup, PrMutation } from "./types.js";
+
+export interface UnsupportedGatewayDeps {
+ /** What the detector found, for the message. */
+ software?: () => ForgeSoftwareName;
+ /**
+ * What the detector found for a SPECIFIC repo, for the message.
+ *
+ * Preferred over {@link software}, which is a single shared value: it was set by
+ * whichever repo was probed most recently, so a mutation on a GitLab repo could
+ * name a different forge entirely once another repo had been detected. The
+ * decision is per repo, so the message must be too.
+ */
+ softwareFor?: (repoPath: string) => ForgeSoftwareName | undefined;
+}
+
+/** Human name for the message; `unknown` gets a vaguer phrasing. */
+function describe(software: ForgeSoftwareName): string {
+ switch (software) {
+ case "gitlab":
+ return "GitLab";
+ case "unknown":
+ return "this forge";
+ default:
+ return software;
+ }
+}
+
+export function createUnsupportedGateway(deps: UnsupportedGatewayDeps = {}): ForgeGateway {
+ const stats: GatewayStats = { execs: 0, exemptExecs: 0, cacheHits: 0 };
+ const name = (repoPath: string): string =>
+ describe(deps.softwareFor?.(repoPath) ?? deps.software?.() ?? "unknown");
+
+ return {
+ // `unknown`, never `none`: we did not look, so we cannot claim there is no PR.
+ prForBranch: async (): Promise => ({ kind: "unknown", reason: "error" }),
+ openPrs: async (): Promise => [],
+ mutatePr: async (
+ repoPath: string,
+ _branch: string,
+ _number: number,
+ verb: PrMutation,
+ ): Promise<{ ok: boolean; error?: string }> => ({
+ ok: false,
+ error: `makit has no ${name(repoPath)} provider yet, so it cannot run "${verb}" on this repository.`,
+ }),
+ stats: () => ({ ...stats }),
+ close: () => {},
+ };
+}
diff --git a/server/src/git.pr_checkout.test.ts b/server/src/git.pr_checkout.test.ts
new file mode 100644
index 00000000..bab4e5fa
--- /dev/null
+++ b/server/src/git.pr_checkout.test.ts
@@ -0,0 +1,298 @@
+/**
+ * Checking out a pull request when the forge is NOT GitHub (SPEC-48 P2).
+ *
+ * The gap this closes: "New worktree from PR" listed Forgejo PRs correctly — the
+ * picker routes through the forge gateway — and then ran `gh pr checkout` to create
+ * the worktree. On a Forgejo remote that fails, so the flow was broken exactly
+ * halfway: the user sees their PRs, picks one, and the worktree never appears.
+ *
+ * Tested against a real local bare repo carrying `refs/pull//head`, which is how
+ * Gitea and Forgejo actually expose PR heads — so this exercises the real git
+ * plumbing with no network and no forge.
+ */
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { chmodSync, mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, join } from "node:path";
+
+import { addWorktreeForPr } from "./git.js";
+
+interface Fixture {
+ origin: string;
+ clone: string;
+ base: string;
+ /** The commit at the tip of the PR head. */
+ prHead: string;
+ cleanup: () => void;
+}
+
+/**
+ * A bare "origin" with one PR published at `refs/pull/7/head`, plus a clone.
+ *
+ * The PR commit is deliberately NOT reachable from any branch in the clone: that is
+ * the whole point of fetching the pull ref, and a fixture where the commit is
+ * already present would pass even if nothing were fetched.
+ */
+function fixture(opts: { sameRepoBranch?: boolean } = {}): Fixture {
+ const dir = mkdtempSync(join(tmpdir(), "makit-prco-"));
+ const origin = join(dir, "origin.git");
+ const work = join(dir, "work");
+ const clone = join(dir, "clone");
+ const g = (cwd: string, ...a: string[]) => execFileSync("git", a, { cwd }).toString();
+
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", origin]);
+ execFileSync("git", ["clone", "-q", origin, work]);
+ g(work, "config", "user.email", "t@t.io");
+ g(work, "config", "user.name", "T");
+ writeFileSync(join(work, "README.md"), "base\n");
+ g(work, "add", ".");
+ g(work, "commit", "-q", "-m", "base");
+ g(work, "push", "-q", "origin", "main");
+
+ // The PR's head commit.
+ g(work, "checkout", "-q", "-b", "feature/login");
+ writeFileSync(join(work, "feature.txt"), "pr work\n");
+ g(work, "add", ".");
+ g(work, "commit", "-q", "-m", "the PR commit");
+ const prHead = g(work, "rev-parse", "HEAD").trim();
+ // Published the way a forge publishes it. `sameRepoBranch` also pushes the branch,
+ // which is what distinguishes a same-repo PR from a fork's.
+ g(work, "push", "-q", "origin", "HEAD:refs/pull/7/head");
+ if (opts.sameRepoBranch === true) g(work, "push", "-q", "origin", "feature/login");
+
+ execFileSync("git", ["clone", "-q", origin, clone]);
+ const base = mkdtempSync(join(tmpdir(), "makit-prco-wt-"));
+ return {
+ origin,
+ clone,
+ base,
+ prHead,
+ cleanup: () => {
+ rmSync(dir, { recursive: true, force: true });
+ rmSync(base, { recursive: true, force: true });
+ },
+ };
+}
+
+test("a non-GitHub PR is checked out from refs/pull//head, without gh", async () => {
+ const f = fixture();
+ try {
+ const r = await addWorktreeForPr({
+ repoPath: f.clone,
+ prNumber: 7,
+ headRefName: "feature/login",
+ baseDir: f.base,
+ checkout: "pull-ref",
+ });
+ // The PR's actual commit is checked out — not the base, which is what a silently
+ // skipped fetch would leave behind.
+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: r.path }).toString().trim();
+ assert.equal(head, f.prHead);
+ assert.equal(readFileSync(join(r.path, "feature.txt"), "utf8"), "pr work\n");
+ } finally {
+ f.cleanup();
+ }
+});
+
+test("it lands on a PR-unique branch, not the PR's head ref name", async () => {
+ // Same reason the gh path passes `--branch`: the primary checkout commonly sits on
+ // the head ref already, and git refuses to check out a branch twice in one repo.
+ const f = fixture({ sameRepoBranch: true });
+ try {
+ execFileSync("git", ["checkout", "-q", "-b", "feature/login", "origin/feature/login"], {
+ cwd: f.clone,
+ });
+ const r = await addWorktreeForPr({
+ repoPath: f.clone,
+ prNumber: 7,
+ headRefName: "feature/login",
+ baseDir: f.base,
+ checkout: "pull-ref",
+ });
+ assert.equal(r.branch, "pr-7-feature-login");
+ } finally {
+ f.cleanup();
+ }
+});
+
+test("a same-repo PR tracks its head branch, so a push updates the PR", async () => {
+ const f = fixture({ sameRepoBranch: true });
+ try {
+ const r = await addWorktreeForPr({
+ repoPath: f.clone,
+ prNumber: 7,
+ headRefName: "feature/login",
+ baseDir: f.base,
+ checkout: "pull-ref",
+ });
+ const upstream = execFileSync(
+ "git",
+ ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
+ { cwd: r.path },
+ )
+ .toString()
+ .trim();
+ assert.equal(upstream, "origin/feature/login");
+ } finally {
+ f.cleanup();
+ }
+});
+
+test("a fork PR still checks out, just without an upstream", async () => {
+ // The head branch does not exist on `origin` for a fork. Refusing the checkout
+ // would make reviewing a contributor's PR impossible; the worktree is the point,
+ // and pushing to someone else's fork was never possible anyway.
+ const f = fixture();
+ try {
+ const r = await addWorktreeForPr({
+ repoPath: f.clone,
+ prNumber: 7,
+ headRefName: "contributor-branch",
+ baseDir: f.base,
+ checkout: "pull-ref",
+ });
+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: r.path }).toString().trim();
+ assert.equal(head, f.prHead);
+ // And genuinely has no upstream: `@{u}` fails rather than resolving to something
+ // wrong, which would send a push to the wrong branch.
+ assert.throws(() =>
+ execFileSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
+ cwd: r.path,
+ stdio: ["ignore", "pipe", "ignore"],
+ }),
+ );
+ } finally {
+ f.cleanup();
+ }
+});
+
+test("a PR number the forge does not publish fails and leaves no litter", async () => {
+ // The empty detached worktree must be rolled back, exactly as the gh path does —
+ // otherwise a mistyped or closed PR leaves a directory that looks like a worktree.
+ const f = fixture();
+ try {
+ await assert.rejects(
+ addWorktreeForPr({
+ repoPath: f.clone,
+ prNumber: 999,
+ headRefName: "nope",
+ baseDir: f.base,
+ checkout: "pull-ref",
+ }),
+ );
+ const worktrees = execFileSync("git", ["worktree", "list"], { cwd: f.clone }).toString();
+ assert.equal(worktrees.includes("pr-999"), false, "the failed worktree was removed");
+ } finally {
+ f.cleanup();
+ }
+});
+
+test("the honoured worktree root is the caller's, so per-repo roots still apply", async () => {
+ const f = fixture();
+ try {
+ const r = await addWorktreeForPr({
+ repoPath: f.clone,
+ prNumber: 7,
+ headRefName: "feature/login",
+ baseDir: f.base,
+ checkout: "pull-ref",
+ });
+ // The EXACT path, not a prefix: `startsWith` also accepts a sibling such as
+ // `${base}-other/...`, so the assertion held for a worktree outside the root the
+ // caller chose -- which is the only thing this test exists to check.
+ const base = execFileSync("realpath", [f.base]).toString().trim();
+ const repoName = basename(f.clone);
+ assert.equal(r.path, join(base, repoName, "pr-7-feature-login"));
+ } finally {
+ f.cleanup();
+ }
+});
+
+// ---------------------------------------------------------------------------
+// The GitHub path, which had NO test before this refactor.
+//
+// `addWorktreeForPr` was one function that always ran `gh pr checkout`; it is now two
+// strategies behind a discriminator. That is exactly the shape of change where a
+// working path regresses silently, so the gh invocation is pinned here — argv and
+// all — via a PATH shim, the same technique manager.test.ts uses.
+// ---------------------------------------------------------------------------
+
+test("the GitHub strategy still runs `gh pr checkout --branch `", async () => {
+ const f = fixture({ sameRepoBranch: true });
+ const bin = mkdtempSync(join(tmpdir(), "makit-fake-gh-"));
+ const argvLog = join(bin, "argv.txt");
+ const prevPath = process.env.PATH;
+ try {
+ const gh = join(bin, "gh");
+ // Records its argv, then does what the real `gh pr checkout --branch` does, so the
+ // rest of the function (HEAD read, branch reporting) runs against a real result.
+ writeFileSync(
+ gh,
+ [
+ "#!/bin/sh",
+ `printf '%s\\n' "$*" >> "${argvLog}"`,
+ 'git fetch --quiet origin refs/pull/7/head || exit 1',
+ 'git checkout -q -b "$5" FETCH_HEAD || exit 1',
+ "",
+ ].join("\n"),
+ );
+ chmodSync(gh, 0o755);
+ process.env.PATH = `${bin}:${prevPath ?? ""}`;
+
+ const r = await addWorktreeForPr({
+ repoPath: f.clone,
+ prNumber: 7,
+ headRefName: "feature/login",
+ baseDir: f.base,
+ // No `checkout` passed: the default must remain gh, so every existing caller
+ // keeps its behaviour.
+ });
+
+ assert.equal(
+ readFileSync(argvLog, "utf8").trim(),
+ "pr checkout 7 --branch pr-7-feature-login",
+ "argv unchanged, including the PR-unique --branch that avoids a checkout collision",
+ );
+ assert.equal(r.branch, "pr-7-feature-login");
+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: r.path }).toString().trim();
+ assert.equal(head, f.prHead);
+ } finally {
+ if (prevPath === undefined) delete process.env.PATH;
+ else process.env.PATH = prevPath;
+ rmSync(bin, { recursive: true, force: true });
+ f.cleanup();
+ }
+});
+
+test("a failing gh still rolls back the empty worktree", async () => {
+ // The rollback moved into a shared branch during the refactor; pinned for gh too so
+ // one strategy cannot keep it while the other loses it.
+ const f = fixture();
+ const bin = mkdtempSync(join(tmpdir(), "makit-fake-gh-"));
+ const prevPath = process.env.PATH;
+ try {
+ const gh = join(bin, "gh");
+ writeFileSync(gh, "#!/bin/sh\necho 'no PR for you' >&2\nexit 1\n");
+ chmodSync(gh, 0o755);
+ process.env.PATH = `${bin}:${prevPath ?? ""}`;
+
+ await assert.rejects(
+ addWorktreeForPr({
+ repoPath: f.clone,
+ prNumber: 7,
+ headRefName: "feature/login",
+ baseDir: f.base,
+ }),
+ /no PR for you|gh pr checkout/,
+ );
+ const worktrees = execFileSync("git", ["worktree", "list"], { cwd: f.clone }).toString();
+ assert.equal(worktrees.includes("pr-7"), false, "no litter left behind");
+ } finally {
+ if (prevPath === undefined) delete process.env.PATH;
+ else process.env.PATH = prevPath;
+ rmSync(bin, { recursive: true, force: true });
+ f.cleanup();
+ }
+});
diff --git a/server/src/git.test.ts b/server/src/git.test.ts
index 4007ad84..0b7614fd 100644
--- a/server/src/git.test.ts
+++ b/server/src/git.test.ts
@@ -7,6 +7,7 @@ import { join } from "node:path";
import {
detectDefaultBranch,
+ resolveDefaultBranch,
detectCurrentBranch,
listWorktrees,
diffStat,
@@ -560,3 +561,176 @@ test("syncBaseBranch refuses when the branch is checked out in two worktrees", a
rmSync(base, { recursive: true, force: true });
}
});
+
+// ---------------------------------------------------------------------------
+// SPEC-48 — the default-branch override, and why it must be checked rather than
+// trusted.
+//
+// The consumer is concrete: `origin/HEAD` is genuinely absent after a
+// `--single-branch` clone or a default-branch rename, and makit then diffs and
+// bases PRs against the wrong branch. The override is the fix. But it is stored
+// after a SYNTAX check only, and a branch can be deleted after it was chosen, so
+// resolution has to confirm the ref still exists.
+// ---------------------------------------------------------------------------
+
+test("resolveDefaultBranch prefers an override that exists over detection", async () => {
+ const repo = makeRepo();
+ try {
+ execFileSync("git", ["branch", "release"], { cwd: repo });
+ assert.equal(await detectDefaultBranch(repo), "main", "detection would say main");
+ assert.equal(await resolveDefaultBranch(repo, "release"), "release");
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
+test("resolveDefaultBranch falls back to detection when the override is gone", async () => {
+ // A branch chosen months ago and since deleted must not silently break the diff
+ // numbers: a stale override is a worse base than git's own answer, not a better
+ // one, so it loses rather than winning and failing.
+ const repo = makeRepo();
+ try {
+ assert.equal(await resolveDefaultBranch(repo, "deleted-long-ago"), "main");
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
+test("resolveDefaultBranch with no override is exactly detection", async () => {
+ const repo = makeRepo();
+ try {
+ assert.equal(await resolveDefaultBranch(repo, undefined), await detectDefaultBranch(repo));
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
+test("an override rescues a repo whose origin/HEAD points at a branch that is gone", async () => {
+ // The real failure, reproduced: `origin/HEAD` still names `master` after the
+ // default branch was renamed to `trunk`, so every diff is measured against a ref
+ // that no longer resolves.
+ const repo = makeRepo();
+ try {
+ const g = (...args: string[]) => execFileSync("git", args, { cwd: repo });
+ g("branch", "trunk");
+ g("remote", "add", "origin", "https://example.test/x/y.git");
+ // Point origin/HEAD at a remote branch that does not exist locally.
+ g("update-ref", "refs/remotes/origin/master", "HEAD");
+ g("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/master");
+ assert.equal(await detectDefaultBranch(repo), "master", "git's answer, and it is wrong");
+ assert.equal(await resolveDefaultBranch(repo, "trunk"), "trunk");
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
+test("a default-branch override naming a remote-only branch is honoured", async () => {
+ // Review finding: `branchExists` checks `refs/heads/` only, so an override naming
+ // a branch that exists on the remote but is not checked out locally was treated as
+ // stale and silently dropped. That is the normal state after a `--single-branch`
+ // clone -- the very case the override exists for: `trunk` is visible as
+ // `origin/trunk` and nothing else.
+ const repo = makeRepo();
+ try {
+ const g = (...args: string[]) => execFileSync("git", args, { cwd: repo });
+ g("remote", "add", "origin", "https://example.test/x/y.git");
+ g("update-ref", "refs/remotes/origin/trunk", "HEAD");
+ assert.equal(await branchExists(repo, "trunk"), false, "not a local branch");
+ assert.equal(
+ await resolveDefaultBranch(repo, "trunk"),
+ "origin/trunk",
+ "the remote knows it, so the override stands -- qualified so it resolves",
+ );
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
+test("an override naming nothing at all is still dropped", async () => {
+ // The guard must not become "accept anything": a branch deleted from both sides is
+ // a worse base than git's own answer.
+ const repo = makeRepo();
+ try {
+ execFileSync("git", ["remote", "add", "origin", "https://example.test/x/y.git"], { cwd: repo });
+ assert.equal(await resolveDefaultBranch(repo, "never-existed"), "main");
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
+test("a remote-only override is returned in a form git can actually resolve", async () => {
+ // Review finding, and a bug the previous round introduced: accepting an override that
+ // exists only as `refs/remotes/origin/` and then returning the BARE name yields a
+ // ref git cannot resolve. `gitrevisions` checks `refs/`, `refs/tags/`,
+ // `refs/heads/` and `refs/remotes/` -- never `refs/remotes/origin/`
+ // -- so `diffStat`, `commitsAhead` and `git worktree add` all received a base that
+ // resolves nowhere: silent zero diffs, zero counts, failed worktree creation.
+ const repo = makeRepo();
+ try {
+ const g = (...args: string[]) => execFileSync("git", args, { cwd: repo });
+ g("remote", "add", "origin", "https://example.test/x/y.git");
+ g("update-ref", "refs/remotes/origin/trunk", "HEAD");
+
+ const base = await resolveDefaultBranch(repo, "trunk");
+ assert.equal(base, "origin/trunk", "qualified, so it resolves");
+ // Proven against git rather than asserted by shape.
+ const resolved = execFileSync("git", ["rev-parse", "--verify", "--quiet", base!], {
+ cwd: repo,
+ })
+ .toString()
+ .trim();
+ assert.ok(resolved.length > 0, "git resolves what we returned");
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
+test("a LOCAL override is returned bare, so the sync path still works", async () => {
+ // `syncBaseBranch` fetches and fast-forwards a LOCAL branch (`git fetch origin `,
+ // then `..origin/`), so a qualified name would break it. The two consumers want
+ // different things, and which refs exist is exactly what distinguishes them.
+ const repo = makeRepo();
+ try {
+ execFileSync("git", ["branch", "trunk"], { cwd: repo });
+ assert.equal(await resolveDefaultBranch(repo, "trunk"), "trunk");
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
+test("syncBaseBranch refuses a remote-tracking base instead of mangling it", async () => {
+ // The other half of the fix. Without this guard the local path runs on
+ // `origin/trunk`: `git fetch origin origin/trunk`, then
+ // `origin/trunk..origin/origin/trunk` -- both nonsense, and the reported reason would
+ // blame the fetch rather than say there is nothing to catch up.
+ const repo = makeRepo();
+ try {
+ const r = await syncBaseBranch(repo, "origin/trunk");
+ assert.equal(r.updated, false);
+ assert.match(r.reason ?? "", /no local branch/i);
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
+test("a LOCAL branch named origin/... is still synced, not mistaken for a remote ref", async () => {
+ // Review finding on the previous fix: the guard matched the `origin/` PREFIX, but
+ // `refs/heads/origin/release` is a legal local branch, and `resolveDefaultBranch`
+ // returns such a name bare. The prefix test then refused to fast-forward a perfectly
+ // ordinary local base. The discriminator has to be which ref actually exists, not how
+ // the name is spelled.
+ const repo = makeRepo();
+ try {
+ execFileSync("git", ["branch", "origin/release"], { cwd: repo });
+ const r = await syncBaseBranch(repo, "origin/release");
+ // It reaches the real sync path, so it fails on the ABSENT REMOTE rather than being
+ // waved through as "nothing to catch up".
+ assert.doesNotMatch(
+ r.reason ?? "",
+ /no local branch/i,
+ "a local branch must not be skipped as remote-tracking",
+ );
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
diff --git a/server/src/git.ts b/server/src/git.ts
index 2de3ca25..c29a07d7 100644
--- a/server/src/git.ts
+++ b/server/src/git.ts
@@ -102,6 +102,58 @@ export async function detectDefaultBranch(repoPath: string): Promise`, and the RETURNED FORM differs by which ref
+ * exists: a local branch comes back bare (the sync path fast-forwards a local
+ * branch), a remote-only one comes back as `origin/` (so every `git`
+ * invocation can resolve it). Callers must therefore treat the result as a REV, and
+ * only `syncBaseBranch` cares about the distinction -- which it checks.
+ *
+ * Checking costs one `rev-parse` and REPLACES detection's one-to-three calls when
+ * the override holds, so the common case gets cheaper rather than dearer.
+ */
+export async function resolveDefaultBranch(
+ repoPath: string,
+ override: string | undefined,
+): Promise {
+ if (override !== undefined && override.length > 0) {
+ // A local branch is returned BARE, because `syncBaseBranch` fetches and
+ // fast-forwards a local branch and a qualified name would break it.
+ if (await branchExists(repoPath, override)) return override;
+ // Known only on the remote: returned QUALIFIED, because git's revision rules never
+ // resolve a bare name against `refs/remotes/origin/` (`gitrevisions` checks
+ // `refs/`, `refs/tags/`, `refs/heads/`, `refs/remotes/` --
+ // not `refs/remotes/origin/`). Returning the bare name handed `diffStat`,
+ // `commitsAhead` and `git worktree add` a base that resolves nowhere: silent zero
+ // diffs, zero counts, and failed worktree creation.
+ if (await remoteBranchExists(repoPath, override)) return `origin/${override}`;
+ }
+ return detectDefaultBranch(repoPath);
+}
+
+/** Whether `refs/remotes/origin/` exists. */
+async function remoteBranchExists(repoPath: string, branch: string): Promise {
+ const r = await git(
+ ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`],
+ repoPath,
+ );
+ return r.code === 0;
+}
+
/** The currently checked-out branch, or null when HEAD is detached. */
export async function detectCurrentBranch(repoPath: string): Promise {
const r = await git(["rev-parse", "--abbrev-ref", "HEAD"], repoPath);
@@ -452,18 +504,39 @@ export function listOpenPrs(
return gateway.openPrs(repoPath, limit, opts);
}
+/**
+ * How a PR's head is fetched into a worktree.
+ *
+ * Two strategies rather than one, because neither generalises:
+ *
+ * `gh` — `gh pr checkout`. Kept for GitHub because it already handles the
+ * fork case and sets up push tracking, and replacing a working path
+ * with a hand-rolled equivalent would be a regression risk taken for
+ * tidiness.
+ * `pull-ref` — plain git against `refs/pull//head`, which is how Gitea and
+ * Forgejo publish PR heads. `gh` cannot be used here at all: it
+ * speaks only to GitHub, so on a Forgejo remote the picker listed the
+ * PRs and the checkout then failed.
+ */
+export type PrCheckoutStrategy = "gh" | "pull-ref";
+
/**
* Create a worktree that checks out an existing PR's head branch. A fresh
- * detached worktree is added first, then `gh pr checkout` fetches the PR head
- * (handling same-repo and fork PRs) and switches the worktree to it. Returns
- * the canonical worktree path + the checked-out branch name. Throws on
- * failure — this is a user-initiated mutation whose error must surface.
+ * detached worktree is added first, then the PR head is fetched into it and a
+ * PR-unique local branch is created. Returns the canonical worktree path + the
+ * checked-out branch name. Throws on failure — this is a user-initiated mutation
+ * whose error must surface.
+ *
+ * [checkout] selects the provider strategy; see {@link PrCheckoutStrategy}. It
+ * defaults to `gh` so GitHub's behaviour is unchanged for any caller that does not
+ * pass one.
*/
export async function addWorktreeForPr(opts: {
repoPath: string;
prNumber: number;
headRefName: string;
baseDir?: string;
+ checkout?: PrCheckoutStrategy;
}): Promise<{ path: string; branch: string }> {
const base = opts.baseDir ?? worktreeBaseDir();
const repoName = basename(resolve(opts.repoPath));
@@ -472,38 +545,104 @@ export async function addWorktreeForPr(opts: {
const slug = slugify(opts.headRefName);
const name = slug ? `pr-${opts.prNumber}-${slug}` : `pr-${opts.prNumber}`;
const target = join(base, repoName, name);
- // Detached checkout of HEAD so the worktree dir exists; gh then moves it to
- // the PR head. No timeout: populating a worktree can take a while.
+ // Detached checkout of HEAD so the worktree dir exists; the strategy then moves
+ // it to the PR head. No timeout: populating a worktree can take a while.
const add = await run("git", ["worktree", "add", "--detach", target], opts.repoPath);
if (add.code !== 0) {
throw new Error(`git worktree add failed: ${add.stderr.trim() || add.stdout.trim() || `exit ${add.code}`}`);
}
- // Always check out onto a PR-unique local branch (`name`). gh's default
- // reuses the PR head-ref as the branch name, which git rejects when that
- // branch is already checked out in another worktree of this repo (commonly
- // the primary checkout sits on it), breaking the flow. A dedicated per-PR
- // branch avoids the collision entirely; `--branch` still tracks the PR head,
- // so pushes update the PR.
- const checkout = await run(
- "gh",
- ["pr", "checkout", String(opts.prNumber), "--branch", name],
- target,
- );
- if (checkout.code !== 0) {
+
+ const failed =
+ (opts.checkout ?? "gh") === "pull-ref"
+ ? await checkoutViaPullRef(target, opts.prNumber, opts.headRefName, name)
+ : await checkoutViaGh(target, opts.prNumber, name);
+ if (failed !== null) {
// Roll back the empty detached worktree so we don't leave litter behind.
// Best-effort: don't let a rollback failure mask the real checkout error.
await removeWorktree(opts.repoPath, target, true).catch(() => {});
- throw new Error(`gh pr checkout ${opts.prNumber} failed: ${checkout.stderr.trim() || `exit ${checkout.code}`}`);
+ throw new Error(failed);
}
- // Report the actual checked-out branch (`name`, from --branch above) by
- // reading HEAD, falling back to headRefName only if the read fails. Callers
- // use this to highlight the worktree's row.
+
+ // Report the actual checked-out branch (`name`) by reading HEAD, falling back to
+ // `name` only if the read fails. Callers use this to highlight the worktree's row.
const head = await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], target);
const actual = head.code === 0 ? head.stdout.trim() : "";
const branch = actual && actual !== "HEAD" ? actual : name;
return { path: realpathSync(target), branch };
}
+/** GitHub: `gh` does the work. Returns an error message, or null on success. */
+async function checkoutViaGh(
+ target: string,
+ prNumber: number,
+ branchName: string,
+): Promise {
+ // Always check out onto a PR-unique local branch. gh's default reuses the PR
+ // head-ref as the branch name, which git rejects when that branch is already
+ // checked out in another worktree of this repo (commonly the primary checkout sits
+ // on it), breaking the flow. A dedicated per-PR branch avoids the collision
+ // entirely; `--branch` still tracks the PR head, so pushes update the PR.
+ const r = await run("gh", ["pr", "checkout", String(prNumber), "--branch", branchName], target);
+ return r.code === 0
+ ? null
+ : `gh pr checkout ${prNumber} failed: ${r.stderr.trim() || `exit ${r.code}`}`;
+}
+
+/**
+ * Forgejo / Gitea: fetch `refs/pull//head` and branch from it.
+ *
+ * That ref is created by the forge for **every** PR including forks', which is why
+ * it is used instead of the head branch name — a fork's branch does not exist on
+ * `origin` at all.
+ *
+ * Upstream tracking is set only when the head branch really is on `origin` (a
+ * same-repo PR), so a push updates the PR. For a fork it is deliberately left
+ * unset: pointing it anywhere would aim a push at a branch that is not the PR's,
+ * and pushing to a contributor's fork was never possible from here anyway.
+ */
+async function checkoutViaPullRef(
+ target: string,
+ prNumber: number,
+ headRefName: string,
+ branchName: string,
+): Promise {
+ const ref = `refs/pull/${prNumber}/head`;
+ const fetched = await run("git", ["fetch", "--quiet", "origin", ref], target);
+ if (fetched.code !== 0) {
+ return `fetching ${ref} failed: ${fetched.stderr.trim() || `exit ${fetched.code}`}. The forge may not publish this pull request, or it may be closed.`;
+ }
+ const checkout = await run("git", ["checkout", "-q", "-b", branchName, "FETCH_HEAD"], target);
+ if (checkout.code !== 0) {
+ return `checking out PR #${prNumber} failed: ${checkout.stderr.trim() || `exit ${checkout.code}`}`;
+ }
+ // Best-effort, and only when the branch genuinely exists on origin. `--` is not
+ // available here, so the ref is checked first rather than trusted.
+ if (headRefName.length > 0) {
+ const onOrigin = await run(
+ "git",
+ ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${headRefName}`],
+ target,
+ );
+ if (onOrigin.code === 0) {
+ const upstream = await run(
+ "git",
+ ["branch", `--set-upstream-to=origin/${headRefName}`, branchName],
+ target,
+ );
+ // Best-effort, but not silent: without an upstream a later `git push` in this
+ // worktree does not update the PR, and "my push did nothing" is unanswerable
+ // if the reason was never recorded. Not fatal -- the worktree is the point,
+ // and it is checked out correctly either way.
+ if (upstream.code !== 0) {
+ log.warn(
+ `[makit] PR #${prNumber}: could not track origin/${headRefName}, so pushing from this worktree will not update the pull request: ${upstream.stderr.trim() || `exit ${upstream.code}`}`,
+ );
+ }
+ }
+ }
+ return null;
+}
+
/**
* Rename a worktree's local branch via `git branch -m`. Runs in the worktree
* so the currently checked-out branch is the one renamed. Throws on failure.
@@ -644,6 +783,17 @@ export interface BaseSyncResult {
* a total failure.
*/
export async function syncBaseBranch(repoPath: string, branch: string): Promise {
+ // A remote-tracking base (`origin/trunk`) has no local branch to fast-forward, so
+ // there is nothing to catch up -- and running the local path on it would issue
+ // `git fetch origin origin/trunk` and compare `origin/trunk..origin/origin/trunk`,
+ // both nonsense. `resolveDefaultBranch` returns this form when the default exists
+ // only on the remote.
+ //
+ // BUT: a local branch can be *literally* named `origin/release` (i.e.
+ // `refs/heads/origin/release`). Check if it's actually local before refusing.
+ if (branch.startsWith("origin/") && !(await branchExists(repoPath, branch))) {
+ return { updated: false, reason: `${branch} has no local branch to catch up` };
+ }
// `run` without a cap, not `git`: this talks to the network inside a
// user-initiated action, and a large or slow repo can legitimately outlast the
// 15s read cap (see GIT_READ_TIMEOUT_MS — mutations are deliberately uncapped).
diff --git a/server/src/github/gateway.ts b/server/src/github/gateway.ts
index 8729c055..f49d6b79 100644
--- a/server/src/github/gateway.ts
+++ b/server/src/github/gateway.ts
@@ -25,6 +25,7 @@ import { route, type RequestPlan, type RouteChoice } from "./router.js";
import { allow, decide } from "./policy.js";
import { normalizeChecks, rollupChecks } from "../git.js";
import type { OpenPr, PullRequestInfo } from "../git.js";
+import type { ForgeGateway, GatewayStats, PrLookup } from "../forge/types.js";
import {
OPEN_PRS_PLAN,
OPEN_PRS_TIMEOUT_MS,
@@ -38,7 +39,6 @@ import {
combinedStatusRestArgv,
openPrsArgv,
prMutationArgv,
- type PrMutation,
openPrsRestArgv,
originRemoteArgv,
parsePrUrl,
@@ -53,11 +53,13 @@ import {
unresolvedThreadsArgv,
} from "./queries.js";
-/** Three-way PR lookup result — a failed lookup is never `none` (§6.5). */
-export type PrLookup =
- | { kind: "pr"; pr: PullRequestInfo }
- | { kind: "none" }
- | { kind: "unknown"; reason: "throttled" | "error" };
+/**
+ * Three-way PR lookup result — a failed lookup is never `none` (§6.5).
+ *
+ * Re-exported from the provider-neutral contract so both providers and every
+ * existing importer keep one definition.
+ */
+export type { PrLookup, GatewayStats } from "../forge/types.js";
/** Result of a `gh` invocation. Matches git.ts's private `run` — never rejects. */
export interface ExecResult {
@@ -75,54 +77,20 @@ export interface TimerHandle {
}
/** Exec/cache counters — spec §10 success criterion 1 (measure the ≥80% cut). */
-export interface GatewayStats {
- /**
- * `gh` calls that SPENT quota. Excludes the exempt `/rate_limit` read (see
- * {@link exemptExecs}) and the local `git remote` lookup, so this is the number
- * the >=80% call-reduction claim is measured against without arithmetic.
- */
- execs: number;
- /** Quota-exempt `gh api rate_limit` reads. Free, but still subprocesses. */
- exemptExecs: number;
- /** Reads served from cache without an exec. */
- cacheHits: number;
-}
+export type { GatewayStats as GithubGatewayStats } from "../forge/types.js";
-export interface GithubGateway {
- prForBranch(repoPath: string, branch: string, opts?: { interactive?: boolean }): Promise;
+export interface GithubGateway extends ForgeGateway {
/**
- * All open PRs for a repo (the "New worktree from PR" picker).
- *
- * Pass `interactive: true` for a user-initiated call: the picker is a click,
- * not a poller, so it must draw on the reserve rather than silently return an
- * empty list — which the user would read as "this repo has no open PRs"
- * (spec §6.3).
- */
- openPrs(repoPath: string, limit: number, opts?: { interactive?: boolean }): Promise;
- /**
- * Run a state-changing `gh pr` verb on the user's behalf (`ready` to take a PR
- * out of draft, `update-branch` to merge the base into it).
- *
- * Always interactive — it is a button press, never a poller — so it spends from
- * the reserve rather than being shed. Invalidates the cached lookup for
- * [branch] on success, otherwise the UI would keep reporting the state the
- * mutation just changed until the TTL expired.
+ * GitHub's quota, which only this provider has: Forgejo exposes no
+ * `rate_limit` endpoint and sends no rate-limit headers. Hence these live here
+ * rather than on {@link ForgeGateway} — see `../forge/types.ts`.
*/
- mutatePr(
- repoPath: string,
- branch: string,
- number: number,
- verb: PrMutation,
- ): Promise<{ ok: boolean; error?: string }>;
budget(): BudgetSnapshot;
/** 60-slot per-minute `{mine, others}` ring for the sparkline (spec §6.6). */
history(): Array<{ mine: number; others: number }>;
refresh(): Promise;
setPaused(paused: boolean): void;
onBudgetChange(fn: (s: BudgetSnapshot) => void): () => void;
- close(): void;
- /** Exec vs. cache-hit counters (T6 surfaces this for the ≥80% claim). */
- stats(): GatewayStats;
}
export interface GatewayDeps {
diff --git a/server/src/github/policy.ts b/server/src/github/policy.ts
index cdee88f8..fc79de27 100644
--- a/server/src/github/policy.ts
+++ b/server/src/github/policy.ts
@@ -64,7 +64,11 @@ export interface Policy {
reserve: number;
}
-const POLL_FAST_MS = 5_000;
+/**
+ * The unthrottled cadence. Exported because a provider with no quota to ration
+ * (Forgejo) must be able to claim it without going through the GitHub ladder.
+ */
+export const POLL_FAST_MS = 5_000;
const POLL_SLOW_MS = 30_000;
const POLL_CRAWL_MS = 120_000;
const POLL_PAUSED_MS = Infinity;
diff --git a/server/src/github/queries.ts b/server/src/github/queries.ts
index af86434e..779bdd4a 100644
--- a/server/src/github/queries.ts
+++ b/server/src/github/queries.ts
@@ -19,6 +19,7 @@
import type { OpenPr } from "../git.js";
import type { RequestPlan } from "./router.js";
+import type { PrMutation } from "../forge/types.js";
/** Timeout (ms) for `gh pr` reads, matching git.ts. */
export const PR_TIMEOUT_MS = 5_000;
@@ -298,8 +299,14 @@ export function parsePrUrl(prUrl: string): { owner: string; repo: string; number
return { owner: m[1], repo: m[2], number: m[3] };
}
-/** A state-changing `gh pr` action the app can run on the user's behalf. */
-export type PrMutation = "ready" | "update-branch" | "merge-squash";
+/**
+ * A state-changing PR action the app can run on the user's behalf.
+ *
+ * Re-exported from the provider-neutral contract: the verbs are the same on every
+ * forge even though how each is performed is not (Forgejo has no `pr ready` --
+ * see forge/forgejo/map.ts).
+ */
+export type { PrMutation } from "../forge/types.js";
/**
* argv for a PR mutation, addressed **by number**.
diff --git a/server/src/manager.ts b/server/src/manager.ts
index 1aa72574..0fb82e47 100644
--- a/server/src/manager.ts
+++ b/server/src/manager.ts
@@ -8,7 +8,7 @@
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
-import { existsSync } from "node:fs";
+import { existsSync, realpathSync } from "node:fs";
import { basename, resolve, join } from "node:path";
import type { AgentAdapter } from "./adapters/adapter.js";
import type { AskUser } from "./uicall.js";
@@ -16,7 +16,17 @@ import { listAgents, fingerprintAgent, type AgentDescriptor } from "./adapters/c
import { CapabilityCache } from "./adapters/capability_cache.js";
import { Session } from "./session.js";
import { sessionTokens } from "./ws/session_tokens.js";
-import { DEFAULT_SESSION_TITLE, type ApprovalPolicy, type ProjectDTO, type RepoDTO, type SessionConfigOption, type SessionDTO, type SessionEvent, type SessionOrigin } from "./protocol.js";
+import {
+ DEFAULT_SESSION_TITLE,
+ type ApprovalPolicy,
+ type ProjectDTO,
+ type RepoDTO,
+ type RepoSettingsDTO,
+ type SessionConfigOption,
+ type SessionDTO,
+ type SessionEvent,
+ type SessionOrigin,
+} from "./protocol.js";
import { spawnBoundError, spawnDepth, type LineageNode } from "./lineage.js";
import { listPiSessions, parseTranscript, type PiSessionMeta } from "./pi-sessions.js";
import { DetachedAdapter } from "./adapters/detached.js";
@@ -26,11 +36,12 @@ import { listAcpSessions } from "./adapters/acp.js";
import { listCodexThreads } from "./adapters/codex.js";
import type { AgentSessionInfo } from "./adapters/adapter.js";
import { listRepos, enrichPrs, type LastKnownPr } from "./repo_service.js";
-import { createGithubGateway, type GithubGateway } from "./github/gateway.js";
+import type { GithubGateway } from "./github/gateway.js";
+import { createDefaultForgeGateway } from "./forge/router.js";
import type { PersistedProject } from "./project-store.js";
import {
isGitRepo,
- detectDefaultBranch,
+ resolveDefaultBranch,
listWorktrees,
addWorktree,
addWorktreeForPr,
@@ -39,11 +50,11 @@ import {
deleteBranch,
syncBaseBranch,
listOpenPrs,
+ type PrCheckoutStrategy,
findOpenPr,
branchExists,
slugify,
slugifyBranch,
- worktreeBaseDir,
run,
type OpenPr,
type WorktreeEntry,
@@ -51,6 +62,16 @@ import {
import type { PrMutation } from "./github/queries.js";
import type { EventStore } from "./storage/event_store.js";
import { log } from "./log.js";
+import {
+ parseRepoSettings,
+ resolveProvider,
+ resolveWorktreeRoot,
+ validateRepoPath,
+ validateWorktreeRoot,
+ type ProviderChoice,
+ type RepoSettings,
+} from "./repo_settings.js";
+import type { ForgeForgetful, ForgeInspector } from "./forge/router.js";
/**
* What a {@link SessionManager.wrapUpWorktree} run actually did. The base-branch
@@ -178,9 +199,31 @@ function reason(e: unknown): string {
export const DEFAULT_CLOSE_GRACE_MS = 10_000;
interface ProjectEntry {
+ /**
+ * Persisted per-repo settings, verbatim. Held as an opaque record so a save
+ * cannot drop keys this build does not understand; typed and validated at the
+ * point of use (`repo_settings.ts`).
+ */
+ settings?: Record;
dto: ProjectDTO;
}
+/**
+ * A path in its canonical form, or `resolve`d when it cannot be canonicalised.
+ *
+ * Used wherever two paths are compared for "same directory". Falls back rather
+ * than throwing because a project whose directory has since been deleted must
+ * still compare, and still be re-pointable -- that is the case re-pointing exists
+ * to fix.
+ */
+function canonicalPath(p: string): string {
+ try {
+ return realpathSync(resolve(p));
+ } catch {
+ return resolve(p);
+ }
+}
+
export class SessionManager extends EventEmitter {
private readonly projects = new Map();
private readonly sessions = new Map();
@@ -211,7 +254,16 @@ export class SessionManager extends EventEmitter {
/** Deadline for a graceful `adapter.close()` before we reap regardless. */
private readonly closeGraceMs: number;
private bridge?: BridgeBinding;
- private readonly _gateway: GithubGateway;
+ /**
+ * The forge gateway.
+ *
+ * Typed as the gateway PLUS the optional inspection/invalidation ports, so the
+ * three call sites that ask it what it decided no longer need an
+ * `as unknown as` double cast. `Partial` is the honest shape: a test may inject a
+ * plain `GithubGateway`, and only the real router implements the ports — which is
+ * exactly why the calls are optional-chained rather than assumed.
+ */
+ private readonly _gateway: GithubGateway & Partial;
constructor(opts: ManagerOpts) {
super();
@@ -222,10 +274,23 @@ export class SessionManager extends EventEmitter {
this.store = opts.store;
this.capabilityCache = opts.capabilityCache;
this.closeGraceMs = opts.closeGraceMs ?? DEFAULT_CLOSE_GRACE_MS;
- // The single GitHub gateway (SPEC-32). A real one over git.ts's `run` unless
- // a fake is injected; `run` resolves `gh` via PATH, so the test PATH-shim
+ // The single forge gateway (SPEC-32). A router over every provider unless a
+ // fake is injected: it dispatches per repo on that repo's own provider setting
+ // and, failing that, on what detection reports -- github.com to the `gh`-backed
+ // gateway, Forgejo/Gitea to the REST one -- and forwards the budget surface to
+ // the gh gateway, which owns the only quota that exists. `run` resolves `gh`
+ // via PATH, so the test PATH-shim
// keeps working. Constructed here does NOT self-refresh (no subprocess).
- this._gateway = opts.gateway ?? createGithubGateway({ exec: run });
+ //
+ // `providerFor` is passed as a bound method, not a captured value: the router
+ // calls it per routing decision, so a provider the user changes at runtime is
+ // honoured on the next poll (SPEC-48 D3").
+ this._gateway =
+ opts.gateway ??
+ createDefaultForgeGateway({
+ exec: run,
+ providerFor: (repoPath) => this.providerFor(repoPath),
+ });
for (const entry of opts.projects) {
// A bare path gets a fresh server-generated id; a restored `{ id, path }`
// keeps its id so a client's persisted projectId stays valid across a
@@ -240,6 +305,8 @@ export class SessionManager extends EventEmitter {
pinned: true,
lastActivityAt: Date.now(),
},
+ // Carried verbatim so a save cannot drop keys this build does not know.
+ settings: typeof entry === "string" ? undefined : entry.settings,
});
}
this.rehydrate();
@@ -288,7 +355,13 @@ export class SessionManager extends EventEmitter {
/** Listing for the home screen. */
listProjects(): ProjectDTO[] {
- return [...this.projects.values()].map((p) => p.dto);
+ return [...this.projects.values()].map((p) => ({
+ ...p.dto,
+ // Include validated settings if present; undefined if absent (optional in DTO)
+ // No cast: the DTO now declares the keys that are actually persisted, so the
+ // compiler checks this shape instead of the cast hiding a mismatch.
+ ...(p.settings ? { settings: p.settings } : {}),
+ }));
}
/**
@@ -299,8 +372,18 @@ export class SessionManager extends EventEmitter {
*/
addProject(path: string): ProjectDTO {
const resolved = resolve(path);
+ // Compared CANONICALLY, so `/tmp/x` and `/private/tmp/x` — one directory on
+ // macOS — cannot become two projects. Settings and the forge decision are both
+ // looked up by path, so two projects at one directory answer for each other.
+ // `repointProject` already refuses this; comparing with `resolve` alone here let
+ // the ordinary add route create exactly the state the other one forbids.
+ //
+ // The path is still STORED as `resolved` rather than canonicalised: that is what
+ // persisted ids are already mapped to, and rewriting it would change the stored
+ // value for every existing project on the next save.
+ const canonical = canonicalPath(resolved);
const existing = [...this.projects.values()].find(
- (p) => resolve(p.dto.path) === resolved,
+ (p) => canonicalPath(p.dto.path) === canonical,
);
if (existing) return existing.dto;
@@ -317,6 +400,112 @@ export class SessionManager extends EventEmitter {
return dto;
}
+ /**
+ * Apply a settings patch to a project and persist it. Returns false for an
+ * unknown id.
+ *
+ * A `null` value **clears** that key rather than storing null: absent means
+ * "inherit", so clearing is how the UI says "go back to inheriting" without a
+ * sentinel. Unknown keys already on disk are untouched — this merges into the
+ * stored record rather than replacing it, so a newer app's field survives an
+ * older daemon writing a neighbouring one.
+ */
+ updateProjectSettings(id: string, patch: Record): boolean {
+ const entry = this.projects.get(id);
+ if (entry === undefined) return false;
+ const next: Record = { ...(entry.settings ?? {}) };
+ for (const [k, v] of Object.entries(patch)) {
+ if (v === null) delete next[k];
+ else next[k] = v;
+ }
+ entry.settings = Object.keys(next).length === 0 ? undefined : next;
+ this.notifyProjectsChanged();
+ return true;
+ }
+
+ /** Persisted settings for a project id, verbatim (for the DTO + tests). */
+ projectSettings(id: string): Record | undefined {
+ return this.projects.get(id)?.settings;
+ }
+
+ /**
+ * Re-point a project at a new root path, **keeping its id** (SPEC-48 D4′).
+ *
+ * Not equivalent to remove-and-re-add, which is why it exists: re-adding mints a
+ * fresh `PersistedProject.id`, and everything keyed to that id — per-repo
+ * settings, session history — is lost. A repo that merely moved on disk should
+ * keep its identity, and preserving the id across a move is the entire reason the
+ * id exists rather than the path being the key.
+ *
+ * Three refusals, each for a failure that would otherwise be silent:
+ *
+ * - **not a git repo** — the constraint D4′ states. A project pointed at a
+ * plain directory has no branches, no forge and no diff, and presents as
+ * broken rather than as misconfigured.
+ * - **already another project's path** — settings and the forge decision are
+ * both looked up BY PATH, so two projects at one path would silently answer
+ * for each other.
+ * - anything {@link validateRepoPath} rejects.
+ *
+ * The forge decision for the OLD path is discarded, so detection re-runs against
+ * the new one: D4′ requires it, because the forge and the default branch may both
+ * change with the move.
+ *
+ * Known limitation, stated rather than hidden: sessions already bound to a
+ * worktree keep their recorded paths. For the case this exists for — a repo that
+ * moved — worktrees live under the worktree root, which is a separate setting and
+ * unaffected; a session whose worktree was the repo directory itself will still
+ * point at the old location.
+ */
+ async repointProject(
+ id: string,
+ rawPath: string,
+ ): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
+ const entry = this.projects.get(id);
+ if (entry === undefined) return { ok: false, error: `No project ${id}.` };
+ const entryPathBefore = entry.dto.path;
+
+ const checked = validateRepoPath(rawPath);
+ if (!checked.ok) return { ok: false, error: checked.error };
+ const next = checked.value;
+
+ // Both sides of every comparison below are canonicalised. Comparing a
+ // canonicalised new path against a stored one that is not is how a duplicate
+ // slips through: on macOS `/tmp/x` and `/private/tmp/x` are the same directory,
+ // and a project restored from `projects.json` holds whichever spelling was
+ // written. Two projects at one path would then look distinct while sharing
+ // settings and a forge decision, because both are looked up BY PATH.
+ const previous = canonicalPath(entry.dto.path);
+ // Re-submitting the same directory -- possibly under a different spelling, since
+ // `/tmp/x` and `/private/tmp/x` are one place -- is a no-op, not a conflict with
+ // itself. Reports the path actually in force rather than the canonical form,
+ // because nothing was stored and claiming otherwise would show the client a
+ // value the store does not hold.
+ if (next === previous) return { ok: true, path: entryPathBefore };
+
+ for (const [otherId, other] of this.projects) {
+ if (otherId !== id && canonicalPath(other.dto.path) === next) {
+ return {
+ ok: false,
+ error: `${next} is already open in makit as "${other.dto.name}".`,
+ };
+ }
+ }
+
+ if (!(await isGitRepo(next))) {
+ return { ok: false, error: `${next} is not a git repository.` };
+ }
+
+ entry.dto = { ...entry.dto, path: next, name: basename(next) };
+ // Drop the routing decision for where the repo used to be, so the forge is
+ // re-detected instead of reported from a stale probe. Keyed on the path the
+ // gateway was actually called with -- the DTO's own value, not its canonical
+ // form, since that is what became the cache key.
+ this._gateway.forgetRepo?.(entryPathBefore);
+ this.notifyProjectsChanged();
+ return { ok: true, path: next };
+ }
+
/** Remove a project by id. Throws on an unknown id. Sessions are left as-is. */
removeProject(id: string): void {
if (!this.projects.has(id)) throw new Error(`unknown project: ${id}`);
@@ -326,7 +515,13 @@ export class SessionManager extends EventEmitter {
private notifyProjectsChanged(): void {
this.onProjectsChanged?.(
- [...this.projects.values()].map((p) => ({ id: p.dto.id, path: p.dto.path })),
+ [...this.projects.values()].map((p) =>
+ // `settings` is included only when present, so an untouched project keeps
+ // its two-key shape on disk and the file stays diffable.
+ p.settings === undefined || Object.keys(p.settings).length === 0
+ ? { id: p.dto.id, path: p.dto.path }
+ : { id: p.dto.id, path: p.dto.path, settings: p.settings },
+ ),
);
}
@@ -618,7 +813,7 @@ export class SessionManager extends EventEmitter {
const base =
baseBranch && (await branchExists(repoPath, baseBranch))
? baseBranch
- : await detectDefaultBranch(repoPath);
+ : await this.defaultBranchFor(repoPath);
// Unborn HEAD (no commits yet): `git worktree add -b` would fail, so run
// the session in the repo dir instead of forking a worktree.
if (!base) return { path: repoPath, branch: null };
@@ -643,6 +838,7 @@ export class SessionManager extends EventEmitter {
// path.
const dirName = this.uniqueWorktreeDir(repoPath, branch.replace(/\//g, "-"));
const path = await addWorktree({
+ baseDir: this.worktreeRootFor(repoPath),
repoPath,
name: dirName,
branch,
@@ -680,7 +876,36 @@ export class SessionManager extends EventEmitter {
const prs = await listOpenPrs(this._gateway, repoPath);
const pr = prs.find((p) => p.number === prNumber);
if (!pr) throw new Error(`PR #${prNumber} is not an open PR of this repo`);
- return addWorktreeForPr({ repoPath, prNumber, headRefName: pr.headRefName });
+ return addWorktreeForPr({
+ repoPath,
+ prNumber,
+ headRefName: pr.headRefName,
+ baseDir: this.worktreeRootFor(repoPath),
+ // Read AFTER `listOpenPrs`, which is what routes the repo — so detection has
+ // run and its decision is available rather than empty.
+ checkout: this.prCheckoutStrategyFor(repoPath),
+ });
+ }
+
+ /**
+ * How a PR should be checked out for [repoPath] (SPEC-48).
+ *
+ * Resolved from the SAME two sources the router uses to pick a gateway, and in the
+ * same order — the user's override first, then routing's decision — so the checkout
+ * cannot disagree with the provider that served the PR list. "New worktree from PR"
+ * used to list Forgejo PRs correctly and then run `gh pr checkout`, so it failed
+ * halfway for every non-GitHub repo.
+ *
+ * Falls back to `gh` when nothing is known: that is the status quo for an
+ * unreadable remote, and the router makes the same choice for the same reason.
+ */
+ prCheckoutStrategyFor(repoPath: string): PrCheckoutStrategy {
+ const chosen = this.providerFor(repoPath);
+ if (chosen === "forgejo" || chosen === "gitea") return "pull-ref";
+ if (chosen === "github") return "gh";
+ // `auto` (or `none`, which never reaches a checkout): believe detection.
+ const software = this._gateway.forgeFor?.(repoPath)?.software;
+ return software === "forgejo" || software === "gitea" ? "pull-ref" : "gh";
}
/**
@@ -890,7 +1115,7 @@ export class SessionManager extends EventEmitter {
const { repoPath, branchDeleted, branchReason } =
await this._removeWorktreeAndBranch(projectId, worktreePath, expectBranch);
- const base = baseBranch ?? (await detectDefaultBranch(repoPath));
+ const base = baseBranch ?? (await this.defaultBranchFor(repoPath));
if (!base) {
return {
branchDeleted,
@@ -1084,14 +1309,87 @@ export class SessionManager extends EventEmitter {
}
/**
- * Find an unused worktree directory name under `/`,
- * appending `-2`, `-3`, … on collision. Needed because two distinct branches
- * can flatten to the same dir name (`feat/new-ui` → `feat-new-ui`), so a
- * unique branch is not enough to guarantee `git worktree add`'s target path
- * is free. Mirrors {@link addWorktree}'s target layout.
+ * The worktree root in force for [repoPath] — the repo's override, else
+ * `MAKIT_WORKTREE_DIR`, else `~/.worktrees` (SPEC-48 D8').
+ *
+ * Re-validated on read, not trusted from the file: `projects.json` is plain JSON
+ * a user can edit by hand, so a write-time check alone is not a guarantee. An
+ * invalid stored value falls back to the inherited root rather than failing the
+ * worktree creation, and says so once.
+ */
+ worktreeRootFor(repoPath: string): string {
+ const settings = this.settingsForPath(repoPath);
+ const resolved = resolveWorktreeRoot(settings, process.env);
+ if (resolved.source !== "override") return resolved.value;
+ const checked = validateWorktreeRoot(resolved.value);
+ if (checked.ok) return checked.value;
+ log.warn(
+ `[makit] ignoring invalid worktree root for ${repoPath}: ${checked.error} — using the inherited root instead`,
+ );
+ return resolveWorktreeRoot(undefined, process.env).value;
+ }
+
+ /**
+ * Parsed settings for the project owning [repoPath], or `{}`.
+ *
+ * Compared CANONICALLY on both sides, like `addProject` and `repointProject`.
+ * `addProject` stores the resolved -- not canonicalised -- spelling, so a project
+ * added through a symlinked path used to fail this lookup whenever a caller supplied
+ * the canonical path (which is what git hands back). Every override then silently
+ * fell back to the default while still being shown in the UI.
*/
- private uniqueWorktreeDir(repoPath: string, base: string): string {
- const parent = join(worktreeBaseDir(), basename(resolve(repoPath)));
+ private settingsForPath(repoPath: string): RepoSettings {
+ const target = canonicalPath(repoPath);
+ for (const p of this.projects.values()) {
+ if (canonicalPath(p.dto.path) === target) return parseRepoSettings(p.settings);
+ }
+ return {};
+ }
+
+ /**
+ * The provider the user chose for [repoPath], or `auto` to believe detection
+ * (SPEC-48 D3").
+ *
+ * Public because the forge router calls it **at routing time**, once per routing
+ * decision — not at construction. That is what makes a changed setting take
+ * effect on the next poll instead of at the next daemon restart, and a setting
+ * that only applies after a restart is indistinguishable from one that does
+ * nothing.
+ *
+ * A path makit does not know reports `auto`: an unknown repo has no override, and
+ * throwing here would break routing for a directory that is merely unregistered.
+ */
+ providerFor(repoPath: string): ProviderChoice {
+ return resolveProvider(this.settingsForPath(repoPath)).value;
+ }
+
+ /**
+ * The default branch in force for [repoPath] — the repo's override when it still
+ * resolves, else git's own answer (SPEC-48 D14/rev 3).
+ *
+ * The ONE place the three consumers read from: the repos snapshot (whose
+ * `defaultBranch` is what the diff +/- numbers and ahead counts are measured
+ * against), `createWorktree`'s base, and `wrapUpWorktree`'s base sync. Each used
+ * to call `detectDefaultBranch` directly, which is why storing an override changed
+ * nothing anywhere — the same mistake R5 caught for the worktree root.
+ */
+ async defaultBranchFor(repoPath: string): Promise {
+ return resolveDefaultBranch(repoPath, this.settingsForPath(repoPath).defaultBranch);
+ }
+
+ /**
+ * Find an unused worktree directory name under
+ * `/`, appending `-2`, `-3`, … on collision.
+ * Needed because two distinct branches can flatten to the same dir name
+ * (`feat/new-ui` → `feat-new-ui`), so a unique branch is not enough to guarantee
+ * `git worktree add`'s target path is free. Mirrors {@link addWorktree}'s layout.
+ *
+ * Reads the SAME per-repo root the creation paths use. If it did not, collision
+ * detection would look in one directory while `git worktree add` wrote to
+ * another — the two would disagree and a real collision would slip through.
+ */
+ uniqueWorktreeDir(repoPath: string, base: string): string {
+ const parent = join(this.worktreeRootFor(repoPath), basename(resolve(repoPath)));
let candidate = base;
let n = 1;
while (existsSync(join(parent, candidate))) {
@@ -1120,7 +1418,60 @@ export class SessionManager extends EventEmitter {
lastKnown: LastKnownPr = () => null,
): Promise {
const includePrs = opts.includePrs ?? true;
- return listRepos(this.listProjects(), this.allSessions(), includePrs, this._gateway, lastKnown);
+ return listRepos(
+ this.listProjects(),
+ this.allSessions(),
+ includePrs,
+ this._gateway,
+ lastKnown,
+ (p) => this.settingsDtoFor(p),
+ );
+ }
+
+ /**
+ * One project's settings as the app sees them: **effective values with their
+ * sources**, so the UI labels rather than guesses.
+ *
+ * The forge is read from the router's own decision record, which is `undefined`
+ * until that repo has actually been routed — reported as absent rather than as a
+ * guess, because "not measured yet" and "no forge" are different statements and
+ * only one of them is worth investigating.
+ */
+ private settingsDtoFor(project: ProjectDTO): RepoSettingsDTO {
+ const stored = parseRepoSettings(this.projects.get(project.id)?.settings);
+ const worktreeRoot = resolveWorktreeRoot(stored, process.env);
+ const provider = resolveProvider(stored);
+ const forge = this._gateway.forgeFor?.(project.path);
+ return {
+ // Re-validated here too: an override read back from a hand-edited file must
+ // not be reported as in force if it would be refused on use.
+ // A stored override that no longer validates falls back to whatever the chain
+ // says WITHOUT relabelling it: dropping the override can land on the env var,
+ // and `source` exists so the app states the origin rather than guessing it.
+ worktreeRoot:
+ worktreeRoot.source === "override" && !validateWorktreeRoot(worktreeRoot.value).ok
+ ? resolveWorktreeRoot(undefined, process.env)
+ : worktreeRoot,
+ provider,
+ // Present ONLY when overridden. Absent means "no override" — the app already
+ // has `RepoDTO.defaultBranch` from git, so repeating it here would be two
+ // sources for one fact.
+ defaultBranch:
+ stored.defaultBranch !== undefined
+ ? { value: stored.defaultBranch, source: "override" as const }
+ : undefined,
+ logoHue: stored.logoHue,
+ // Asked of the router as its own question, NOT derived from `forge`. Those two
+ // facts have three states between them — not measured, no remote, a forge — and
+ // one boolean cannot hold three: deriving it made every un-polled repo claim to
+ // have no origin, which is the one reading that sends the user hunting for a
+ // problem that does not exist.
+ //
+ // `true` when the router has not reached this repo yet, so the app says "not
+ // identified yet" (a probe pending) rather than "no remote" (a conclusion).
+ hasRemote: this._gateway.hasRemoteFor?.(project.path) ?? true,
+ forge,
+ };
}
/**
diff --git a/server/src/project-store.test.ts b/server/src/project-store.test.ts
index 36ab3941..c6941d38 100644
--- a/server/src/project-store.test.ts
+++ b/server/src/project-store.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
+import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -119,3 +119,61 @@ test("browseDirectory throws on a non-directory path", () => {
rmSync(root, { recursive: true, force: true });
}
});
+
+// ---------------------------------------------------------------------------
+// Per-repo settings must survive load → save → load, unknown keys included: an
+// older daemon paired with a newer app must not silently drop a field, and
+// rewriting one key must not lose its siblings.
+// ---------------------------------------------------------------------------
+
+test("settings round-trip losslessly, including keys this build does not know", () => {
+ const dir = mkdtempSync(join(tmpdir(), "makit-ps-settings-"));
+ const file = join(dir, "projects.json");
+ const repo = mkdtempSync(join(tmpdir(), "makit-repo-"));
+ writeFileSync(
+ file,
+ JSON.stringify({
+ projects: [
+ { id: "a", path: repo, settings: { worktreeRoot: "/h/t", futureThing: { deep: 1 } } },
+ ],
+ }),
+ );
+
+ const once = loadProjects(file);
+ assert.deepEqual(once[0].settings, { worktreeRoot: "/h/t", futureThing: { deep: 1 } });
+
+ saveProjects(file, once);
+ const twice = loadProjects(file);
+ assert.deepEqual(twice[0].settings, { worktreeRoot: "/h/t", futureThing: { deep: 1 } });
+});
+
+test("a project with no settings keeps the exact two-key shape on disk", () => {
+ // Otherwise every untouched project gains `"settings": {}` on the next save and
+ // the file churns for no reason.
+ const dir = mkdtempSync(join(tmpdir(), "makit-ps-plain-"));
+ const file = join(dir, "projects.json");
+ const repo = mkdtempSync(join(tmpdir(), "makit-repo-"));
+ saveProjects(file, [{ id: "a", path: repo }]);
+ const raw = JSON.parse(readFileSync(file, "utf8")) as { projects: Record[] };
+ assert.deepEqual(Object.keys(raw.projects[0]).sort(), ["id", "path"]);
+});
+
+test("a malformed settings value degrades that repo, it does not stop the load", () => {
+ const dir = mkdtempSync(join(tmpdir(), "makit-ps-bad-"));
+ const file = join(dir, "projects.json");
+ const a = mkdtempSync(join(tmpdir(), "makit-repo-a-"));
+ const b = mkdtempSync(join(tmpdir(), "makit-repo-b-"));
+ writeFileSync(
+ file,
+ JSON.stringify({
+ projects: [
+ { id: "a", path: a, settings: "not-an-object" },
+ { id: "b", path: b, settings: { worktreeRoot: "/h/t" } },
+ ],
+ }),
+ );
+ const loaded = loadProjects(file);
+ assert.equal(loaded.length, 2, "the good repo must still load");
+ assert.equal(loaded[0].settings, undefined);
+ assert.deepEqual(loaded[1].settings, { worktreeRoot: "/h/t" });
+});
diff --git a/server/src/project-store.ts b/server/src/project-store.ts
index 33219b55..2d92f0ec 100644
--- a/server/src/project-store.ts
+++ b/server/src/project-store.ts
@@ -28,6 +28,15 @@ import { log } from "./log.js";
export interface PersistedProject {
id: string;
path: string;
+ /**
+ * Per-repo settings, as persisted. Kept as an opaque record rather than a typed
+ * `RepoSettings` so that **unknown keys survive a round trip**: an older daemon
+ * paired with a newer app must not silently drop a field it does not
+ * understand, and a hand-edited file must not lose its siblings when one key is
+ * rewritten. Typing and validation happen in `repo_settings.ts`, at the point of
+ * use.
+ */
+ settings?: Record;
}
/** Absolute path of the projects persistence file. */
@@ -79,7 +88,15 @@ export function loadProjects(file: string): PersistedProject[] {
typeof (entry as { path?: unknown }).path === "string"
) {
const { id, path } = entry as PersistedProject;
- if (isDirectory(path)) out.push({ id, path });
+ if (!isDirectory(path)) continue;
+ const rawSettings = (entry as { settings?: unknown }).settings;
+ // Carried through verbatim. A malformed value is dropped here rather than
+ // rejected, so one bad repo cannot stop the daemon starting.
+ const settings =
+ typeof rawSettings === "object" && rawSettings !== null && !Array.isArray(rawSettings)
+ ? (rawSettings as Record)
+ : undefined;
+ out.push(settings === undefined ? { id, path } : { id, path, settings });
}
}
return out;
@@ -96,7 +113,13 @@ export function loadProjects(file: string): PersistedProject[] {
export function saveProjects(file: string, projects: PersistedProject[]): void {
try {
mkdirSync(dirname(file), { recursive: true });
- const rows = projects.map((p) => ({ id: p.id, path: p.path }));
+ // `settings` is written only when present, so an untouched project keeps the
+ // exact two-key shape it has always had and the file stays diffable.
+ const rows = projects.map((p) =>
+ p.settings === undefined || Object.keys(p.settings).length === 0
+ ? { id: p.id, path: p.path }
+ : { id: p.id, path: p.path, settings: p.settings },
+ );
writeFileSync(file, JSON.stringify({ projects: rows }, null, 2) + "\n");
} catch (e) {
log.warn(`[makit] failed to write projects file ${file}: ${(e as Error).message}`);
diff --git a/server/src/protocol.ts b/server/src/protocol.ts
index 1d449fb9..e5c13fce 100644
--- a/server/src/protocol.ts
+++ b/server/src/protocol.ts
@@ -680,6 +680,30 @@ export interface ProjectDTO {
path: string;
pinned: boolean;
lastActivityAt: number;
+ /**
+ * Per-repo settings, VERBATIM as persisted (SPEC-48).
+ *
+ * The key names are the stored ones -- `provider` and `logoHue`, not `gitProvider`
+ * and `logo`. They differed until a review caught it, and a cast in the manager hid
+ * the mismatch, so a client reading `settings.gitProvider` always got `undefined`.
+ *
+ * Unknown keys are preserved on purpose: a newer app's field must survive an older
+ * daemon writing a neighbouring one, so this is deliberately open rather than a
+ * closed shape. The RESOLVED, effective values live in `RepoDTO.settings`
+ * ({@link RepoSettingsDTO}), which is what the UI should render.
+ */
+ settings?: {
+ /** Provider override; absent means "believe detection". */
+ provider?: string | null;
+ /** Absolute canonicalised worktree root; absent inherits. */
+ worktreeRoot?: string | null;
+ /** Default-branch override; absent inherits git's answer. */
+ defaultBranch?: string | null;
+ /** Monogram palette index; absent derives the hue from the name. */
+ logoHue?: number | null;
+ /** Anything a newer client stored. Never dropped. */
+ [key: string]: unknown;
+ };
}
/**
@@ -762,6 +786,47 @@ export interface WorktreeDTO {
* Repo-centric home-screen unit. Wraps a {@link ProjectDTO} with git
* intelligence: the current + default branch and the list of live worktrees.
*/
+/** Where an effective per-repo value came from. Drives the badge, never inferred. */
+export type SettingSourceDTO = "override" | "environment" | "default";
+
+/** An effective value plus its source, so the app labels rather than guesses. */
+export interface ResolvedDTO {
+ value: T;
+ source: SettingSourceDTO;
+}
+
+/**
+ * Per-repo settings as the app sees them: **effective values with their sources**,
+ * not the raw stored record.
+ *
+ * The app is told facts and never derives them — the rule that stopped it
+ * re-deriving the forge from a PR URL. So the server resolves the chain
+ * (`override → environment → default`) and sends the answer plus why.
+ */
+export interface RepoSettingsDTO {
+ /** Where new worktrees for this repo are created. Never blank. */
+ worktreeRoot: ResolvedDTO;
+ /** `auto` believes detection; `none` means talk to no forge at all. */
+ provider: ResolvedDTO<"auto" | "none" | "forgejo" | "gitea" | "github">;
+ /** Absent when neither an override nor `origin/HEAD` gave one. */
+ defaultBranch?: ResolvedDTO;
+ /** Monogram hue index; absent = derive it from the name. */
+ logoHue?: number;
+ /**
+ * Whether the repo has an `origin` remote at all. False means no forge is
+ * possible — a **different statement** from "not identified yet", and rendering
+ * them alike implies a probe is pending when none can help.
+ */
+ hasRemote: boolean;
+ /**
+ * What detection concluded. **Absent means not measured yet**, never "no forge":
+ * routing only happens when a PR operation runs, so a quiet repo may genuinely
+ * not know. `authed` is omitted for GitHub, where `gh`'s budget is not
+ * host-specific authentication. The token is never sent.
+ */
+ forge?: { software: string; host: string; authed?: boolean };
+}
+
export interface RepoDTO {
id: string;
name: string;
@@ -772,6 +837,12 @@ export interface RepoDTO {
defaultBranch: string | null;
currentBranch: string | null;
worktrees: WorktreeDTO[];
+ /**
+ * Per-repo settings. Optional so an older app renders no settings section rather
+ * than a fabricated one, and a newer app paired with an older server does the
+ * same.
+ */
+ settings?: RepoSettingsDTO;
}
export interface SessionDTO {
diff --git a/server/src/repo_service.ts b/server/src/repo_service.ts
index 9a1380d9..d8647572 100644
--- a/server/src/repo_service.ts
+++ b/server/src/repo_service.ts
@@ -13,9 +13,17 @@
import type { ProjectDTO, PullRequestDTO, RepoDTO, WorktreeDTO } from "./protocol.js";
import type { Session } from "./session.js";
import type { GithubGateway } from "./github/gateway.js";
+import type { RepoSettingsDTO } from "./protocol.js";
+
+/**
+ * Supplies one project's settings DTO. Injected rather than reached for: the
+ * resolution chain lives in `repo_settings.ts` and the forge decision in the
+ * router, and `listRepos` should not know about either.
+ */
+export type RepoSettingsLookup = (project: ProjectDTO) => RepoSettingsDTO | undefined;
import {
isGitRepo,
- detectDefaultBranch,
+ resolveDefaultBranch,
detectCurrentBranch,
listWorktrees,
diffStat,
@@ -65,24 +73,40 @@ export async function listRepos(
includePrs: boolean,
gateway: GithubGateway,
lastKnown: LastKnownPr,
+ settingsFor?: RepoSettingsLookup,
): Promise {
// Bounded fan-out across projects (SPEC-17 P3 × #66 concurrency cap).
- const repos = await mapLimit(projects, PROJECT_CONCURRENCY, (p) => repoSnapshot(p, sessions));
+ const repos = await mapLimit(projects, PROJECT_CONCURRENCY, async (p) => {
+ // Settings are resolved BEFORE the snapshot because the snapshot needs one of
+ // them: `defaultBranch` is the base every diff +/- number and ahead count is
+ // measured against, so an override that arrived only in the settings blob would
+ // leave the row claiming one base while the numbers used another.
+ const settings = settingsFor?.(p);
+ const repo = await repoSnapshot(p, sessions, settings?.defaultBranch?.value);
+ return settings === undefined ? repo : { ...repo, settings };
+ });
return includePrs ? enrichPrs(repos, gateway, lastKnown) : repos;
}
/**
* Git-only snapshot of one project (no `gh`/network). Per-worktree diff stats
* are read in parallel but bounded ({@link WORKTREE_CONCURRENCY}).
+ *
+ * [defaultBranchOverride] is the user's stored choice; it wins only if the branch
+ * still resolves — see {@link resolveDefaultBranch}.
*/
-async function repoSnapshot(dto: ProjectDTO, sessions: Session[]): Promise {
+async function repoSnapshot(
+ dto: ProjectDTO,
+ sessions: Session[],
+ defaultBranchOverride?: string,
+): Promise {
const repoPath = dto.path;
const gitRepo = await isGitRepo(repoPath);
// Branch detection + worktree enumeration are independent reads — run
// them concurrently rather than in a serial chain.
const [defaultBranch, currentBranch, entries] = gitRepo
? await Promise.all([
- detectDefaultBranch(repoPath),
+ resolveDefaultBranch(repoPath, defaultBranchOverride),
detectCurrentBranch(repoPath),
listWorktrees(repoPath),
])
diff --git a/server/src/repo_settings.test.ts b/server/src/repo_settings.test.ts
new file mode 100644
index 00000000..84decae6
--- /dev/null
+++ b/server/src/repo_settings.test.ts
@@ -0,0 +1,264 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, sep } from "node:path";
+
+import {
+ defaultWorktreeRoot,
+ parseRepoSettings,
+ resolveProvider,
+ resolveWorktreeRoot,
+ validateBranch,
+ validateProvider,
+ validateRepoPath,
+ validateWorktreeRoot,
+} from "./repo_settings.js";
+
+/** A throwaway "home" so the containment rule can be exercised for real. */
+function home(): string {
+ return mkdtempSync(join(tmpdir(), "makit-home-"));
+}
+
+// ---------------------------------------------------------------------------
+// Resolution: the effective value AND its source, because the UI labels it.
+// ---------------------------------------------------------------------------
+
+test("an override wins, and is reported as an override", () => {
+ const r = resolveWorktreeRoot({ worktreeRoot: "/h/custom" }, { MAKIT_WORKTREE_DIR: "/h/env" }, "/h");
+ assert.deepEqual(r, { value: "/h/custom", source: "override" });
+});
+
+test("with no override the env var is used, and named as the environment", () => {
+ // Named, because the app cannot change the daemon's env and must render it
+ // read-only rather than offering an edit that would silently fail.
+ const r = resolveWorktreeRoot({}, { MAKIT_WORKTREE_DIR: "/h/env" }, "/h");
+ assert.deepEqual(r, { value: "/h/env", source: "environment" });
+});
+
+test("with neither, the built-in default is used", () => {
+ const r = resolveWorktreeRoot(undefined, {}, "/h");
+ assert.deepEqual(r, { value: join("/h", ".worktrees"), source: "default" });
+ assert.equal(defaultWorktreeRoot("/h"), join("/h", ".worktrees"));
+});
+
+test("an exported-but-empty env var falls through to the default", () => {
+ // Honouring "" would create worktrees at the filesystem root.
+ const r = resolveWorktreeRoot({}, { MAKIT_WORKTREE_DIR: "" }, "/h");
+ assert.equal(r.source, "default");
+});
+
+test("an empty override string is not an override", () => {
+ const r = resolveWorktreeRoot({ worktreeRoot: "" }, {}, "/h");
+ assert.equal(r.source, "default");
+});
+
+test("provider resolves to auto when unset, and to an override when set", () => {
+ assert.deepEqual(resolveProvider(undefined), { value: "auto", source: "default" });
+ assert.deepEqual(resolveProvider({ provider: "none" }), { value: "none", source: "override" });
+});
+
+// ---------------------------------------------------------------------------
+// Validation. Each case is chosen to reach a DIFFERENT rule, in the order the
+// rules actually run — a case that trips an earlier rule proves nothing about a
+// later one.
+// ---------------------------------------------------------------------------
+
+test("a relative path is rejected by the absolute rule", () => {
+ const v = validateWorktreeRoot("work/trees", "/h");
+ assert.equal(v.ok, false);
+ assert.match((v as { error: string }).error, /absolute/i);
+});
+
+test("an absolute path containing '..' is rejected ON SIGHT, not collapsed", () => {
+ // Collapsing would yield a valid path that is not the one the user typed.
+ const h = home();
+ // Built by string concatenation, NOT `path.join`: join collapses `..` itself, so
+ // a joined path can never exercise this rule.
+ const v = validateWorktreeRoot(`${h}${sep}work${sep}..${sep}..${sep}etc`, h);
+ assert.equal(v.ok, false);
+ assert.match((v as { error: string }).error, /\.\./);
+});
+
+test("a not-yet-existing root is ACCEPTED and canonicalised via its ancestor", () => {
+ // The common case: the user names a directory before creating it. `realpath`
+ // fails outright on a missing path, so a naive rule would reject this.
+ const h = home();
+ const v = validateWorktreeRoot(join(h, "work", "trees", "deep"), h);
+ assert.equal(v.ok, true);
+ // Compared against the REAL home: on macOS /var is a symlink to /private/var, so
+ // the canonicalised result legitimately differs from the input. Resolving that is
+ // the point of the rule, not a bug in it.
+ assert.equal(
+ (v as { value: string }).value,
+ join(realpathSync(h), "work", "trees", "deep"),
+ );
+});
+
+test("an existing root is stored canonicalised", () => {
+ const h = home();
+ mkdirSync(join(h, "trees"));
+ const v = validateWorktreeRoot(join(h, "trees"), h);
+ assert.equal(v.ok, true);
+ assert.ok((v as { value: string }).value.endsWith(`${sep}trees`));
+});
+
+test("a symlink whose target escapes home is rejected", () => {
+ // Reached with a REAL symlink: a '..' string is rejected by the earlier rule and
+ // never gets here, so it cannot exercise canonicalisation.
+ const h = home();
+ const outside = mkdtempSync(join(tmpdir(), "makit-outside-"));
+ symlinkSync(outside, join(h, "escape"));
+ const v = validateWorktreeRoot(join(h, "escape", "trees"), h);
+ assert.equal(v.ok, false);
+ assert.match((v as { error: string }).error, /home directory/i);
+});
+
+test("a path outside home is rejected even when it exists", () => {
+ const h = home();
+ const v = validateWorktreeRoot(tmpdir(), h);
+ assert.equal(v.ok, false);
+});
+
+test("a file where a directory is required is rejected", () => {
+ const h = home();
+ writeFileSync(join(h, "afile"), "x");
+ const v = validateWorktreeRoot(join(h, "afile"), h);
+ assert.equal(v.ok, false);
+ assert.match((v as { error: string }).error, /not a directory/i);
+});
+
+test("empty input is rejected", () => {
+ assert.equal(validateWorktreeRoot(" ", "/h").ok, false);
+});
+
+test("home itself is allowed", () => {
+ const h = home();
+ assert.equal(validateWorktreeRoot(h, h).ok, true);
+});
+
+// ---------------------------------------------------------------------------
+// Provider + branch
+// ---------------------------------------------------------------------------
+
+test("provider accepts exactly the five choices and nothing else", () => {
+ for (const p of ["auto", "none", "forgejo", "gitea", "github"]) {
+ assert.equal(validateProvider(p).ok, true, p);
+ }
+ for (const bad of ["gitlab", "", "GITHUB", 7, null, undefined]) {
+ assert.equal(validateProvider(bad).ok, false, String(bad));
+ }
+});
+
+test("branch validation rejects what git itself would refuse", () => {
+ assert.equal(validateBranch("main").ok, true);
+ assert.equal(validateBranch("feat/thing-1").ok, true);
+ for (const bad of ["", " ", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b", "a..b", "-lead", "x.lock"]) {
+ assert.equal(validateBranch(bad).ok, false, JSON.stringify(bad));
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Defensive parse: a hand-edited file must never stop the daemon.
+// ---------------------------------------------------------------------------
+
+test("a known key of the wrong type is dropped, not trusted", () => {
+ // `worktreeRoot: 42` must never reach path handling.
+ assert.deepEqual(parseRepoSettings({ worktreeRoot: 42 }), {});
+ assert.deepEqual(parseRepoSettings({ provider: "gitlab" }), {});
+ assert.deepEqual(parseRepoSettings({ defaultBranch: "a b" }), {});
+ assert.deepEqual(parseRepoSettings({ logoHue: -1 }), {});
+ assert.deepEqual(parseRepoSettings({ logoHue: 1.5 }), {});
+});
+
+test("a non-object degrades to inherit-everything rather than throwing", () => {
+ for (const bad of [null, undefined, 7, "x", []]) {
+ assert.deepEqual(parseRepoSettings(bad), {});
+ }
+});
+
+test("provider 'auto' is not stored — the default stays implicit", () => {
+ assert.deepEqual(parseRepoSettings({ provider: "auto" }), {});
+});
+
+test("valid values survive the parse", () => {
+ assert.deepEqual(
+ parseRepoSettings({
+ worktreeRoot: "/h/trees",
+ provider: "gitea",
+ defaultBranch: "develop",
+ logoHue: 3,
+ }),
+ { worktreeRoot: "/h/trees", provider: "gitea", defaultBranch: "develop", logoHue: 3 },
+ );
+});
+
+// ---------------------------------------------------------------------------
+// SPEC-48 D4' — re-pointing a repository's root path.
+//
+// A different rule set from the worktree root, and the differences are the
+// interesting part:
+//
+// - it must ALREADY EXIST. A worktree root is created on demand, so naming one
+// before it exists is the common case; a repository you have not got is not a
+// repository, and accepting the path would detach the project from its
+// sessions with nothing to reattach to.
+// - it is NOT confined to $HOME. That rule exists for the worktree root because
+// the daemon creates and, via prune, REMOVES directories under it. makit never
+// deletes a repo path, and a checkout on an external volume or a shared mount
+// is ordinary — refusing it would be security theatre with a real cost.
+// ---------------------------------------------------------------------------
+
+test("a relative repo path is refused — it would resolve against the daemon's cwd", () => {
+ const r = validateRepoPath("Work/Diana");
+ assert.equal(r.ok, false);
+});
+
+test("a repo path containing '..' is refused on sight, not collapsed", () => {
+ // Same reasoning as the worktree root: collapsing yields a path the user did not
+ // type, and this one decides where every session's git operations run.
+ const r = validateRepoPath("/Users" + sep + "x" + sep + ".." + sep + "etc");
+ assert.equal(r.ok, false);
+ assert.match(r.ok ? "" : r.error, /\.\./);
+});
+
+test("a repo path that does not exist is refused", () => {
+ const r = validateRepoPath(join(tmpdir(), "makit-definitely-not-here-4919"));
+ assert.equal(r.ok, false);
+});
+
+test("a file is refused — a repository is a directory", () => {
+ const dir = mkdtempSync(join(tmpdir(), "makit-rp-"));
+ const file = join(dir, "a-file");
+ writeFileSync(file, "x");
+ try {
+ assert.equal(validateRepoPath(file).ok, false);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("an existing directory is accepted and canonicalised", () => {
+ // Canonicalised so `/tmp` and `/private/tmp` cannot become two projects for one
+ // directory — which would make settings lookup by path ambiguous.
+ const dir = mkdtempSync(join(tmpdir(), "makit-rp-"));
+ try {
+ const r = validateRepoPath(dir);
+ assert.equal(r.ok, true);
+ assert.equal(r.ok && r.value, realpathSync(dir));
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("a repo path OUTSIDE $HOME is accepted — unlike the worktree root", () => {
+ // The asymmetry is deliberate, and asserted so it cannot be "tidied" into
+ // consistency later: nothing is ever deleted under a repo path.
+ const dir = mkdtempSync(join(tmpdir(), "makit-rp-"));
+ try {
+ assert.equal(validateRepoPath(dir).ok, true, "a repo on /tmp is legitimate");
+ assert.equal(validateWorktreeRoot(dir).ok, false, "a worktree root there is not");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/server/src/repo_settings.ts b/server/src/repo_settings.ts
new file mode 100644
index 00000000..d897f897
--- /dev/null
+++ b/server/src/repo_settings.ts
@@ -0,0 +1,338 @@
+/**
+ * repo_settings.ts — per-repo settings: the schema, how a value resolves, and
+ * what is allowed to be written.
+ *
+ * Three responsibilities, kept apart on purpose:
+ *
+ * 1. {@link RepoSettings} — the persisted shape. Every field optional, because
+ * **absent means "inherit"**, never "empty". A blank worktree root that
+ * silently means `~/.worktrees` is how worktrees end up somewhere the user
+ * did not expect.
+ * 2. {@link resolveWorktreeRoot} and friends — the effective value plus the
+ * SOURCE it came from, so the UI can label it rather than guess.
+ * 3. {@link validateWorktreeRoot} — what a client may store.
+ *
+ * The resolution chain is deliberately three levels, not four:
+ * `repo override → env var → built-in default`. There is no global settings store
+ * to inherit from, and inventing a level for one that does not exist would be a
+ * framework rather than a capability.
+ */
+
+import { existsSync, realpathSync, statSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, isAbsolute, join, normalize, sep } from "node:path";
+
+/** Where an effective value came from. Rendered as a badge; never inferred by the app. */
+export type SettingSource = "override" | "environment" | "default";
+
+/** One resolved setting: the value in force, and why. */
+export interface Resolved {
+ value: T;
+ source: SettingSource;
+}
+
+/**
+ * The provider a repo should use, or `auto` to believe detection.
+ *
+ * `none` is not the absence of a choice — it is the instruction "talk to no forge
+ * for this repository". A purely local repo and a mirror whose forge you do not
+ * care about both need it, and neither is served by `auto` failing.
+ */
+export type ProviderChoice = "auto" | "none" | "forgejo" | "gitea" | "github";
+
+const PROVIDER_CHOICES: readonly ProviderChoice[] = [
+ "auto",
+ "none",
+ "forgejo",
+ "gitea",
+ "github",
+];
+
+/** Persisted per-repo settings. Absent field = inherit; never a sentinel value. */
+export interface RepoSettings {
+ /** Absolute, canonicalised worktree root for this repo. */
+ worktreeRoot?: string;
+ /** Provider override; `auto` is stored as absent so the default stays implicit. */
+ provider?: Exclude;
+ /** Default branch override, used when `origin/HEAD` is absent or wrong. */
+ defaultBranch?: string;
+ /** Monogram hue index, chosen from a fixed palette (see the app's RepoMonogram). */
+ logoHue?: number;
+}
+
+/**
+ * How many hues a repo monogram can take, mirroring `RepoMonogram`'s palette in
+ * `app/lib/ui/home/repo_monogram.dart`.
+ *
+ * Enforced as a RANGE rather than trusted: `paletteAt` wraps with `%`, so a stored
+ * `6` would render as index 0 -- a colour the user never chose, indistinguishable
+ * from having chosen 0. Rejecting out-of-range keeps one hue per stored value.
+ */
+export const LOGO_HUE_COUNT = 6;
+
+/** Built-in worktree root when nothing else says otherwise. */
+export function defaultWorktreeRoot(home: string = homedir()): string {
+ return join(home, ".worktrees");
+}
+
+/**
+ * The worktree root in force for a repo, and where it came from.
+ *
+ * `MAKIT_WORKTREE_DIR` is a **level in the chain**, not a competitor: it already
+ * exists and must keep working, and because the app cannot change the daemon's
+ * environment it is reported as `environment` and rendered read-only.
+ *
+ * An empty-string env var resolves to the default rather than to `""` — an
+ * exported-but-blank variable is a mistake, and honouring it would create
+ * worktrees at the filesystem root.
+ */
+export function resolveWorktreeRoot(
+ settings: RepoSettings | undefined,
+ env: Record,
+ home: string = homedir(),
+): Resolved {
+ const override = settings?.worktreeRoot;
+ if (override !== undefined && override.length > 0) {
+ return { value: override, source: "override" };
+ }
+ const fromEnv = env.MAKIT_WORKTREE_DIR;
+ if (fromEnv !== undefined && fromEnv.length > 0) {
+ return { value: fromEnv, source: "environment" };
+ }
+ return { value: defaultWorktreeRoot(home), source: "default" };
+}
+
+/** The provider choice in force. Absent = `auto`. */
+export function resolveProvider(settings: RepoSettings | undefined): Resolved {
+ const p = settings?.provider;
+ return p === undefined ? { value: "auto", source: "default" } : { value: p, source: "override" };
+}
+
+/** A rejected write, with a reason the UI can show verbatim. */
+export interface Invalid {
+ ok: false;
+ error: string;
+}
+export interface Valid {
+ ok: true;
+ value: T;
+}
+export type Validation = Valid | Invalid;
+
+/**
+ * Validate and canonicalise a worktree root.
+ *
+ * The order of the rules matters, and each exists for a different attack or
+ * mistake:
+ *
+ * 1. **Absolute only.** A relative root would resolve against the daemon's cwd,
+ * which is not a place the user can see or reason about.
+ * 2. **No `..` segment, rejected on sight** — not collapsed. Collapsing would
+ * silently produce a valid path that is not the one the user typed, which is
+ * exactly how a confused write becomes a surprising delete later (prune).
+ * 3. **Canonicalise through the nearest EXISTING ancestor.** A worktree root
+ * that does not exist yet is the common case — `~/work/worktrees` before it
+ * has been created — and `realpath` fails outright on a missing path, so a
+ * naive rule would reject the normal case. The remaining, not-yet-created
+ * segments are then required to be plain names.
+ * 4. **The resolved ancestor must be inside `$HOME`.** The daemon creates and,
+ * via prune, REMOVES directories under this root; a root outside the home
+ * directory turns a settings row into a filesystem weapon.
+ *
+ * Callers must re-validate on read-back before use: `projects.json` is plain JSON
+ * a user can edit by hand, so a write-time check alone is not a guarantee.
+ */
+export function validateWorktreeRoot(
+ raw: string,
+ home: string = homedir(),
+): Validation {
+ const input = raw.trim();
+ if (input.length === 0) return { ok: false, error: "Worktree root cannot be empty." };
+ if (!isAbsolute(input)) {
+ return { ok: false, error: "Worktree root must be an absolute path." };
+ }
+ // Split the RAW input, not a normalised copy: `normalize` COLLAPSES `..`, so
+ // checking the normalised form makes this rule dead code and lets
+ // `/home/you/work/../../etc` through to be rejected later by the containment
+ // rule with a misleading message. Found by a test that could not fail until the
+ // test itself stopped using `path.join`, which collapses too.
+ if (input.split(sep).some((seg) => seg === "..")) {
+ return {
+ ok: false,
+ error: "Worktree root must not contain '..'. Give the path you mean, not a path relative to another.",
+ };
+ }
+
+ // Walk up to the nearest existing ancestor and canonicalise THAT, so a
+ // not-yet-created root is accepted while symlink escapes are still resolved.
+ let existing = normalize(input);
+ const trailing: string[] = [];
+ while (!existsSync(existing)) {
+ const parent = dirname(existing);
+ if (parent === existing) {
+ return { ok: false, error: `No part of ${input} exists, so it cannot be checked.` };
+ }
+ trailing.unshift(existing.slice(parent.length + 1));
+ existing = parent;
+ }
+
+ let realAncestor: string;
+ try {
+ realAncestor = realpathSync(existing);
+ if (!statSync(realAncestor).isDirectory()) {
+ return { ok: false, error: `${existing} is not a directory.` };
+ }
+ } catch {
+ return { ok: false, error: `Could not resolve ${existing}.` };
+ }
+
+ const realHome = (() => {
+ try {
+ return realpathSync(home);
+ } catch {
+ return home;
+ }
+ })();
+ if (realAncestor !== realHome && !realAncestor.startsWith(realHome + sep)) {
+ return {
+ ok: false,
+ error: "Worktree root must be inside your home directory.",
+ };
+ }
+
+ return { ok: true, value: trailing.length === 0 ? realAncestor : join(realAncestor, ...trailing) };
+}
+
+/**
+ * Validate and canonicalise a repository's root path, for re-pointing a project
+ * that moved on disk (D4′).
+ *
+ * Shares two rules with {@link validateWorktreeRoot} — absolute only, and `..`
+ * rejected on sight rather than collapsed — and deliberately differs on two:
+ *
+ * - **It must already exist.** A worktree root is created on demand, so naming
+ * one before it exists is the normal case. A repository you have not got is
+ * not a repository: accepting the path would detach the project from its
+ * sessions with nothing to reattach to, which is the exact failure the P1
+ * notice existed to avoid.
+ * - **It is NOT confined to `$HOME`.** That rule protects the worktree root
+ * because the daemon creates and, via prune, REMOVES directories beneath it.
+ * makit never deletes a repo path, and a checkout on an external volume or a
+ * shared mount is ordinary — refusing it would be security theatre with a real
+ * cost to real users.
+ *
+ * Canonicalised so `/tmp` and `/private/tmp` cannot become two projects for one
+ * directory: settings and the forge decision are both looked up BY PATH, so two
+ * spellings of one repo would silently disagree about its configuration.
+ *
+ * Being a git repository is NOT checked here — that needs a subprocess, and this
+ * stays synchronous and pure-ish so it can be unit-tested and reused. The caller
+ * checks it (see `SessionManager.repointProject`).
+ */
+export function validateRepoPath(raw: string): Validation {
+ const input = raw.trim();
+ if (input.length === 0) return { ok: false, error: "Repository path cannot be empty." };
+ if (!isAbsolute(input)) {
+ return { ok: false, error: "Repository path must be an absolute path." };
+ }
+ // Split the RAW input: `normalize` collapses `..`, which would make this dead
+ // code — the mistake this file has already made once.
+ if (input.split(sep).some((seg) => seg === "..")) {
+ return {
+ ok: false,
+ error: "Repository path must not contain '..'. Give the path you mean.",
+ };
+ }
+ let real: string;
+ try {
+ real = realpathSync(normalize(input));
+ } catch {
+ // Not "does not exist": `realpath` also fails on a permission error or an I/O
+ // error, and telling someone their present-but-unreadable directory is missing
+ // sends them to create a path that is already there.
+ return { ok: false, error: `Could not resolve ${input}. Check it exists and is readable.` };
+ }
+ try {
+ if (!statSync(real).isDirectory()) {
+ // Reports the CANONICAL path, which is the thing actually inspected: with
+ // `/tmp` symlinked to `/private/tmp`, echoing the input describes a different
+ // place from the one that failed.
+ return { ok: false, error: `${real} is not a directory.` };
+ }
+ } catch {
+ return { ok: false, error: `Could not inspect ${real}.` };
+ }
+ return { ok: true, value: real };
+}
+
+/** Validate a provider choice coming off the wire. */
+export function validateProvider(raw: unknown): Validation {
+ if (typeof raw !== "string" || !PROVIDER_CHOICES.includes(raw as ProviderChoice)) {
+ return { ok: false, error: `Unknown provider '${String(raw)}'.` };
+ }
+ return { ok: true, value: raw as ProviderChoice };
+}
+
+/**
+ * Validate a default-branch override.
+ *
+ * Rejects the characters git itself refuses in a ref name, so a typo cannot be
+ * stored and then fail deep inside a `git` invocation where the message is
+ * unrecognisable.
+ */
+export function validateBranch(raw: string): Validation {
+ const b = raw.trim();
+ if (b.length === 0) return { ok: false, error: "Branch name cannot be empty." };
+ const bad = (): Invalid => ({ ok: false, error: `'${b}' is not a valid branch name.` });
+ // The rules `git check-ref-format --branch` applies, in the same spirit: a name
+ // stored here is later handed to git as an argv element, and one git refuses fails
+ // deep inside a plumbing call where the message is unrecognisable. Shell
+ // metacharacters need no special handling -- every call goes through `execFile`
+ // with an argv array, never a shell string.
+ if (/[\s~^:?*[\\]/.test(b)) return bad();
+ // ASCII control characters and DEL.
+ // eslint-disable-next-line no-control-regex
+ if (/[\u0000-\u001f\u007f]/.test(b)) return bad();
+ if (b.includes("..") || b.includes("@{")) return bad();
+ if (b.startsWith("-") || b.startsWith("/") || b.startsWith(".")) return bad();
+ if (b.endsWith("/") || b.endsWith(".") || b.endsWith(".lock")) return bad();
+ if (b.includes("//")) return bad();
+ // No path component may begin with `.` or end with `.lock` -- `feat/.x` is refused
+ // by git even though the whole string does not start with a dot.
+ if (b.split("/").some((seg) => seg.length === 0 || seg.startsWith(".") || seg.endsWith(".lock"))) {
+ return bad();
+ }
+ return { ok: true, value: b };
+}
+
+/**
+ * Parse a persisted `settings` object defensively.
+ *
+ * Unknown keys are **preserved** by the caller (see `project-store`), but a KNOWN
+ * key of the wrong type is dropped rather than trusted: a hand-edited
+ * `worktreeRoot: 42` must not reach path handling. Never throws — a bad settings
+ * object degrades that one repo to "inherit everything".
+ */
+export function parseRepoSettings(raw: unknown): RepoSettings {
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
+ const r = raw as Record;
+ const out: RepoSettings = {};
+ if (typeof r.worktreeRoot === "string" && r.worktreeRoot.length > 0) {
+ out.worktreeRoot = r.worktreeRoot;
+ }
+ const provider = validateProvider(r.provider);
+ if (provider.ok && provider.value !== "auto") out.provider = provider.value;
+ if (typeof r.defaultBranch === "string") {
+ const b = validateBranch(r.defaultBranch);
+ if (b.ok) out.defaultBranch = b.value;
+ }
+ if (
+ typeof r.logoHue === "number" &&
+ Number.isInteger(r.logoHue) &&
+ r.logoHue >= 0 &&
+ r.logoHue < LOGO_HUE_COUNT
+ ) {
+ out.logoHue = r.logoHue;
+ }
+ return out;
+}
diff --git a/server/src/repo_settings_wiring.test.ts b/server/src/repo_settings_wiring.test.ts
new file mode 100644
index 00000000..67c62764
--- /dev/null
+++ b/server/src/repo_settings_wiring.test.ts
@@ -0,0 +1,694 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { tmpdir, homedir } from "node:os";
+import { join } from "node:path";
+
+import { SessionManager } from "./manager.js";
+import { createForgeRouter } from "./forge/router.js";
+import { createNoForgeGateway } from "./forge/none.js";
+import { resolveWorktreeRoot, validateWorktreeRoot } from "./repo_settings.js";
+
+/**
+ * The behaviour the feature exists for: two repos, different roots, resolved from
+ * persisted settings — and the invalid-value path, which must degrade rather than
+ * break worktree creation.
+ *
+ * Exercised through `SessionManager.worktreeRootFor`, the ONE place all three
+ * consumers (`addWorktree`, `addWorktreeForPr`, `uniqueWorktreeDir`) now read
+ * from. Before this, only the env var was consulted and every repo shared a root.
+ */
+function manager(projects: Array<{ id: string; path: string; settings?: Record }>) {
+ return new SessionManager({
+ adapterFactory: (() => {
+ throw new Error("not used");
+ }) as never,
+ onProjectsChanged: () => {},
+ defaultModel: "m",
+ store: undefined as never,
+ capabilityCache: undefined as never,
+ projects,
+ gateway: {
+ prForBranch: async () => ({ kind: "none" }) as never,
+ openPrs: async () => [],
+ mutatePr: async () => ({ ok: true }),
+ budget: () => ({}) as never,
+ history: () => [],
+ refresh: async () => ({}) as never,
+ setPaused: () => {},
+ onBudgetChange: () => () => {},
+ close: () => {},
+ stats: () => ({ execs: 0, exemptExecs: 0, cacheHits: 0 }),
+ } as never,
+ });
+}
+
+/** As {@link manager}, but with a gateway that also answers forge inspection. */
+function managerWithInspector(
+ projects: Array<{ id: string; path: string; settings?: Record }>,
+ inspector: {
+ forgeFor: (p: string) => unknown;
+ hasRemoteFor: (p: string) => boolean | undefined;
+ },
+) {
+ const m = manager(projects);
+ Object.assign((m as unknown as { _gateway: object })._gateway, inspector);
+ return m;
+}
+
+test("two repos with different overrides get different worktree roots", () => {
+ const home = homedir();
+ const a = mkdtempSync(join(tmpdir(), "makit-a-"));
+ const b = mkdtempSync(join(tmpdir(), "makit-b-"));
+ const rootA = join(home, ".makit-test-trees-a");
+ const m = manager([
+ { id: "a", path: a, settings: { worktreeRoot: rootA } },
+ { id: "b", path: b },
+ ]);
+ assert.equal(m.worktreeRootFor(a), rootA, "A follows its override");
+ assert.equal(
+ m.worktreeRootFor(b),
+ resolveWorktreeRoot(undefined, process.env).value,
+ "B inherits",
+ );
+});
+
+test("an override that is no longer valid degrades to the inherited root", () => {
+ // `projects.json` is hand-editable, so a stored value can go bad after the write
+ // check passed. Worktree creation must still work.
+ const a = mkdtempSync(join(tmpdir(), "makit-c-"));
+ const m = manager([{ id: "a", path: a, settings: { worktreeRoot: "/etc/nope" } }]);
+ assert.equal(m.worktreeRootFor(a), resolveWorktreeRoot(undefined, process.env).value);
+});
+
+test("a repo makit does not know inherits rather than throwing", () => {
+ const m = manager([]);
+ assert.equal(
+ m.worktreeRootFor("/tmp/not-a-project"),
+ resolveWorktreeRoot(undefined, process.env).value,
+ );
+});
+
+test("the stored root is used verbatim once validated, symlinks resolved", () => {
+ // The home directory is required, not incidental: `validateWorktreeRoot` refuses a
+ // root outside $HOME. `mkdtempSync` gives it a unique name so parallel runs cannot
+ // collide, and the `finally` removes it -- this used to leave a directory behind in
+ // the developer's home on every run.
+ const real = mkdtempSync(join(homedir(), ".makit-test-trees-real-"));
+ // Created before the `try` so the `finally` always owns it: removing it on the last
+ // line of the body leaked the directory whenever an assertion above it failed.
+ const a = mkdtempSync(join(tmpdir(), "makit-d-"));
+ try {
+ const checked = validateWorktreeRoot(real);
+ assert.equal(checked.ok, true);
+ const m = manager([{ id: "a", path: a, settings: { worktreeRoot: real } }]);
+ assert.equal(m.worktreeRootFor(a), (checked as { value: string }).value);
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ rmSync(real, { recursive: true, force: true });
+ }
+});
+
+test("collision detection looks in the repo's OWN root, not the global one", () => {
+ // Reviewer finding R5: if `uniqueWorktreeDir` consulted the inherited root while
+ // `addWorktree` wrote to the override, the two would disagree and a real
+ // collision would slip through. Proven by making a name collide in the
+ // OVERRIDDEN root only.
+ const repo = mkdtempSync(join(tmpdir(), "makit-coll-"));
+ const repoName = repo.split("/").pop()!;
+ // Unique per run and removed afterwards: a fixed name accumulated one
+ // `` subdirectory in the developer's home on every single run.
+ const overrideRoot = mkdtempSync(join(homedir(), ".makit-test-trees-collide-"));
+ try {
+ mkdirSync(join(overrideRoot, repoName, "feat-x"), { recursive: true });
+
+ const overridden = manager([
+ { id: "a", path: repo, settings: { worktreeRoot: overrideRoot } },
+ ]);
+ assert.notEqual(
+ overridden.uniqueWorktreeDir(repo, "feat-x"),
+ "feat-x",
+ "the existing dir under the OVERRIDE must be seen",
+ );
+
+ const inherited = manager([{ id: "a", path: repo }]);
+ assert.equal(
+ inherited.uniqueWorktreeDir(repo, "feat-x"),
+ "feat-x",
+ "the same name is free under the inherited root",
+ );
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ rmSync(overrideRoot, { recursive: true, force: true });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// P2 — the provider choice reaches the router, and the DTO stops conflating
+// "no remote" with "not measured yet".
+// ---------------------------------------------------------------------------
+
+test("the manager reports a repo's stored provider choice, so routing can honour it", () => {
+ // The router asks this at routing time (not at construction), which is what lets
+ // a changed setting take effect without restarting the daemon.
+ const a = mkdtempSync(join(tmpdir(), "makit-p1-"));
+ const b = mkdtempSync(join(tmpdir(), "makit-p2-"));
+ const m = manager([
+ { id: "a", path: a, settings: { provider: "forgejo" } },
+ { id: "b", path: b },
+ ]);
+ assert.equal(m.providerFor(a), "forgejo");
+ assert.equal(m.providerFor(b), "auto", "no override means believe detection");
+});
+
+test("a repo makit does not know reports auto rather than throwing", () => {
+ const m = manager([]);
+ assert.equal(m.providerFor("/nowhere"), "auto");
+});
+
+test("an un-routed repo reports hasRemote true — not measured is not 'no remote'", async () => {
+ // The bug this pins: hasRemote was `forge !== undefined`, so a repo the router had
+ // not reached yet claimed to have no origin. That made the app's "not identified
+ // yet" wording unreachable and sent the reader looking for a missing remote that
+ // was never missing.
+ const a = mkdtempSync(join(tmpdir(), "makit-hr1-"));
+ const m = manager([{ id: "a", path: a }]);
+ const [repo] = await m.listRepos({ includePrs: false });
+ assert.equal(repo.settings?.hasRemote, true);
+ assert.equal(repo.settings?.forge, undefined, "and the forge is still absent");
+});
+
+test("a routed repo with no readable origin reports hasRemote false", async () => {
+ const a = mkdtempSync(join(tmpdir(), "makit-hr2-"));
+ const m = managerWithInspector([{ id: "a", path: a }], {
+ forgeFor: () => undefined,
+ hasRemoteFor: () => false,
+ });
+ const [repo] = await m.listRepos({ includePrs: false });
+ assert.equal(repo.settings?.hasRemote, false);
+});
+
+test("a routed repo with a forge reports hasRemote true and the forge", async () => {
+ const a = mkdtempSync(join(tmpdir(), "makit-hr3-"));
+ const forge = { software: "forgejo" as const, host: "git.example", authed: true, source: "override" as const };
+ const m = managerWithInspector([{ id: "a", path: a }], {
+ forgeFor: () => forge,
+ hasRemoteFor: () => true,
+ });
+ const [repo] = await m.listRepos({ includePrs: false });
+ assert.equal(repo.settings?.hasRemote, true);
+ assert.deepEqual(repo.settings?.forge, forge);
+});
+
+// ---------------------------------------------------------------------------
+// SPEC-48 — the default-branch override reaches all THREE consumers.
+//
+// The same shape as the worktree-root fix (T2/R5): a resolver is not a feature
+// until every consumer reads from it. `detectDefaultBranch` was called directly in
+// three places, so the stored override affected none of them — the diff numbers,
+// the base a new worktree branches from, and the branch wrap-up syncs.
+// ---------------------------------------------------------------------------
+
+/**
+ * A real repo with one commit on `main`, plus each of [extra] as a branch carrying
+ * its OWN extra commit.
+ *
+ * The divergent commit is load-bearing, not decoration: a branch created from
+ * `main` without one has the same tip, so `merge-base` cannot tell which of the two
+ * a worktree forked from and the assertion would pass no matter what the production
+ * code chose.
+ */
+function repoWithBranches(extra: string[]): string {
+ const dir = mkdtempSync(join(tmpdir(), "makit-db-"));
+ const g = (...args: string[]) => execFileSync("git", args, { cwd: dir });
+ g("init", "-q", "-b", "main");
+ g("config", "user.email", "t@t.io");
+ g("config", "user.name", "Test");
+ writeFileSync(join(dir, "README.md"), "hi\n");
+ g("add", ".");
+ g("commit", "-q", "-m", "initial");
+ for (const b of extra) {
+ g("checkout", "-q", "-b", b);
+ writeFileSync(join(dir, `${b}.txt`), `${b}\n`);
+ g("add", ".");
+ g("commit", "-q", "-m", `on ${b}`);
+ g("checkout", "-q", "main");
+ }
+ return dir;
+}
+
+test("the manager resolves a repo's default branch through the override", async () => {
+ const a = repoWithBranches(["trunk"]);
+ try {
+ const m = manager([{ id: "a", path: a, settings: { defaultBranch: "trunk" } }]);
+ assert.equal(await m.defaultBranchFor(a), "trunk");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("without an override the manager falls back to git's answer", async () => {
+ const a = repoWithBranches([]);
+ try {
+ const m = manager([{ id: "a", path: a }]);
+ assert.equal(await m.defaultBranchFor(a), "main");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("the repos snapshot reports the overridden default branch, so the diff base follows", async () => {
+ // `RepoDTO.defaultBranch` is what `diffStat` and `commitsAhead` measure against
+ // (repo_service.ts). If the snapshot ignores the override, every +/- number in the
+ // UI is measured from the wrong base while the Settings row claims otherwise.
+ const a = repoWithBranches(["trunk"]);
+ try {
+ const m = manager([{ id: "a", path: a, settings: { defaultBranch: "trunk" } }]);
+ const [repo] = await m.listRepos({ includePrs: false });
+ assert.equal(repo.defaultBranch, "trunk");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("a new worktree branches from the overridden default", async () => {
+ // The base a session's branch forks from. Getting this wrong means the PR is
+ // opened against the wrong base and shows unrelated commits.
+ const a = repoWithBranches(["trunk"]);
+ const root = mkdtempSync(join(homedir(), ".makit-test-db-"));
+ try {
+ const m = manager([{ id: "a", path: a, settings: { defaultBranch: "trunk", worktreeRoot: root } }]);
+ const { path: wt } = await m.createWorktree("a", undefined, "from-trunk");
+ const mergeBase = execFileSync("git", ["merge-base", "HEAD", "trunk"], { cwd: wt })
+ .toString()
+ .trim();
+ const trunkTip = execFileSync("git", ["rev-parse", "trunk"], { cwd: a }).toString().trim();
+ assert.equal(mergeBase, trunkTip, "the new branch forked from trunk, not main");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// SPEC-48 D4' — re-pointing a project that moved on disk.
+//
+// Why this is not "remove and re-add": that mints a new `PersistedProject.id`, and
+// everything keyed to it — per-repo settings, session history — is lost. Preserving
+// the id across a move is the entire reason the id exists.
+// ---------------------------------------------------------------------------
+
+test("re-pointing keeps the project id and its settings", async () => {
+ // The whole justification. If the settings do not survive, the user has done
+ // remove-and-re-add by a longer route.
+ const from = repoWithBranches([]);
+ const to = repoWithBranches([]);
+ try {
+ const m = manager([{ id: "a", path: from, settings: { logoHue: 4 } }]);
+ const r = await m.repointProject("a", to);
+ assert.equal(r.ok, true);
+ const [dto] = m.listProjects();
+ assert.equal(dto.id, "a", "the id is preserved");
+ assert.equal(dto.path, realpathSync(to));
+ assert.deepEqual(m.projectSettings("a"), { logoHue: 4 });
+ } finally {
+ rmSync(from, { recursive: true, force: true });
+ rmSync(to, { recursive: true, force: true });
+ }
+});
+
+test("re-pointing at something that is not a git repo is refused", async () => {
+ // The constraint D4' names explicitly: re-validate that the target is a git repo,
+ // because a project silently pointed at a plain directory has no branches, no
+ // forge and no diff — and looks merely broken rather than misconfigured.
+ const from = repoWithBranches([]);
+ const plain = mkdtempSync(join(tmpdir(), "makit-plain-"));
+ try {
+ const m = manager([{ id: "a", path: from }]);
+ const r = await m.repointProject("a", plain);
+ assert.equal(r.ok, false);
+ assert.match(r.ok ? "" : r.error, /git/i);
+ assert.equal(m.listProjects()[0].path, from, "and the project is untouched");
+ } finally {
+ rmSync(from, { recursive: true, force: true });
+ rmSync(plain, { recursive: true, force: true });
+ }
+});
+
+test("re-pointing onto another project's path is refused", async () => {
+ // Two projects at one path makes settings and the forge decision — both looked up
+ // BY PATH — ambiguous, so one repo would silently answer for the other.
+ const a = repoWithBranches([]);
+ const b = repoWithBranches([]);
+ try {
+ const m = manager([
+ { id: "a", path: a },
+ { id: "b", path: b },
+ ]);
+ const r = await m.repointProject("a", b);
+ assert.equal(r.ok, false);
+ assert.match(r.ok ? "" : r.error, /already/i);
+ assert.equal(m.listProjects()[0].path, a);
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ rmSync(b, { recursive: true, force: true });
+ }
+});
+
+test("re-pointing a project at its own path is a no-op, not a duplicate error", async () => {
+ // Re-submitting the same value must not read as a conflict with itself.
+ const a = repoWithBranches([]);
+ try {
+ const m = manager([{ id: "a", path: a }]);
+ assert.equal((await m.repointProject("a", a)).ok, true);
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("re-pointing an unknown project is refused rather than creating one", async () => {
+ const a = repoWithBranches([]);
+ try {
+ const m = manager([]);
+ const r = await m.repointProject("ghost", a);
+ assert.equal(r.ok, false);
+ assert.equal(m.listProjects().length, 0);
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("a new path given via a symlink is stored canonicalised", async () => {
+ // `settingsForPath` and the router's decision map are both keyed by path, so an
+ // uncanonicalised value would mean the repo's own settings stop resolving for it.
+ // The symlink must point at a DIFFERENT directory: aliasing the project's own path
+ // is the no-op case and would prove nothing about the stored value.
+ const from = repoWithBranches([]);
+ const to = repoWithBranches([]);
+ const link = join(mkdtempSync(join(tmpdir(), "makit-link-")), "alias");
+ symlinkSync(to, link);
+ try {
+ const m = manager([{ id: "a", path: from, settings: { provider: "gitea" } }]);
+ assert.equal((await m.repointProject("a", link)).ok, true);
+ const stored = m.listProjects()[0].path;
+ assert.equal(stored, realpathSync(to), "stored resolved, not as the alias");
+ assert.equal(m.providerFor(stored), "gitea", "and its settings still resolve");
+ } finally {
+ rmSync(from, { recursive: true, force: true });
+ rmSync(to, { recursive: true, force: true });
+ }
+});
+
+test("re-pointing at an equivalent spelling of the same directory changes nothing", async () => {
+ // `/tmp/x` and `/private/tmp/x` are one directory on macOS. Treating that as a
+ // move would re-run detection for no reason and report a path the store does not
+ // hold, so the no-op reports what is actually in force.
+ const a = repoWithBranches([]);
+ const alias = join(mkdtempSync(join(tmpdir(), "makit-alias-")), "same");
+ symlinkSync(a, alias);
+ try {
+ const m = manager([{ id: "a", path: a }]);
+ const r = await m.repointProject("a", alias);
+ assert.equal(r.ok, true);
+ assert.equal(r.ok && r.path, m.listProjects()[0].path, "reports the path in force");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("re-pointing re-runs detection rather than keeping the old forge decision", async () => {
+ // D4' names this: the forge and the default branch may both change with the move,
+ // so a cached decision for the OLD path must not be what the UI reports.
+ const from = repoWithBranches([]);
+ const to = repoWithBranches([]);
+ const forgotten: string[] = [];
+ try {
+ const m = manager([{ id: "a", path: from }]);
+ Object.assign((m as unknown as { _gateway: object })._gateway, {
+ forgetRepo: (p: string) => forgotten.push(p),
+ });
+ assert.equal((await m.repointProject("a", to)).ok, true);
+ assert.deepEqual(forgotten, [from], 'keyed on the path the gateway was called with');
+ } finally {
+ rmSync(from, { recursive: true, force: true });
+ rmSync(to, { recursive: true, force: true });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// SPEC-48 — "New worktree from PR" has to work on BOTH providers.
+//
+// Listing already routed through the gateway, so the picker showed Forgejo PRs
+// correctly. The CHECKOUT did not: it ran `gh pr checkout` unconditionally, which
+// speaks only to GitHub. The flow was therefore broken exactly halfway — the user
+// saw their PRs, picked one, and the worktree never appeared.
+//
+// The strategy is chosen from the router's own decision, so it agrees with whichever
+// provider actually served the list.
+// ---------------------------------------------------------------------------
+
+test("a GitHub repo checks out via gh, preserving today's behaviour", async () => {
+ const a = repoWithBranches([]);
+ try {
+ const m = managerWithInspector([{ id: "a", path: a }], {
+ forgeFor: () => ({ software: "github", host: "github.com", source: "detected" }),
+ hasRemoteFor: () => true,
+ });
+ assert.equal(m.prCheckoutStrategyFor(a), "gh");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("a Forgejo repo checks out via the pull ref, because gh cannot reach it", async () => {
+ const a = repoWithBranches([]);
+ try {
+ const m = managerWithInspector([{ id: "a", path: a }], {
+ forgeFor: () => ({ software: "forgejo", host: "git.example", authed: true, source: "detected" }),
+ hasRemoteFor: () => true,
+ });
+ assert.equal(m.prCheckoutStrategyFor(a), "pull-ref");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("a Gitea repo does the same — one REST API, one checkout path", async () => {
+ const a = repoWithBranches([]);
+ try {
+ const m = managerWithInspector([{ id: "a", path: a }], {
+ forgeFor: () => ({ software: "gitea", host: "git.example", authed: true, source: "detected" }),
+ hasRemoteFor: () => true,
+ });
+ assert.equal(m.prCheckoutStrategyFor(a), "pull-ref");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("an OVERRIDE to Forgejo also changes how the PR is checked out", async () => {
+ // The override's whole purpose is a repo detection could not identify. If it moved
+ // the listing but not the checkout, "New worktree from PR" would still fail for
+ // exactly the repos the override exists to rescue.
+ const a = repoWithBranches([]);
+ try {
+ const m = managerWithInspector([{ id: "a", path: a, settings: { provider: "forgejo" } }], {
+ // Detection reports the unidentifiable case; the override is what decides.
+ forgeFor: () => ({ software: "forgejo", host: "priv.example", authed: true, source: "override" }),
+ hasRemoteFor: () => true,
+ });
+ assert.equal(m.prCheckoutStrategyFor(a), "pull-ref");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("an unrouted or unreadable repo falls back to gh, the status quo", async () => {
+ // Same rule the router itself follows for an unreadable remote: don't change where
+ // such a repo fails.
+ const a = repoWithBranches([]);
+ try {
+ const m = manager([{ id: "a", path: a }]);
+ assert.equal(m.prCheckoutStrategyFor(a), "gh");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("the provider override wins over a stale detection record", async () => {
+ // Belt and braces: the strategy is read from the same override the router honours,
+ // so it cannot disagree with the gateway that served the list even if the decision
+ // record is behind.
+ const a = repoWithBranches([]);
+ try {
+ const m = managerWithInspector([{ id: "a", path: a, settings: { provider: "github" } }], {
+ forgeFor: () => ({ software: "forgejo", host: "old.example", authed: true, source: "detected" }),
+ hasRemoteFor: () => true,
+ });
+ assert.equal(m.prCheckoutStrategyFor(a), "gh", "the user said GitHub");
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+test("the picker itself routes per repo: Forgejo and GitHub side by side", async () => {
+ // The end of the chain the user actually touches: `manager.listOpenPrs` is what the
+ // `worktree.prs` command calls. Two repos in ONE manager, so this cannot pass by a
+ // global default -- which is the way a routing bug usually hides.
+ const served: string[] = [];
+ const fj = repoWithBranches([]);
+ const gh = repoWithBranches([]);
+ const stub = (name: string) => ({
+ prForBranch: async () => ({ kind: "none" }) as never,
+ openPrs: async () => {
+ served.push(name);
+ return [];
+ },
+ mutatePr: async () => ({ ok: true }),
+ stats: () => ({ execs: 0, exemptExecs: 0, cacheHits: 0 }),
+ close: () => {},
+ });
+ try {
+ const router = createForgeRouter({
+ github: {
+ ...stub("github"),
+ budget: () => ({}) as never,
+ history: () => [],
+ refresh: async () => ({}) as never,
+ setPaused: () => {},
+ onBudgetChange: () => () => {},
+ } as never,
+ forgejo: stub("forgejo") as never,
+ unsupported: stub("unsupported") as never,
+ none: stub("none") as never,
+ // Wired to the manager below, exactly as production wires it.
+ providerFor: (p) => m.providerFor(p),
+ resolveInstance: async (p) => ({
+ host: p === gh ? "github.com" : "git.example",
+ baseUrl: "https://git.example",
+ }),
+ detect: async () => "unknown" as never,
+ });
+ const m = manager([
+ { id: "fj", path: fj, settings: { provider: "forgejo" } },
+ { id: "gh", path: gh },
+ ]);
+ Object.assign((m as unknown as { _gateway: object })._gateway, router);
+
+ await m.listOpenPrs("fj");
+ await m.listOpenPrs("gh");
+ assert.deepEqual(served, ["forgejo", "github"], "each repo's PRs came from its own provider");
+ } finally {
+ rmSync(fj, { recursive: true, force: true });
+ rmSync(gh, { recursive: true, force: true });
+ }
+});
+
+test("a repo set to None offers no PRs, so the picker cannot reach a checkout", async () => {
+ // The checkout strategy for `none` is moot only because the list is empty. Asserted
+ // rather than assumed: if None ever returned PRs, the user could pick one and the
+ // checkout would run against a forge they told makit to ignore.
+ const a = repoWithBranches([]);
+ try {
+ const router = createForgeRouter({
+ github: {
+ prForBranch: async () => ({ kind: "none" }) as never,
+ openPrs: async () => [{ number: 1, title: "t", headRefName: "h", isDraft: false, url: "u" }],
+ mutatePr: async () => ({ ok: true }),
+ stats: () => ({ execs: 0, exemptExecs: 0, cacheHits: 0 }),
+ close: () => {},
+ budget: () => ({}) as never,
+ history: () => [],
+ refresh: async () => ({}) as never,
+ setPaused: () => {},
+ onBudgetChange: () => () => {},
+ } as never,
+ forgejo: {} as never,
+ unsupported: {} as never,
+ none: createNoForgeGateway(),
+ providerFor: (p) => m.providerFor(p),
+ resolveInstance: async () => ({ host: "github.com", baseUrl: "https://github.com" }),
+ detect: async () => "unknown" as never,
+ });
+ const m = manager([{ id: "a", path: a, settings: { provider: "none" } }]);
+ Object.assign((m as unknown as { _gateway: object })._gateway, router);
+ assert.deepEqual(await m.listOpenPrs("a"), []);
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Review findings on the P2 work itself.
+// ---------------------------------------------------------------------------
+
+test("addProject will not open one directory twice under two spellings", async () => {
+ // Found while reviewing `repointProject`: that method refuses a duplicate path
+ // using a CANONICAL comparison, but `addProject` compared with `resolve` only,
+ // which does not follow symlinks. So the state repointProject carefully forbids —
+ // two projects at one directory, where settings and the forge decision are both
+ // looked up BY PATH and therefore answer for each other — was still reachable by
+ // the ordinary "add a project" route, and the two entry points disagreed about
+ // what counts as the same repo.
+ const real = repoWithBranches([]);
+ const link = join(mkdtempSync(join(tmpdir(), "makit-addlink-")), "alias");
+ symlinkSync(real, link);
+ try {
+ const m = manager([]);
+ const first = m.addProject(real);
+ const second = m.addProject(link);
+ assert.equal(second.id, first.id, "the same directory is the same project");
+ assert.equal(m.listProjects().length, 1);
+ } finally {
+ rmSync(real, { recursive: true, force: true });
+ }
+});
+
+test("a project reached by its canonical path still resolves its own settings", async () => {
+ // Review finding: `addProject` stores the RESOLVED (not canonicalised) spelling,
+ // while `settingsForPath` compared with `resolve` on both sides. A project added via
+ // a symlinked path therefore failed lookup when a caller supplied the canonical
+ // path, and `worktreeRootFor`, `providerFor` and `defaultBranchFor` all silently
+ // fell back to the defaults -- the override present on disk and shown in the UI,
+ // doing nothing.
+ const real = repoWithBranches([]);
+ // The parent is retained so it can be removed too: only the symlink inside it was
+ // being cleaned up, leaving one empty temp directory per run.
+ const linkParent = mkdtempSync(join(tmpdir(), "makit-slink-"));
+ const link = join(linkParent, "alias");
+ symlinkSync(real, link);
+ try {
+ const m = manager([{ id: "a", path: link, settings: { provider: "gitea" } }]);
+ assert.equal(m.providerFor(link), "gitea", "found by the stored spelling");
+ assert.equal(
+ m.providerFor(realpathSync(real)),
+ "gitea",
+ "and by the canonical one, which is what git hands back",
+ );
+ } finally {
+ rmSync(linkParent, { recursive: true, force: true });
+ rmSync(real, { recursive: true, force: true });
+ }
+});
+
+test("an invalid override falls back WITHOUT mislabelling an environment root", async () => {
+ // Review finding: the fallback re-resolved correctly and then overwrote the source
+ // with "default". `SettingSourceDTO` exists so the app labels the origin rather than
+ // guessing, so with MAKIT_WORKTREE_DIR set the badge stated the wrong one.
+ const a = repoWithBranches([]);
+ const envRoot = mkdtempSync(join(homedir(), ".makit-test-env-"));
+ const prev = process.env.MAKIT_WORKTREE_DIR;
+ process.env.MAKIT_WORKTREE_DIR = envRoot;
+ try {
+ const m = manager([{ id: "a", path: a, settings: { worktreeRoot: "/etc/nope" } }]);
+ const [repo] = await m.listRepos({ includePrs: false });
+ assert.equal(repo.settings?.worktreeRoot.value, envRoot);
+ assert.equal(repo.settings?.worktreeRoot.source, "environment");
+ } finally {
+ if (prev === undefined) delete process.env.MAKIT_WORKTREE_DIR;
+ else process.env.MAKIT_WORKTREE_DIR = prev;
+ rmSync(a, { recursive: true, force: true });
+ rmSync(envRoot, { recursive: true, force: true });
+ }
+});
diff --git a/server/src/server.ts b/server/src/server.ts
index 894e6850..3d506455 100644
--- a/server/src/server.ts
+++ b/server/src/server.ts
@@ -64,6 +64,7 @@ import { register as registerSessionCommands } from "./ws/commands/session.js";
import { register as registerProjectCommands } from "./ws/commands/project.js";
import { register as registerWorktreeCommands } from "./ws/commands/worktree.js";
import { register as registerRepoCommands } from "./ws/commands/repo.js";
+import { register as registerRepoSettingsCommands } from "./ws/commands/repo_settings.js";
import { register as registerGithubCommands } from "./ws/commands/github.js";
import { register as registerMetricsCommands } from "./ws/commands/metrics.js";
import { register as registerDebugCommands } from "./ws/commands/debug.js";
@@ -73,7 +74,7 @@ import { watchWorktrees } from "./worktree_watcher.js";
import { watchPrs } from "./pr_watcher.js";
import { watchBudget } from "./github/budget_watch.js";
import { fetchOpenPr } from "./git.js";
-import { decide } from "./github/policy.js";
+import { forgePollIntervalMs } from "./forge/cadence.js";
import { attachMediaRoute } from "./media/route.js";
import { sharedMediaStore } from "./media/store.js";
import {
@@ -357,7 +358,7 @@ export function startWsServer(opts: ServerOpts) {
// quota burn (≥2N calls every 5s); feeding the policy's pollIntervalMs lets
// the 5s→30s→120s→paused ladder actually take effect. `Infinity` (paused)
// stops polling rather than busy-looping.
- intervalMs: () => decide(gateway.budget()).pollIntervalMs,
+ intervalMs: () => forgePollIntervalMs(gateway),
});
https.on("close", () => prWatcher.close());
@@ -783,6 +784,14 @@ export function startWsServer(opts: ServerOpts) {
stopForward: (grantId: string, deviceId?: string) =>
void forwardGrants.stop(grantId, ownerOf(deviceId)),
rescanPorts: () => void portsService.rescanNow(),
+ // A per-repo settings write or a re-point changed the projects. BOTH snapshots
+ // go out: `repos.snapshot` carries the settings, and `projects.snapshot`
+ // carries `path`/`name`, which a re-point also changes -- sending only the
+ // first left every client showing the old location.
+ onProjectsChanged: () => {
+ broadcastSnapshots();
+ void broadcastReposSnapshot();
+ },
},
registry,
);
@@ -929,7 +938,7 @@ export function startWsServer(opts: ServerOpts) {
// -------- command handlers (OCP registry) -------------------------------
// (registration is delegated to the module-level `buildCommandRouter` so the
- // capability-map completeness test can build the real router — see below.)
+ // capability-map completeness test can build the real router -- see below.)
// -------- session fan-out + snapshots -----------------------------------
@@ -1128,6 +1137,7 @@ export function buildCommandRouter(
registerProjectCommands(r, deps);
registerWorktreeCommands(r, deps);
registerRepoCommands(r, deps);
+ registerRepoSettingsCommands(r, deps);
registerGithubCommands(r, deps);
registerMetricsCommands(r, deps);
registerPortsCommands(r, deps);
diff --git a/server/src/ws/commands/deps.ts b/server/src/ws/commands/deps.ts
index ac747e71..cfb607b5 100644
--- a/server/src/ws/commands/deps.ts
+++ b/server/src/ws/commands/deps.ts
@@ -35,6 +35,13 @@ export interface CommandDeps {
readonly budgetWatch: BudgetWatch;
/** Re-send the projects + sessions snapshots to every authed client. */
broadcastSnapshots(): void;
+ /**
+ * Called when per-repo settings change, so every client re-renders from one
+ * source. REQUIRED, for the same reason `onPortsWatchersChanged` is: a router built
+ * without it acks a settings write and then no client re-renders, which is
+ * indistinguishable from the write being lost. `server.ts` always supplies it.
+ */
+ onProjectsChanged(): void;
/** Recompute + broadcast the repo-centric snapshot (git-only then PR-enriched). */
broadcastReposSnapshot(): Promise;
/** Broadcast the current GitHub budget to every authed client (SPEC-32). */
diff --git a/server/src/ws/commands/repo_settings.ts b/server/src/ws/commands/repo_settings.ts
new file mode 100644
index 00000000..d81b3614
--- /dev/null
+++ b/server/src/ws/commands/repo_settings.ts
@@ -0,0 +1,204 @@
+/**
+ * Per-repo settings commands (SPEC-48).
+ *
+ * `repo.settings.set` is the only write, and it is **gated on a loopback
+ * connection**. That is not a UI convention — it is checked here, on the server,
+ * against `WsClient.isLocal`, which `server.ts` derives from the socket's real
+ * remote address. The precedent is SPEC-37 decision 6, where the same flag already
+ * refuses a non-loopback client's reported pid: *"a non-loopback client must
+ * connect normally but may not ask us to sample an arbitrary pid."*
+ *
+ * The reason is concrete: `worktreeRoot` is a path the daemon **creates
+ * directories under and, via prune, removes**. A paired phone that could set it
+ * arbitrarily would be directing host filesystem operations at a path of its
+ * choosing. Reads are unrestricted; writes are host-only.
+ *
+ * A refusal is an explicit error, never a silent no-op — a settings row that
+ * appears to save and does not is worse than one that says it cannot.
+ */
+
+import { WireErrorCode } from "../../protocol/codec.js";
+import type { CommandRouter } from "../command_router.js";
+import type { CommandDeps } from "./deps.js";
+import {
+ LOGO_HUE_COUNT,
+ validateBranch,
+ validateProvider,
+ validateWorktreeRoot,
+ type RepoSettings,
+} from "../../repo_settings.js";
+
+/** Fields a client may write, and how each is validated. */
+type Patch = Partial>;
+
+/**
+ * The keys a client may write. Checked BEFORE the value, because the clear-a-setting
+ * branch used to run first and so skipped this rule entirely for a `null` value:
+ * `{wroktreeRoot: null}` was acked and the typo written into the patch. A settings
+ * write that silently stores a misspelling is worse than one that refuses, because
+ * the user believes the setting exists.
+ *
+ * A `Set` rather than a property test, so inherited names (`__proto__`,
+ * `constructor`) are not keys — assigning `applied["__proto__"]` invokes the
+ * prototype setter instead of creating an own property.
+ */
+const WRITABLE_KEYS: ReadonlySet = new Set([
+ "worktreeRoot",
+ "provider",
+ "defaultBranch",
+ "logoHue",
+]);
+
+export function register(r: CommandRouter, deps: CommandDeps): void {
+ const { manager, onProjectsChanged } = deps;
+
+ r.register("repo.settings.set", (ctx) => {
+ if (!ctx.client.isLocal) {
+ ctx.err(
+ // No `forbidden` code exists on this wire; `unauthorized` is the closest
+ // honest one and the app already renders it as a refusal.
+ WireErrorCode.Unauthorized,
+ "Repository settings can only be changed on the machine running makit.",
+ );
+ return;
+ }
+
+ const projectId = typeof ctx.env.projectId === "string" ? ctx.env.projectId : "";
+ if (projectId.length === 0) {
+ ctx.err(WireErrorCode.BadRequest, "projectId is required.");
+ return;
+ }
+ const patch = ctx.env.settings;
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
+ ctx.err(WireErrorCode.BadRequest, "settings must be an object.");
+ return;
+ }
+
+ const applied: Record = {};
+ for (const [key, raw] of Object.entries(patch as Patch)) {
+ // The key rule comes first, so it applies to a clear as well as to a write.
+ if (!WRITABLE_KEYS.has(key)) {
+ ctx.err(WireErrorCode.BadRequest, `Unknown setting '${key}'.`);
+ return;
+ }
+ // `null` clears a setting: absent means inherit, so clearing is how the UI
+ // says "go back to inheriting" without inventing a sentinel value.
+ if (raw === null) {
+ applied[key] = null;
+ continue;
+ }
+ switch (key) {
+ case "worktreeRoot": {
+ if (typeof raw !== "string") {
+ ctx.err(WireErrorCode.BadRequest, "worktreeRoot must be a string.");
+ return;
+ }
+ const v = validateWorktreeRoot(raw);
+ if (!v.ok) {
+ ctx.err(WireErrorCode.BadRequest, v.error);
+ return;
+ }
+ applied.worktreeRoot = v.value;
+ break;
+ }
+ case "provider": {
+ const v = validateProvider(raw);
+ if (!v.ok) {
+ ctx.err(WireErrorCode.BadRequest, v.error);
+ return;
+ }
+ // `auto` is the default, so it is stored as absence rather than as a
+ // value — otherwise "believe detection" and "no opinion" would differ on
+ // disk while meaning the same thing.
+ applied.provider = v.value === "auto" ? null : v.value;
+ break;
+ }
+ case "defaultBranch": {
+ if (typeof raw !== "string") {
+ ctx.err(WireErrorCode.BadRequest, "defaultBranch must be a string.");
+ return;
+ }
+ const v = validateBranch(raw);
+ if (!v.ok) {
+ ctx.err(WireErrorCode.BadRequest, v.error);
+ return;
+ }
+ applied.defaultBranch = v.value;
+ break;
+ }
+ case "logoHue": {
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0 || raw >= LOGO_HUE_COUNT) {
+ ctx.err(
+ WireErrorCode.BadRequest,
+ `logoHue must be an integer from 0 to ${LOGO_HUE_COUNT - 1}.`,
+ );
+ return;
+ }
+ applied.logoHue = raw;
+ break;
+ }
+ default:
+ // Unreachable: `WRITABLE_KEYS` already rejected anything not listed above.
+ // Kept so adding a key to that set without handling it here fails loudly
+ // rather than silently storing an unvalidated value.
+ ctx.err(WireErrorCode.BadRequest, `Unknown setting '${key}'.`);
+ return;
+ }
+ }
+
+ const ok = manager.updateProjectSettings(projectId, applied);
+ if (!ok) {
+ ctx.err(WireErrorCode.BadRequest, `No project ${projectId}.`);
+ return;
+ }
+ ctx.ack();
+ // `updateProjectSettings` has already persisted. Re-broadcast so every client
+ // re-renders from one source rather than from whatever it optimistically
+ // assumed — including the client that just wrote.
+ onProjectsChanged?.();
+ });
+
+ /**
+ * Re-point a project at a new root path (D4′).
+ *
+ * A separate command from `repo.settings.set`, not a field in its patch, because
+ * it is not a setting: it mutates the project record, must re-validate that the
+ * target is a git repository, and re-runs forge detection. Folding an async
+ * filesystem-and-subprocess check into a loop that validates plain values would
+ * also break that loop's all-or-nothing property — one bad field would abort a
+ * move that had already happened.
+ *
+ * Same loopback gate, for a sharper reason than the worktree root: this names the
+ * directory every session's git commands run in.
+ */
+ r.register("repo.path.set", async (ctx) => {
+ if (!ctx.client.isLocal) {
+ ctx.err(
+ WireErrorCode.Unauthorized,
+ "A repository's path can only be changed on the machine running makit.",
+ );
+ return;
+ }
+ const projectId = typeof ctx.env.projectId === "string" ? ctx.env.projectId : "";
+ if (projectId.length === 0) {
+ ctx.err(WireErrorCode.BadRequest, "projectId is required.");
+ return;
+ }
+ const path = typeof ctx.env.path === "string" ? ctx.env.path : "";
+ if (path.length === 0) {
+ ctx.err(WireErrorCode.BadRequest, "path is required.");
+ return;
+ }
+
+ const result = await manager.repointProject(projectId, path);
+ if (!result.ok) {
+ // Verbatim: the reasons are actionable ("not a git repository", "already open
+ // as X"), and a generic message would discard the only part the user can act
+ // on.
+ ctx.err(WireErrorCode.BadRequest, result.error);
+ return;
+ }
+ ctx.ack();
+ onProjectsChanged?.();
+ });
+}
diff --git a/server/test/ws/agents_catalog.test.ts b/server/test/ws/agents_catalog.test.ts
index c6356853..8ffbb1a5 100644
--- a/server/test/ws/agents_catalog.test.ts
+++ b/server/test/ws/agents_catalog.test.ts
@@ -65,7 +65,10 @@ function routerWith(manager: Partial): { router: Command
onPortsWatchersChanged: () => {},
sendPortsSnapshot: () => {},
...docsDepsStub,
- ...portsDepsStub,
+ // Required since a settings write that acks without re-broadcasting is
+ // indistinguishable from a lost write; this harness observes neither.
+ onProjectsChanged: () => {},
+ ...portsDepsStub,
askDevice: async () => ({}) as Envelope,
} satisfies CommandDeps;
register(router, deps);
diff --git a/server/test/ws/pr_commands.test.ts b/server/test/ws/pr_commands.test.ts
index 5e42915f..2fee15fd 100644
--- a/server/test/ws/pr_commands.test.ts
+++ b/server/test/ws/pr_commands.test.ts
@@ -65,6 +65,9 @@ function routerWith(manager: Partial) {
onPortsWatchersChanged: () => {},
sendPortsSnapshot: () => {},
...docsDepsStub,
+ // Required since a settings write that acks without re-broadcasting is
+ // indistinguishable from a lost write; this harness observes neither.
+ onProjectsChanged: () => {},
...portsDepsStub,
askDevice: async () => ({}) as Envelope,
} satisfies CommandDeps;
diff --git a/server/test/ws/repo_settings_commands.test.ts b/server/test/ws/repo_settings_commands.test.ts
new file mode 100644
index 00000000..07f15e01
--- /dev/null
+++ b/server/test/ws/repo_settings_commands.test.ts
@@ -0,0 +1,373 @@
+/**
+ * `repo.settings.set` — the only per-repo write, and the loopback gate on it.
+ *
+ * The gate is the point of this file. A paired phone that could set `worktreeRoot`
+ * would be directing host filesystem operations at a path of its choosing: the
+ * daemon creates directories under that root and, via prune, removes them. So the
+ * refusal is asserted here, on the server, against `WsClient.isLocal` — the same
+ * flag that already refuses a non-loopback client's reported pid (SPEC-37 D6).
+ */
+import { after, test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir, homedir } from "node:os";
+import { join, sep } from "node:path";
+
+import { CommandRouter } from "../../src/ws/command_router.js";
+import { register } from "../../src/ws/commands/repo_settings.js";
+import type { CommandDeps } from "../../src/ws/commands/deps.js";
+import { portsDepsStub } from "./ports_deps_stub.js";
+import type { WsClient, OutgoingFrame } from "../../src/ws/client.js";
+import type { Envelope } from "../../src/protocol.js";
+
+type FakeClient = WsClient & { sent: OutgoingFrame[] };
+
+function fakeClient(isLocal: boolean): FakeClient {
+ const sent: OutgoingFrame[] = [];
+ return {
+ sent,
+ authed: true,
+ subscribed: new Set(),
+ watchingMetrics: false,
+ watchingPorts: false,
+ watchingDocs: false,
+ isLocal,
+ send: (frame) => sent.push(frame),
+ close: () => {},
+ };
+}
+
+const cmd = (fields: Partial