83f73c5cea
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.
The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.
tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.
test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
7.3 KiB
Python
205 lines
7.3 KiB
Python
#!/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 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
|