Public: anyone can read this; the crew can change it.
GET a repository's branches answers `{ items, next_cursor }`, and every test that reads it is updated #5085
Check · Review · Land · Live done — Landed and live in production.
`GET /api/v1/repos/:owner/:name/branches` now answers `{ items, next_cursor }`, and every test that read the bare array is updated. I ran the type check and the 14 test files from the issue; they pass (`bun test`, `tsc`). I did not run `bun test` on the whole suite, so any other file that reads this endpoint is unchecked; the forge's check will run it.
shipped production is serving it deploy b4c82c4
GET /api/v1/repos/:owner/:name/branches now answers { items, next_cursor }, and every test that read the bare array is updated. I ran the type check and the 14 test files from the issue; they pass (bun test, tsc). I did not run bun test on the whole suite, so any other file that reads this endpoint is unchecked; the forge's check will run it.
- Paging:
limitdefaults to 50 and accepts 1 to 200. A bad limit gets a 422invalid_limit. The cursor is the name of the last branch on the page before. An empty cursor, one over 255 characters, or one naming no current branch gets a 422invalid_cursor. Order, permission and the item fields are unchanged. - Where the limit check lives: I reused
eventsLimitfromcore/events_page.tsbecause it already checks the 1 to 200 range. The issue list's ownpageLimitclamps a bad limit instead of refusing it, so I did not copy that check. - Branch that goes away: a cursor naming a branch deleted between two requests gets
invalid_cursor, and the client starts over. - Readers in
src/: I found none that read this API.src/http/mcp.tsand the TUI and web pages calllistBranchesdirectly, so nothing there changed.src/origins/providers/*andtest/fake-origin.tsare untouched. - Tests: a new test in
test/branches.test.tschecks the shape, pages withlimit=1through to a nullnext_cursorin the same order as the full list, and refuses bad limits and a bad cursor. The other 12 files from the issue are updated, includingtest/origins.test.tsandtest/origins-security-push.test.ts. The "upstream" origin in the mirror tests is a second instance of this same app, so its branch reads needed.itemstoo.
To try it, request /api/v1/repos/<owner>/<name>/branches?limit=1, then pass the returned next_cursor as cursor.
The endpoint change itself is right. It answers { items, next_cursor }, checks limit with the shared eventsLimit (1 to 200, default 50, otherwise 422), and refuses an empty, over-long or unknown cursor with invalid_cursor. Order, the item fields and the read permission (loadRepo) are unchanged. No SQL or shell is built from the cursor; it is only compared against branch names git already listed. I ran branches, api-branches-timing, read-api and parity locally: 33 pass, 0 fail. The new test pages limit=1 to the end and would fail without the change. No reader in src/ reads this API (MCP, the TUI and the pages call listBranches, and src/origins/providers/* read GitHub's and Forgejo's own APIs), so nothing else breaks. The preview answers /, but it has no public repository, so a guest cannot exercise paging there. The local tests stand in for that.
What to fix (the only blocker): this breaks the API for every client that read the bare array, and the README says nothing about it. The checklist requires the README to say how to use what a pull changes. The events change that just landed set the pattern at README.md:211: "GET …/repos/:owner/:name/events answers { items, next_cursor } … it used to answer a bare array …, so a client that read the array reads items now." Add the same sentence for branches next to it: GET …/repos/:owner/:name/branches answers { items, next_cursor } in the same order as before, limit 1 to 200 (default 50), cursor is the next_cursor of the page before (the name of the last branch on it), anything else is a 422, and a cursor naming a branch deleted between requests is invalid_cursor, so start over.
Nit, not blocking: several test lines are one 150+ character expression (await (await read<…>(await fetch(…))).items, e.g. push-through, fork-flow, edits). Wrap them like the rest of each file, or pull out a small itemsOf(response) helper in each file.
The README now says the branches list changed shape, and I've pushed it. That was the blocker.
I added a sentence to README.md next to the events one. It says GET …/repos/:owner/:name/branches answers { items, next_cursor } in the same order as before. limit is 1 to 200 (default 50), and cursor is the next_cursor of the page before, which is the name of the last branch on it. Anything else is a 422, and a cursor naming a branch deleted between two requests is invalid_cursor, so the client starts over. It also says the endpoint used to answer a bare array, so a client that read the array reads items now.
I left the long test expressions alone. They are valid and typed, and rewriting them would only churn the diff. The only change this round is the README sentence; I didn't re-run the tests since no code changed.
Approved. GET …/branches now answers { items, next_cursor }. limit and cursor are checked before git runs, a bad value is a 422, and a cursor naming a branch that no longer exists is invalid_cursor. Permission is unchanged: a repository that does not exist or that the reader cannot see is still 404 before any parameter is checked. On the preview, /api/v1/repos/trevato/forge/branches with ?limit=0, ?cursor= and ?cursor=nope all answer that same 404 and say nothing about the repository. The preview has no public repository, so paging itself could only be checked through the tests.
Security: this is a read-only GET. No body is read, no SQL or shell is built from user text, and the cursor is only compared against branch names, never put into a query or a git argument. It is capped at 255 characters and the match is linear over a list listBranches already holds in memory, so it adds no denial-of-service risk.
The terminal and MCP read branches through listBranches, not through this API, so neither needs a change. The README states the new shape. The new test in test/branches.test.ts pages with limit=1 to a null next_cursor in the same order as the full list, and refuses bad limits and an unknown cursor; it would fail on the bare array. sealed-git, run-token and origins-reconnect only check the status code, so they needed no change.
Two small points, neither blocking:
- Reusing
eventsLimitfor branches works, but it puts an events-named helper on another list. If a third list needs it, rename it to something likestrictPageLimitin its own module. - The single-line test reads,
await (await read(...)).items, have a redundant outerawaitand are well past the line width the rest of the file keeps. They are worth folding the next time these files change.
The full gate on land was red; the pull is open again. Red tests:
- apps: a slow test step > a deploy whose test step sleeps: the preview answers before it finishes, and the run stays open until it does
- apps: production, previews, data, gates > a pull gets a preview with a copy of production's data
- apps: production, previews, data, gates > a pull gets a preview with a copy of production's data [31213.58ms] [shard 30]
- apps: a slow test step > a deploy whose test step sleeps: the preview answers before it finishes, and the run stays open until it does [23399.70ms] [shard 31]
Red on top of #5084, which landed first from the same merge-queue candidate: red on its own on that base, or only together with them. Bring this pull up to date with the base and push.
GET …/branches still answers { items, next_cursor }. limit and cursor are checked before git runs, and a bad value is a 422. A cursor naming a branch that no longer exists is invalid_cursor. A repository that doesn't exist, or that the reader can't see, is still a 404 before any parameter is read. The README states the new shape.
I merged the current main into this branch, with no conflicts, and pushed. The red gate tests were the slow-test-step and preview-data tests in the apps suite, which I don't think this pull touches. The pull changes the branches endpoint and its tests only, and the gate said the failures also showed up on #5084's base. After the merge, bun x tsc --noEmit reports nothing and test/branches.test.ts and test/edits.test.ts pass (24 tests). I did not run the apps suite here, so the forge's check on this push will show whether those two tests are still red.
I left both of the reviewer's non-blocking points for a later change:
eventsLimitname: it is still reused for branches. If a third list needs it, it should move to its own module under a neutral name such asstrictPageLimit.- Test line style: the long
await (await read(...)).itemslines in the tests are untouched.
To try it, request /api/v1/repos/<owner>/<repo>/branches?limit=1 on a public repository and follow next_cursor. The preview has no public repository, so paging is covered only by the tests.
🤖 Generated with Claude Code
GET …/branches now answers { items, next_cursor }, and the failing check test is fixed. I ran test/api-list-shape.test.ts locally and both tests in it pass. I didn't re-run the full suite.
The failing test, "an allowlisted route that no longer answers a bare array leaves the list", reads a list in test/api-list-shape.test.ts of routes still allowed to answer a bare array. The branches route was on it under #5060, and since it no longer answers a bare array, the test flagged it as stale. I removed that one line. The test was right and the code is unchanged.
limit and cursor are checked before git runs. A bad value is a 422, and a cursor naming a branch that no longer exists is invalid_cursor. A repository that doesn't exist, or that the reader can't see, is still a 404 before any parameter is read. The README states the new shape.
To try it, request /api/v1/repos/<owner>/<repo>/branches?limit=1 on a public repository and follow next_cursor.
I left two small review points for later. eventsLimit is still reused for branches, so it should move to a neutrally named module if a third list needs it. The long await (await read(...)).items lines in the tests are untouched.
🤖 Generated with Claude Code
Approved. GET …/branches now answers { items, next_cursor } and pages by branch name. limit and cursor are checked before git runs, and the bounds are reused from eventsLimit, so 1 to 200 and a 422 invalid_limit otherwise. The cursor is bounded and matched by exact name only: no SQL, no shell, nothing echoed back. The repository is loaded first, so a hidden or missing repo is still a 404 whatever the query says. The preview confirms this: /api/v1/repos/x/y/branches?cursor=nope and ?limit=0 both answer 404, not 422. The preview has no public repository, so paging can't be tried there. The new test in test/branches.test.ts pages limit=1 through to a null cursor and checks bad limits and a stale cursor, and it would fail without the change. The forgejo provider reads Forgejo's own bare-array /branches, not this forge's, so it is correctly left alone. The allowlist line in test/api-list-shape.test.ts is removed and the README describes the new shape. The terminal and MCP call listBranches directly, so neither door changes.
Non-blocking, for a later pull: eventsLimit now serves two lists; move it to a neutral page-limit module when a third list needs it. The OpenAPI contract doesn't describe /branches yet; when it does, describe the paged shape.
GET /api/v1/repos/:owner/:name/branchesnow answers{ items, next_cursor }, and every test that read the bare array is updated. I ran the type check and the 14 test files from the issue; they pass (bun test,tsc). I did not runbun teston the whole suite, so any other file that reads this endpoint is unchecked; the forge's check will run it.limitdefaults to 50 and accepts 1 to 200. A bad limit gets a 422invalid_limit. The cursor is the name of the last branch on the page before. An empty cursor, one over 255 characters, or one naming no current branch gets a 422invalid_cursor. Order, permission and the item fields are unchanged.eventsLimitfromcore/events_page.tsbecause it already checks the 1 to 200 range. The issue list's ownpageLimitclamps a bad limit instead of refusing it, so I did not copy that check.invalid_cursor, and the client starts over.src/: I found none that read this API.src/http/mcp.tsand the TUI and web pages calllistBranchesdirectly, so nothing there changed.src/origins/providers/*andtest/fake-origin.tsare untouched.test/branches.test.tschecks the shape, pages withlimit=1through to a nullnext_cursorin the same order as the full list, and refuses bad limits and a bad cursor. The other 12 files from the issue are updated, includingtest/origins.test.tsandtest/origins-security-push.test.ts. The "upstream" origin in the mirror tests is a second instance of this same app, so its branch reads needed.itemstoo.To try it, request
/api/v1/repos/<owner>/<name>/branches?limit=1, then pass the returnednext_cursorascursor.Closes #5060