fix: resolve the login pin from a git worktree #46

Merged
claude merged 2 commits from fix/worktree-login-pin into main 2026-08-10 13:25:15 +00:00
Collaborator

Closes #24.

Stacked PR — base is fix/no-store-for-label-payloads, not main. It
builds on the repo-root resolution that branch added to _gitea.py, so review
or merge that one first.

The bug

_gitea.require_login called find_pin(os.getcwd()) and walked up from there
and nowhere else. A git worktree is a sibling of the main checkout, not a
descendant, and .claude/settings.local.json is untracked — so the pin sits in
the main checkout, is not on the worktree's parent chain, and every script that
talks to Gitea (pull.py, push.py, remote.py, comment.py, labels.py,
wiki_*.py) died inside a worktree with "no login pinned". In the same
directory tea itself worked: the guard hook had a second, different copy of
the search.

The fix

The search order is written once, in skills/auth/scripts/pin.py, and both
callers import it — _gitea.require_login and hooks/tea-guard.sh:

  1. $CLAUDE_PROJECT_DIR
  2. a hint the caller supplies (the hook passes its payload's cwd; scripts
    pass nothing)
  3. the current directory

Each start directory is searched up its parent chain, and only if that finds
nothing, up the parent chain of the main working tree of any linked worktree
met on the way — reached by reading gitdir: out of the .git file and
following commondir. Files, not git rev-parse: a PreToolUse hook runs before
every Bash call and should not fork to answer this.

The search still starts at the working directory, never at __file__ — the
constraint in #24. The asymmetry with issue.store_root and
_gitea.PAYLOAD_ROOT, which are anchored on their own module, is deliberate
and now documented in pin.py and AGENTS.md: where an installation keeps its
files is a fact about the installation, whose login a project runs under is a
fact about the project, and a plugin pointed at somebody else's tree must not
answer the second from its own directory.

The identity layer is the home for it because both callers may import it
without crossing a layer: no domain learns what a login is, and the bridge
does not have to reach into skills/issue for walk-up logic.

Consequences: /tea:auth never needs running inside a worktree, so no second
settings.local.json lands in a directory that disappears with the branch (the
skill now says to write it beside the common .git); and push.py can run
where the work is, so branch:ref is the worktree's branch instead of the
main checkout's, which the old cwd workaround got wrong.

Tests

tests/test_login_pin.py, 20 cases, stdlib unittest, temp dirs, no network:
the hop against a hand-built layout and against a real git worktree add; a
sync script run from the worktree finding the login; no pin anywhere still
erroring; the scripts' own directory not being a pin source; push.py from the
worktree sending ref: feature; the hook and a script answering the same
directory alike; and two mechanical checks that neither caller keeps a second
copy of the walk.

Three existing fixtures now copy skills/auth/scripts because the transport
imports it. python3 -m unittest discover -s tests — 264 pass, up from 244 on
the base branch, nothing regressed.

Verified by hand from the worktree with CLAUDE_PROJECT_DIR unset: both
remote.py and hooks/tea-guard.sh resolve the pin out of the main checkout.

🤖 Generated with Claude Code

Closes #24. **Stacked PR — base is `fix/no-store-for-label-payloads`, not `main`.** It builds on the repo-root resolution that branch added to `_gitea.py`, so review or merge that one first. ## The bug `_gitea.require_login` called `find_pin(os.getcwd())` and walked up from there and nowhere else. A git worktree is a *sibling* of the main checkout, not a descendant, and `.claude/settings.local.json` is untracked — so the pin sits in the main checkout, is not on the worktree's parent chain, and every script that talks to Gitea (`pull.py`, `push.py`, `remote.py`, `comment.py`, `labels.py`, `wiki_*.py`) died inside a worktree with "no login pinned". In the same directory `tea` itself worked: the guard hook had a second, different copy of the search. ## The fix The search order is written once, in **`skills/auth/scripts/pin.py`**, and both callers import it — `_gitea.require_login` and `hooks/tea-guard.sh`: 1. `$CLAUDE_PROJECT_DIR` 2. a hint the caller supplies (the hook passes its payload's `cwd`; scripts pass nothing) 3. the current directory Each start directory is searched up its parent chain, and only if that finds nothing, up the parent chain of the **main working tree of any linked worktree** met on the way — reached by reading `gitdir:` out of the `.git` *file* and following `commondir`. Files, not `git rev-parse`: a PreToolUse hook runs before every Bash call and should not fork to answer this. **The search still starts at the working directory, never at `__file__`** — the constraint in #24. The asymmetry with `issue.store_root` and `_gitea.PAYLOAD_ROOT`, which *are* anchored on their own module, is deliberate and now documented in `pin.py` and `AGENTS.md`: where an installation keeps its files is a fact about the installation, whose login a project runs under is a fact about the project, and a plugin pointed at somebody else's tree must not answer the second from its own directory. The identity layer is the home for it because both callers may import it without crossing a layer: no domain learns what a login is, and the bridge does not have to reach into `skills/issue` for walk-up logic. Consequences: `/tea:auth` never needs running inside a worktree, so no second `settings.local.json` lands in a directory that disappears with the branch (the skill now says to write it beside the common `.git`); and `push.py` can run where the work is, so `branch:` → `ref` is the worktree's branch instead of the main checkout's, which the old cwd workaround got wrong. ## Tests `tests/test_login_pin.py`, 20 cases, stdlib `unittest`, temp dirs, no network: the hop against a hand-built layout and against a real `git worktree add`; a sync script run from the worktree finding the login; no pin anywhere still erroring; the scripts' own directory not being a pin source; `push.py` from the worktree sending `ref: feature`; the hook and a script answering the same directory alike; and two mechanical checks that neither caller keeps a second copy of the walk. Three existing fixtures now copy `skills/auth/scripts` because the transport imports it. `python3 -m unittest discover -s tests` — 264 pass, up from 244 on the base branch, nothing regressed. Verified by hand from the worktree with `CLAUDE_PROJECT_DIR` unset: both `remote.py` and `hooks/tea-guard.sh` resolve the pin out of the main checkout. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude changed target branch from fix/no-store-for-label-payloads to main 2026-08-10 13:24:45 +00:00
claude added 1 commit 2026-08-10 13:24:45 +00:00
`_gitea.require_login` walked up from CWD and nowhere else. A worktree is a
sibling of the main checkout, not a descendant, and `settings.local.json` is
untracked — so the pin lives in the main checkout only, is not on the
worktree's parent chain, and the whole tracker half of the plugin died there
with "no login pinned". In the same directory the guard resolved it fine,
because it had a search of its own: one order, written twice, disagreeing.

It is written once now, in skills/auth/scripts/pin.py, and both callers import
it — the transport and hooks/tea-guard.sh. $CLAUDE_PROJECT_DIR, then a hint the
caller supplies (the hook passes its payload's cwd), then the current
directory; each searched up its parent chain, and only if that finds nothing,
across into the main working tree of a linked worktree met on the way, reached
by reading `gitdir:` out of the `.git` FILE and following `commondir`. No
subprocess — a PreToolUse hook runs before every Bash call and must not fork to
answer this.

The search still starts at the working directory and never at `__file__`,
deliberately asymmetric with `issue.store_root` and `_gitea.PAYLOAD_ROOT`.
Where an installation keeps its files is a fact about the installation; whose
login a project runs under is a fact about the project, and a plugin pointed at
somebody else's tree must not answer that from its own directory. pin.py says
so in as many words, so the next reader does not "fix" the inconsistency.

Two consequences fall out of it. `/tea:auth` no longer has any reason to run
inside a worktree, so no second pin lands in a directory that is deleted with
the branch — the skill now says to write it beside the common `.git`. And the
scripts can run where the work is: the workaround the bug forced, cwd in the
main checkout, made push.py send that checkout's branch as `ref`, which is the
one thing `branch:` exists to record.

tests/test_login_pin.py holds both halves: the hop against a hand-built layout
and against a real `git worktree add`, a run from the worktree finding the
login, no pin anywhere still erroring, the scripts' own directory not becoming
a source, `ref` coming out as the worktree's branch, and the hook and a script
answering the same directory alike. Two mechanical checks keep the callers from
growing a second copy of the walk. Three existing fixtures now copy
skills/auth/scripts, which the transport imports.

Refs #24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
naudachu added 1 commit 2026-08-10 13:25:12 +00:00
claude merged commit 74a0e3b173 into main 2026-08-10 13:25:15 +00:00
Sign in to join this conversation.