Skip to content

fix: resolve project config from bare root when primary worktree is off default branch - #3462

Merged
max-sixty merged 5 commits into
mainfrom
fix/issue-3461
Jul 14, 2026
Merged

fix: resolve project config from bare root when primary worktree is off default branch#3462
max-sixty merged 5 commits into
mainfrom
fix/issue-3461

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Problem

In a bare-repo layout, running wt switch --create <branch> from the bare root silently skips all project hooks (pre-start, post-start, …) when the primary worktree happens to be checked out to a non-default branch. No error, no "declined" message — the operation reports success and only user hooks fire.

Root cause, as diagnosed in #3461: from the bare root, project_config_path() locates the project config via primary_worktree(), which looks up the worktree whose branch equals the default branch. When an agent-driven workflow briefly parks a PR/feature branch in the primary worktree, no worktree holds the default branch, so primary_worktree() returns None, project_config_path() returns None, and HookPlanBuilder drops the project source silently. This is the gap left by #1692's primary_worktree() fallback for the bare-root path.

Solution

Scope the fix to project-config resolution. When the default-branch worktree isn't checked out anywhere (primary_worktree() is None), fall back to the first non-bare worktree that actually ships a .config/wt.toml rather than dropping all project hooks. Project config is a repo-level artifact present in every worktree, so this restores hooks without depending on the default branch's checkout state.

primary_worktree() semantics are deliberately unchanged — see the design note below.

Testing

Added test_bare_repo_project_config_found_when_primary_on_non_default_branch in tests/integration_tests/bare_repository.rs, a sibling to the existing #1691 tests. It commits a post-start hook to the primary worktree's config, checks out a non-default branch there, runs wt switch --create from the bare root, and asserts the hook fires. It fails on main (marker never written) and passes with this change. The full test_bare_repo* suite (24 tests, including test_bare_repo_ignores_config_in_bare_root) still passes — the fallback scans worktree paths, never the bare root, so a config placed in the bare root is still correctly ignored.

Design note for reviewers

The issue proposed fixing primary_worktree() itself to identify the primary "by role, e.g. the first non-linked worktree." I did not take that path, for two reasons:

  • Git records no "primary" among linked worktrees in a bare layout. All worktrees are linked, and git worktree list --porcelain orders them alphabetically, not by role or creation. In a main + feat layout with the primary off-branch, "the first worktree" is feat, not main — so that fallback would pick the wrong worktree.
  • Changing primary_worktree() broadly would also move {{ primary_worktree_path }}, copy-ignored's source, and is_primary_worktree(), and contradicts the documented terminology (primary worktree = default-branch worktree for bare repos). copy-ignored already fails with a clear error in this state rather than silently, so the silent loss is specific to project config.

If a maintainer would prefer a role-based primary_worktree() redefinition (which would also set {{ primary_worktree_path }} in this state), that's a larger, separate change with a real semantic decision — happy to follow up.


Closes #3461 — automated triage

…t branch

In a bare-repo layout, running a command from the bare root resolves
project config via primary_worktree(), which finds the worktree whose
branch equals the default branch. When the primary worktree is briefly
checked out to a non-default branch (common in agent-driven workflows),
no worktree holds the default branch, so project_config_path() returned
None and every project hook was silently dropped with no error.

Fall back to the first non-bare worktree that ships a .config/wt.toml
when the default-branch worktree isn't checked out anywhere. Project
config is a repo-level artifact present in every worktree, so this
restores project hooks without depending on the default branch's
checkout state. Scoped to project-config resolution; primary_worktree()
semantics are unchanged.

Closes #3461

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Self-review (COMMENT — can't self-approve). The fix is well-scoped and the regression test genuinely fails on main: with the primary parked off the default branch, primary_worktree() is None, the hook plan is empty, and the marker never gets written. The is_file() gate is right for the fallback (it has to find which worktree carries the config), and it correctly avoids the bare root because list_worktrees() already filters bare entries (worktrees.rs:31, .filter(|wt| !wt.bare)) — so test_bare_repo_ignores_config_in_bare_root stays green.

One design point worth a maintainer's eye (inline). Also: the fallback's safety hinges on that !wt.bare filter, but no test pins it in the off-branch statetest_bare_repo_ignores_config_in_bare_root only covers the primary-present case where this loop never runs. A companion negative test (bare-root config present and primary parked off-branch, asserting the hook does not fire) would lock the invariant down so a future change to the filter can't silently start reading bare_root/.config/wt.toml.

Comment thread src/git/repository/config.rs Outdated
The new test in 07aa23c wrote the `config show` builder call on one
line; rustfmt wraps `.current_dir(...)` onto its own line. Fixes the
failing `lint` (cargo fmt) check.
@max-sixty

Copy link
Copy Markdown
Owner

I'm skeptical of "find the first", seems arbitrary and likely to lead to confusion

how about loading it in with git show? I realize it'll be another 50ms, but seems reasonable?

Replace the worktree-scan fallback with an object-store read of the
default branch's committed .config/wt.toml (git show <default>:.config/wt.toml)
when the primary worktree is parked off the default branch. This reads the
canonical default-branch config rather than whichever branch a worktree
happens to hold, addressing review feedback that 'find the first' worktree
was arbitrary.
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Agreed — and reading it via git show is not just cleaner, it's more correct. "Find the first worktree with a config" also reads whatever branch that worktree happens to be parked on, so a divergent .config/wt.toml on a feature branch would win over the default branch's. Loading git show <default-branch>:.config/wt.toml always reads the canonical default-branch config regardless of what's checked out where.

Pushed in a666864:

One scoping note: project_config_path() returns a PathBuf that write-commands (wt config create / update) target, and git show yields content, not a path — so the object-store read lives in the content loader rather than in project_config_path(). In this transient state the write-commands resolve to None (exactly as they did before #3461); only config reads (hooks) get the default-branch fallback. Happy to extend it to the write path too if you'd prefer, but that felt like scope creep for this fix.

The regression test now also pins the semantics: after parking the primary off-branch it overwrites the working-tree config with a divergent hook and asserts that hook does not run — proving resolution reads the committed default-branch tree, not the checked-out files.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Self-review (COMMENT — can't self-approve). The git show rework reads cleanly and the two new tests pin the important semantics: the default branch's committed config wins over the parked worktree's on-disk files, and an absent config resolves to None rather than a phantom. One merge gate and one narrow correctness question below.

codecov/patch is failing (merge gate). Six added lines are uncovered, both defensive branches the two new tests never exercise:

  • src/config/project.rs lines 276–280 — the map_err wrapper for default_branch_project_config_content() returning Err. Only a git show spawn failure hits this; a non-zero exit is already folded into Ok(None) inside the helper, so the happy-path tests never take it.
  • src/git/repository/config.rs line 724 — the let Some(branch) = self.default_branch() else { return Ok(None) } guard. In the bare + primary-off-branch state the default-branch ref still exists (it's just checked out nowhere), so default_branch() returns Some and this arm is never reached; both new tests park main off-branch with main still a live ref.

Both are genuinely hard to drive deterministically. Per the codecov merge-gate rule in CLAUDE.md (§Coverage), this needs either a targeted test or explicit maintainer sign-off before merge — flagging rather than rubber-stamping over the red signal.

Override-contract question (narrow). default_branch_project_config_content() isn't gated on WORKTRUNK_PROJECT_CONFIG_PATH. project_config_path() documents that override as authoritative — "A missing file at that path still resolves to Ok(None)" — and the test at switch.rs (test_switch_create_honors_project_config_path_override) asserts it "overrides the path for every config read." But in the new fallback path, a set-but-missing override in the bare + primary-off-branch state falls through the _ arm in load() and reads git show <default>:.config/wt.toml instead of resolving to None — so the override is no longer authoritative there. Contrived (non-existent override target + bare + off-branch), but it does make that doc/contract false in exactly the state this PR adds. Worth a one-line guard (if std::env::var_os("WORKTRUNK_PROJECT_CONFIG_PATH").is_some() { return Ok(None); }) at the top of the helper if you want the override to stay the single source; happy to leave it if you consider the combination out of scope.

Address codecov/patch: restructure default_branch_project_config_content to
read HEAD:.config/wt.toml (bare HEAD is a symref to the default branch, always
resolvable, no branch lookup) and match on the git show result so the no-config
arm is exercised by the negative test. Returns Option, dropping the error
closure in ProjectConfig::load. All new lines are now covered.
@max-sixty
max-sixty merged commit f66b49e into main Jul 14, 2026
37 checks passed
@max-sixty
max-sixty deleted the fix/issue-3461 branch July 14, 2026 07:56
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Small refinement in 228df21: the read uses git show HEAD:.config/wt.toml, not a resolved branch name. At the bare root the repo's own HEAD is a symbolic ref to the default branch (it's what local default-branch inference reads), so it reads the same committed config, always resolves in a bare repo, and needs no branch lookup — which also let the helper fold its defensive None paths into a single match arm the negative test exercises, keeping codecov/patch at 100%.

CI is green — test (linux/macos/windows), code-coverage, and codecov/patch all pass.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Correction to my note above on 228df21: git show HEAD:.config/wt.toml is only equivalent to the default branch at the bare root. git show resolves HEAD against the invocation cwd's per-worktree HEAD, and the read runs with discovery_path (the cwd) as its working directory — so when wt is invoked from inside a linked worktree parked on another branch (the agent case this fix targets), HEAD is that worktree's branch. The fallback then reads the wrong branch's config, or — when the invoking worktree's branch has no committed config — drops the default branch's config and every project hook, reintroducing #3461. The two regression tests here only invoke from the bare root, so they didn't catch it.

Opened #3466 to resolve the default branch by name (<default-branch>:.config/wt.toml, cwd-independent — what your original "load it in with git show" request did), with a regression test that invokes from inside a linked worktree.

Verified end-to-end

Bare repo; main (default) carries a post-start hook and is checked out nowhere; a second worktree other whose branch dropped the config on disk:

  • Invoked from the bare rootgit show HEAD: reads main's config → hook fires ✅ (what the merged tests cover).
  • Invoked from the other worktreeHEAD = other → no config → hook dropped ❌.

git show main:.config/wt.toml reads the config correctly from either cwd. The new test fails against the merged HEAD: behavior and passes with the by-name fix; the two existing #3461 tests stay green.

max-sixty added a commit that referenced this pull request Jul 14, 2026
Semantic resolution beyond the textual conflict: main's #3462 committed
default-branch config fallback (default_branch_project_config_content)
now returns None when WORKTRUNK_PROJECT_CONFIG_PATH is set — an explicit
override names the config source outright, so the committed fallback
must not supersede it (it exists for test isolation, where reading the
repo's own committed config is the leak being prevented). Pinned by
test_bare_repo_committed_config_does_not_supersede_override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
max-sixty pushed a commit that referenced this pull request Jul 14, 2026
Follow-up to #3462 (merged). That PR's final commit changed the
object-store fallback to read `git show HEAD:.config/wt.toml`, and this
reintroduces #3461 in the common agent case.

## The bug

`git show` resolves `HEAD` against the **invocation cwd's per-worktree
HEAD**, and the read runs with `discovery_path` (the cwd `wt` was
invoked from) as its working directory. When `wt` runs from *inside a
linked worktree parked on another branch* — exactly the agent-driven
workflow the #3461 fix targets — `HEAD` is that worktree's branch, not
the default branch. So the fallback either reads the wrong branch's
config or, when the invoking worktree's branch carries no committed
config, drops the default branch's config and every project hook.

The merged PR's two regression tests only invoke `wt` from the **bare
root**, where `HEAD` does point at the default branch, so they can't
catch this.

## Verified end-to-end

Bare repo; default branch `main` has `.config/wt.toml` with a
`post-start` hook; `main` checked out in no worktree; a second worktree
`other` whose branch dropped the config on disk:

| Invoked from | `git show HEAD:.config/wt.toml` | Hook fires? |
|---|---|---|
| bare root | reads `main`'s config | ✅ (what the merged tests cover) |
| `other` worktree | reads `other`'s HEAD → no config | ❌
**default-branch config dropped** |

The absolute ref `git show main:.config/wt.toml` reads the config
correctly from any cwd — which is what the pre-regression commit (and
@max-sixty's original "load it in with git show" request on #3462) did.

## The fix

Resolve the default branch **by name**
(`<default-branch>:.config/wt.toml`), which is cwd-independent. This is
a one-line change back to an absolute ref, plus a doc-comment
correction.

The added regression test invokes `wt switch --create` from inside the
`other` worktree and asserts the default branch's hook fires. It fails
against the merged `HEAD:` behavior (marker never written) and passes
with the fix; the two existing #3461 tests stay green.

## Coverage note

`self.default_branch()?` restores an early-`None` return when the
default branch can't be determined (bare repo with no resolvable
default). That branch is a defensive guard and isn't exercised by the
tests — the `HEAD` form avoided it, which is likely why it was
introduced, but correctness outranks covering a one-line
unreachable-in-practice guard. Flagging in case `codecov/patch` trips on
it.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
max-sixty added a commit that referenced this pull request Jul 14, 2026
…ot (#3460)

`WORKTRUNK_PROJECT_CONFIG_PATH` was returned verbatim, so a relative
value resolved against the process cwd while the default
`.config/wt.toml` is anchored to the worktree root. Running `wt` from a
subdirectory made a relative override silently no-op (or read an
unintended file). This is the config-path footgun from #3454 (the RFC
there is a separate discussion).

A relative override now resolves against the same worktree root as the
default (current worktree, or the primary worktree at a bare root), so
it behaves identically from any subdirectory. Values that can't be
anchored fail loudly instead of guessing:

- relative with no worktree root to anchor to (bare repo with no linked
worktrees) errors
- Windows forms that are neither fully absolute nor relative
(drive-relative `C:cfg`, rooted-but-driveless `\cfg` or `/tmp/x`) error
rather than resolving against the process drive or cwd

Absolute values are unchanged (returned verbatim, no git calls), which
keeps the test-isolation use of the var working. An empty value still
means no project config, as before. `wt config show` (human format) and
`wt config update` previously folded a `project_config_path()` error
into "not in a git repository" / a silent skip; they now propagate it,
matching the JSON path.

Merging main brought in #3462's committed default-branch config fallback
for bare repos whose primary worktree is parked off the default branch.
That fallback now stands down when `WORKTRUNK_PROJECT_CONFIG_PATH` is
set: an explicit override names the config source outright (a missing
file there means no project config), and the override exists for test
isolation, where reading the repo's own committed config is exactly the
leak being prevented.

The human format of `wt config show` now reports "No project config"
when no location exists (bare repo with no worktrees, empty override)
instead of the inaccurate "Not in a git repository", which stays
reserved for actually being outside a repo.

Compatibility: invoked from the worktree root, cwd and root coincide, so
old and new resolution agree. From a subdirectory the old resolution
found nothing, which is the bug being fixed, so no deprecation shim is
included.

Thanks @indexzero for reporting in #3454.

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@max-sixty max-sixty mentioned this pull request Jul 15, 2026
max-sixty added a commit that referenced this pull request Jul 15, 2026
Release v0.68.0. Highlights: on-demand picker preview tabs (#3439),
bare-repo project config read from the object store when the default
branch is checked out nowhere (#3462), shell detection via the process
tree (#3455), and `wt config state` flagging a stale default-branch
cache (#3478). Full details in CHANGELOG.md.

> _This was written by Claude Code on behalf of max_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automated-fix Automated CI fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wt switch --create silently skips project pre-start hooks when primary worktree is on a non-default branch (bare-repo layout)

2 participants