fb5445915f
`issue.store_root` and `_gitea.PAYLOAD_ROOT` were anchored on `__file__`, on
the reasoning that where an installation keeps its files is a fact about the
installation. That holds for an installation and not for a store.
Installed, the plugin therefore resolved every project's issues inside its own
directory — and a plugin cache is versioned, so the store moved on each
update:
~/.claude/plugins/cache/tea/tea/2.0.0/tmp/issues 5 files, 2 origin: local
~/.claude/plugins/cache/tea/tea/2.1.0/tmp/issues 12 files
~/.claude/plugins/cache/claude-skills/tea/2.2.0/ empty, the current one
Issues written from one project were invisible from the next, and an `origin:
local` file — which IS the issue, the only copy — was stranded a version bump
at a time. Two of them were.
The store is a fact about the project, exactly as the login pin is. So the
anchor is now an explicit marker an operator creates, `.tea/`, searched for up
from $CLAUDE_PROJECT_DIR and then cwd — the pin's order, so the two cannot
disagree about which project this is. Inferred markers were tried and are worse
than useless here: `.git` is in every clone including this plugin's own, and
the agents-sync hook writes an AGENTS.md next to every AGENTS.md, so the plugin
root always carried one and cwd never got a turn.
With no marker anywhere, `store_root()` is None and every entry point reports
which directories it searched. A store in a plausible-looking directory is the
failure this replaces, so nothing falls back to one.
- `.tea/` holds the store and the transport's scratchpad: `.tea/issues`,
`.tea/payload`. One marker, one walk, one gitignore line.
- `issue_init.py` creates it, moves an old `tmp/issues` store in rather than
copying, adds `.tea/` to `.gitignore`, and refuses to pick a winner when both
sides hold the same file name.
- A linked worktree has no marker — it is gitignored — and reaches the main
checkout's store by the hop the pin already took.
- `parents`, `gitdir_of` and `main_worktree` move from `pin.py` into the domain
and `pin.py` imports them. The domain depends on nothing, so it is the layer
all three callers can borrow from, and the walk stays written once: the
guard, the transport and the store cannot disagree about a directory.
The suite stopped copying the script layers into its fixtures. That is what hid
this: with the scripts inside the fixture, the installation and the project
were the same directory. They are now deliberately far apart, and a regression
test asserts the plugin tree gains no files when commands run against a project
somewhere else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
155 lines
6.1 KiB
Python
155 lines
6.1 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__
|
|
|
|
where does this installation keep its files a fact about the plugin
|
|
whose login does this project run under a fact about the project
|
|
which issues does it have 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.
|
|
|
|
This module answered that way first and alone; `issue.store_root` and
|
|
`_gitea.PAYLOAD_ROOT` were anchored on `__file__` until an installed plugin was
|
|
found keeping other projects' issues inside its own versioned cache directory.
|
|
They resolve from the working directory now too, and the walk they share is the
|
|
one below — `issue.parents`, `gitdir_of`, `main_worktree` moved down into the
|
|
domain, which is the layer that depends on nothing and so is the only one all
|
|
three can borrow from. One written copy: the guard, the transport and the store
|
|
cannot disagree about a directory.
|
|
|
|
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
|
|
import sys
|
|
|
|
# The domain owns the walk (see above). It is stdlib-only and imports nothing,
|
|
# so the tea-guard hook inherits no new weight by reaching it through here.
|
|
sys.path.append(os.path.abspath(os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
os.pardir, os.pardir, "issue", "scripts")))
|
|
from issue import parents, gitdir_of, main_worktree # noqa: E402,F401
|
|
|
|
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 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
|