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:
@@ -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
|
||||
Reference in New Issue
Block a user