Skip to content

feat(list): add structured repo metadata to JSON output - #3021

Merged
max-sixty merged 8 commits into
max-sixty:mainfrom
jeremy0dell:fix-structured-repo-provider-semantics
Jun 18, 2026
Merged

max-sixty merged 8 commits into
max-sixty:mainfrom
jeremy0dell:fix-structured-repo-provider-semantics

Conversation

@jeremy0dell

@jeremy0dell jeremy0dell commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Extends the JSON repository metadata added around repo_url with structured repo objects for wt list, wt list statusline, and PR CI output.

repo_url and ci.repo_url stay as compatibility fields and continue to carry the same URL strings. repo describes the local checkout's primary remote. ci.repo describes the repository targeted by ci.url, which can differ for fork PRs.

The provider field now uses the same [forge].platform parser as CI detection. That keeps configured values consistent with existing behavior: azuredevops is accepted, while values such as " github " are ignored and fall back to host detection.

ci.repo now receives the same configured provider hint that CI detection used to find the PR or MR. That keeps repo.provider and ci.repo.provider aligned on opaque hosts such as git.company.com with platform = "github".

Azure DevOps keeps the shape guard: only Azure-shaped URLs with an organization, project, and repository report provider: "azure-devops" and set project. If config says Azure DevOps but the URL is a generic non-Azure shape like https://git.example.com/owner/repo, Worktrunk keeps the old repo_url value but emits provider: "unknown" with generic host, owner, and name metadata.

Notes

  • GitRemoteUrl::repo_info() owns the remote URL metadata shape and reuses CiPlatform for configured provider overrides.
  • repo_info_from_ref_url_with_provider() lets ci.repo use the same configured provider hint while keeping repo_url_from_ref_url() unchanged.
  • Valid configured noncanonical Azure URLs such as https://git.example.com/org/project/_git/repo still report Azure DevOps with project.
  • The standard integration-test fixture now rewrites copied linked-worktree gitdir pointers to absolute slash-normalized temp paths, matching git worktree add output and avoiding Git-version-dependent prunable worktree results.

Tests

  • cargo test --lib repo_info --features shell-integration-tests
  • cargo test --bins test_json_repo_provider_from_git_repo_provider --features shell-integration-tests
  • cargo test --test integration test_list_full_json_ci_repo_uses_configured_provider_for_opaque_host
  • cargo test --test integration test_list_full_json_ci_repo_from_pr_url
  • cargo test --test integration test_list_json_configured_azure_generic_remote_is_unknown
  • cargo test --test integration test_docs_are_in_sync
  • cargo test --test integration --features shell-integration-tests test_help
  • cargo test --test integration --features shell-integration-tests integration_tests::bare_repository::test_bare_repo_equivalent_to_normal_repo
  • cargo test --test integration --features shell-integration-tests integration_tests::ci_status::test_list_full_filters_by_repo_owner
  • cargo test --test integration --features shell-integration-tests integration_tests::switch::test_switch_with_relative_worktree_paths
  • cargo llvm-cov --features shell-integration-tests --cobertura --output-path /tmp/worktrunk-cobertura.xml
  • cargo run -- hook pre-merge --yes

CI is green on head 8bcb05b1f, including Linux, macOS, Windows, Codecov patch, and Codecov project.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing as a draft — flagging anything that looks worth a quick fix. Mark ready for a full review.

The structure is clean and the test coverage across forges (including the Azure non-canonical and unknown-fallback cases) is thorough. One design subtlety worth deciding on before this finalizes:

The top-level repo (from the remote URL, GitRemoteUrl::repo_info) consults the configured [forge].platform override, but ci.repo (from the PR/MR URL, repo_info_from_ref_url) derives provider purely from URL shape + host substring — it never sees the config override. For a forge on a non-canonical host these two can disagree for the same logical provider: e.g. a GitHub Enterprise host like git.company.com with platform = "github" reports repo.provider = "github" but ci.repo.provider = "unknown" (the pull marker only matches when the host contains("github")). Same story for Azure on a generic host.

Worth deciding whether that's intended (ci.repo reflects only what the URL encodes) and, if so, documenting it in the repo object section — a consumer filtering on .ci.repo.provider would otherwise be surprised that it can diverge from .repo.provider.

@jeremy0dell
jeremy0dell force-pushed the fix-structured-repo-provider-semantics branch from 96e5f62 to f3613b5 Compare June 9, 2026 06:45
@jeremy0dell
jeremy0dell marked this pull request as ready for review June 9, 2026 06:45

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structured repo / ci.repo shapes are clean, and threading the configured [forge].platform hint into ci.repo resolves the repo.provider vs ci.repo.provider divergence from the draft round — the opaque-host test makes that explicit. The override-wins precedence in repo_info() also matches the existing choose_pr_provider_forge_platform_override_wins convention, so that's consistent.

Two things to address before merge, both likely to surface as codecov/patch misses (a hard gate on this repo):

  • repo_web_url() is now dead — its only two callers (json_output.rs, statusline.rs) both moved to repo_info() in this PR, and nothing else references it. Its body changed in the diff but is never exercised. Deletion candidate per the dead-code policy.
  • The v3 fallback branch in azure_repo_info_parts has no test coverage.

Details inline.

Comment thread src/git/repository/remotes.rs Outdated
Comment thread src/git/url.rs
@jeremy0dell
jeremy0dell force-pushed the fix-structured-repo-provider-semantics branch from f3613b5 to d11bb4e Compare June 9, 2026 15:04
worktrunk-bot
worktrunk-bot previously approved these changes Jun 9, 2026
@jeremy0dell jeremy0dell changed the title Fix structured repo provider semantics feat(list): add structured repo metadata to JSON output Jun 9, 2026
@jeremy0dell
jeremy0dell force-pushed the fix-structured-repo-provider-semantics branch from d11bb4e to a4b6c3f Compare June 9, 2026 15:49

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structured repo / ci.repo shapes read well, and the per-forge ref-URL tests are thorough. Two things stand out on this revision, plus one inline cleanup.

Windows test regression from the fixture change. affected tests (windows, advisory) failed on integration_tests::bare_repository::test_bare_repo_equivalent_to_normal_repo (bare_repository.rs:580) with left: 4, right: 7 — the normal-repo wt list shows three extra rows, i.e. the feature-a/b/c worktrees the test removes up front are still listed. cargo-affected selected this test specifically because the PR edits src/testing/mod.rs, and the ci run on the base commit (ba6a3c2b5) was green, so this is new to the PR rather than a pre-existing flake. The suspect is the fixture switch from relative gitdir links (_git→.git rewrite) to absolute dunce::canonicalize-based links — git worktree remove against repo.root_path().parent().join(...) no longer matches the registered worktree path on Windows. The required test (windows) job runs the full suite and is still pending; it will likely hit the same failure, which would block merge. I can't run Windows here to pin the exact mismatch — could you verify this leg locally on Windows (or in CI) before this lands? If the relative-link approach was itself causing the "Git-version-dependent prunable" results, it may be worth splitting the fixture change into its own PR so it can be validated across platforms independently of the metadata feature.

ci.repo Azure parsing has dead and untested branches. In azure_repo_segments (src/git/remote_ref/mod.rs), its only caller passes a host parsed from a PR/MR web URL (parse_ref_url_parts rejects non-http(s) schemes), so the ssh.dev.azure.com / v3 branch can never fire — Codecov flags those lines as uncovered, and per the dead-code policy in CLAUDE.md it's a deletion candidate. Inline suggestion below. (This is the analogue of the prior round's azure_repo_info_parts fix in url.rs, where the v3 branch is reachable because ssh remotes are valid there — the asymmetry is correct, the ssh branch just doesn't belong in the ref-URL path.)

The adjacent .visualstudio.com branch is the opposite case: reachable (Azure PR URLs on *.visualstudio.com hosts) but exercised by no test, so it's also flagged by Codecov. Worth a https://myorg.visualstudio.com/myproject/_git/myrepo/pullrequest/9 case in repo_info_from_ref_url_per_forge. The remaining patch misses are the trivial provider-mapping arms (From<GitRepoProvider> for JsonRepoProvider, From<CiPlatform> for GitRepoProvider) that no test threads a GitLab/Gitea/Azure value through.

Happy to push a commit removing the ssh branch and adding the visualstudio + provider-mapping coverage if useful — the Windows fixture question is the one I can't resolve from here.

Comment thread src/git/remote_ref/mod.rs
@jeremy0dell
jeremy0dell force-pushed the fix-structured-repo-provider-semantics branch from a4b6c3f to 2ccf160 Compare June 9, 2026 16:16
@jeremy0dell
jeremy0dell force-pushed the fix-structured-repo-provider-semantics branch from 2ccf160 to 8bcb05b Compare June 9, 2026 16:37

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feature code reads well — the repo / ci.repo split, the [forge].platform override threading, and the per-forge ref-URL coverage all look solid, and repo_url stays byte-identical to the old repo_web_url() path (both route Azure through fork_remote_url with the same host mapping, so no regression there).

One thing that will fail CI, in the regenerated help snapshots: both help_list_long.snap and help_list_narrow_80.snap flipped their info.env block from GIT_EDITOR: "" to GIT_PAGER: "". That line isn't set by the harness — isolate_subprocess_env (src/testing/mod.rs, the std::env::vars() scrub) env_removes every GIT_* key present in the parent environment, and insta-cmd records each removed key as : "". So the env block captures whichever GIT_* vars happened to exist on the host at regeneration time. CI's environment has GIT_EDITOR (every other help snapshot on main shows GIT_EDITOR: "", none show GIT_PAGER), so when the help_list_* tests run in test (linux|macos|windows) they'll record GIT_EDITOR: "" and mismatch these committed snapshots.

These two were almost certainly regenerated in an environment that had GIT_PAGER set but not GIT_EDITOR. The robust fix is to regenerate with the same GIT_* env CI has (e.g. unset GIT_PAGER / ensure GIT_EDITOR is set, then cargo insta test); the inline suggestions below are the direct one-line patch to match what CI will produce.

Comment thread tests/snapshots/integration__integration_tests__help__help_list_long.snap Outdated
Comment thread tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap Outdated

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The merge of main is clean. Re-flagging one thing from the prior review that's still live: the two help_list_* snapshots were resolved but the fix wasn't applied — both still record GIT_PAGER: "" where every other help snapshot in the tree (39 of them) records GIT_EDITOR: "". That line is an accidental capture of whichever GIT_* var existed on the regeneration host (isolate_subprocess_env records each scrubbed GIT_* key as : ""), so CI — which has GIT_EDITOR set — will record GIT_EDITOR: "" and the still-pending test (linux|macos|windows) jobs will mismatch these two. Inline one-line reverts below; the robust alternative is to regenerate under CI's GIT_* env.

Comment thread tests/snapshots/integration__integration_tests__help__help_list_long.snap Outdated
Comment thread tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap Outdated
@worktrunk-bot
worktrunk-bot dismissed their stale review June 9, 2026 16:56

Dismissing my earlier approval: the two help_list snapshots still record GIT_PAGER:"" (every other help snapshot records GIT_EDITOR:""), so the required test (linux|macos|windows) jobs will fail. Re-flagged with inline reverts. Will re-approve once the snapshots match CI's env.

Comment thread src/git/url.rs
}
}

impl From<CiPlatform> for GitRepoProvider {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@worktrunk-bot should these be one struct? I worry a bit when I see this mapping...

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are actually three enums in this chain, each pinned to a different boundary, and only one of the two mappings is avoidable:

  • CiPlatform (4 variants, no Unknown) — config parsing (forge.platform, via strum Display/EnumString) and CI detection. Repository::ci_platform() returns Option<CiPlatform>, where None is the "not a forge we fetch CI for" state.
  • GitRepoProvider (those 4 + Unknown) — repo metadata, where Unknown is a legitimate value: a parseable URL whose host and config don't identify a supported forge.
  • JsonRepoProvider (same 5 + #[serde(rename)] + JsonSchema) — the wire format.

The mapping you flagged, From<CiPlatform> for GitRepoProvider, exists purely because GitRepoProvider is CiPlatform plus Unknown. Collapsing the two into one enum isn't free in either direction:

  • CiPlatform can't absorb Unknown without it leaking everywhere CI is fetched — ci_platform() / configured_ci_platform() already use Option to mean "no supported forge", and there's no CI to fetch for Unknown, so it becomes a dead arm in every CI path.
  • JsonRepoProvider is the established Json* serde-boundary pattern. Folding it in would put serde/schemars derives on a src/git/ core type and weld the JSON representation to the internal enum's shape.

If the worry is specifically the two variant lists drifting out of sync, the genuine reduction is to model "+Unknown" in the type rather than as a parallel list:

enum GitRepoProvider {
    Known(CiPlatform),
    Unknown,
}

That deletes the 4-arm From<CiPlatform> match (p.into() becomes Known(p)) and makes "this is CiPlatform plus Unknown" impossible to get out of sync. The cost is at the ~10 match sites in remote_ref/mod.rs, which become GitRepoProvider::Known(CiPlatform::GitHub) etc., and the From<GitRepoProvider> for JsonRepoProvider boundary still has to exist. So it trades one mechanical mapping for slightly noisier matches — worth it if the can't-drift guarantee is the thing you want, a wash otherwise.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should worry about coupling the serialization to the types. Do we have separate types anywhere else in the repo?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — for a 1:1 enum like this, no, and the convention actually runs the other way: derive serde on the domain enum and serialize it directly, no parallel wire enum, no From.

The closest precedent is a sibling field in this very struct. JsonCi.source (json_output.rs:230) embeds the domain enum CiSource directly — assigned verbatim (source: pr.source, L543). CiSource lives in the CI layer (ci_status/mod.rs:296), carries its own #[serde(rename = "pr", alias = "pull-request")], and derives Serialize, Deserialize, JsonSchema right on the domain type. CiStatus does the same with rename_all = "kebab-case". So there's no JsonCiSource mirror — JsonRepoProvider + From<GitRepoProvider> is the only enum split of its kind in the tree.

Coupling serde onto a src/git/ type also already has precedent: IntegrationReason (git/mod.rs:103) derives serde::Serialize with kebab-case.

So I'd agree with collapsing it: add Serialize, JsonSchema to GitRepoProvider, move the per-variant renames onto it, drop JsonRepoProvider, its From impl (L306), and the round-trip test. That deletes a match arm you'd otherwise have to keep in sync each time a forge is added.

Two small notes if you go that way
  • Keep explicit per-variant renames, not rename_all. rename_all = "kebab-case" would emit git-hub/git-lab, so the variants need #[serde(rename = "github")] etc. (same as CiSource's explicit rename). azure-devops/unknown are unaffected.
  • JsonSchema would be the first schemars derive in src/git/ (serde::Serialize is already there via IntegrationReason; JsonSchema isn't). It's the same crate, so no new dependency — just the git module starting to carry a wire-schema derive. If keeping schemars out of src/git/ is a goal, that's the one argument for leaving the mirror in json_output.rs; otherwise the CiSource shape is the more consistent pick.

The one thing the separate-Json* convention genuinely buys you is at the struct level — JsonItem/JsonCi/JsonRepo reshape and rename domain data with no 1:1 counterpart, so they earn their keep. A 1:1 enum mirror doesn't.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented this!

Comment thread docs/content/list.md Outdated
Comment on lines +244 to +245
| `repo_url` | string | Web URL of the repository the PR/MR targets; absent for branch workflow URLs |
| `repo` | object | Structured metadata for the repository the PR/MR targets; never includes `remote` |

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the purpose of having both these fields?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repo_url is already released JSON API — it shipped in v0.56.0 via #2941, so consumers can be parsing it today. repo is the structured form this PR adds, and repo.url carries the byte-identical string: repo_url is now literally repo.map(|r| r.url.clone()) (json_output.rs:464, and the ci.repo_url analogue at L545).

So both exist for backward compatibility: an existing repo_url consumer keeps working, while new consumers read host / owner / name / provider / project off repo instead of re-parsing the URL string. Same split for ci.repo_url vs ci.repo.

The alternative is to drop repo_url in favor of repo.url — that's the cleaner shape, but it's a breaking change to a field released two versions ago, so it's a deprecation call rather than a free cleanup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @max-sixty I added this just as a QOL improvement on top of repo_url in 0.56.0 so that worktrunk providers don't need to parse up the repo URL but instead can grab the repo identity props directly. I know it's not the prettiest thing to have these similar props in the JSON API.

Quick context is I'm working on an ai agent orchestration app on top of worktrunk (love worktrunk btw!) in TS and this seemed like a win/win for worktrunk and wosm

@max-sixty

Copy link
Copy Markdown
Owner

These two were almost certainly regenerated in an environment that had GIT_PAGER set but not GIT_EDITOR. The robust fix is to regenerate with the same GIT_* env CI has (e.g. unset GIT_PAGER / ensure GIT_EDITOR is set, then cargo insta test); the inline suggestions below are the direct one-line patch to match what CI will produce.

@worktrunk-bot could you open a separate PR that makes running tests much more robust to the environment? folks who are contributing shouldn't have to worry about matching their local environment at all

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Opened #3037 for this.

Root cause: insta-cmd records Command::env_remove'd keys as KEY: "", and isolate_subprocess_env scrubs whichever GIT_*/WORKTRUNK_* vars exist in the parent env — so the recorded env: block depends on the host (CI has GIT_EDITOR, your box had GIT_PAGER). It's cosmetic (insta compares only the body, never the info: block), but it's exactly the machine-to-machine churn you flagged.

The fix is a dynamic .env redaction in add_standard_env_redactions that drops the empty-valued GIT_*/WORKTRUNK_* scrub markers, so regeneration produces the same block on any machine — no need to match CI's GIT_* environment. No snapshots are regenerated (the info: block isn't compared, so a metadata-only change never triggers a rewrite); they converge as bodies change. I noted in the PR that a one-shot cargo insta test --accept bulk regen is an easy follow-up if you'd rather have the tree uniform now.

@max-sixty

Copy link
Copy Markdown
Owner

@worktrunk-bot can you do a pass over this and find places we can simplify / cut the code mass? it seems @jeremy0dell has done a good job, but maybe the LLM has bloated the code a bit, based on the triple Struct case

What other opportunities do we have for simplifying and canonicalizing? I'm keen to keep the code sleek.

you can apply them directly if they're relatively persuasive, unless @jeremy0dell would rather apply them himself (@jeremy0dell you're obv free to revert anything)

JsonRepo mirrored GitRepoInfo field-for-field, with only an extra
`remote`, plus a manual `from_git_repo_info` mapping. Move `remote` onto
GitRepoInfo and serialize it directly, so a single canonical struct backs
both the `repo` and `ci.repo` JSON objects. Repository::repo_info() now
populates GitRepoInfo::remote and returns just the struct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Did a simplification pass, @max-sixty. The "triple struct" was GitRepoInfo + JsonRepo + JsonRepoProvider — @jeremy0dell already collapsed the provider enum in Collapse repo provider JSON enum, so the remaining leg was JsonRepo mirroring GitRepoInfo field-for-field with only an extra remote, plus a hand-written from_git_repo_info mapping.

Applied (refactor(list): collapse JsonRepo into GitRepoInfo, net −34 lines): moved remote onto GitRepoInfo, gave it the serde rename/skip rules, and deleted JsonRepo + the mapping. One canonical struct now backs both the repo and ci.repo JSON objects; Repository::repo_info() populates GitRepoInfo::remote and returns just the struct (no more (String, GitRepoInfo) tuple to thread through two call sites). repo_info_from_ref_url-derived metadata leaves remote unset, so ci.repo serializes identically to before. Full suite + clippy green locally; @jeremy0dell, revert freely if you'd rather shape it differently.

Other opportunities I looked at

Left alone deliberately — the bigger remaining duplication, but risky. Provider detection and Azure org/project/repo extraction now live in two places: GitRemoteUrl::repo_info / azure_repo_info_parts in src/git/url.rs (parses a remote URL) and repo_info_from_ref_url_with_provider / azure_repo_segments in src/git/remote_ref/mod.rs (parses a PR/MR URL). They look similar but operate on genuinely different shapes — the PR URL carries the repo as its own segment, so the _git/v3 marker offsets differ, and the whole point of this PR was getting those per-shape semantics exactly right. Unifying them would be the largest cut but the highest-risk one, so I'd rather flag it than fold it in here.

Tiny, safe if you want it. In repo_info_from_ref_url_with_provider the Unknown-fallback pair

let name = repo_segments.last()?.to_string();
let owner = repo_segments[1..repo_segments.len() - 1].join("/");

appears twice (Azure-unrecognized branch and the final branch) — could hoist to one let (owner, name) = ... above the if.

Intentional, not bloat. The top-level repo_url and ci.repo_url string fields duplicate repo.url / ci.repo.url, but the PR description calls those out as compatibility fields, so I left them.

@max-sixty

Copy link
Copy Markdown
Owner

@worktrunk-bot please resolve conflicts

…rovider-semantics

# Conflicts:
#	docs/content/list.md
#	skills/worktrunk/reference/list.md
#	src/cli/mod.rs
#	src/commands/list/json_output.rs
#	tests/snapshots/integration__integration_tests__help__help_list_long.snap
#	tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap
@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Resolved the conflicts with main and pushed the merge (c68725a).

The conflicts were all in the wt list --format=json surface, where main had landed ci.review_state while this PR adds ci.repo / repo:

  • src/commands/list/json_output.rs — kept both new JsonCi fields (repo then review_state); the auto-merged constructor already populates both. Updated the inline test_json_ci_serialization snapshot to the merged field order.
  • src/cli/mod.rs (ci object help table) — kept main's refined repo_url wording, plus the repo row and ### repo object section from this PR, and main's review_state row.
  • Generated mirrors (docs/content/list.md, skills/worktrunk/reference/list.md, help_list_long / help_list_narrow_80 snapshots) — regenerated via the docs-sync + insta pipeline rather than hand-merged. The narrow snapshot also picked up main's list-rendering changes (CI #NNN column, colored ○ summary glyph).

cargo build, clippy, and the list / ci_status / json_output / test_help / test_docs_are_in_sync suites are all green locally.

max-sixty pushed a commit that referenced this pull request Jun 12, 2026
#3037)

Opened at @max-sixty's request in
#3021 (comment)
— make running/regenerating tests robust to the contributor's local
environment so nobody has to match CI's `GIT_*` setup.

## The problem

`insta_cmd` records every env var a test command sets **or removes** —
Rust's `Command::env_remove` registers in `get_envs()`, and insta-cmd
serializes a removal as `KEY: ""`. `isolate_subprocess_env` scrubs
whichever `GIT_*` / `WORKTRUNK_*` keys exist in the **parent**
environment, so the recorded `env:` block depends on the host:

- CI has `GIT_EDITOR` set → `GIT_EDITOR: ""`
- A contributor's box might have `GIT_PAGER` → `GIT_PAGER: ""`, or
neither, or both

This is what bit #3021: `help_list_long.snap` /
`help_list_narrow_80.snap` were regenerated on a box with `GIT_PAGER`
and picked up `GIT_PAGER: ""` instead of CI's `GIT_EDITOR: ""`.

Note this is **cosmetic, not a CI failure**: insta's `matches_fully`
compares only the snapshot body (`self.contents == other.contents`),
never the `info:`/`env:` metadata. But the spurious machine-to-machine
diff is exactly what makes contributors think they have to match CI's
environment.

## The fix

A dynamic `.env` redaction in `add_standard_env_redactions`
(`strip_host_scrubbed_env_markers`) drops every empty-valued `GIT_*` /
`WORKTRUNK_*` entry from the recorded block. It runs as its own pass
*after* the per-key `.env.*` redactions, so affirmatively-set vars
(config paths → `[TEST_CONFIG]`, coverage → `[LLVM_PROFILE_FILE]`)
already hold non-empty placeholders and are kept. The explicitly-named
unconditional scrubs (`NO_COLOR`, `SHELL`, `PSModulePath`) are
host-independent and left intact.

Result: regenerating on any machine produces the same `env:` block,
regardless of the contributor's `GIT_*` environment.

## Why no snapshots are regenerated

The `info:` block is never compared, so a metadata-only difference never
triggers a rewrite — even `INSTA_UPDATE=always` leaves an unchanged-body
snapshot alone. Existing committed snapshots keep their (now-redundant)
markers until their body next changes, at which point the rewrite drops
them cleanly. This avoids a disruptive ~1000-file cosmetic diff that
would conflict with every in-flight snapshot PR; the robustness applies
to all future regenerations from this point on.

If you'd prefer the tree uniform now, a follow-up `cargo insta test
--accept` bulk regen is a clean separate commit — happy to push it.

## Verification

- New unit test `strip_host_scrubbed_env_markers_normalizes_env_block`
covers drop/keep/passthrough.
- Confirmed end-to-end: deleting `help_list_long.snap` and forcing a
fresh write drops `GIT_EDITOR: ""` while keeping
`NO_COLOR`/`SHELL`/`PSModulePath` and the `"0"`-valued
`WORKTRUNK_TEST_*` vars.
- `help` (57), `snapshot_formatting_guard` (6), and `ci_status` (68)
snapshot suites pass; `cargo fmt --check` and `cargo clippy --tests
--all-features` clean.

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

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread docs/content/list.md
| `is_previous` | boolean | Previous worktree from wt switch |
| `ci` | object | CI status (see below); absent when no CI |
| `repo_url` | string | Repository web URL derived from the primary remote; absent when the remote URL cannot be parsed |
| `repo` | object | Structured repository metadata (see below); includes `remote` |

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this, vs. the "never includes remote" phrase below? Can you explain?

Ideally we would canonicalize onto repo, deprecate the repo_url variable in favor of repo.url.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remote here isn't repo data — it's a single string naming the local git remote the metadata was derived from (e.g. "origin"). See GitRepoInfo: the field is Option<String>, "Set only for the top-level repo of wt list; absent for PR/MR-URL-derived metadata such as ci.repo."

The two phrases describe where that name can exist:

  • Top-level repo is built in Repository::repo_info() from self.primary_remote() → info.remote = Some(remote). It came from a local remote, so it can report which one → "includes remote".
  • ci.repo is derived from the PR/MR URL (for fork PRs, the upstream target), not from any local remote, so there's no remote name to attach → "never includes remote".

So the asymmetry is exactly that one field. I can make the docs clearer — the column descriptions read like remote is a sub-object. Something like "names the local git remote it was derived from (e.g. origin)" on the repo row, dropping the bare "includes remote" / "never includes remote" phrasing, would be less ambiguous.

On canonicalizing onto repo and deprecating repo_url/ci.repo_url in favor of repo.url/ci.repo.url: agreed that's the cleaner end state. It's orthogonal to this PR (which keeps them as compat fields), so I'd suggest a follow-up rather than folding it in here — and since these are documented JSON fields, dropping them is a soft break for consumers, so a deprecation-noted transition (keep emitting, document as deprecated, remove later) reads better than a hard cut. Happy to open that follow-up PR if you'd like it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that canonicalizing onto repo would improve the API but yeah, I wouldn't want anything to break

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@max-sixty Just checking, do you want me to close this? I can make a PR canonicalizing onto repo or just leave the API alone for now and parse everything on my end

@max-sixty
max-sixty merged commit 0681a2e into max-sixty:main Jun 18, 2026
36 checks passed
@max-sixty

Copy link
Copy Markdown
Owner

sorry for the delay and thank you again @jeremy0dell !

@max-sixty max-sixty mentioned this pull request Jun 18, 2026
max-sixty added a commit that referenced this pull request Jun 18, 2026
Release v0.60.0 (minor bump from 0.59.0).

Minor because semver-checks flagged a breaking library-API change and
five new features landed since v0.59.0. The data-loss surface review (4
independent finders) cleared the one change that touches it (relocating
dirty linked worktrees, #3104).

### Improved
- Package installs now complete branch/worktree names via dynamic
registration (#3105, thanks @bendrucker)
- `wt step relocate` moves dirty linked worktrees (#3104, thanks
@lunaynx for reporting)
- `--dry-run` previews print to stdout (#3085)
- Picker gutter distinguishes local (`/`) and remote (`|`) branches
(#3115)
- `wt list --format=json` gains structured `repo`/`ci.repo` metadata
(#3021, thanks @jeremy0dell)
- `wt step eval --format=json` (#3106)
- `--format=claude-code` rejected on `wt list` / `wt config state get`
(#3116)

### Fixed
- `cargo install worktrunk` builds from crates.io again (#3124, fixes
#3123, thanks @kerrickstaley for reporting)
- Tab-completion works in repos with no commits (#3097, closes #3094)
- `wt config state get ci-status --format=json` honors
`[forge].platform` (#3120)

### Documentation
- `wt list --help` example tables fit the terminal (#3125)
- `/wt-switch-create` cross-repo handling reworked (#3118)
- Help-text and web-doc refinements (#3096, #3108, #3110, #3112, #3126)

Full notes in `CHANGELOG.md`.

> _This was written by Claude Code on behalf of max_
max-sixty added a commit that referenced this pull request Jun 18, 2026
Reconcile the v0.60.0 release with the custom-columns feature. Both sides
changed `JsonItem::from_list_item`: main replaced the `repo_url` arg with a
`repo: Option<&GitRepoInfo>` + `ci_provider_override` pair (#3021, #3120),
while this branch added a trailing `custom_columns` arg. The merged signature
carries all three (main's two, then `custom_columns`), and every call site
(`to_json_items`, the statusline path, and the JSON unit tests) passes the
combined argument list.

`ItemKind::Branch` also gained a `BranchScope` payload on main (gutter sigils,
#3115); the `new_branch_with_scope` constructor now sets both `custom_values`
and `kind: ItemKind::Branch(scope)`. `to_json_items` keeps this branch's
snapshot-based `all_vars` read alongside main's `repo_info`/forge-platform
lookups. The `wt list --help` snapshot was regenerated from the merged
`cli/mod.rs`, combining main's width-chopped example tables with the
custom-columns column row and config section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Jun 18, 2026
Reconcile the v0.60.0 release with the custom-columns feature. Both sides
changed `JsonItem::from_list_item`: main replaced the `repo_url` arg with a
`repo: Option<&GitRepoInfo>` + `ci_provider_override` pair (#3021, #3120),
while this branch added a trailing `custom_columns` arg. The merged signature
carries all three (main's two, then `custom_columns`), and every call site
(`to_json_items`, the statusline path, and the JSON unit tests) passes the
combined argument list.

`ItemKind::Branch` also gained a `BranchScope` payload on main (gutter sigils,
#3115); the `new_branch_with_scope` constructor now sets both `custom_values`
and `kind: ItemKind::Branch(scope)`. `to_json_items` keeps this branch's
snapshot-based `all_vars` read alongside main's `repo_info`/forge-platform
lookups. The `wt list --help` snapshot was regenerated from the merged
`cli/mod.rs`, combining main's width-chopped example tables with the
custom-columns column row and config section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
worktrunk 0.60.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

- **Package installs complete branch and worktree names**: A plain `brew install worktrunk` (or other package install) now tab-completes branch and worktree names, not just subcommands and flags. `wt config shell completions <shell>` emits a dynamic registration that calls the binary at completion time (the maintained `clap_complete::env` path), matching what `gh`, `rustup`, and `kubectl` ship. ([#3105](max-sixty/worktrunk#3105), thanks @bendrucker)

- **`wt step relocate` moves dirty linked worktrees**: Relocating a linked worktree with uncommitted or untracked changes no longer skips it. `git worktree move` carries those files along, so the dirty-skip was a worktrunk policy rather than a git limitation. The main worktree still skips when dirty (without `--commit`), since its relocation falls back to `git checkout`, which refuses to switch over a dirty tree. ([#3104](max-sixty/worktrunk#3104), thanks @lunaynx for reporting)

- **`--dry-run` previews print to stdout**: `wt hook <type>`, `wt step relocate`, `wt step prune`, `wt step copy-ignored`, and `wt config shell install`/`uninstall` now send their `--dry-run` preview to stdout (the command's answer) while narration stays on stderr. This matches `wt list`, `git clean -n`, and `terraform plan`, and keeps previews pageable and pipeable. (Breaking: scripts reading these previews from stderr should now read stdout) ([#3085](max-sixty/worktrunk#3085))

- **Picker gutter distinguishes local and remote branches**: With `--branches` and `--remotes`, the `wt switch` picker marks each row's kind in the gutter: `/` for a local branch without a worktree and `|` for a remote branch, alongside the existing `@`/`^`/`+` worktree glyphs. ([#3115](max-sixty/worktrunk#3115))

- **`wt list --format=json` gains structured `repo` metadata**: JSON output adds `repo` (the local checkout's primary remote) and `ci.repo` (the repository `ci.url` targets, which differs for fork PRs) objects carrying `host`/`owner`/`name`/`provider`; the existing `repo_url` and `ci.repo_url` strings remain. The provider honors the configured `[forge].platform` on hosts that can't be auto-detected. ([#3021](max-sixty/worktrunk#3021), thanks @jeremy0dell)

- **`wt step eval --format=json`**: `wt step eval` gains a structured `{name, template, result}` JSON lane on stdout, the machine-readable analog of its `-v` view. Text mode is unchanged. ([#3106](max-sixty/worktrunk#3106))

- **`--format=claude-code` rejected where it never applied**: `wt list` and `wt config state get` accepted `--format=claude-code` silently and treated it as `table`; the value only ever meant anything on `wt list statusline`. Both now fail fast with `invalid value 'claude-code'`. (Breaking: `wt list --format=claude-code` and `wt config state get --format=claude-code` now error) ([#3116](max-sixty/worktrunk#3116))

### Fixed

- **`cargo install worktrunk` builds from crates.io**: Installing from crates.io failed to compile with `environment variable VERGEN_GIT_DESCRIBE not defined at compile time`, because the package archive has no `.git` for the build script to read. The version lookup now falls back to the cargo package version when git-describe is unavailable. ([#3124](max-sixty/worktrunk#3124), fixes [#3123](max-sixty/worktrunk#3123), thanks @kerrickstaley for reporting)

- **Tab-completion works in repos with no commits**: `wt switch <TAB>` and `wt remove <TAB>` returned nothing on a fresh `git init` repo, because the unborn default branch has no entry under `refs/heads/`. Completion now also draws on worktree branches, so the unborn `main` (and any `wt switch --create` branch on an empty repo) completes. ([#3097](max-sixty/worktrunk#3097), closes [#3094](max-sixty/worktrunk#3094))

- **`wt config state get ci-status --format=json` honors `[forge].platform`**: On a self-hosted host the parser can't recognize (Gitea, Azure DevOps, GitHub Enterprise on a generic domain), this path reported `provider: "unknown"` while `wt list --format=json` reported the configured provider. All structured-output paths now route the `[forge].platform` override through one accessor, so they stay consistent. ([#3120](max-sixty/worktrunk#3120))

### Documentation

- **`wt list --help` example tables fit the terminal**: The captured `wt list` example tables in `--help` chop to terminal width with a dimmed ellipsis (matching real `wt list`) instead of word-wrapping and shearing their columns; hand-authored command examples still wrap. ([#3125](max-sixty/worktrunk#3125))

- **`/wt-switch-create` cross-repo handling reworked**: The Claude Code plugin command's skill reworks how it creates and enters a worktree in another repo, built around what the harness actually does (`EnterWorktree` re-roots within the current repo; a `cd` reaches another repo when it's in `additionalDirectories`). The procedure dropped from five steps to three. ([#3118](max-sixty/worktrunk#3118))

- **Help-text and docs refinements**: `--format` help renders its values inline (`[possible values: table, json]`) instead of an expanded block; `wt switch` and `wt step push` help got smaller clarifications; and web-doc terminal blocks no longer wrap command-only lines. ([#3096](max-sixty/worktrunk#3096), [#3108](max-sixty/worktrunk#3108), [#3110](max-sixty/worktrunk#3110), [#3112](max-sixty/worktrunk#3112), [#3126](max-sixty/worktrunk#3126))

## Install worktrunk 0.60.0

### Install prebuilt binaries via shell script

```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-installer.sh | sh && wt config shell install
```

### Install prebuilt binaries via powershell script

```sh
powershell -ExecutionPolicy Bypass -c "irm https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-installer.ps1 | iex"; git-wt config shell install
```

### Install prebuilt binaries via Homebrew

```sh
brew install worktrunk && wt config shell install
```

## Download worktrunk 0.60.0

|  File  | Platform | Checksum |
|--------|----------|----------|
| [worktrunk-aarch64-apple-darwin.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-aarch64-apple-darwin.tar.xz) | Apple Silicon macOS | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-aarch64-apple-darwin.tar.xz.sha256) |
| [worktrunk-x86_64-apple-darwin.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-x86_64-apple-darwin.tar.xz) | Intel macOS | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-x86_64-apple-darwin.tar.xz.sha256) |
| [worktrunk-x86_64-pc-windows-msvc.zip](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-x86_64-pc-windows-msvc.zip) | x64 Windows | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-x86_64-pc-windows-msvc.zip.sha256) |
| [worktrunk-aarch64-unknown-linux-musl.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-aarch64-unknown-linux-musl.tar.xz) | ARM64 MUSL Linux | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-aarch64-unknown-linux-musl.tar.xz.sha256) |
| [worktrunk-x86_64-unknown-linux-musl.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-x86_64-unknown-linux-musl.tar.xz) | x64 MUSL Linux | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.60.0/worktrunk-x86_64-unknown-linux-musl.tar.xz.sha256) |



### Install via Cargo

```sh
cargo install worktrunk && wt config shell install
```

### Install via Winget (Windows)

```sh
winget install max-sixty.worktrunk && git-wt config shell install
```

### Install via AUR (Arch Linux)

```sh
paru worktrunk-bin && wt config shell install
```
</pre>
  <p>View the full release notes at <a href="https://github.com/max-sixty/worktrunk/releases/tag/v0.60.0">https://github.com/max-sixty/worktrunk/releases/tag/v0.60.0</a>.</p>
</details>
<hr>

See merge request: Harmonybrew/homebrew-core!11933
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.

4 participants