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 1d7abc11ae
11 changed files with 774 additions and 68 deletions
+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