fix: resolve the login pin from a git worktree

`_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>
This commit is contained in:
naudachu
2026-08-10 18:12:04 +05:00
parent 627df76812
commit 46b6909728
11 changed files with 773 additions and 68 deletions
+27 -3
View File
@@ -32,13 +32,37 @@ So:
3. **One login:** propose it; confirm before writing.
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
`url` so the operator's choice is unambiguous. Never decide for them.
5. Merge the chosen name into `.claude/settings.local.json` under `env`
(do not clobber other keys):
5. Merge the chosen name into the **project root's**
`.claude/settings.local.json` under `env` (do not clobber other keys):
```json
{ "env": { "GITEA_LOGIN": "<chosen-name>" } }
```
**In a git worktree, write it to the main checkout, never to the worktree.**
A worktree is deleted when the branch is done, taking a pin written into it
with it, and one repository with two pins is one repository with two
identities. Both the guard and the scripts already reach the main checkout's
pin from inside any worktree — so there is nothing to pin a second time.
`git rev-parse --path-format=absolute --git-common-dir` names the `.git` to
write beside.
6. Done — it is live. The guard resolves the pin from the file on the next
`tea` call; no restart needed. Tell the operator which login is now pinned.
`tea` call; no restart needed. Tell the operator which login is now pinned,
and which file it went in.
## Where the pin is looked for
One search order, written once in `scripts/pin.py` and imported by both the
`tea-guard` hook and the sync/wiki transport — they cannot disagree about a
directory, and a test asserts neither keeps a copy of the walk.
`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's
`cwd`), then the current directory. Each is searched up its parent chain; only
if that finds nothing does the search cross into the main working tree of a
linked worktree, via `gitdir:` in the `.git` file. The plugin's own directory
is never a source — a plugin pointed at somebody else's project must take the
identity from that project, not from where it happens to be installed.
If a script reports "no login pinned", that is the honest answer: nothing was
found anywhere on that order. Pin one — at the project root.
## Identity-safety rules
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
pin.py — where the operator's Gitea login pin is, and how it is found.
**The search order lives here and nowhere else.** The `tea-guard` hook imports
this module; so does the transport every sync and wiki script runs on. Two
copies of the order is exactly how a git worktree came to have a working hook
and a dead transport in the same directory: `tea` resolved the login, the
scripts said "no login pinned", and the error told the operator to pin what was
already pinned.
Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse
hook runs before every Bash call and must not fork a process to answer this.
The pin is a file the OPERATOR owns and `/tea:auth` writes:
<project root>/.claude/settings.local.json -> env.GITEA_LOGIN
## Search order
Start directories, in order, first hit wins:
1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set
2. an explicit hint the hook passes the Bash tool's cwd; scripts pass
nothing and go straight to 3
3. the current directory
Each start directory is searched the same way:
a. up the parent chain, from the directory itself to the filesystem root
b. then, for each LINKED WORKTREE seen on that chain, up the parent chain
of that repository's main working tree
(b) is the whole point of this module. A worktree is a *sibling* of the main
checkout, not a descendant, so `.claude/settings.local.json` — untracked, and
therefore only ever in the main checkout — is not on the parent chain of (a).
Git knows the two trees are one repository: a worktree's `.git` is a FILE
holding `gitdir: <path>`, and `<path>/commondir` points back at the shared
`.git`. `git rev-parse --git-common-dir` answers the same question by forking;
this reads the files.
## Why the search does not start at __file__
Deliberate asymmetry with `issue.store_root` and `_gitea.PAYLOAD_ROOT`, which
*are* anchored on their own module's location. Two different questions:
where does this installation keep its files a fact about the plugin
whose login does this project run under a fact about the project
A plugin installed outside any repository and pointed at somebody else's tree
must answer the second one from the tree it was pointed at. Anchoring the pin
on `__file__` would make the plugin's own directory an identity source, which
is how a checkout ends up acting under a login nobody chose for it. So the
search runs from the working directory upward — and reaches a worktree's main
checkout by asking git, not by walking somewhere else.
Finding nothing is a real answer: `(None, None)` means there is no pin, and the
caller says so. This module never guesses a login.
"""
import json
import os
SETTINGS_PARTS = (".claude", "settings.local.json")
ENV_KEY = "GITEA_LOGIN"
PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR"
def settings_path(root):
"""The pin file for a project root. The one place this path is spelled."""
return os.path.join(root, *SETTINGS_PARTS)
def read_pin(path):
"""The login in a settings file, or None.
Unreadable, not JSON, no `env`, empty string — all the same answer. A
broken file is not a login and is not worth a traceback in a hook."""
try:
with open(path) as f:
value = (json.load(f).get("env") or {}).get(ENV_KEY)
except Exception:
return None
if isinstance(value, str) and value.strip():
return value.strip()
return None
def parents(start):
"""`start` and every ancestor of it, up to the filesystem root."""
d = os.path.abspath(start)
while True:
yield d
parent = os.path.dirname(d)
if parent == d:
return
d = parent
def gitdir_of(d):
"""The private git directory `d/.git` points at, or None.
Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a
directory and there is nothing to follow."""
p = os.path.join(d, ".git")
if not os.path.isfile(p):
return None
try:
with open(p) as f:
head = f.read(4096)
except OSError:
return None
for line in head.splitlines():
line = line.strip()
if line.startswith("gitdir:"):
target = line[len("gitdir:"):].strip()
if not target:
return None
if not os.path.isabs(target):
target = os.path.join(d, target)
return os.path.abspath(target)
return None
def main_worktree(d):
"""If `d` is a linked worktree, the main working tree of its repository.
`<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir`
file holds a path to `<main>/.git`; the main working tree is its parent.
The `.git` basename check keeps this to worktrees: a submodule's `.git`
is a pointer too, but it points into `<super>/.git/modules/…`, and the
tree it belongs to is already on the parent chain."""
gitdir = gitdir_of(d)
if not gitdir or not os.path.isdir(gitdir):
return None
common = gitdir
marker = os.path.join(gitdir, "commondir")
if os.path.isfile(marker):
try:
with open(marker) as f:
rel = f.read().strip()
except OSError:
rel = ""
if rel:
common = os.path.abspath(os.path.join(gitdir, rel))
if os.path.basename(common) != ".git":
return None
root = os.path.dirname(common)
if root and os.path.isdir(root) and root != os.path.abspath(d):
return root
return None
def search(start):
"""(login, path) for one start directory: the parent chain, then the main
checkout of any worktree met on it. (None, None) when there is no pin.
The chain comes first and always wins, so the worktree branch can only
ever find a pin that walking up would not have found at all."""
hops = []
for d in parents(start):
login = read_pin(settings_path(d))
if login:
return login, settings_path(d)
root = main_worktree(d)
if root and root not in hops:
hops.append(root)
for root in hops:
# One level of indirection, never two: a main checkout is not itself a
# linked worktree, so this loop cannot chain and cannot cycle.
for d in parents(root):
login = read_pin(settings_path(d))
if login:
return login, settings_path(d)
return None, None
def start_dirs(hint=None):
"""The ordered, deduplicated start directories.
`hint` is the caller's own idea of where the work is happening — the hook
passes the `cwd` from its payload, which is the directory the Bash command
will actually run in. A script has no payload and passes nothing."""
try:
cwd = os.getcwd()
except OSError: # cwd deleted out from under us
cwd = None
out = []
for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd):
if not d:
continue
d = os.path.abspath(d)
if d not in out:
out.append(d)
return out
def find_pin(hint=None):
"""(login, path) for the first start directory that has a pin, else
(None, None). The entry point; everything above is its parts."""
for start in start_dirs(hint):
login, path = search(start)
if login:
return login, path
return None, None
+13 -2
View File
@@ -32,8 +32,13 @@ index.
## Scripts
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
operator's pin from `.claude/settings.local.json` themselves, the same source
the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
operator's pin from `.claude/settings.local.json` through
`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls,
not merely the same file, so a directory where `tea` works is a directory where
these work. That includes a **git worktree**, whose untracked pin sits in the
main checkout: the search crosses to it through the `gitdir:` in `.git`, and
there is nothing to pin a second time. No pin anywhere → exit with a pointer to
`/tea:auth`.
| Script | What it does |
|---|---|
@@ -312,6 +317,12 @@ a git repo no `ref` is sent and a warning names the issues that went up without
one. Reading the branch is the only thing these scripts ask git for — they
never check out, create, or write anything.
The branch comes from the **current directory**, so run `push.py` from the tree
the work is on. In a git worktree that is the worktree, and it is now also
where the pin resolves from: the old workaround for the pin — run the scripts
with cwd in the main checkout — sent the main checkout's branch as `ref`, which
is the one thing `branch:` exists to record.
## What crosses the boundary, and what does not
| domain | Gitea | note |
+24 -21
View File
@@ -7,8 +7,10 @@ query quirks. It does NOT know what an issue is: no sections, no acceptance
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
lives one layer further out in skills/issue/scripts/issue.py.
Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up
from CWD — the same file /tea:auth writes and the tea-guard hook reads. No
Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
Where that file is searched for is NOT written here — skills/auth/scripts/pin.py
owns the search order, and the tea-guard hook imports the same module, so `tea`
and the scripts can never disagree about which login a directory runs under. No
script here accepts a login argument: the operator's pin is the only identity
they will use. No pin -> exit with a pointer to /tea:auth.
@@ -100,29 +102,30 @@ PAYLOAD_ROOT = payload_root()
# --------------------------------------------------------------------------
# login
# --------------------------------------------------------------------------
# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the
# single written copy of the search order, and the tea-guard hook calls the
# same function. When the two had a copy each, a git worktree got a hook that
# resolved the pin and a transport that did not — in the same directory.
#
# Note the asymmetry with PAYLOAD_ROOT above, and with issue.store_root: those
# are anchored on their own file, this is not, and both are right. 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 installed
# outside any repository must not answer it from its own directory. See the
# module docstring in pin.py.
def find_pin(start_dir=None):
"""Walk up from start_dir; return the login from the first
.claude/settings.local.json carrying a non-empty env.GITEA_LOGIN."""
d = os.path.abspath(start_dir or ".")
while True:
p = os.path.join(d, ".claude", "settings.local.json")
if os.path.isfile(p):
try:
with open(p) as f:
v = (json.load(f).get("env") or {}).get("GITEA_LOGIN")
if isinstance(v, str) and v.strip():
return v.strip()
except Exception:
pass
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
sys.path.append(os.path.abspath(
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts")))
import pin # noqa: E402
def require_login():
login = find_pin(os.getcwd())
"""The operator's pinned login, or exit pointing at /tea:auth.
No pin found is reported as exactly that. It stays a truthful message: the
fix for "the pin is somewhere this search does not reach" belongs in
pin.py, never in a hint here that sends the operator to pin it twice."""
login, _ = pin.find_pin()
if not login:
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
return login