Skip to content

fix(gitea): key tea api errors on the HTTP status line, not the response shape - #3713

Merged
max-sixty merged 2 commits into
mainfrom
gitea-blank-error-message-shape
Aug 2, 2026
Merged

max-sixty merged 2 commits into
mainfrom
gitea-blank-error-message-shape

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

tea api copies the response body to stdout and exits 0 whatever the HTTP status, so both Gitea call sites guessed from the body: a message key meant the request had failed, anything else was the resource. tea api --include writes the status line and headers to stderr and leaves stdout the bare body, so the status is available after all.

Availability

--include shipped with the api subcommand itself in v0.12.0 — cmd/api.go doesn't exist at v0.11.1 — and the implementation is byte-identical through v0.15.1. A tea old enough to lack the flag has no api to send it to, so nothing that can make this call rejects it. tea's own api_test.go passes flags ahead of the endpoint ({"-d", "@file", "/test"}), which settles the argument order.

What the status buys

Two things the shape check couldn't do:

A body Gitea didn't write is now an error. A reverse proxy's HTML page has no message key, so it used to read as the resource — and wt list then logged Failed to parse tea api pulls JSON, blaming a Gitea API change for a gateway that never reached Gitea. Verified by restoring the old key and re-running: proxy_page_is_an_error fails against it and passes with the status.

Retriability stops being a text sniff. is_retriable_error was looking for 429 or connection somewhere in Gitea's prose, which never carries the code. 429 and 5xx are now the server saying "later"; every other 4xx is a token or a missing resource that repeating the call won't change. gh and glab still sniff because they bury the status inside prose.

Visible change: a blanked 500 shows the error indicator where it used to paint the same blank cell as a healthy branch with no CI. gitea_blanked_500 and gitea_not_found pin both halves of that contrast.

The body's three states

The status decides; the body only supplies text, and its three states are three different things to say — so api_error_message reports all three rather than flattening them. The switch path prints the code in its headline:

✗ Gitea API error 404 for PR #9999 on owner/test-repo: pull request does not exist [index: 9999]
✗ Gitea API error 500 for PR #101 on owner/test-repo, but the response carried no message — Gitea hides 5xx messages from non-admin tokens
✗ Gitea API error 502 for PR #101 on owner/test-repo, and the response body is not one Gitea sends

An exit-0 tea that writes no status line is a backstop rather than a fall-through: --include prints the moment the response does, so its absence means nothing arrived, and reading stdout as the resource there is the failure the status line exists to prevent. switch_pr_gitea_no_status_line covers it.

With the CI path no longer reading the message, api_error_message drops to module-private.

Tests

Every mock tea response now carries the two streams a real one writes, via tea_api_include_stderr. setup_mock_tea takes the status explicitly, so each test states the HTTP response it stands for instead of leaving it implied — which also collapses four hand-rolled inline mocks into it.

Cost

--include writes the whole header block, not just the status line, so captured stderr now holds whatever the server returns — a Set-Cookie among it, on a Gitea that issues one. That reaches disk only under -vv, which logs every subprocess's output to subprocess.log. There's no narrower flag, and the status isn't available any other way; it's noted in the module docs.

🤖 Generated with Claude Code

This was written by Claude Code on behalf of Maximilian

max-sixty and others added 2 commits August 2, 2026 12:02
`tea api` copies the body to stdout and exits 0 whatever the HTTP status,
so both Gitea call sites guessed from the body: a `message` key meant the
request had failed, anything else was the resource. That reads a body
written in front of Gitea — a reverse proxy's error page — as a resource,
and `wt list` then blamed a Gitea API change for a gateway that never
reached Gitea.

`tea api --include` writes the status line and headers to stderr, leaving
stdout the bare body, so the status is available after all. It shipped
with the `api` subcommand itself in tea v0.12.0 and is unchanged since:
a `tea` old enough to lack `--include` has no `api` to send it to. The
flag also settles the ordering question — tea's own tests pass flags
ahead of the endpoint.

The status now decides, and the body only supplies text. Its three
states are three things to say, so `api_error_message` reports all
three rather than flattening: Gitea's message, Gitea's envelope blanked
for a non-admin token, and a body Gitea didn't write. The switch path
gains the code in its headline (`Gitea API error 404 for PR #9999 …`).

The CI cell is unchanged — an error with no text is still non-retriable,
so a blanked 500 still renders blank.

One cost: `--include` writes the whole header block, so captured stderr
now holds whatever the server returns, a `Set-Cookie` among it. That
reaches disk only under `-vv`. There is no narrower flag, and the status
is not available any other way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the status line in hand, the CI backend was still asking
`is_retriable_error` to find "429" or "connection" somewhere in Gitea's
prose. Gitea never writes the code into the message, so the sniff was
answering from whatever the text happened to contain — and a 500 whose
message Gitea blanked for a non-admin token left nothing to match at
all, so a server error painted the same blank cell as a healthy branch
with no CI.

The status answers directly: 429 and 5xx are the server saying "later",
every other 4xx is a token or a missing resource that repeating the call
won't change. `gh` and `glab` keep sniffing because they bury the status
inside prose; Gitea no longer has to.

That leaves the message with no reader on this path — a CI cell has no
room for it, and it only ever reached the user as a boolean — so
`api_error_message` drops to module-private, serving the switch path
that does print it.

Visible change: a blanked 500 now shows the error indicator instead of a
blank cell. `gitea_blanked_500` and `gitea_not_found` pin both halves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@max-sixty
max-sixty marked this pull request as ready for review August 2, 2026 19:08
@max-sixty
max-sixty merged commit 305f8fc into main Aug 2, 2026
39 of 40 checks passed
@max-sixty
max-sixty deleted the gitea-blank-error-message-shape branch August 2, 2026 19:24
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
worktrunk 0.72.0

Created-by: HarmonybrewBot
Commit-by: HarmonybrewBot
Merged-by: HarmonybrewBot
Description: Created by `brew bump`

---

Created with `brew bump-formula-pr`.<details>
  <summary>release notes</summary>
  <pre>## Release Notes

### Improved

- **A host carrying a forge's name anywhere resolves to that forge again**: 0.71.0 required `github`, `gitlab`, or `gitea` as a whole dot-separated label, which read as an ownership check but wasn't one — an attacker controls their own DNS — while shutting out self-hosters with hyphenated names. `github-enterprise.acme.com`, `mygithub.com`, and the `github-personal` SSH alias classify again, so CI status, `wt switch --prs`, and `repo.provider` work with no config. ([#3673](max-sixty/worktrunk#3673))

- **One `[projects."…"]` entry can cover every repository on a host, and can set the forge**: A key containing `*` matches any run of characters, `/` included, so `[projects."git.company.example/*"]` covers a whole host; every matching entry applies, least- to most-specific. The table also gained `forge.platform` and `forge.hostname`, so a self-hosted host needs one entry here rather than a block in every repo. [Docs](https://worktrunk.dev/config/#user-project-specific-settings) ([#3701](max-sixty/worktrunk#3701), thanks @chrishas35 for the request and @witt-bit for the workspace-scoped case it partly serves)

- **`wt merge` and `wt step push` leave the target worktree's uncommitted changes in place**: The autostash that held a dirty target's changes restored them as unstaged; both now advance the target with a compare-and-swap `update-ref` and `read-tree -m -u`, which leaves uncommitted work untouched. (Breaking: the fast-forward path no longer runs `git push`, so `pre-push` and the receive-side hooks no longer fire, and a failed sync errors with the ref rolled back.) ([#3703](max-sixty/worktrunk#3703), [#3684](max-sixty/worktrunk#3684), [#3693](max-sixty/worktrunk#3693), thanks @gubasso for reporting)

- **Approval state and branch-removal outcomes are machine-readable**: `wt config approvals list --format=json` reports whether a non-interactive run would stop for approval. `wt remove` and `wt step prune` replace `branch_deleted` with `branch_outcome`: `deleted`, `deferred`, `not_attempted`, `retained_unmerged`, `retained_checked_out`, `retained_raced`, `retained_failed`. (Breaking.) ([#3710](max-sixty/worktrunk#3710), thanks @NathanaelRea for the requests)

- **Every commit hash worktrunk prints follows `core.abbrev`**: The `wt list` table, `wt switch --prs`'s `log` tab, and `wt config state`'s CI cache table sliced to 8 characters while `--format=json` carried git's `%h`. All now ask git how wide it abbreviates in this repo. ([#3676](max-sixty/worktrunk#3676), [#3677](max-sixty/worktrunk#3677))

- **`wt list --format=json` schema 2 has a published JSON Schema**: [worktrunk.dev/schema/list-v2.json](https://worktrunk.dev/schema/list-v2.json) holds the contract, and `wt list --print-schema` prints the same document. Four fields that were bare strings are now enumerated vocabularies; the emitted JSON is unchanged. ([#3747](max-sixty/worktrunk#3747))

- **A detached worktree is named by its commit, not `-`**: The Branch cell hardcoded `-`, which reads as missing data rather than a state; it now carries the row's abbreviated HEAD, and the picker and statusline name the worktree the same way. ([#3675](max-sixty/worktrunk#3675))

### Fixed

- **Piped output is plain and no longer panics**: `wt list | head -3` exited 101 with `failed printing to stdout: Broken pipe`, and `wt list` wrote ANSI to a pipe unconditionally. Every stdout surface now exits cleanly, and the human-read ones are plain when piped unless `CLICOLOR_FORCE=1`. ([#3746](max-sixty/worktrunk#3746), [#3766](max-sixty/worktrunk#3766))

- **A CI check that hasn't finished no longer reads as passed**: Each forge's status parser missed documented values, so a GitHub PR parked on an approval gate showed green, and GitLab's `canceling` and Azure DevOps's `postponed` read as no CI at all. ([#3741](max-sixty/worktrunk#3741), [#3740](max-sixty/worktrunk#3740))

- **The shell wrappers survive an `rm` alias and a failing `--execute`**: Aliases bake into the wrapper at parse time, so `alias rm='rm -v'` reached its cleanup — noise on zsh and bash, and on nushell an abort that leaked three temp files, as a failing `--execute` body also did. ([#3714](max-sixty/worktrunk#3714), thanks @Ar4l), ([#3732](max-sixty/worktrunk#3732), [#3734](max-sixty/worktrunk#3734))

- **An alias or hook wrapping `wt switch` or `wt remove` keeps your subdirectory**: The user's position came from the `wt` process's cwd, which inside an alias body is the worktree root, so an aliased `wt remove` from `feature/apps/gateway` landed at the primary worktree's root. Fixes [#3723](max-sixty/worktrunk#3723). ([#3724](max-sixty/worktrunk#3724), thanks @vivienm for reporting)

- **`wt merge` and `wt step push` refuse a target worktree parked mid-operation**: The two-tree sync refuses an unmerged index but not a stopped cherry-pick or rebase whose conflict was already staged, so the push range could land in a paused target and be committed by `--continue`. ([#3759](max-sixty/worktrunk#3759))

- **The Claude plugin's worktree-remove hook resolves against the worktree path**: The hook anchored at `CLAUDE_PROJECT_DIR`, which the `claude agents` view routinely leaves outside any repository, so `wt remove` died with `not a git repository` and the session became undeletable. Its guard now also requires a `.git` entry. ([#3754](max-sixty/worktrunk#3754), [#3767](max-sixty/worktrunk#3767), thanks @judewang for the fix and the report)

- **A Gitea API error is reported as one, not as a parse failure**: `tea api` exits 0 whatever the HTTP status, so both call sites guessed from the body's shape and blamed an API change for an API error. `--include` surfaces the status instead. ([#3713](max-sixty/worktrunk#3713), [#3600](max-sixty/worktrunk#3600))

- **`--print-schema` and the doc-generation help flags name the right command**: All three found the subcommand by scanning `argv` for a `/wt` suffix, which never matches `wt.exe` under a backslash path, so on Windows they read the binary's own path as the command. ([#3762](max-sixty/worktrunk#3762))

- **A multibyte shell name no longer panics**: `extract_filename_from_path` sliced at `len() - 4` to test for `.exe` with no char-boundary check: `SHELL=/bin/日本語 wt config show` panicked, and on macOS every process name goes through it during shell detection. ([#3727](max-sixty/worktrunk#3727))

- **`wt` installed under a dotted name generates shell integration for that name**: `binary_name` used `file_stem`, which cuts at the last dot, so `wt config shell init bash` under `wt.old` emitted a wrapper for `wt`. It now strips only the executable suffix. ([#3719](max-sixty/worktrunk#3719))

- **Concurrent `wt step prune` removals no longer race the worktree registry**: `git worktree remove` reads every sibling under `.git/worktrees/`, so two overlapping removals could have one read a sibling mid-teardown. Registry-mutating removals now serialize behind a second lock. ([#3692](max-sixty/worktrunk#3692))

- **The `wt switch` first-run offer previews the legacy files it removes**: Accepting "Install shell integration?" could delete a deprecated worktrunk-managed wrapper the prompt never named. What gets removed is unchanged. ([#3656](max-sixty/worktrunk#3656))

- **`wt config create --project` writes a resolvable link**: The comment it writes into `.config/wt.toml` carried a raw Zola target, because the link-conversion regex stopped at the first `]` — here the one closing a nested code span. ([#3731](max-sixty/worktrunk#3731))

- **`wt list --branches` counts a local branch containing `/` as local**: The summary tally classified branch-only rows by `branch.contains('/')`, so a local `feature/login` counted under "N remote branches". ([#3687](max-sixty/worktrunk#3687))

- **`wt step relocate`'s human summary counts template-error branches as skipped**: `--format=json` already folded them into `skipped`; the human tally undercounted by the number of branches whose `worktree-path` template failed to expand. ([#3688](max-sixty/worktrunk#3688))

### Documentation

- **SignPath attribution appears with the artifacts it describes**: The code-signing notice and a route to the policy now sit in the install section's Windows block on the README and the docs landing page, as SignPath Foundation's OSS program requires. ([#3709](max-sixty/worktrunk#3709))

- **`wt step copy-ignored`'s `--require-include` example renders as a terminal block**: It was the only `console` block in the command's long help missing the `$ ` prefix. ([#3706](max-sixty/worktrunk#3706))

### Internal

- **Library API rework** (Breaking library API): `cargo-semver-checks` fails five lints — `LegacyForgeAlias` and `Repository::legacy_forge_alias` removed with the forge-classification revert, `Repository::forge_platform_override` removed for one shared resolver, `stage_worktree_removal` gained two parameters, `UserProjectOverrides` gained a `forge` field, and the `real-repo-benches` feature was removed. ([#3673](max-sixty/worktrunk#3673), [#3694](htt

See merge request: Harmonybrew/homebrew-core!16275
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants