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:
+67
-33
@@ -64,11 +64,14 @@ the domain layer, it is in the wrong place.
|
||||
- `scripts/issue_tree.py` — draw the dependency graph
|
||||
- `scripts/issue_evict.py` — remove closed issues from the store; never an
|
||||
`origin: local` one
|
||||
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
||||
- `scripts/issue_index.py` — rebuild `.tea/issues/INDEX.md`
|
||||
- `scripts/issue_init.py` — create the `.tea/` marker that makes a directory
|
||||
a project; migrates an old `tmp/issues` store in, adds `.tea/` to
|
||||
`.gitignore`. Idempotent, and refuses to pick a winner on a name clash
|
||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
||||
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
||||
- `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids,
|
||||
the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py`
|
||||
the remote-id map, `.tea/payload/`; the login comes from `auth/pin.py`
|
||||
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
||||
- `scripts/close.py` — the state field, both ways; explicit ids only
|
||||
- `scripts/evict.py` — refresh `state:` from Gitea, then hand the decision to
|
||||
@@ -104,19 +107,29 @@ and then — only if that found nothing — up the parent chain of the **main
|
||||
working tree of any linked worktree** met on the way, reached by reading
|
||||
`gitdir:` out of a `.git` *file* and following `commondir`.
|
||||
|
||||
**The pin is not resolved from `__file__`, and that asymmetry with
|
||||
`issue.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.**
|
||||
Where an installation keeps its files is a fact about the installation; whose
|
||||
login a project runs under is a fact about the project. A plugin installed
|
||||
outside any repository and pointed at somebody else's tree must not answer the
|
||||
second question from its own directory. So the search runs from the working
|
||||
directory upward — and reaches a worktree's main checkout by asking git.
|
||||
**Nothing here resolves from `__file__`, and the walk is written once.**
|
||||
`issue.parents`, `issue.gitdir_of` and `issue.main_worktree` live in the domain
|
||||
— the layer that depends on nothing, and therefore the only one all three
|
||||
callers may borrow from — and `pin.py` imports them. The guard, the transport
|
||||
and the store cannot disagree about a directory, because there is one walk.
|
||||
|
||||
Two failures this replaces, both worth remembering: a git worktree is a
|
||||
*sibling* of the main checkout, so the untracked pin is not on its parent chain
|
||||
and the whole sync layer died there while `tea` in the same directory worked;
|
||||
and the cure it invited — `/tea:auth` inside the worktree — writes a second
|
||||
settings file into a directory that is deleted with the worktree.
|
||||
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.** A plugin installed outside any repository and pointed at
|
||||
somebody else's tree must answer both from the tree it was pointed at.
|
||||
|
||||
Three failures this replaces, all worth remembering:
|
||||
|
||||
- a git worktree is a *sibling* of the main checkout, so the untracked pin is
|
||||
not on its parent chain, and the whole sync layer died there while `tea` in
|
||||
the same directory worked;
|
||||
- the cure that invited — `/tea:auth` inside the worktree — writes a second
|
||||
settings file into a directory that is deleted with the worktree;
|
||||
- and the store and the payload root, which *were* anchored on `__file__`,
|
||||
resolved inside the installed plugin: `~/.claude/plugins/cache/tea/tea/2.0.0/
|
||||
tmp/issues`, a **versioned** directory. Issues written from one project were
|
||||
invisible from the next and stranded by every plugin update. Two `origin:
|
||||
local` files — the only copy of that work, by definition — were found there.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -129,21 +142,26 @@ stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
|
||||
packages, so a test that needs the domain module imports it with
|
||||
`sys.path.insert`.
|
||||
|
||||
**A test never touches `tmp/issues/` or `tmp/payload/`.** Anything that needs a
|
||||
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
|
||||
`.git` marker, a copy of the script layers, fixture issues — and runs the real
|
||||
scripts inside it as subprocesses. That is the only way to test behavior that depends on where a
|
||||
**A test never touches `.tea/issues/` or `.tea/payload/`.** Anything that needs
|
||||
a store builds a throwaway project in a `tempfile.TemporaryDirectory()` — a
|
||||
`.tea/` marker and fixture issues — and runs the real scripts inside it as
|
||||
subprocesses. That is the only way to test behavior that depends on where a
|
||||
script is run from, and it keeps the developer's own store out of the blast
|
||||
radius.
|
||||
|
||||
`tmp/payload/` is in that list because `_gitea.PAYLOAD_ROOT` is resolved once,
|
||||
from the module's own location: a test that stubs the transport *below* `api()`
|
||||
— at `subprocess`, to exercise a non-2xx — reaches the real write. Such a test
|
||||
patches `PAYLOAD_ROOT` to its own temp directory too.
|
||||
**The scripts are not copied into the fixture.** They stay where they really
|
||||
live, and the fixture is somewhere else entirely — that separation *is* the
|
||||
contract: a plugin is installed in one place and used on projects in another.
|
||||
The suite used to copy both layers in, which made the two the same directory
|
||||
and hid the `__file__` bug completely.
|
||||
|
||||
**A subprocess fixture strips `CLAUDE_PROJECT_DIR`** unless the test is about
|
||||
it. It is the first anchor of the walk, so the harness's own value would point
|
||||
every fixture at this repository.
|
||||
|
||||
## Local issue store
|
||||
|
||||
`tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a
|
||||
`.tea/issues/` (gitignored) holds **two kinds of file, and only one of them is a
|
||||
store.** An `origin: local` issue lives here and nowhere else — this file *is*
|
||||
the issue, and losing it loses the work. Anything with `origin: gitea` is a
|
||||
**cache**: the tracker has it, this copy is a working copy, and it is deleted
|
||||
@@ -152,11 +170,25 @@ the moment a push confirms the tracker is up to date.
|
||||
One flat markdown file per issue, named by its slug, with one metadata field per
|
||||
line so plain grep works without a parser.
|
||||
|
||||
- **The path is `<repo root>/tmp/issues`, resolved from `issue.py`'s own
|
||||
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
|
||||
nearest `.git` or `AGENTS.md` — so every script in both layers sees one store
|
||||
whatever directory it is run from. An explicit `--out` overrides it and is
|
||||
- **The path is `<project root>/.tea/issues`, where the project root is the
|
||||
nearest ancestor of the WORKING DIRECTORY holding a `.tea/` marker.**
|
||||
`issue.project_root()` walks up from `$CLAUDE_PROJECT_DIR`, then cwd — the
|
||||
same order and the same walk as the login pin, since both answer "which
|
||||
project is this". Every script in both layers sees one store from anywhere
|
||||
inside the project; a `cd` into a *different* project answers with that
|
||||
project's store, which is the point. An explicit `--out` overrides it and is
|
||||
used exactly as typed; a relative `--out` stays relative to cwd.
|
||||
- **The marker is created by `issue_init.py`, never inferred.** An operator
|
||||
states that a directory is a project; nothing guesses it. `.git` was tried as
|
||||
the marker and is in every clone including this plugin's own — see below.
|
||||
- **No marker anywhere is an answer, not a fallback.** `store_root()` returns
|
||||
None and every entry point reports which directories it searched. A store
|
||||
placed in a plausible-looking directory is the failure this replaces.
|
||||
- **A linked worktree resolves to the main checkout.** The marker is gitignored,
|
||||
so a worktree never has one; it is the same project on another branch, and it
|
||||
reaches the store by the same hop the pin takes. Do not run `issue_init.py`
|
||||
in a worktree — one project would get two stores, and the second disappears
|
||||
with the branch.
|
||||
- Nothing creates the store as a side effect of a write. Readers distinguish
|
||||
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
|
||||
and they say so on stderr.
|
||||
@@ -177,7 +209,7 @@ line so plain grep works without a parser.
|
||||
`origin: local` issue is never touched by any of this.
|
||||
- The slug survives the round trip because it goes up in the body as
|
||||
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
|
||||
`tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
|
||||
`.tea/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
|
||||
fresh clone, another machine — the file comes back under the same name and
|
||||
every `depends:` that points at it still resolves.
|
||||
- `.remote.json` is therefore no longer "an index over the files": it is the
|
||||
@@ -213,16 +245,18 @@ line so plain grep works without a parser.
|
||||
|
||||
## Request payloads
|
||||
|
||||
`tmp/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
|
||||
`.tea/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
|
||||
one file per named request, kept after the call for a retry or a post-mortem.
|
||||
It is **not a store and holds nobody's only copy** — deleting it costs nothing.
|
||||
|
||||
- One directory for every caller, resolved from `_gitea.py`'s own location, so
|
||||
which command wrote a body does not change where it landed. `_gitea.api` takes no directory argument; that it once did
|
||||
is exactly how a label bootstrap came to create `tmp/issues/`.
|
||||
- One directory for every caller, a sibling of the store under the same marker
|
||||
and resolved by the same walk, so which command wrote a body does not change
|
||||
where it landed — and the scratchpad and the store can never end up in two
|
||||
different projects. `_gitea.api` takes no directory argument; that it once
|
||||
did is exactly how a label bootstrap came to create the issue store.
|
||||
- It is created lazily, by the first write of a run, and only then: a `--dry-run`
|
||||
or a run with nothing to send leaves no directory behind.
|
||||
- **A scratchpad may never sit inside a store.** Store contents are the thing
|
||||
being tracked; request bodies are debris of the transport. When the two share
|
||||
a path, an operation that touches no issue at all still materializes the issue
|
||||
store, and the operator's `ls tmp/issues` starts lying about what exists.
|
||||
store, and the operator's `ls .tea/issues` starts lying about what exists.
|
||||
|
||||
Reference in New Issue
Block a user