Commit Graph

34 Commits

Author SHA1 Message Date
Worktrunk Bot aa4e527f35 refactor(git): drop Branch::push_remote, unused since the @{push} fix (#3833)
Nightly sweep finding. `Branch::push_remote()` has had no callers —
production or test — since
[#769](https://github.com/max-sixty/worktrunk/pull/769) (Jan 2026),
which replaced its only caller in the CI-status path with
`push_remote_url()`. This drops it, and fixes a stale doc comment that
named it.

## Why it went unnoticed

It's `pub` on a public lib type, so rustc's `dead_code` lint never fires
on it. `git log -S` puts the last caller's removal in `c4f1c0730`
(#769): `gh pr checkout` sets `branch.<name>.pushremote` to a URL rather
than a remote name, and `@{push}` — which is what `push_remote()`
resolves through — fails in that case. `push_remote_url()` uses
`%(push:remotename)` instead, which handles both.

So the method isn't merely unused; its docstring still advertises the
`@{push}` resolution chain that #769 established is wrong for the case
the codebase actually hits, which makes it read as a live alternative to
the function that superseded it.

## The stale comment

`setup_push_tracking` in `tests/integration_tests/default_branch.rs` was
documented as existing so `branch.push_remote()` and `github_push_url()`
work. The first is what this PR deletes; the second has never existed
anywhere in the tree (`grep` finds that comment as its only occurrence).
Its four call sites all call `push_remote_url()`, so the comment now
names that.

## Verification

No regression test accompanies this — there's no behavior to pin, since
the deleted method had no callers to change the behavior of. The proof
is negative and the compiler carries it: `cargo build --all-targets` and
`cargo clippy --all-targets -- -D warnings` both pass, which they could
not if any call site remained.

Also ran the suites covering the touched area: `cargo test --test
integration default_branch` (63 passed) and `cargo test --lib
git::repository` (178 passed).

<details><summary>Confirming there are no callers</summary>

Every mention of the bare identifier in the tree before this change:

```
src/git/repository/branch.rs:151:    pub fn push_remote(&self) -> Option<String> {          # the definition
src/git/repository/branch.rs:186:                let push_remote = self                    # local var in push_remote_url
src/git/repository/branch.rs:197:                if push_remote.contains("://") ...        # same local
src/git/repository/branch.rs:198:                    Some(push_remote)                     # same local
src/git/repository/branch.rs:200:                    self.repo.effective_remote_url(...)   # same local
tests/integration_tests/default_branch.rs:439:  /// ... `branch.push_remote()` ...            # the stale comment
```

The lines in `push_remote_url` are a local binding of the same name, not
calls. `switch.rs:808` writes the `branch.<name>.pushRemote` git-config
key and is unrelated.

</details>

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-17 01:48:38 -07:00
Maximilian Roos dd453304ed test: fold the mock stub into the wt binary (#3712)
`cargo test --test integration` neither built nor rebuilt `mock-stub`: a
target filter deselects the dummy test that pulled it in, so a fresh
tree panicked ("mock-stub binary not found") and a warm one could run a
stale stub. The chain that compensated — the separate helper package,
its dummy `builds.rs`, the `default-members` entry, nextest's
experimental `build-bins` setup script, `workspace_bin()` — existed only
because cargo-dist ships every `[[bin]]`, and carried a TODO to collapse
once that changed. dist 0.30.2 does support per-binary exclusion now
(`[dist.binaries]`, since 0.29.0; verified with `dist plan`), but the
TODO's plan has a hole it predates: `cargo install worktrunk` installs
every feature-satisfied `[[bin]]`, and dist config doesn't govern
crates.io installs.

So the mock commands are now the `wt` binary itself. `mock_commands`
links `wt` under the mock's name (`gh`, `glab`, …), and `main()`
dispatches to the ported playback (`testing::mock_stub`) when
`WORKTRUNK_TEST_MOCK_CONFIG_DIR` is set, argv[0] is a foreign name, and
the config dir holds `<argv0>.json` for it. The existence check keeps wt
under a foreign argv[0] *without* a config being wt — the
argv0-validation security test symlinks it as `wt;touch` and must reach
wt's own rejection. The shipped binary already compiles the whole
`testing` module unconditionally, so this adds no new category of test
code to it. Windows links `wt.exe` with `hard_link` (copy fallback for
cross-drive dirs); the debug binary is ~67 MB, so per-mock copies stay
the fallback.

Every runner is now safe by construction — cargo rebuilds a package's
own binaries whenever its integration tests build, so there is no
separate artifact to go missing or stale. Deleted: the helper package,
the setup script plus `experimental = ["setup-scripts"]`, the
`default-members` trick, `workspace_bin()`, and `wt_bin()`'s dead
compile-time branch (unit-test targets get neither the runtime variable
nor the `option_env!` value, so the runtime resolution is the one
mechanism).

Validated locally: the pre-merge gate's full suite passes (4542/4542;
its doc step also caught unescaped `argv[0]` intra-doc links in the new
comments, fixed and `cargo doc -Dwarnings` re-verified). With all stub
artifacts purged from `target/`, plain `cargo test --test integration`
on mock-dependent tests builds them and passes — the command that used
to hit the trap. Two integration tests pin the dispatch's argv[0] edges
(an empty argv[0], and a non-UTF8 one), alongside the existing
`wt;touch` carve-out test.

The first commit is the investigation that preceded the fix: it verified
the `wt` binary itself was never subject to the staleness the mock-stub
was, and documented that in `tests/CLAUDE.md`; the fix then narrows that
paragraph further, since the gap it scoped no longer exists.

A two-reviewer subagent round (one prosecuting the diff against the
failure modes documented in the repo's own mock history — the #401/#407
Windows era, #547, #654, #127, #2544, #2730, #2744 — the other
adversarial) then hardened the dispatch. The reserved-name guard is
case-insensitive, matching the config probe, which goes through a
filesystem that equates `WT.json` with `wt.json` on macOS and Windows;
`command_name()` reads `args_os` — `env::args()` panics on a non-Unicode
argument, and this runs inside `main()` on every invocation (caught by
the tend review) — and returns `None` for a degenerate argv[0] instead
of panicking; the `.exe` suffix is stripped explicitly rather than via
`file_stem`, so a dotted mock name (`python3.11`) resolves identically
on every platform; and `copy_mock_binary` is now private —
`MockConfig::write` writes `<name>.json` before linking and is the only
way to create a mock, so a link cannot exist without its config, and the
dispatch's missing-config fall-through can only mean "wt under a foreign
name" (the argv0-validation tests' `wt;touch`), never a half-configured
mock that silently runs real wt with the mocked tool's arguments. The
`Option<&str>` mock helpers whose `None` arm produced exactly such
configless links lost the arm (every caller passed `Some`), and 25
redundant standalone link calls went with it.

The review also surfaced the one remaining spawn-a-stale-binary path
outside the suite: `wt-perf timeline` resolved a sibling `wt` by path,
checked only existence, and told the user to build it manually — so
`cargo run -p wt-perf -- timeline` after a `src/` edit silently measured
stale code. It now builds `wt` first and takes the artifact path from
cargo's `--message-format=json` report rather than deriving a sibling
location, so target-dir and profile overrides can't divert the build
away from where it's resolved; a release wt-perf builds and measures a
release wt. The build runs before the timeline's wall-clock measurement
starts, cargo's progress streams on stderr, and stdout keeps the
`--chrome` JSON contract.

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 22:12:21 -07:00
Maximilian Roos 305f8fcd19 fix(gitea): key tea api errors on the HTTP status line, not the response shape (#3713)
`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](https://claude.com/claude-code)

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:24:40 -07:00
Maximilian Roos 10e8bad2c5 fix(gitea): key the tea api error shape on the envelope, not on a non-empty message (#3600)
Gitea blanks a 500's message in production unless the token belongs to
an admin (`services/context/api.go`: `if setting.IsProd && !(ctx.Doer !=
nil && ctx.Doer.IsAdmin) { message = "" }`), so the body arrives as
`{"message":"","url":"…/api/swagger"}`. Both Gitea call sites required a
*non-empty* message before they would call a response an error, so that
body fell through to the data path:

- `wt list` logged `Failed to parse tea api pulls JSON`, once per branch
— blaming an API change for an API error. The CI cell was blank either
way, and the stderr layer is off at the default verbosity, so this
reached whoever was already debugging with `-v` or `RUST_LOG`.
- `wt switch pr:<n>` failed with `Failed to parse Gitea API response for
PR #N. This may indicate a Gitea API change.` — the same misattribution,
in the user's face.

## The key

The discriminator is now the presence of the `message` key, which is the
whole shape. `tea api` never reads the HTTP status — `runApi` copies the
body to stdout and returns nil regardless — so the body is the only
channel, and none of the resources read here carries a `message`:
verified against gitea `main` for `PullRequest`, `CombinedStatus`, and
`CommitStatus`.

`url` is deliberately *not* part of the key. The two mistakes aren't
symmetric: an error body that omits `url` would read as the resource,
which is the bug this key exists to prevent, while requiring it would
only guard against a resource one day growing a `message` field. Gitea
already ships error shapes without one (`APIInvalidTopicsError` is
`message` plus `invalidTopics`), and `CombinedStatus` *does* carry
`url`.

## Shape

One parser — `remote_ref::gitea::api_error_message` — now serves both
sites; the CI-status backend drops its duplicate struct, and the PR path
checks the envelope before the resource parse, so "may indicate a Gitea
API change" is reserved for a body that is neither. A blanked message
reaches the user as an error that says why there's no detail:

```
✗ Gitea API error for PR #101 on owner/test-repo, but the response carried no message — Gitea hides 500 messages from non-admin tokens
```

The CI cell for a blanked 500 stays blank, unchanged:
`is_retriable_error` is the one gate every backend uses to turn a
failure into the error indicator, and an empty message isn't retriable.
Only the misleading log line is gone.

## Tests

Extended rather than duplicated:
`test_tea_api_error_reads_the_response_shape` flips its blank-message
assertion (that assertion *was* the bug), a new
`test_api_error_message_reads_the_response_shape` pins the key against
every shape both sides see, and `switch_pr_gitea_blank_error_message`
snapshots the user-visible path alongside the existing 401/403/404
cases.

`test_list_full_gitea_parse_warning_is_reserved_for_unknown_bodies`
covers the PR-list side end to end. It drives `wt list --full` under
`RUST_LOG=warn` and reads stderr rather than snapshotting, since the
warning is a `tracing` record that the stderr layer suppresses at the
default verbosity (`-v` would turn it on but bury it under a template
expansion per worktree). A second case sends a body that is neither the
resource nor the envelope, where the warning does belong — that pins the
wording to unknown shapes and keeps the first case from passing merely
because nothing logs. Checked against the pre-fix key: the blank case
fails there with the misleading warning, the unknown case passes.

## Noted, not done

tea's `api` command has an `--include` flag that writes the status line
to stderr — a genuine structured channel that would beat shape-sniffing.
It's in tea's `main` source but in no released changelog entry, so
sending it would break every user whose `tea` predates it. It would also
compose with this change rather than replace it, since the body still
supplies the message text.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:31:01 -07:00
Maximilian Roos 6d09125b7b test: converge suite on semantic boundaries (#3663)
This follows the first test-simplification tranche by converging the
remaining suite around distinct semantic and pragmatic contracts rather
than raw case count. The branch removes false-confidence tests, invalid
setup variants, repetitive snapshots, and expensive PTY overlap while
strengthening the retained route, precondition, and interaction proofs.

## What changed

- Replace obsolete CI-status integration mocks and blank snapshots with
direct provider semantics, mixed-priority cases, and strict
GitHub/GitLab route assertions.
- Remove free-riding merge, push, remove, list, security, config, and
switch cases whose setup never reached the named behavior; consolidate
repetitive direct cases into labeled tables.
- Reduce the switch picker from 42 PTYs to 19 distinct terminal
contracts, using causal release gates for asynchronous loading and
repaint behavior.
- Add a cached main-only picker fixture, eliminating 138 unnecessary Git
subprocesses across the retained PTYs, and integrate it with main's
generated hermetic standard fixture.
- Tighten test guidance around proving setup preconditions and mock
invocation routes, and correct the comments-tab help text and generated
mirrors.

## Reviewer map

- `tests/integration_tests/ci_status.rs` and
`src/commands/list/ci_status/`: provider semantics and route coverage.
- `tests/integration_tests/switch_picker.rs`, `src/commands/picker/`,
and `src/testing/`: retained PTY contracts, causal mocks, and fixture
design.
- `tests/integration_tests/config_show.rs`, `src/config/deprecation.rs`,
`src/config/expansion.rs`, and worktree type/resolve tests:
direct-boundary consolidation.
- `tests/CLAUDE.md`: the testing rules extracted from the
false-confidence cases found during the survey.

The measured loop removed 98 tests, 65 snapshots, and 23 picker PTYs.
Controlled warm Nextest execution improved from a 79.593-second mean to
72.574 seconds (8.8%), while comparable production-line coverage moved
from 97.32% to 97.23%. The tracked PR diff is a net deletion of more
than 5,700 lines.

## Validation

- `cargo run -- hook pre-merge --yes` after syncing current `main`:
4,468 passed, one configured skip; docs, doctests, clippy, formatting,
policy checks, and snapshots green.
- `task coverage` on the completed change before the base sync: 4,465
passed, one configured skip; 97.23% comparable production-line coverage.
- Three independent final audits found no remaining lost beliefs,
fixture hazards, or safe PTY consolidations.

> _This was written by Claude Code on behalf of max_.
2026-07-30 00:01:32 -07:00
Maximilian Roos a285ed308d refactor(tests): bring the mock-stub env vars under the WORKTRUNK_TEST_ prefix (#3621)
`MOCK_CONFIG_DIR` and `MOCK_CALL_LOG_DIR` were the only two
worktrunk-invented environment variables without the `WORKTRUNK_`
prefix. That is not just a naming inconsistency:
`isolate_subprocess_env` scrubs the parent environment by prefix —
`GIT_*` and `WORKTRUNK_*` — so an unprefixed name is the one thing a
test child inherits from whoever ran the suite. Renaming them brings
them under that scrub.

- `MOCK_CONFIG_DIR` → `WORKTRUNK_TEST_MOCK_CONFIG_DIR`
- `MOCK_CALL_LOG_DIR` → `WORKTRUNK_TEST_MOCK_CALL_LOG_DIR`

`TEST` rather than a bare `WORKTRUNK_` because both are read only by
`tests/helpers/mock-stub` — they are the protocol between the harness
and its helper binary, and `wt` itself never reads either one. That
matches the ~15 existing `WORKTRUNK_TEST_*` knobs.

## The snapshot half

Mechanical but not a substitution: the `env:` block is byte-sorted by
key, so the renamed entry moves position within it and a `sed` in place
would leave it where the old name sorted. All 971 affected blocks were
rewritten by dropping the old line, inserting the new one, and
re-sorting — the aggregate diff is exactly one removed and one added
line per file:

```
971 files changed, 971 insertions(+), 971 deletions(-)
-    MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]"
+    WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]"
```

The redaction placeholder follows its neighbours' convention in
`add_standard_env_redactions` (`WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR` →
`[TEST_NU_VENDOR_AUTOLOAD]`), so it now reads `[TEST_MOCK_CONFIG]`
rather than repeating the full key. `MOCK_CALL_LOG_DIR` appears in no
snapshot — its two call sites are `.output()` assertion tests — so it
needs no redaction.

## Keeping it from regressing

`tests/CLAUDE.md` gains the rule under "Where a new environment variable
goes": name it `WORKTRUNK_TEST_*`, and the rule covers the
harness↔helper-binary protocol, not just knobs `wt` itself reads.
Without it the next helper-binary variable gets named `MOCK_*` again and
the hermeticity hole reopens.

## Verification

`cargo run -- hook pre-merge --yes` passes (exit 0) on the merged tree:
4607 tests including `--features shell-integration-tests`, `pre-commit
run --all-files`, clippy, doctests. No pending snapshots. A repo-wide
sweep finds no remaining unprefixed spelling.

## Merged main

#3620 landed while this was in flight and regenerated several `for_each`
snapshots that still carried the old key, so main is merged in here. It
resolved with no conflicts, and the result is what you'd want rather
than what git happened to produce: those blocks now carry #3620's new
keys (`GIT_ALLOW_PROTOCOL`, `CLAUDE_CONFIG_DIR`,
`WORKTRUNK_TEST_PARENT_SHELL`) *and* the renamed key, each in sorted
position. The sweep and the gate above both ran after the merge.

#3620 also rewrote the "Where a new environment variable goes" section
this branch adds to — three layers became four. Both edits survived; the
new naming paragraph follows the updated layer list.

The two advisory `affected tests` checks are red for the same reason,
and merging clears them: `cargo affected` errors on `git diff stdout was
not valid UTF-8`, because the diff from this PR's base contains 16
binary files — the `tests/fixtures/standard/` git objects and index
files that #3620 deleted. This branch's own commit contributes none. A
PR based after #3620 won't see them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:44:57 -07:00
Maximilian Roos 58444440d7 fix(ci-status): classify tea failures by shape; report azure-devops extension (#3597)
Two follow-ups from #3595, both in code that talks to a forge CLI.

## Gitea CI status had the same exit-0 blind spot as the switch path

`tea api` never reads the HTTP status — it copies the response body to
stdout and exits 0 — so the `!output.status.success()` gate in
`src/commands/list/ci_status/gitea.rs` never fired for a 4xx/5xx. #3595
fixed this for `wt switch pr:<n>`; the CI-status backend is the second
instance of the same bug.

The commit-status path was the quieter one: every field of
`GiteaCombinedStatus` carries `#[serde(default)]`, so Gitea's `APIError`
body deserialized as `{state: "", total_count: 0}` — an error
indistinguishable from "this commit has no statuses". The PR-list path
was noisier but less harmful: the error body failed to parse and logged
`Failed to parse tea api pulls JSON`, blaming an API change for what was
an API error.

`tea_api_error` now reads the response shape — the `APIError` body,
whose message feeds the existing retriable sniff — and a non-zero exit
keeps meaning what it always meant, `tea` itself failing (transport
error, no login). A proxy's HTML error page stays unclassified, as
before: sniffing arbitrary bodies is the string-heuristic pile this
removes.

## `wt config show --full` reports the azure-devops extension

The whole `az repos` command group ships in that extension, so without
it CI status stays blank however well `az` is authenticated — a
persistent, user-fixable condition, which is what this surface is for.
`azure_devops_extension_installed` (added in #3595) answers it in JSON:

```console
✓ az installed & authenticated
▲ azure-devops extension not installed; run az extension add --name azure-devops
```

An `az` that can't answer counts as installed, so an unanswered check
never becomes an accusation. The `wt switch pr:<n>` bail now uses the
same sentence — one wording for one condition, which is the one-line
change to `switch_pr_azure_extension_not_installed.snap`.

## Testing

The two new Gitea tests were checked against the unfixed source:
reverting `gitea.rs` alone fails both, so they pin the fix rather than
describe it. The two pre-existing retriable tests kept their exit-1
shape but lost their fabricated payload — `exit 1` alongside `429 Too
Many Requests` is the combination `tea` can't produce — and now carry a
real transport error, which is the case that does exit non-zero. `config
show --full` gets a case per extension state.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:54:15 -07:00
Jeremy Odell 0681a2e0d0 feat(list): add structured repo metadata to JSON output (#3021) 2026-06-17 23:45:18 -07:00
Maximilian Roos bd84d7479f Release v0.59.0 (#3100)
Release v0.59.0 (minor). The bump is minor because two
backward-incompatible behavior changes ship: in the `wt switch` picker,
`1`–`5` no longer switch preview tabs (digits now filter; tabs move to
`Alt-1`–`Alt-5` / `Tab`); and `wt step eval --dry-run` is removed in
favor of `-v`. `cargo semver-checks` reports no library API breakage.

### Highlights

- **Improved**: picker frees digit keys for filtering (preview tabs to
`Alt`); squash templates use `commit_details` instead of the deprecated
`commits`; `wt step eval -v` lists template variables.
- **Fixed**: picker no longer freezes on the first keystroke with many
worktrees (#3087, thanks @bendrucker; fixes #2926, thanks @mahume for
reporting); tab-completion covers all hook types; Ctrl-C on a concurrent
alias reports the right exit code.

See `CHANGELOG.md` for the full set.

### Bundled test fix

This PR also carries one non-release commit: `test(ci_status): cover '?'
worker-thread label in snapshot filter`. The
`test_list_full_with_invalid_configured_platform` snapshot filter only
normalized `[a-zA-Z]` worker-thread labels, but `label_for_thread_index`
emits `?` for thread IDs above 52. On high-core machines the rayon pools
span enough threads to hit that, so the gate failed locally (CI's low
core count never triggers it). The filter now covers the full label
alphabet. Test-only; not in the changelog.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:04:19 -07:00
Maximilian Roos c511d3fa2b feat(list): show PR/MR number in the CI column (#3041)
The CI column in `wt list --full` (and the statusline) previously showed
a single colored dot. It now shows the branch's open PR/MR reference —
`#3035` on GitHub/Gitea/Azure DevOps, `!3035` on GitLab — colored by CI
status, dimmed when stale, and hyperlinked to the PR. When no number is
available (branch workflows without a PR/MR, pre-number cache entries,
or a number wider than the allocated column), the cell shows a bare `#`
in the same colors. Fetch errors always render `⚠`, even when a number
is known — Error and Conflicts share yellow, so a yellow `#3035` would
read as a conflicted PR.

The branch merges main's review-state feature (#3044): review colors
(magenta/cyan) and draft dimming apply to the number cells exactly as
they did to the dot, and the `--help` legend shows colored `#` samples
for all seven states (the interim version had dropped the colored
samples from the legend entirely).

## The width problem

`wt list` renders skeleton-first: column widths are fixed before any CI
data arrives, and the table never resizes mid-render. The PR number's
width therefore has to be known up front. The solution is a repo-level
ratchet cache (`.git/wt/cache/pr-number/max.json`) holding the largest
PR number any fetch has seen — PR numbers are monotonic per repo, so the
value needs no invalidation. Pre-skeleton, `collect` reads that one file
and sizes the column exactly; on a cold cache the estimate is 5 chars
(`#9999`). A number that outgrows the estimate renders as the bare `#`
for that run and sizes correctly on the next run once the ratchet
records it. The ratchet is deliberately separate from the per-branch
`ci-status/` entries so the width hint isn't coupled to branch-entry
retention, and `detect` re-ratchets on cache hits too, so a deleted or
racily regressed `max.json` heals from locally cached numbers instead of
waiting out the TTL.

## Reviewer's map

- `src/commands/list/ci_status/mod.rs` — `PrRef` (number + forge sigil,
`PrRef::pr`/`PrRef::mr` constructors), `PrStatus.number` (serde-default
so pre-existing cache entries still deserialize, rendering `#` until
their 30–60s TTL expires), `format_cell` width-aware renderer with the
Error guard, ratchet in `detect` (both cache-hit and fetch paths)
- `src/commands/list/ci_status/cache.rs` — `MaxPrNumber` ratchet
(read/ratchet/clear)
- `src/commands/list/ci_status/{github,gitlab,gitea,azure}.rs` — each
fetcher populates the number (`gh --json number`, `iid`, Gitea `number`,
`pullRequestId`); GitLab's mr-view-failure path carries the
iid/URL/review state into the error status so the `⚠` stays clickable
- `src/commands/list/layout.rs`, `collect/mod.rs` — width estimate
threading
- `src/commands/list/render.rs`, `model/item.rs` — table cell and
statusline both go through `format_cell`
- `src/commands/list/json_output.rs` — `ci.number` field
- `src/commands/config/state.rs` — ratchet shown by `state get`/`cache
get` (table + JSON) and swept with the CI cache category, including the
deprecated `ci-status clear --all` path
- `src/md_help.rs`, `src/help.rs` — legend colorization rules rewritten
from `●` to `#` (terminal + website)

Most of the diff is snapshot churn from the column width and glyph
changes plus regenerated docs mirrors.

Known trade-offs: concurrent statusline ratchet writes can transiently
lose an update (monotonic, re-learns on the next render, documented at
the write site); one anomalously high PR number widens the column until
`wt config state cache clear`; an open Azure DevOps PR still shows gray
`NoCI` instead of its pipeline status — a pre-existing gap, now marked
`TODO(azure-pr-pipeline)`.

Testing: unit tests for `format_cell` (including the Error-with-number
and oversized-number link cases)/`pr_ref_width`/ratchet/width estimates;
integration coverage for all four forges with real numbers (the Gitea
mocks now exercise the number path too), review-state × number
composition, the GitLab mr-view-failure `⚠` and branch-pipeline success
paths, cache-TTL expiry → refetch, the statusline number view, and the
`wt config state` surfaces.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:36:44 -07:00
Maximilian Roos 214d82aeda Add PR/MR review state to wt list CI status (#3044)
Adds PR/MR review state to `wt list`'s CI status, merged into the
existing CI dot color.

## What

- New `ReviewState` (`approved` / `changes_requested` / `pending` /
`draft`) on `PrStatus`, absent when the forge reports no review signal —
branches with no review activity render exactly as before.
- **GitHub**: `reviewDecision,isDraft` join the existing single `gh pr
list --json` call (no extra API cost). Draft wins over the decision; an
empty `reviewDecision` (no required reviewers, no reviews) maps to
absent rather than `pending`, so solo repos don't show a perpetual
waiting state.
- **GitLab**: `draft` + `detailed_merge_status == "not_approved"` →
`draft`/`pending`; MR list data carries no approved/changes-requested
signal. Gitea/Azure: none.
- **Display**: review state merges into the CI dot in
`PrStatus::color()` — conflicts (yellow) > changes-requested
(**magenta**) > running (blue) > failed (red) > review-required
(**cyan**) > base CI color; draft dims like staleness. Cool colors mean
waiting (blue: on CI, cyan: on a human), warm mean act;
changes-requested outranks running because waiting can't clear it.
Magenta was the one color not already carrying a meaning in the list
row; cyan is reused from the working-tree symbols column, where the
differing glyph disambiguates.
- **JSON**: `ci.review_state` in `wt list --format=json`, vocabulary
matching Claude Code's statusline `pr.review_state` so the two surfaces
agree on names.

## Notes for review

- The merge lives in `PrStatus::color()`/`style()` — `CiStatus` stays
pure CI (cache values and JSON `status` strings unchanged). Both the
table and the statusline render through `format_indicator` → `style()`,
so there's a single chokepoint.
- Old CI cache files deserialize unchanged (the new field is an
`Option`).
- `docs/content/list.md` and `skills/worktrunk/reference/list.md` are
regenerated from `src/cli/mod.rs`. The HTML and terminal help colorizers
learned magenta/cyan (they previously mapped only the five existing
colors). Also fixed a pre-existing missing `repo_url` row in the
ci-object doc table.
- Testing: unit tables for the color merge and both forge mappings;
integration snapshots (mocked `gh`) for changes-requested /
review-required / approved / draft verifying the exact ANSI codes; the
JSON field name is pinned by an inline snapshot.

If this change is bad, it's most likely because the dot now encodes two
axes (CI health and review attention) in one color — a consumer who
reads green as "CI passed" still gets that, but red-vs-magenta now
distinguishes who rejected the branch, which is more vocabulary to
learn. The `--format=json` output keeps the axes separate for anyone who
wants their own logic.

Ref #2950

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:14:41 -07:00
Maximilian Roos 93812660c3 refactor(log): migrate from env_logger to tracing-subscriber
Replaced env_logger with a layered tracing-subscriber setup that routes log
records structurally by target and verbosity level. Three layers cooperate:

- stderr: off at `-vv`; otherwise `$RUST_LOG` or `-v` baseline with styled
  output (bold commands, red errors, thread labels)
- trace.log: `-vv` only, excludes `SUBPROCESS_FULL_TARGET`, plain text format
- output.log: `-vv` only, `SUBPROCESS_FULL_TARGET` only, raw passthrough

The `log` crate calls throughout the codebase are bridged into `tracing` via
`tracing_log::LogTracer`, so the migration is transparent to existing call sites
(~100 remain using `log::debug!` etc.). Routing is now per-layer filtering
rather than a post-format decision, and `RUST_LOG` merges with the verbose flag
via `EnvFilter::builder().with_default_directive(...).from_env_lossy()` (env
wins when set). File sinks are opened before subscriber init so their internal
`Repository::current()` calls don't emit into a half-built pipeline.
2026-05-25 16:34:24 -07:00
Maximilian Roos 6732433056 fix(log): honor RUST_LOG at -v and -vv, not just -v 0 (#2901)
## Summary

`wt`'s `-v 0` (no flag) honors `RUST_LOG` — `RUST_LOG=debug wt list`
shows debug records. `-v` and `-vv` silently ignored it: they hardcoded
`filter_level(Info)` / `filter_level(Debug)` and dropped any `RUST_LOG`
directives on the floor.

Now all three verbosity levels flow through the same builder shape: the
flag sets a baseline (`Off` / `Info` / `Debug`), and
`env_logger::Builder::parse_default_env()` layers `RUST_LOG` on top.
When `RUST_LOG` is set it wins (raising or lowering the level, and
supporting per-target directives like `worktrunk=trace`). When it's
unset the flag baseline stands.

Examples:

- bare `wt` → Off (silent, unchanged)
- `RUST_LOG=debug wt` → Debug (unchanged)
- `wt -v` → Info (unchanged)
- `RUST_LOG=trace wt -vv` → Trace globally (was silently capped at
Debug)
- `RUST_LOG=worktrunk=trace wt -v` → Info + Trace for `worktrunk` (was
silently ignored)

The flag still owns the file-sink side effects (`-vv+` opens `trace.log`
/ `output.log`); only the level filter is now `RUST_LOG`-aware.

## Design choice

Three patterns are common in Rust CLIs:

1. **env wins when set, flag is the default** (`env_logger` convention;
e.g. cargo via `CARGO_LOG`)
2. **flag wins when both are set** (e.g. ripgrep, which doesn't honor
`RUST_LOG` at all and uses its own `--debug` / `--trace`)
3. **max(flag, env)** with per-target additions

I picked (1). The task framing makes the case directly — `RUST_LOG`
users are debugging and know exactly what they want; the flag-only user
wants a level; the two shouldn't fight silently. (1) is also the
documented `env_logger` pattern (`builder.filter_level(...);
builder.parse_default_env();`) and gives the smallest implementation.
(3) was tempting for "the verbose user always wins", but it's not
natively supported by `env_logger` — extracting just the env's
global-wildcard level (not its per-target ceilings) has no clean API,
and any hand-rolled merge complicates an otherwise five-line function
for the small win of "`RUST_LOG=warn` doesn't downgrade `wt -vv`".

## Adjacent fix

`src/testing/mod.rs::configure_cli_command` was setting `RUST_LOG=warn`
as a blanket default for every CLI test. That was harmless under the old
behavior (the flag overrode it at `-vv`); under the new behavior it
would silently cap all `-vv` tests at Warn, starving `trace.log` of the
debug-level `[wt-trace]` records the diagnostic tests assert on. Dropped
the default; the one test that genuinely needed the warn-level
diagnostic (`test_list_full_with_invalid_configured_platform`, asserting
on the `log::warn!` for invalid CI platform) opts in via
`cmd.env("RUST_LOG", "warn")` per-invocation.

## Test plan

- New `test_rust_log_overrides_verbose_flag` in
`tests/integration_tests/diagnostic.rs` — asserts `-v` alone suppresses
`[wt-trace]` records (Info baseline) and `RUST_LOG=debug wt -v` surfaces
them (env raises).
- Existing `test_rust_log_debug_fallback_without_vv` still covers
`RUST_LOG=debug` at `-v 0`.
- All 3835 tests in `cargo run -- hook pre-merge --yes` pass.
2026-05-25 14:58:56 -07:00
Maximilian Roos c0d9482335 refactor(ci-status): align Gitea PR detection with the GitHub backend (#2732)
Mirrors the GitHub backend's PR-resolution shape in the Gitea backend:
query the primary remote's open PRs and filter by the branch's push
owner against `head.repo.owner.login`. PRs opened from a fork (head
owner ≠ upstream owner) now match — previously the Gitea path queried
the fork's own (usually empty) `/pulls` and never found them.

Adds an explicit `&limit` matching `MAX_PRS_TO_FETCH`, so the Gitea and
GitHub backends fetch the same number of candidate PRs instead of
relying on Gitea's default page size.

With the fork-PR and page-size limitations resolved in code, the module
docstring drops from ~36 lines (API spec, "two paths", "known
limitations") to a short header matching `github.rs` / `azure.rs`. The
remaining per-quirk note (`mergeable` is computed asynchronously) moves
inline next to the conflict check.

The `--remotes` test that previously asserted the PR path used the
branch's own remote now documents that the PR path intentionally uses
the primary remote; it still verifies the SHA-status fallback honors
`branch.remote` (it passes via that fallback).

A Gitea analogue of github's `test_list_full_filters_by_repo_owner`
(`test_list_full_with_gitea_fork_pr`) locks in the fork-PR match: two
Gitea remotes, the `feature` branch pushes to the fork, and the mock
includes a decoy PR from a different head owner. The green CI indicator
fails loudly if a future change reverts to querying/filtering by the
branch's own remote.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:54:20 -07:00
Maximilian Roos 8425f29044 fix(ci-status): resolve Gitea owner/repo from the branch's own remote (#2707)
Brings the Gitea CI-status path to parity with the GitHub path when
resolving owner/repo for a branch. Today `gitea_owner_repo` always reads
the primary remote, so a `wt list --remotes --full` row for a
remote-only branch on a non-primary remote (e.g. `gitea/feature` when
`origin` points elsewhere) silently misses CI. The new
`gitea_owner_repo_for_branch` reads the branch's own remote via
`effective_remote_url` for remote branches and falls back to the primary
remote's raw URL for local branches.

The local-branch fallback keeps using `remote_url` (raw) rather than
`effective_remote_url` to match `git::remote_ref::gitea`'s existing
`pr:` resolver — `tea` resolves the host from its own login config, and
only owner/repo are extracted from the URL. The remote-branch arm uses
`effective_remote_url` for symmetry with `github_owner_repo_for_branch`,
so `url.insteadOf` aliases still resolve.

Test: a new `--remotes --full` integration test stands up two Gitea
remotes (`origin` → `owner/test-repo`, `fork` → `forkowner/test-repo`),
mirrors a `fork/feature-remote` ref, and mocks `tea` to answer only
`forkowner/test-repo` paths. The green `●` in the snapshot confirms the
lookup hits the branch's remote — a primary-remote lookup would silently
return no CI.

Out of scope: the `wt switch pr:` Gitea resolver in
`src/git/remote_ref/gitea.rs` keeps its primary-remote-only behavior.
Fork PRs (head owner ≠ queried owner) remain unmatched — the limitation
docstring is updated to reflect the actual residual gap.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-11 11:44:41 -07:00
Maximilian Roos bfb117a665 feat(ci-status): experimental Gitea CI-status detection (#2702)
`wt list --full` and `wt config show --full` now recognize Gitea repos,
mirroring the existing GitHub / GitLab / Azure DevOps support.
Previously Gitea was wired up only for the `wt switch pr:<N>` shortcut.

## What this does

`wt list --full` shows a CI indicator for Gitea repos:
- **PR path**: lists open PRs (`tea api
repos/{o}/{r}/pulls?state=open`), finds the one whose head branch
matches, surfaces conflicts from `mergeable` plus the real CI state from
the PR head commit's combined status, and links the indicator to the PR
(`html_url`).
- **Branch fallback** when no PR exists: queries the combined commit
status of the local HEAD (`tea api repos/{o}/{r}/commits/{sha}/status`).
Both Gitea Actions and external CI report into commit statuses, so this
is the pass/fail/pending rollup — the equivalent of GitHub's check-runs
API.

`wt config show --full` adds a `tea`/Gitea diagnostics row when the
repo's a Gitea remote; `render_ci_tool_status` now derives the
auth-setup command per CLI (\`az login\`, \`tea login add\`, else
\`<tool> auth login\`) — which also fixes a pre-existing wrong hint for
\`az\`.

\`forge.platform = \"gitea\"\` now opts a repo into Gitea CI detection
(in addition to its existing role for the \`pr:\` shortcut). The
previous \"valid for \`pr:\`, but no CI\" special-case in
\`configured_ci_platform\` is removed; \`CiPlatform::Gitea\` flows
through the same dispatch as the other forges.

## Design notes

Mirrors \`azure.rs\`'s two-path structure but improves on it: when a PR
exists, Azure's path only ever surfaces conflicts/queued (pass/fail is
unreachable once a PR is found). Gitea's path additionally queries the
PR head commit's combined status, so PRs with passing CI show green
instead of blank. Cost: two \`tea api\` calls per PR'd branch; results
cached 30–60s.

Auth detection reads \`tea\`'s config file (\`has_any_login\`) rather
than invoking \`tea\` — matches the existing \`is_authed_for\` to avoid
OAuth-refresh side effects.

## Known limitations (experimental)

- Only the first page of open PRs (Gitea's default page size,
newest-first) is inspected; in a repo with many open PRs, ours could
fall off the page.
- The PR lookup queries the primary remote's repo only; PRs from a fork
(head repo owner ≠ that repo's owner) aren't matched.
- \`mergeable\` is computed asynchronously by Gitea, so a freshly-opened
PR can briefly report a false conflict until the check completes
(self-corrects when the cache expires).

## Docs

The \`--help\` CI section (\"GitHub/GitLab pipeline status\") stays
generic, matching #1256's precedent of not enumerating Azure there
either. The \`forge.platform\` value lists, the \`config show --full\`
\"CI status requires …\" string, the \"Invalid CI platform … Expected
…\" warning, and the field docs on \`[forge]\`/\`[ci]\` all enumerate
Gitea.

## Verification

API claims verified live against \`gitea.com\` (v1.26-dev) and
\`codeberg.org\` (Forgejo, Gitea-1.22 base), plus the Gitea source and
\`tea\` v0.14.0 source — the combined-status endpoint, the PR JSON
shape, the \`tea api\` query-string passthrough, and the \`?state=open\`
requirement (Gitea's pulls list does NOT default to \`open\` — Swagger's
\`default\` is doc-only). New tests cover
passed/failed/running/conflicts/no-ci/retriable-error (×2
paths)/no-\`tea\`-installed/\`config show --full\` Gitea diagnostics —
10 new integration tests + unit tests for parsing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:02:19 -07:00
Maximilian Roos 00c4508671 refactor(ci): move CiPlatform to the lib crate, cache on RepoCache, drop the "override" framing (#2692)
Follows up on #2686 (the original "dedup the warning" fix), which parked
the configured-platform state in a process-wide `OnceLock` because
`CiPlatform` lived in the binary crate and `RepoCache` in the library
crate.

**Move `CiPlatform` to the lib crate.** It's now
`worktrunk::git::CiPlatform` (a new `src/git/ci_platform.rs` module),
with `Repository::ci_platform(remote_hint)` replacing the free
`platform_for_repo` and a private `Repository::configured_ci_platform()`
reading `forge.platform` / `ci.platform`. The configured value caches on
a `OnceCell` field of `RepoCache` instead of a process global — same
once-per-`wt list` warning dedup, but the repo-scoped data no longer
lives in a process static. `src/commands/list/ci_status/platform.rs` is
now pure forge dispatch (`gh` / `glab` / `az`) over a `CiPlatform`; the
inherent `detect_*` methods became free functions so the enum could move
without dragging the bin-crate backends along.

**Drop the "override" framing.** `forge.platform` / `ci.platform` is now
described everywhere as "the platform, falling back to URL detection
when unset" rather than "overriding detection" — config docs (`## Forge
platform`), the `ProjectForgeConfig` / `ProjectCiConfig` struct and
accessor docs, `wt config state ci-status` help, and a few test names
(`test_list_full_with_configured_platform_github`,
`test_list_full_with_invalid_configured_platform`,
`test_switch_pr_gitea_forge_platform`). Genuine "override" uses
elsewhere (config-path/env-var overrides, `wt config state
default-branch set`) are untouched.

**Fix a pre-existing bug.** `forge.platform = "gitea"` is a valid value
— the `wt switch pr:` shortcut uses it to pick `tea` — but worktrunk
fetches CI status only from GitHub/GitLab, so `wt list` used to print
`Invalid CI platform in config: 'gitea'. Expected 'github' or 'gitlab'.`
for it. `configured_ci_platform()` now recognizes `gitea` as a known
forge it just doesn't show CI for: no warning, blank CI column. Added
`test_list_full_with_gitea_forge_platform` (asserts blank CI and empty
stderr).

Branch is merged with `main`, so the dispatch and detection handle the
new `AzureDevOps` variant alongside GitHub and GitLab.

`cargo run -- hook pre-merge --yes` passes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:26:12 -07:00
mikeyroush 1ca73ab52c feat: experimental Azure DevOps support (#1256)
## Summary

Adds **experimental Azure DevOps support** alongside the existing GitHub and GitLab integrations:

- `wt switch pr:<N>` resolves Azure DevOps PRs via the `az` CLI (auto-detected from `dev.azure.com` / `ssh.dev.azure.com` / `*.visualstudio.com` remotes, or pinned via `[forge] platform = "azure-devops"`).
- `wt list --full` surfaces Azure DevOps PR and pipeline CI status.
- `wt config show --full` reports `az` install/auth state when Azure DevOps is the detected platform.

GitHub still wins in mixed-remote setups; `forge.platform` is the override. Requires the `azure-devops` CLI extension (`az extension add --name azure-devops`).

## Context

Originally proposed by @mikeyroush; reimplemented against current `main` to pick up the `[forge]` config section, `url.insteadOf` fallback, the `handle_switch` consolidation, and the new Gitea provider that all landed after the original branch was opened. The dispatch path (`choose_pr_provider`) is shared with GitHub/Gitea — Azure is just a fourth provider in the same priority chain.

Implementation notes worth a reviewer's attention:

- Azure DevOps URLs don't fit the standard `host/owner/repo` shape (`dev.azure.com/{org}/{project}/_git/{repo}`), so `Repository::find_remote_for_azure` matches on `org` + `project` + `repo` instead of the owner-based path used for the other forges.
- Pipeline/PR web URLs are constructed from `org/project/build-id`, not the API's `url` field (which is a REST endpoint).
- `*.visualstudio.com` legacy hosts encode the org in the hostname; the URL helpers handle both shapes.

Fixes #1144

## Test plan

- `cargo run -- hook pre-merge --yes` — 3631 tests pass, clippy + fmt clean
- Unit tests cover the host-aware URL helpers, `find_remote_for_azure` (all URL shapes + the same-org/different-project collision case), and `choose_pr_provider` dispatch
- Integration tests bring Azure to parity with the other forges (see Coverage):
  - 13 `test_switch_pr_azure_*` tests mirroring the Gitea suite — same-repo, fork, `*.visualstudio.com` host, create/base conflicts, not-found, az-not-installed, `forge.platform` override, invalid JSON, generic server error, auth error, missing `azure-devops` extension, undeterminable org/host
  - 9 `test_list_full_with_azure_*` tests covering `detect_azure_pr` (conflicts, queued, stale, retriable error) and `detect_azure_pipeline` (passed/failed/running, stale, no runs, retriable error)
- Manual validation: `wt switch pr:<N>` and `wt list --full` against an Azure DevOps repo

## Coverage

The `az`-shelling code (`fetch_pr_info`, `detect_azure_pr`, `detect_azure_pipeline`) is now exercised by integration tests via new `setup_mock_az*` helpers (modeled on `setup_mock_gh` / `setup_mock_glab`) — covering the happy paths plus the not-found / auth / extension-missing / generic-error / retriable-error branches. The non-`az` parts (URL parsing, provider dispatch, remote matching) remain unit-tested.
2026-05-10 23:41:52 -07:00
Maximilian Roos 68869761da Fix fork CI queries, branch tracking remote check, and diverged integration target (#1812)
Three edge-case fixes found via Codex:

**1. CI check-runs fallback queries the wrong repo for fork workflows**
— `detect_github_commit_checks` was using `github_owner_repo()` (first
GitHub remote in the repo) instead of the branch's actual push
destination. For fork workflows where branches push to a different
repository, this queried the upstream repo's API and found no check
runs. Added `github_owner_repo_for_branch()` that resolves through
`pushRemote` → `remote.pushDefault` → tracking remote, with fallback to
repo-wide remote.

**2. `branch_tracks_ref` only checked `.merge`, not `.remote`** — When
`wt switch pr:N` found an existing branch tracking `refs/pull/N/head`,
it considered it a match regardless of which remote was configured. A
branch could track the right ref path but have the wrong remote, so
push/pull would be wired to the wrong repository. Now also checks
`branch.<name>.remote` when an expected remote is provided.

**3. `effective_integration_target` missed remote merges when local main
diverged** — When local `main` had extra commits (diverged from
`origin/main`), the function returned `main` instead of `origin/main`,
causing `wt remove` to miss branches that were squash-merged on the
remote. Now prefers upstream in the diverged case, since remote merges
should still count as integrated.

All three have regression tests.

> _This was written by Claude Code on behalf of maximilian_

---------

Co-authored-by: Maximilian Roos <maximilian@Maximilians-MacBook-Pro.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-30 10:07:28 -07:00
worktrunk-bot 1afa9c230b refactor: rename get_* functions to bare nouns (#1586) 2026-03-17 08:00:16 -07:00
Maximilian Roos d358f2ffeb test: fill codecov/patch coverage gaps from recent PRs (#865)
* docs: add coverage guidance and simplify shell integration docs

Add codecov/patch approval requirement to coverage guidance. Simplify skip-shell-integration-prompt documentation to reflect automatic behavior when declining first-run prompt. Add test coverage for FormattedMessage::into_inner().

* docs: simplify shell prompt and add coverage guidance

Simplify skip-shell-integration-prompt documentation to clarify that
declining the prompt sets the config automatically, and add guidance on
codecov/patch approval requirements. Include tests for reload_projects_from
error paths to improve coverage on config validation.

* docs: add coverage guidance and simplify shell integration docs

Add codecov/patch approval requirement to coverage section and document
the zero-as-unlimited rule for concurrent command limits with tests.
Simplify shell integration prompt documentation to reflect automatic
behavior when declining installation.

* Add coverage for codecov/patch and GitLab edge cases

- Add guidance on codecov/patch approval requirement to CLAUDE.md
- Simplify skip-shell-integration-prompt documentation across all config files
- Add tests for GitLab MR detection without project ID (single, empty, multiple)
2026-01-25 23:07:34 -08:00
Maximilian Roos d51e04f408 fix(glab): full compatibility audit and fixes (#770)
* Refactor GitLab CI status to use two-step MR resolution

Split MR lookup into separate list and view calls to properly fetch
pipeline status. The `glab mr list` command doesn't return head_pipeline
fields, requiring a second `glab mr view <iid>` call to get complete
details.

- Create GitLabMrListEntry for basic MR info from list command
- Create GitLabMrInfo for full MR details from view command
- Add fetch_mr_details() helper for the second API call
- Update mock infrastructure to handle compound command matching
- Remove redundant --state=opened flag from mr list call
- Fix glab ci list to use --ref instead of BRANCH env var

* fix(glab): correct status mappings found via adversarial testing

Round 2 of adversarial testing found additional issues:

- Move "manual" from Failed to Running - a manual pipeline is waiting
  for user action, not failed
- Remove ci_must_pass → Failed mapping - it's a policy constraint
  ("CI must pass to merge"), not a failure indicator
- Add is_retriable_error check to detect_gitlab_pipeline - rate limit
  and network errors were being cached as "no CI"
- Return Error (not NoCI) when mr view fails after finding MR

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mock): support iid-specific mr view matching

Extend mock-stub to support triple command matching (e.g., "mr view 1"
before "mr view"). This allows tests to verify that the code correctly
passes the iid argument to glab mr view.

- Add triple matching priority in mock-stub command resolution
- Update test helper to create separate mock responses per iid
- Reorder test data to prove filtering works (not just taking first)

Co-Authored-By: Claude <noreply@anthropic.com>

* test: add coverage for GitLab CI error paths

Add tests for two previously uncovered code paths in gitlab.rs:
- When `glab mr view` fails after finding an MR (returns error status)
- When `glab ci list` returns a rate limit error (returns error status)

Both tests verify that the CI column shows ⚠ (error indicator) rather
than silently treating these failures as "no CI".

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-20 13:18:18 -08:00
Maximilian Roos c4f1c0730d fix: detect CI status when pushremote is a URL (#769)
`gh pr checkout` sets `branch.<name>.pushremote` to a URL directly
rather than a remote name. The `@{push}` syntax fails in this case
because it expects a remote name to resolve to a tracking ref.

Use `%(push:remotename)` via `git for-each-ref` instead, which returns
either a remote name or URL directly without failing. Then check if
the result is a URL (use directly) or remote name (look up its URL).

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-20 12:14:03 -08:00
Maximilian Roos 9d43844515 refactor: reduce reliance on primary remote for CI and fork URLs (#753)
* feat: reduce reliance on primary remote for CI status and fork URLs

- CI platform detection now searches all remotes instead of just the
  primary remote, improving fork workflow support
- MR fetch now errors with actionable hint instead of silently falling
  back to primary remote when target project remote is not found
- PR/MR fork URL construction uses the matched base/target repo's remote
  URL to infer SSH vs HTTPS format preference

New Repository methods:
- all_remote_urls(): returns all (name, url) pairs
- find_remote_url_where(): finds first URL matching a predicate
- first_remote_url(): convenience for fallback URL format detection

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: use gh/glab config for git protocol instead of inferring from remotes

Previously, fork PR/MR URL construction inferred SSH vs HTTPS protocol
by examining existing remote URLs, falling back to `first_remote_url()`
when no specific remote was found. This was complex and could guess wrong.

Now we query the CLI tools directly:
- `gh config get git_protocol` for GitHub PRs
- `glab config get git_protocol` for GitLab MRs

This uses the user's explicit preference (configured in gh/glab) and is
consistent with how these tools handle clones.

Also removes the arbitrary "first PR" fallback in CI detection - if we
can't determine the branch owner, we now return None instead of guessing.

Co-Authored-By: Claude <noreply@anthropic.com>

* Refactor git URL parsing with platform detection helpers

Extract platform detection logic into `GitRemoteUrl` methods (`is_github()`,
`is_gitlab()`), add support for `git://` protocol, and remove the
`find_remote_url_where()` method in favor of explicit filtering. Make
`GitRemoteUrl` public for use across modules. Simplify platform detection in
CI status and branch logic by using the new structured URL parsing.

* Simplify GitHub remote lookup in CI status detection

* Refactor fork_remote_url to accept host parameter

Extract host from PR info instead of deriving it from PR URL. This
simplifies the function signature and moves URL parsing logic to where
the PR info is fetched, reducing duplication across call sites.

* refactor: validate host parsing from PR URL

Replace unwrap_or with explicit validation to ensure host is not empty.
Use with_context to provide error details when host parsing fails.

* test: add coverage for GitRemoteUrl::host() method

Co-Authored-By: Claude <noreply@anthropic.com>

* test: add coverage for fork_remote_url SSH/HTTPS formats

Extract format helpers and test them explicitly to ensure both SSH and
HTTPS code paths are covered regardless of local gh config.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: avoid short-circuit evaluation in URL protocol tests

Use array.contains() instead of || to ensure both expected URL
formats are evaluated, improving branch coverage.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-19 21:51:11 -08:00
Maximilian Roos cd8ac3e060 perf(tests): add pre-built fixture to speed up Windows CI (#634)
* perf(tests): add pre-built fixture to speed up Windows CI

Instead of creating repos from scratch for each test, copy a pre-built
fixture with worktrees and remote already configured. This should
significantly reduce test time on Windows where process spawning is slow.

- Add tests/fixtures/standard/ with repo, 3 worktrees, and bare remote
- Update TestRepo to copy fixture instead of running git init/commit
- Add fixture cleanup to doc-generating tests for clean output
- Remove git sample hooks and boilerplate from fixture

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): correctly clean fixture state for isolated tests

- Change `remove_fixture_worktrees()` to take `&mut self` and clear the
  worktrees map so `add_worktree()` can recreate branches
- Add remote removal to select and approval tests to prevent
  `origin/main` from appearing in snapshots
- Update select snapshots for fixture-based commit hashes

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): use relative paths in fixture for portability

The fixture gitdir files contained absolute paths to the local
development machine, causing CI failures. Updated to use relative
paths which work after the _git → .git rename during fixture copy.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): remove invalid remote from bare repo fixture

The bare repository in the fixture had a remote configured with an
absolute path to the local machine, which wouldn't exist on CI.
Bare repositories don't need remotes configured.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): remove origin in approval tests for consistent project_id

Tests manually create approvals using a computed project_id based on
repo path. With the fixture's origin remote, worktrunk computes a
different project_id from the remote URL. Removing origin makes both
use the same fallback (directory path).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): improve fixture copy robustness for CI

- Add platform-specific copy: `cp -r` on Unix, `robocopy` on Windows
- Add verification that essential paths exist after copy
- Add verification that origin.git is a valid git repository after rename
- Include stderr in error messages for better debugging

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): add .gitkeep to preserve empty git directories in fixture

Git doesn't track empty directories, so objects/info, objects/pack, and
refs/tags were missing on CI after checkout. These directories are
required for a valid git repository.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): ensure LF line endings in fixture git files

Add .gitattributes to force LF line endings for git internal files
(packed-refs, HEAD, config, etc.) to prevent CRLF corruption on Windows.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): handle file copies separately from directories on Windows

robocopy only works with directories, not files. Use Rust's std::fs::copy
for individual files like .gitattributes, and robocopy only for directories.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): enable rerere in test git config for consistent output

Git's rerere feature produces "Recorded preimage" messages during
conflicts. Enable this in test config so output is consistent between
local machines and CI.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): restore accidentally deleted hook_clear tests

The tests test_hook_clear_no_approvals and test_hook_clear_with_approvals
were accidentally removed during the fixture refactoring. This restores them
with the necessary remote removal so project_identifier matches "repo"
(directory name) instead of the fixture's remote URL.

Also adds remote removal to test_hook_show_approval_status for consistency.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: remove unreferenced ping_pong snapshots

These snapshots used feature_a but the tests now use feature, leaving
these orphaned.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-14 18:48:39 -08:00
Maximilian Roos f0f3e18629 test: add GitLab MR status and shell config creation tests (#546)
Add coverage for:
- GitLab MR detection via mock glab (passed, failed, running, pending,
  conflicts, stale, no CI, project ID filtering)
- Shell config file creation when targeting a shell explicitly

288 lines of tests for 47 lines of coverage gain.

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-11 18:34:40 -08:00
Maximilian Roos 2e2777c404 refactor: simplify Repository API and eliminate magic cwd lookups (#519)
* refactor: simplify Repository API and eliminate magic cwd lookups

- Add WorktreeView abstraction accessed via repo.current_worktree()
- Move worktree-specific methods (root, current_branch, is_dirty) to WorktreeView
- Refactor CI status functions to take &Repository instead of implicitly
  calling Repository::current()
- Remove repo_root string parameters from CI detection functions; derive
  working directory from &Repository internally
- Add primary_remote_url() convenience method combining primary_remote()
  and remote_url()
- Fix post-removal operations to use Repository::at(main_path) instead of
  Repository::current() which fails when cwd is deleted

This is an intermediate step toward a cleaner Repository/Worktree separation.
The WorktreeView will eventually be renamed to Worktree once the cache is
moved onto Repository.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: update doctests to match Repository::current() signature

Repository::current() now returns Repository directly, not Result<Repository>.
Update doc examples to not use `?` on the return value.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: add coverage for CI platform detection

Add integration tests for:
- Platform override in project config (`[ci] platform = "github"`)
- GitLab remote URL detection

These tests cover the changed lines in get_platform_for_repo() and
exercise the GitLab remote detection path.

Co-Authored-By: Claude <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* chore: remove unused WorktreeView::run_command_check

This method was added but never used. All call sites use
Repository::run_command_check instead.

Co-Authored-By: Claude <noreply@anthropic.com>

* Compute git-common-dir eagerly on Repository construction

This change improves performance and simplifies the codebase by computing
the git common directory once when Repository is created, rather than
lazily on first access. The git-common-dir is now stored directly on the
Repository struct instead of in a OnceCell, making it immediately available
without runtime initialization overhead.

Key changes:
- Repository::current() and Repository::at() now return Result
- git_common_dir() returns &Path instead of Result<PathBuf>
- wt_logs_dir() returns PathBuf instead of Result<PathBuf>
- All callers updated to handle the new error types
- Cache lookups use the eagerly-computed path as key
- Repository now implements Clone for easier passing to background tasks

* chore: remove unused WorktreeView::path() and ::repo()

These accessors were added but never used. Removing dead code.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: remove CI tools hint code from wt list

Remove the hint that suggested installing/authenticating gh/glab when
CI status couldn't be fetched. This simplifies the codebase:

- Remove get_gitlab_host_for_repo function
- Remove CiToolsStatus::any_available method
- Remove hint display logic from list command
- Remove parse_remote_host import

The hint was nice-to-have but not essential. Users can still see CI
status when tools are available; the hint just won't appear when
they're not.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-10 13:29:25 -08:00
Maximilian Roos ecb5fe3d97 test: parameterize CI status tests (#516)
Consolidate 12 CI status tests into 7 parameterized test functions using
rstest #[case]. Same coverage with 70% less code:

- PR status tests (4 cases): passed, failed, running, conflicts
- StatusContext tests (2 cases): pending, failure
- Workflow run tests (2 cases): completed, running
- Special cases (4 tests): stale PR, mixed types, no checks, owner filter

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-10 10:51:03 -08:00
Maximilian Roos 928b3017cd test: remove verbose assertion messages and doc comments (#458)
* Simplify assert messages by removing redundant descriptions

Remove verbose assertion messages that repeat the condition being tested.
Messages like "Should contain indicator" when asserting `contains("●")` are
redundant since the code is self-documenting. This reduces noise in tests
while maintaining clarity through meaningful assertion failures.

* test: remove verbose assertion messages and doc comments

Removed facile test verbosity across three categories:

1. **Doc comments** (~236 lines): Removed `/// Test X` comments that just
   restated test function names. Kept multi-line explanations that add context.

2. **Test names** (9 functions): Shortened overly verbose names by removing
   filler words while maintaining clarity:
   - test_merge_when_primary_not_on_default_but_default_has_worktree →
     test_merge_primary_not_on_default_with_default_worktree
   - test_complete_switch_option_prefix_shows_options_not_branches →
     test_complete_switch_option_prefix_shows_options

3. **Assertion messages** (19 instances): Removed messages that just restate
   the check:
   - assert!(path.exists(), "Path should exist") → assert!(path.exists())
   - assert!(x.is_file(), "Should be a file") → assert!(x.is_file())

Kept messages that add semantic meaning (e.g., "hook should have run") or
include debugging variables.

Total: 279 lines removed across 31 files. All 723 integration + 401 unit tests
passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* test: remove unreferenced snapshot files from renamed tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 16:51:44 -08:00
Maximilian Roos a7bb80365e Enable Windows CI status tests via MOCK_SCRIPT_DIR (#407)
* Enable Windows CI status tests via MOCK_SCRIPT_DIR

mock-stub.exe now passes MOCK_SCRIPT_DIR environment variable to bash,
allowing scripts to reliably find sibling files (like JSON test data)
without relying on $0/dirname/pwd which fail in Git Bash on Windows CI.

- Add script_dir computation and MOCK_SCRIPT_DIR env var to mock-stub.exe
- Update mock gh script to use MOCK_SCRIPT_DIR when available
- Remove all 12 Windows ignore attributes from ci_status.rs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mock-stub): use Git Bash explicitly on Windows, not WSL bash

On Windows, there are multiple 'bash' executables in PATH:
- C:\Windows\System32\bash.exe - WSL wrapper (fails if WSL not installed)
- C:\Program Files\Git\bin\bash.exe - Git Bash (what we need)

The WSL wrapper was being found first and failing with 'Windows Subsystem
for Linux has no installed distributions' because WSL isn't installed on
GitHub Actions Windows runners.

Fix by adding find_bash() that explicitly looks for Git Bash at known
installation paths before falling back to the WSL wrapper.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: cargo fmt

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mock-stub): collapse nested if statements for clippy

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: remove debug logging from Windows CI fix

Remove the debug file-based logging that was added during troubleshooting.
Keep the MOCK_DEBUG conditional stderr logging for future debugging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(mock-stub): simplify find_bash to single path check

GitHub Actions Windows runners have Git at C:\Program Files\Git.
No need for multiple fallback paths.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-03 23:30:48 -08:00
Maximilian Roos e8f1037719 Enable Windows test support with cross-platform mock infrastructure (#401)
* test: add cross-platform mock command helpers for Windows tests

Add mock_commands.rs module with platform-abstracted mock creation:
- create_mock_command: branching mock based on first argument
- create_simple_mock: fixed output mock (e.g., for llm)
- create_mock_command_2arg: two-argument matching (e.g., uv run pytest)
- High-level helpers: create_mock_cargo, create_mock_llm_*, etc.

On Unix: creates shell scripts with #!/bin/sh shebang
On Windows: creates .cmd/.bat batch files with goto-based control flow

Migrate 3 merge.rs tests from inline shell scripts to cross-platform mocks:
- test_readme_example_complex
- test_readme_example_hooks_post_create
- test_readme_example_hooks_pre_merge

Enable ci_status.rs tests on Windows (PATHEXT handling already works).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* simplify: target Git Bash for Windows test mocks

Change from maintaining parallel shell/batch implementations to a unified
shell script approach. Since hooks already require Git Bash on Windows
(documented in FAQ), tests can assume Git Bash is available.

The new approach:
- Write all mock logic as shell scripts (#!/bin/sh)
- On Windows, create thin .bat/.cmd shims that invoke `bash script.sh`
- Single source of truth for mock behavior

This reduces mock_commands.rs from 558 to 380 lines (-178 lines) while
simplifying maintenance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: remove unused MockBranch2, clarify Git Bash requirement

Code review fixes:
- Delete unused MockBranch2 and create_mock_command_2arg (YAGNI)
- Clarify Git Bash requirement in module documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: write mock scripts without extension for Git Bash on Windows

The hooks run through Git Bash, which doesn't recognize .bat/.cmd files
as executables. Git Bash needs shell scripts without extensions that it
can execute directly via shebang.

Now on Windows we create:
- `name` (no extension) - shell script for Git Bash
- `name.cmd` and `name.bat` - shims for CMD/PowerShell

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: use .cmd extension for LLM command path on Windows

When worktrunk runs the LLM command directly (not via PATH lookup),
Windows requires a .cmd or .bat extension for direct execution.
Use llm.cmd on Windows, llm (no extension) on Unix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: add .gitattributes for consistent line endings across platforms

Force LF line endings for all text files to ensure consistent commit
hashes across platforms. Without this, Windows may use CRLF which
changes file content hashes and thus commit hashes, causing snapshot
test failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: skip CI status tests on Windows

Rust's Command::new("gh") uses CreateProcessW which only searches for
.exe files, not .bat/.cmd files. Our mock infrastructure creates batch
files that work for Git Bash but aren't found by CreateProcessW.

The CI status feature works identically on Windows — it just calls gh/glab
binaries. The core functionality is verified by Unix tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: set filemode=false in test template for cross-platform consistency

Git on Windows handles file modes differently than Unix. With filemode=true,
Git tries to track executable bits, but Windows reports them differently,
causing tree hash differences and thus different commit hashes.

Setting filemode=false ensures Git records all files as 100644 (non-executable)
on both platforms, producing identical commit hashes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert "fix: set filemode=false in test template for cross-platform consistency"

This reverts commit 31d5661a53.

* fix: skip merge tests with commit hashes on Windows

Git on Windows handles file modes differently than Unix, causing tree hashes
(and thus commit hashes) to differ. Tests that have commit hashes in their
snapshot output are skipped on Windows.

The underlying merge functionality works correctly on Windows - only the
snapshot verification differs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: redact commit hashes in snapshots for cross-platform consistency

Instead of skipping tests on Windows, redact commit hashes to [HASH] in
snapshot output. Git on Windows produces different tree hashes due to
filemode handling, but the underlying functionality works correctly.

The hash redaction filter matches "Squashed @ <hash>" and "Committed @ <hash>"
patterns and replaces the 7-char hex hash with [HASH].

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: simplify mock command helpers with inline shell scripts

- Remove MockBranch struct and create_mock_command() function
- Change create_simple_mock() to accept &str instead of &[&str]
- Convert all mock helpers to use inline shell scripts with raw strings
- Each mock's behavior is now directly visible (no indirection)
- Simpler to read and maintain

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add mock-stub.exe for Windows Command::new() compatibility

Rust's Command::new() uses CreateProcessW on Windows, which only searches
for .exe files (doesn't respect PATHEXT). This was causing CI status tests
to be skipped on Windows.

The solution is simple: mock-stub.exe is a 10-line stub that delegates to
bash with a companion shell script. When copied as gh.exe, it runs
`bash gh <args>`.

Changes:
- Add tests/helpers/mock-stub/ with the stub binary
- Update write_mock_script() to copy mock-stub.exe on Windows
- Remove #[cfg_attr(windows, ignore)] from CI status tests (12 tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: convert Windows paths to MSYS2 style for bash in mock-stub

Git Bash on Windows doesn't understand Windows-style paths like
`D:\temp\bin\gh`. Convert to MSYS2 style `/d/temp/bin/gh` before
passing to bash.

This fixes the CI status tests that were failing on Windows because
the mock `gh` command couldn't find its companion script.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: use write_mock_script for all gh/glab mocks

The CI status tests were failing on Windows because setup_mock_gh_with_ci_data
and setup_mock_ci_tools_unauthenticated were creating mock scripts directly
with fs::write instead of using write_mock_script. This meant on Windows:
- Only .cmd/.bat files were created
- No .exe files were created (via mock-stub)
- Rust's Command::new("gh") couldn't find the mocks

Also update the gh mock to use relative paths via $SCRIPT_DIR so it works
when invoked through mock-stub.exe on Windows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): embed JSON in gh mock for Windows compatibility

Instead of writing JSON to separate files and using `cat` to read them,
embed the JSON data directly in the shell script. This avoids path
conversion issues when mock-stub.exe invokes bash on Windows - the
SCRIPT_DIR path from `dirname "$0"` wasn't being converted properly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): use heredoc for multiline JSON in gh mock

Using echo with single-quoted multiline strings doesn't work reliably
on Windows with Git Bash. Switch to heredoc syntax (cat <<'PREOF')
which is more robust for multiline content and prevents any shell
interpretation of special characters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): flatten JSON for cross-platform shell compatibility

Multiline JSON in shell scripts causes issues on Windows with Git Bash.
Instead of heredocs or multiline echo, flatten JSON to single line and
use printf which is the most portable approach across shells.

This eliminates any line ending or multiline escaping issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): capture and forward bash output in mock-stub

On Windows, relying on handle inheritance for stdout/stderr between
mock-stub.exe and bash doesn't work reliably when the parent process
(wt.exe) uses pipes for output capture.

Instead, explicitly capture bash's stdout/stderr using .output() and
forward them to our own streams. This ensures the mock command output
actually reaches the caller.

Also flatten JSON to single line for simpler shell escaping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): use pre-computed MSYS2 path for JSON files

Instead of trying to compute SCRIPT_DIR at runtime in the shell script
(which fails when mock-stub.exe invokes bash), compute the MSYS2-style
path in Rust and embed it directly in the script.

This writes JSON to separate files and reads them using an absolute
path that works correctly on both Unix and Windows with Git Bash.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): use dirname to find JSON files in mock script

Use `$(cd "$(dirname "$0")" && pwd)` to get the script's directory.
This works because mock-stub.exe already converts the script path to
MSYS2 format before invoking bash, so $0 is in Unix format.

Removes the need for compile-time path conversion in Rust code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: use Command::new directly for tool_available on Windows

Instead of wrapping through `cmd /c`, use Command::new(tool) directly.
On Windows, CreateProcessW searches PATH for .exe files, which finds
our mock-stub.exe files correctly.

The cmd.exe wrapper was causing issues because it may have different
PATH handling behavior that bypassed our test mocks.

Also add optional MOCK_DEBUG logging to mock-stub for debugging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): preserve PATH variable case on Windows

On Windows, environment variables are case-insensitive but Rust stores
them case-sensitively. If the existing variable is named "Path" and we
set "PATH", we create a duplicate that may not be respected.

Find the actual case of the PATH variable and use that when setting
our modified value.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): skip CI status tests on Windows

The mock gh command infrastructure for JSON file reading doesn't work
reliably on Windows CI. The mock-stub.exe successfully invokes bash,
but the SCRIPT_DIR path resolution and JSON file reading fails.

Skip these specific tests on Windows while keeping the rest of the
Windows test support. Tests that only need simple mock behavior
(like `wt config show` which just checks gh --version) work fine.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: address Codex review feedback for Windows test support

Two issues identified by Codex gpt-5.2 review:

1. mock-stub output flushing: `write_all()` + `exit()` could lose
   buffered stdout/stderr. Added explicit `flush()` calls before exit.
   This likely explains the "JSON file reading fails" issue in CI.

2. PATH variable case: `make_path_with_mock_bin()` now preserves the
   original PATH variable name to avoid creating duplicate env vars
   on Windows (where vars are case-insensitive but Rust stores them
   case-sensitively).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* test: re-enable CI status tests on Windows

The flush fix in mock-stub may have resolved the JSON reading issue.
Testing on Windows CI to confirm.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert "test: re-enable CI status tests on Windows"

This reverts commit 74c223e203.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-03 19:43:05 -08:00
Maximilian Roos d13eb0c1d2 refactor(tests): simplify test helpers and remove unnecessary worktrees (#326)
- Add `check_git_status()` helper to verify git command success
- Add thin wrappers `run_git`, `run_git_in`, `git_output` using check_git_status
- Simplify `repo_with_main_worktree` fixture to no-op (primary is already on main)
- Remove unnecessary `add_main_worktree()` calls from merge tests and fixtures
- Fix latent test bugs where git commands were silently failing
- Use `set_temp_home_env` helper in post_start_commands tests
- Fix test using non-existent "develop" branch
- Fix test needing `repo_with_remote` fixture

Net reduction: ~208 lines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-31 01:25:45 -08:00
Maximilian Roos bceedcade7 Fix test isolation for wt commands and improve test API (#314)
Tests that spawn `wt` must be isolated from the host environment to
prevent directive leakage (test commands writing to user's shell
directive file), config pollution, and git interference.

Changes:
- Add `TestRepo::wt_command()` returning pre-configured Command
- Rename `clean_cli_env` → `configure_wt_cmd` (parallel to git)
- Make `git_command()` consistent (no args, chain `.args()`)
- Fix approval_ui.rs and readme_sync.rs missing isolation
- Add `#[must_use]` to command builders
- Document pattern in tests/CLAUDE.md

The public API is now 3 symmetric methods:
- `repo.wt_command()` - for wt commands with TestRepo
- `repo.git_command()` - for git commands
- `wt_command()` - for wt without TestRepo (help tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-30 20:29:27 -08:00
Maximilian Roos 71b35e76d7 Enable coverage for PTY tests and add CI status tests (#222)
* Add integration tests for config and state commands

Add three new tests for project config creation in config_init.rs:
- test_config_create_project_creates_file: verifies project config file creation
- test_config_create_project_already_exists: tests idempotent behavior

Add test_state_get_logs_with_files in config_state.rs to verify log file
listing displays correct table headers and file information.

* Enable coverage for PTY tests and add CI status tests

- Add configure_pty_command() and pass_coverage_env_to_pty_cmd() helpers
  for passing LLVM coverage env vars through PTY command builders
- Update select.rs, approval_pty.rs, shell_wrapper.rs to use helpers
- Enable --features shell-integration-tests in dev/coverage.sh
- Add CI status parsing integration tests with mock gh commands

Coverage for select.rs improved from 47% to 86%.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Consolidate PTY setup into shared helpers

Add open_pty() and open_pty_with_size() helpers to reduce
boilerplate in PTY tests. Net reduction of 80 lines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add shell_command() helper for shell-specific PTY setup

Consolidate the repeated shell setup pattern from shell_wrapper.rs tests
into a shared helper. The helper handles:
- env_clear + HOME + PATH (with optional bin_dir prefix)
- Shell-specific env vars (ZDOTDIR for zsh)
- Shell-specific isolation flags (--norc, --no-rcs, --no-config)
- Coverage env var passthrough

Net reduction: 65 lines (122 deleted, 57 added).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix Windows mock gh command not being found

On Windows, PATHEXT defines extension search order (.COM;.EXE;.BAT;.CMD;...).
This means Windows searches ALL directories for .exe before ANY directory
for .cmd. The real gh.exe on GitHub Actions runners was being found before
our mock gh.cmd, causing CI status tests to fail.

Fix by modifying PATHEXT to prefer .CMD over .EXE in the test environment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix Windows mock: create .bat files and adjust PATHEXT

Create both .bat and .cmd mock files on Windows, and set PATHEXT to
prefer .BAT before .EXE. This ensures Rust's Command::new finds our
mock scripts instead of the real gh.exe installed on CI runners.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Consolidate setup_mock_gh() to delegate to setup_mock_gh_with_ci_data()

Reduces ~85 lines of duplicated mock script code by having setup_mock_gh()
delegate to setup_mock_gh_with_ci_data("[]", "[]"). Both mocks now use
the same implementation with empty JSON arrays for PR/run data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix Windows mock gh: use %~dp0 for reliable relative paths

The batch file now uses %~dp0 (directory of the batch script itself)
instead of absolute paths. This avoids issues with path formatting
differences between how Rust generates paths and how cmd.exe's type
command interprets them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Skip CI status tests on Windows

Rust's Command::new("gh") doesn't use PATHEXT for resolution - it looks
for .exe directly. Our mock gh.bat scripts aren't found, so we can't
mock the gh command reliably on Windows without creating a real .exe
wrapper.

TODO: Re-enable once we have a reliable Windows mocking solution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-15 15:29:51 -08:00