fix: resolve the issue store from the project, not the plugin
`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>
This commit is contained in:
@@ -14,19 +14,20 @@ 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.
|
||||
|
||||
Also holds the id map (tmp/issues/.remote.json), which pairs a remote key with
|
||||
Also holds the id map (.tea/issues/.remote.json), which pairs a remote key with
|
||||
a local slug, and the paths of the store-side files this layer writes. All of
|
||||
it is transport bookkeeping, not domain data — the domain never reads any of
|
||||
it, and losing the map still costs a re-pull and not information: the slug it
|
||||
records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
|
||||
pull rebuilds the entry from the tracker. See `rebuild_map`.
|
||||
|
||||
Request bodies go to tmp/payload/, which is this module's own scratchpad and
|
||||
Request bodies go to .tea/payload/, which is this module's own scratchpad and
|
||||
NOT a store: nothing in it is anybody's only copy, and writing one must never
|
||||
materialize tmp/issues/ on a checkout that has none. Bootstrapping labels
|
||||
materialize .tea/issues/ on a project that has none. Bootstrapping labels
|
||||
touches no issue at all — it used to leave a store behind anyway, because the
|
||||
request file had nowhere else to live. One directory, every caller, resolved
|
||||
from this file the way the two domains resolve theirs.
|
||||
request file had nowhere else to live. One directory, every caller, resolved by
|
||||
the domain's project walk so the scratchpad and the store cannot land in
|
||||
different projects.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
@@ -48,20 +49,25 @@ PAGE_SLACK = 4
|
||||
# --------------------------------------------------------------------------
|
||||
# where request bodies land
|
||||
# --------------------------------------------------------------------------
|
||||
# Anchored on THIS FILE, like issue.store_root, so every caller — sync,
|
||||
# whatever comes next — writes to one directory whatever it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
|
||||
# inside somebody's store, because a scratchpad that looks like store contents
|
||||
# is how this went wrong the first time. `tmp/` is already gitignored.
|
||||
|
||||
PAYLOAD_PARTS = ("tmp", "payload")
|
||||
|
||||
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
|
||||
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
|
||||
# git; the agents-sync hook only ever puts one at a repository root.
|
||||
REPO_MARKERS = (".git", "AGENTS.md")
|
||||
# A sibling of the store under `.tea/`, never a directory inside it: a
|
||||
# scratchpad that looks like store contents is how this went wrong the first
|
||||
# time, when a label bootstrap that touches no issue at all materialized
|
||||
# `tmp/issues/` on a fresh checkout. Same marker, same walk, one directory for
|
||||
# every caller — see payload_root below.
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# The domain owns "which project is this" and this layer imports it rather than
|
||||
# walking the tree a second time. Two copies of the walk is how the guard and
|
||||
# the transport once disagreed about a worktree; the same trap, one layer over.
|
||||
_ISSUE_SCRIPTS = os.path.abspath(
|
||||
os.path.join(_HERE, os.pardir, os.pardir, "issue", "scripts"))
|
||||
if _ISSUE_SCRIPTS not in sys.path:
|
||||
sys.path.append(_ISSUE_SCRIPTS)
|
||||
import issue as _issue # noqa: E402
|
||||
|
||||
PAYLOAD_PARTS = (_issue.MARKER, "payload")
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||
@@ -76,30 +82,15 @@ def now_iso():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def repo_root(start):
|
||||
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
|
||||
d = os.path.abspath(start)
|
||||
while True:
|
||||
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
|
||||
return d
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
def payload_root(start=None):
|
||||
"""Absolute path of the request-body scratchpad.
|
||||
"""Absolute path of the request-body scratchpad, or None with no project.
|
||||
|
||||
`start` overrides the anchor so the resolution can be exercised against a
|
||||
scratch tree. Outside a repository, cwd gets a turn, then the cwd-relative
|
||||
location stands — made absolute so an error can name the directory it
|
||||
really wrote to."""
|
||||
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
|
||||
root = repo_root(anchor)
|
||||
if root:
|
||||
return os.path.join(root, *PAYLOAD_PARTS)
|
||||
return os.path.abspath(os.path.join(*PAYLOAD_PARTS))
|
||||
scratch tree. Sibling of the store, under the same marker and resolved by
|
||||
the same walk: which command wrote a body does not change where it landed,
|
||||
and neither does which directory it was run from."""
|
||||
root = _issue.project_root(start)
|
||||
return os.path.join(root, *PAYLOAD_PARTS) if root else None
|
||||
|
||||
|
||||
PAYLOAD_ROOT = payload_root()
|
||||
@@ -113,12 +104,13 @@ PAYLOAD_ROOT = payload_root()
|
||||
# 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.
|
||||
# The pin resolves from the working directory upward, and PAYLOAD_ROOT and
|
||||
# issue.store_root now do the same. They did not always: those two were
|
||||
# anchored on their own file, on the reasoning that 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 so is which issues it has. The identity
|
||||
# layer was right first; the other two followed it. See pin.py's docstring for
|
||||
# the walk, and issue.py's for what the old anchor cost.
|
||||
|
||||
_AUTH_SCRIPTS = os.path.abspath(
|
||||
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
|
||||
@@ -157,6 +149,8 @@ def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
||||
if method != "GET":
|
||||
cmd += ["-X", method]
|
||||
if payload is not None:
|
||||
if PAYLOAD_ROOT is None:
|
||||
die(_issue.no_project_error())
|
||||
os.makedirs(PAYLOAD_ROOT, exist_ok=True)
|
||||
path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
|
||||
with open(path, "w") as f:
|
||||
@@ -495,7 +489,7 @@ def rebuild_map(root, issues):
|
||||
|
||||
Gitea the issue, and — in `<!-- tea:id … -->` — its slug
|
||||
.remote.json a local number -> slug ledger, a cache of that marker
|
||||
tmp/issues/*.md whatever happens to be checked out right now
|
||||
.tea/issues/*.md whatever happens to be checked out right now
|
||||
|
||||
Which makes this a MERGE and never a replacement: it starts from what is
|
||||
already recorded and adds what the remaining files say. What it cannot
|
||||
|
||||
Reference in New Issue
Block a user