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:
naudachu
2026-08-11 13:38:39 +05:00
parent 27e4b6b1da
commit fb5445915f
30 changed files with 1193 additions and 430 deletions
+18 -68
View File
@@ -41,11 +41,9 @@ 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
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
@@ -54,11 +52,28 @@ 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"
@@ -85,71 +100,6 @@ def read_pin(path):
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.