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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tea",
"description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login.",
"version": "2.2.0",
"version": "2.3.0",
"author": {
"name": "naudachu"
},
+67 -33
View File
@@ -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.
+22 -6
View File
@@ -141,14 +141,16 @@ skills/
issue_ac.py list the body's checkboxes; tick one
issue_tree.py draw the dependency graph
issue_evict.py drop closed issues the tracker also has
issue_index.py rebuild tmp/issues/INDEX.md
issue_index.py rebuild .tea/issues/INDEX.md
issue_init.py create the .tea/ marker that makes a
directory a project; migrates tmp/issues in
sync/ /tea:sync — the bridge to Gitea
SKILL.md
scripts/
map.py md <-> Gitea JSON, pure functions, no I/O
_gitea.py transport: login pin, tea api, pagination, filters
pull.py Gitea -> tmp/issues/
push.py tmp/issues/ -> Gitea, then drops the local file
pull.py Gitea -> .tea/issues/
push.py .tea/issues/ -> Gitea, then drops the local file
remote.py discovery listing to stdout
comment.py post or edit a comment
close.py the state field, both ways
@@ -164,9 +166,23 @@ ever disagree, `AGENTS.md` is the one being worked from.
## Local issue store
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
a parser.
Issues live in `.tea/issues/` as flat markdown with one metadata field per line
— so `grep -l 'labels:.*type/bug' .tea/issues/*.md` works without a parser.
**Run `issue_init.py` once per project.** It creates the `.tea/` marker, which
is what every script resolves the store from: they walk up from the working
directory to the nearest one. The marker is never inferred from the tree, and
with none anywhere the commands stop and name the directories they searched
rather than picking a plausible one.
```bash
python3 <plugin>/skills/issue/scripts/issue_init.py
```
It is idempotent, adds `.tea/` to `.gitignore`, and moves an older
`tmp/issues` store in if it finds one. Don't run it inside a git worktree: the
marker is gitignored, so a worktree has none by design and reaches the main
checkout's store on its own — exactly like the login pin.
An `origin: local` file **is** the issue — the store, and the only copy.
Anything with `origin: gitea` is a working copy of something the tracker
+1 -1
View File
@@ -103,7 +103,7 @@ ran:
issue_index.py ok INDEX.md rebuilt
push.py wire-sqlc-appclick FAIL exit 1
touched: tmp/issues/{a,b,c}.md, tmp/issues/INDEX.md
touched: .tea/issues/{a,b,c}.md, .tea/issues/INDEX.md
failed: push.py wire-sqlc-appclick
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
+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.
+44 -21
View File
@@ -5,7 +5,7 @@ description: Work with this project's issues as units of work — create, read,
# /tea:issue — issues as units of work
An issue is a markdown file in `tmp/issues/`. This skill covers everything you
An issue is a markdown file in `.tea/issues/`. This skill covers everything you
do **with** an issue: writing one, reading one, checking it against the
canonical format, and walking the dependency graph.
@@ -20,7 +20,7 @@ labels, templates, and language rules.
## Identity: the slug
The file name is the id and the id is a slug — `tmp/issues/wire-sqlc-appclick.md`.
The file name is the id and the id is a slug — `.tea/issues/wire-sqlc-appclick.md`.
It never changes, not when the title changes and not when the issue is pushed
somewhere. Tracker numbers live in a metadata field (`gitea: owner/repo#42`),
never in a file name and never in `depends:`.
@@ -34,31 +34,54 @@ All offline, all in `<skill-base-dir>/scripts/`.
| Script | What it does |
|---|---|
| `issue_new.py --type T --title "…"` | create `tmp/issues/<slug>.md` from the type's template |
| `issue_init.py [--at DIR] [--dry-run]` | make a directory a project: create `.tea/`, migrate an old `tmp/issues` store in, add `.tea/` to `.gitignore`. Idempotent |
| `issue_new.py --type T --title "…"` | create `.tea/issues/<slug>.md` from the type's template |
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
| `issue_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one |
| `issue_tree.py [id…]` | draw the dependency graph from `depends:` |
| `issue_evict.py [id…] [--dry-run]` | remove closed issues from the store; **never** an `origin: local` one |
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
| `issue_index.py` | rebuild `.tea/issues/INDEX.md` |
| `issue.py` | the domain module the others import — not a command |
```
tmp/issues/INDEX.md table of every issue — read this first
tmp/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
tmp/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
tmp/issues/tree-<id>.md saved graph (issue_tree.py --write)
.tea/issues/INDEX.md table of every issue — read this first
.tea/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
.tea/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
.tea/issues/tree-<id>.md saved graph (issue_tree.py --write)
```
## Where the store is
`<repo root>/tmp/issues`**not** `tmp/issues` relative to wherever you are
standing. The scripts resolve it by walking up from their own file to the
nearest `.git` or `AGENTS.md`, so they all see one store no matter which
directory you run them from, and a `cd` earlier in the session changes nothing.
`<project root>/.tea/issues`**not** `.tea/issues` relative to wherever you
are standing. The project root is the nearest directory up from where you are
that holds a `.tea/` marker: the scripts walk up from `$CLAUDE_PROJECT_DIR`,
then from the current directory. So they all see one store no matter which
subdirectory you run them from, and a `cd` earlier in the session changes
nothing — while a `cd` into a *different* project correctly gets that project's
issues.
`--out` overrides that and is taken **literally**: an absolute path is used as
given, a relative one stays relative to the current directory. Nothing rewrites
what you typed.
**A project has a store because somebody ran `issue_init.py` in it.** The
marker is never inferred from the tree: `.git` is in every clone including this
plugin's own, and inferring from one is how an installed plugin came to keep
other projects' issues inside its own cache directory.
**With no marker anywhere, every command stops and says so**, naming the
directories it searched. It does not fall back to a plausible directory. If you
see that message, either you are not in the project you think you are, or the
project has not been initialized — run:
```bash
python3 <skill-base-dir>/scripts/issue_init.py
```
**In a git worktree, do not initialize.** `.tea/` is gitignored, so a worktree
never has one; the scripts reach the main checkout's store on their own, the
same way the login pin does. Initializing there gives one project two stores,
and the second one is deleted with the branch.
`--out` overrides all of it and is taken **literally**: an absolute path is
used as given, a relative one stays relative to the current directory. Nothing
rewrites what you typed.
Two things follow, and both are deliberate:
@@ -73,11 +96,11 @@ Metadata is one field per line with inline lists precisely so plain `grep`
works. `INDEX.md` first, then the files:
```bash
grep -l 'labels:.*type/bug' tmp/issues/*.md # all bugs
grep -l 'origin: local' tmp/issues/*.md # never pushed anywhere
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
grep -A3 '## Acceptance criteria' tmp/issues/wire-*.md
grep -c '^- \[ \]' tmp/issues/wire-sqlc-appclick.md # open checkboxes
grep -l 'labels:.*type/bug' .tea/issues/*.md # all bugs
grep -l 'origin: local' .tea/issues/*.md # never pushed anywhere
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it
grep -A3 '## Acceptance criteria' .tea/issues/wire-*.md
grep -c '^- \[ \]' .tea/issues/wire-sqlc-appclick.md # open checkboxes
```
Read whole files only for the issues the task actually needs.
@@ -204,7 +227,7 @@ canonical format is a procedure, not improvisation.
filled in. Re-run `issue_index.py` if the labels changed.
The procedure is identical for `origin: local` and `origin: gitea` — it works
on `tmp/issues/<id>.md`, and this layer does not know the difference. Getting
on `.tea/issues/<id>.md`, and this layer does not know the difference. Getting
the rewritten body into the tracker is a separate decision — `push.py --update`
in `/tea:sync` — and is no part of this.
@@ -10,7 +10,7 @@ sync layer's business — see `/tea:sync`.
## Identity
An issue is one file, `tmp/issues/<id>.md`, and `id` is a slug: lowercase
An issue is one file, `.tea/issues/<id>.md`, and `id` is a slug: lowercase
ASCII, digits, single dashes, derived from the title. **The slug is the
identity.** It is stable for the life of the issue — a retitled issue keeps its
slug; an issue pushed to a tracker, deleted locally and fetched back a month
@@ -18,7 +18,7 @@ later keeps it too. Tracker numbers are a foreign key stored in a field, never
the name of anything.
```
tmp/issues/wire-sqlc-appclick.md
.tea/issues/wire-sqlc-appclick.md
```
A slug never contains a dot, which is how the store tells an issue from the
@@ -86,7 +86,7 @@ It is not a *permanent* state, and it is what the file's fate depends on:
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
**A successful push deletes `tmp/issues/<id>.md`** (and `<id>.comments.md`), on
**A successful push deletes `.tea/issues/<id>.md`** (and `<id>.comments.md`), on
create and on `--update` alike. What is in the store is what has not left this
machine; everything else is fetched again when it is needed. The rule, its
safety conditions, and how the slug survives are `/tea:sync`'s to state.
@@ -183,7 +183,7 @@ sections the reference actually came from.
Draw the graph with `issue_tree.py`. The reverse direction is a grep:
```bash
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md
```
## Shared rules
+181 -48
View File
@@ -16,7 +16,7 @@ only on this machine are first-class, not drafts on their way somewhere.
Identity is a slug derived from the title, and it is the only identity the
domain has. The file name is the id:
tmp/issues/wire-sqlc-appclick.md
.tea/issues/wire-sqlc-appclick.md
---
id: wire-sqlc-appclick
@@ -42,8 +42,8 @@ issue and a synced one without the domain learning a second vocabulary.
Every metadata field is one line and lists are inline, so plain grep works
without a parser:
grep -l 'labels:.*type/bug' tmp/issues/*.md
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
grep -l 'labels:.*type/bug' .tea/issues/*.md
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it
"""
import collections
import os
@@ -52,61 +52,182 @@ import re
# --------------------------------------------------------------------------
# where the store lives
# --------------------------------------------------------------------------
# `<repo root>/tmp/issues`, absolute, resolved once at import.
# `<project root>/.tea/issues`, absolute, resolved once at import — where the
# project root is the nearest directory up from the WORKING DIRECTORY that an
# operator has run `issue_init.py` in.
#
# It used to be the relative `tmp/issues`, which made "the store" whatever
# directory the shell happened to be standing in. One `cd` — and a `cd` outlives
# the command that ran it — was enough for readers to report an empty store on a
# full one and for writers to quietly build a second store beside the first.
# Two anchors have been wrong here, in this order. First the relative
# `tmp/issues`, which made "the store" whatever directory the shell happened to
# be standing in: one `cd` — and a `cd` outlives the command that ran it — and
# readers reported an empty store on a full one while writers built a second
# store beside the first. Then `__file__`, on the reasoning that a script's own
# location is a fact about the installation while cwd is a fact about the last
# `cd`. That reasoning holds for an installation; it does not hold for a STORE.
#
# The anchor is THIS FILE, not the working directory. A script's own location is
# a fact about the installation; cwd is a fact about the last `cd`. Walking up
# from __file__ therefore hands every script in both layers the same answer no
# matter where it is invoked from — including from inside tmp/issues itself.
# Anchored on `__file__`, an installed plugin resolves the store inside its own
# directory — and a plugin cache is versioned, so `~/.claude/plugins/cache/tea/
# tea/2.0.0/tmp/issues` stopped being found the moment the plugin became 2.1.0.
# Issues written from one project landed in the plugin and were invisible from
# the next. `origin: local` files — which ARE the issue, the only copy — were
# stranded a version bump at a time.
#
# So: the store is a fact about the PROJECT, exactly as the login pin is (see
# auth/scripts/pin.py, which has always resolved this way and says why). The
# anchor is an explicit marker an operator created, not a marker inferred from
# the tree: `.git` is present in every clone including this plugin's own, and
# AGENTS.md was worse still — the agents-sync hook writes one next to every
# AGENTS.md, so the plugin root always carried one and cwd never got a turn.
#
# Nothing is guessed when the marker is absent. `store_root()` returns None and
# the callers report which directories were searched; a wrong directory that
# looks like it worked is the failure this replaces.
#
# An explicit --out still wins over all of this, and is used exactly as typed: a
# relative --out stays relative to cwd, because that is what the operator asked
# for. There is no environment override; the store is where the repo is.
# for.
STORE_PARTS = ("tmp", "issues")
# `.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")
_HERE = os.path.dirname(os.path.abspath(__file__))
MARKER = ".tea"
STORE_PARTS = (MARKER, "issues")
def repo_root(start):
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None.
def anchors(start=None):
"""The directories a root search starts from, in order, first hit wins.
Markers, not a fixed number of `..` hops: how deep this file sits below the
root is an implementation detail of the repo layout, and the layout is not
a promise."""
`start` overrides them and exists so the resolution can be exercised
against a scratch tree. Otherwise: the project Claude Code was opened on,
then the working directory. The same order as `pin.search_dirs`, for the
same reason — both answer "which project is this", and a project that
disagrees with itself about that has two identities."""
if start is not None:
return [os.path.abspath(start)]
out = []
for d in (os.environ.get("CLAUDE_PROJECT_DIR"), os.getcwd()):
if d and os.path.isdir(d):
d = os.path.abspath(d)
if d not in out:
out.append(d)
return out
# The walk itself — the parent chain and the hop out of a linked worktree —
# lives here rather than in the identity layer that first needed it, because
# the domain is the layer everything else may depend on and it depends on
# nothing. `pin.py` imports these three; one written copy of the walk means the
# guard, the transport and the store cannot disagree about a directory. They
# did once: in a worktree, `tea` worked and every script said "no login
# pinned".
def parents(start):
"""`start` and every ancestor of it, up to the filesystem root."""
d = os.path.abspath(start)
while True:
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
return d
yield d
parent = os.path.dirname(d)
if parent == d:
return None
return
d = parent
def store_root(start=None):
"""Absolute path of the issue store.
def gitdir_of(d):
"""The private git directory `d/.git` points at, or None.
`start` overrides the anchor and exists so the resolution can be exercised
against a scratch tree. When these scripts are not inside a repository at
all, cwd gets a turn; failing that the historical cwd-relative location
stands, made absolute so an error message can name the directory it really
looked in."""
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
root = repo_root(anchor)
if root:
return os.path.join(root, *STORE_PARTS)
return os.path.abspath(os.path.join(*STORE_PARTS))
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 project_root(start=None):
"""Nearest ancestor of an anchor (inclusive) holding `.tea/`, or None.
A marker, not a fixed number of `..` hops: how deep a caller sits below the
root is an implementation detail of the project layout, and the layout is
not a promise. Walking up means every script sees one store from anywhere
inside the project — including from inside the store itself — while a `cd`
into a DIFFERENT project correctly answers with that project's store.
A linked worktree is the same project on another branch, and the marker is
gitignored, so it is only ever in the main checkout: the chain is searched
first and always wins, then the main working tree of any worktree met on
it. Initializing inside a worktree would give one project two stores, and
the directory holding the second one disappears with the branch."""
for anchor in anchors(start):
hops = []
for d in parents(anchor):
if os.path.isdir(os.path.join(d, MARKER)):
return d
main = main_worktree(d)
if main and main not in hops:
hops.append(main)
for root in hops:
# One level of indirection, never two: a main checkout is not
# itself a linked worktree, so this cannot chain and cannot cycle.
for d in parents(root):
if os.path.isdir(os.path.join(d, MARKER)):
return d
return None
def store_root(start=None):
"""Absolute path of the issue store, or None when no project was found."""
root = project_root(start)
return os.path.join(root, *STORE_PARTS) if root else None
def no_project_error(start=None):
"""Why no store could be resolved, naming every directory searched.
The searched directories are the anchors, not the whole chain above them:
an operator who sees the two places the search began knows immediately
whether it began where they meant it to."""
return ("no %s/ found — searched up from %s. Run issue_init.py in the "
"project you mean to track issues in."
% (MARKER, " and ".join(anchors(start)) or "nowhere"))
ISSUE_ROOT = store_root()
@@ -577,16 +698,20 @@ class StoreMissing(Exception):
def __init__(self, root):
self.root = root
Exception.__init__(self, "store %s does not exist" % root)
Exception.__init__(self, no_project_error() if root is None
else "store %s does not exist" % root)
def store_exists(root):
return os.path.isdir(root)
return root is not None and os.path.isdir(root)
def require_store(root):
"""Assert the store is there before reading or writing it."""
if not os.path.isdir(root):
"""Assert the store is there before reading or writing it.
`root` is None when no project was found at all — a different failure from
a project whose store has not been created yet, and StoreMissing says so."""
if not store_exists(root):
raise StoreMissing(root)
return root
@@ -597,7 +722,11 @@ def create_store(root):
Only the commands that legitimately bootstrap a store call this — issue_new
and pull — and both announce it. Nothing creates a store as a side effect of
a write any more: a missing directory is something to report, not something
to conjure."""
to conjure. An unresolved root is never conjured either: without a marker
there is no project to create a store IN, and guessing one is how a store
ended up inside the plugin."""
if root is None:
raise StoreMissing(None)
if os.path.isdir(root):
return False
os.makedirs(root)
@@ -607,7 +736,11 @@ def create_store(root):
def store_error(root):
"""Why `root` cannot be read as a store, or None when it holds issues.
The two messages are distinct on purpose — see StoreMissing."""
The three messages are distinct on purpose — no project at all, a project
with no store, and a store with nothing in it are three different things to
do next."""
if root is None:
return no_project_error()
if not os.path.isdir(root):
return ("store %s does not exist — nothing was created; pass --out to "
"point elsewhere" % root)
@@ -629,7 +762,7 @@ def all_ids(root):
Without that rule `wire-sqlc.comments` reads as an issue called
`wire-sqlc.comments`, and a bare `push.py` tries to file the comment thread
as a unit of work."""
if not os.path.isdir(root):
if not store_exists(root):
return []
return sorted(f[:-3] for f in os.listdir(root)
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))
+4 -1
View File
@@ -81,9 +81,12 @@ def main(argv=None):
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", metavar="N|TEXT", help="tick one item: number or substring")
g.add_argument("--uncheck", metavar="N|TEXT", help="untick one item: number or substring")
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: .tea/issues)")
args = ap.parse_args(argv)
if args.out is None:
sys.exit("issue_ac.py: %s" % issue.no_project_error())
path = issue.path_of(args.out, args.id)
if not os.path.exists(path):
sys.exit("issue_ac.py: no issue %r in %s" % (args.id, args.out))
@@ -27,7 +27,7 @@ def main():
ap.add_argument("--quiet", action="store_true", help="exit code only")
ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
problem = issue.store_error(args.out)
@@ -156,10 +156,12 @@ def main(argv=None):
ap.add_argument("--dry-run", action="store_true",
help="print what would be removed; touch nothing")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args(argv)
root = args.out
if root is None:
sys.exit("issue_evict.py: %s" % issue.no_project_error())
if not issue.store_exists(root):
sys.exit("issue_evict.py: store %s does not exist — nothing to evict" % root)
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""
issue_index.py — rebuild tmp/issues/INDEX.md from what is on disk. Offline.
issue_index.py — rebuild .tea/issues/INDEX.md from what is on disk. Offline.
A map of the local store, nothing else. The `origin` column is the only place
the index acknowledges that a tracker exists: `local` means the issue has never
left this machine, `gitea` means the sync layer has pushed or pulled it. Both
are ordinary issues here.
The store is <repo root>/tmp/issues unless --out says otherwise; an existing
The store is <project root>/.tea/issues unless --out says otherwise; an existing
store with nothing in it gets an "_empty_" table, a store that is not there is
an error rather than a directory to create.
@@ -100,7 +100,7 @@ def build(root):
def main():
ap = argparse.ArgumentParser(description="Rebuild the local issue index (offline)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
# An existing store with nothing in it is a legitimate thing to index — it
# gets an "_empty_" table. A store that is not there is not.
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
issue_init.py — make this project one that tracks issues. Offline.
issue_init.py initialize the current directory
issue_init.py --at ~/code/x initialize somewhere else
issue_init.py --dry-run say what it would do, touch nothing
Creates `.tea/` — the marker every other script resolves the store from. The
marker is deliberately something an operator makes, not something inferred from
the tree: `.git` is in every clone including this plugin's own, so a plugin that
inferred its root from one wrote issues into itself. See issue.py's docstring.
Initializing is therefore a statement, and the only one that matters here:
*this* directory is the project whose issues live in it. It is answered once,
by a person, and every script downstream reads the answer instead of guessing.
What it does, all of it idempotent:
- creates `.tea/issues/` and `.tea/payload/`
- moves an existing `tmp/issues/` and `tmp/payload/` in, if it finds them
- adds `.tea/` to `.gitignore`
The move is the migration off the old layout and it is a move, not a copy: two
stores is the state this whole change exists to prevent, and a store left
behind at the old path is a store somebody will edit by accident. It refuses to
overwrite — if both locations hold a file of the same name, it stops and says
so rather than picking a winner.
`.tea/` is gitignored because an `origin: local` issue is the only copy of that
work and the operator, not this script, decides what goes in a shared history.
Committing the store is a legitimate choice — drop the line if you make it.
"""
import argparse
import os
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
LEGACY = {"issues": os.path.join("tmp", "issues"),
"payload": os.path.join("tmp", "payload")}
def gitignore_lines(path):
if not os.path.isfile(path):
return []
with open(path) as f:
return [line.rstrip("\n") for line in f]
def add_to_gitignore(path, entry, dry_run=False):
"""Append `entry` unless some line already ignores it. True when written."""
lines = gitignore_lines(path)
if any(line.strip().rstrip("/") == entry.rstrip("/") for line in lines):
return False
if dry_run:
return True
trailer = "" if not lines or lines[-1] == "" else "\n"
with open(path, "a") as f:
f.write("%s%s\n" % (trailer, entry))
return True
def migrate(src, dst, dry_run=False):
"""Move the contents of `src` into `dst`. Returns what it moved, or None.
Contents, not the directory, so an already-created destination is not a
reason to refuse. A name that exists on both sides is: that is two versions
of one issue, and which one survives is not a decision a migration gets to
make quietly."""
if not os.path.isdir(src):
return None
names = sorted(os.listdir(src))
if not names:
return []
clashes = [n for n in names if os.path.exists(os.path.join(dst, n))]
if clashes:
sys.exit("issue_init.py: %s and %s both hold %s — move or delete one "
"side first; nothing was changed"
% (src, dst, ", ".join(clashes[:5])
+ (" (+%d more)" % (len(clashes) - 5) if len(clashes) > 5 else "")))
if dry_run:
return names
os.makedirs(dst, exist_ok=True)
for n in names:
shutil.move(os.path.join(src, n), os.path.join(dst, n))
try:
os.rmdir(src) # only when we emptied it
except OSError:
pass
return names
def run(root, dry_run=False):
"""Initialize `root`. Returns a list of lines describing what happened."""
done = []
marker = os.path.join(root, issue.MARKER)
fresh = not os.path.isdir(marker)
for name in ("issues", "payload"):
d = os.path.join(marker, name)
if not os.path.isdir(d):
if not dry_run:
os.makedirs(d)
done.append("created %s" % os.path.join(issue.MARKER, name))
for name, legacy in LEGACY.items():
src = os.path.join(root, legacy)
moved = migrate(src, os.path.join(marker, name), dry_run)
if moved:
done.append("moved %d file(s) from %s to %s"
% (len(moved), legacy, os.path.join(issue.MARKER, name)))
elif moved == []:
done.append("%s was empty — nothing to move" % legacy)
if add_to_gitignore(os.path.join(root, ".gitignore"),
issue.MARKER + "/", dry_run):
done.append("added %s/ to .gitignore" % issue.MARKER)
if not done:
done.append("already initialized — nothing to do")
elif fresh:
done.append("%s now tracks issues in %s/issues"
% (root, issue.MARKER))
return done
def main():
ap = argparse.ArgumentParser(
description="Create the .tea/ marker that makes a directory a project")
ap.add_argument("--at", default=os.getcwd(),
help="directory to initialize (default: cwd)")
ap.add_argument("--dry-run", action="store_true",
help="report what would happen; change nothing")
args = ap.parse_args()
root = os.path.abspath(args.at)
if not os.path.isdir(root):
sys.exit("issue_init.py: %s is not a directory" % root)
existing = issue.project_root(root)
if existing and existing != root:
sys.stderr.write(
"warning: %s already sits inside the project at %s — a second "
"marker here gives it a second store, and the nearer one wins.\n"
% (root, existing))
for line in run(root, args.dry_run):
print(("would: " if args.dry_run else "") + line)
return 0
if __name__ == "__main__":
sys.exit(main())
+15 -5
View File
@@ -16,7 +16,7 @@ tracker and removes the file.
issue_new.py --type bug --title "Fix tea-guard crash on empty settings" \
--depends wire-sqlc-appclick --milestone v0.2
Writes tmp/issues/<slug>.md prefilled with the type's template, prints the
Writes .tea/issues/<slug>.md prefilled with the type's template, prints the
path, and rebuilds INDEX.md. Fill the sections in an editor or with Edit; run
issue_check.py when done.
@@ -156,9 +156,14 @@ def main():
ap.add_argument("--depends", action="append", default=[],
help="id this issue depends on; repeat")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
# Before anything reads the store path — slug collision, dependency check.
# There is no store to be second-guessed about when there is no project.
if args.out is None:
sys.exit("issue_new.py: %s" % issue.no_project_error())
labels = ["type/%s" % args.type]
if args.severity:
labels.append("severity/%s" % args.severity)
@@ -183,9 +188,14 @@ def main():
depends=args.depends)
# The first issue in a fresh checkout has to create the store, but it says
# so — and it says where, because the path is absolute.
if issue.create_store(args.out):
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
# so — and it says where, because the path is absolute. A store it cannot
# place at all is a different answer: creating one is only ever allowed
# inside a project somebody initialized.
try:
if issue.create_store(args.out):
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
except issue.StoreMissing as e:
sys.exit("issue_new.py: %s" % e)
path = issue.save(args.out, iss)
issue_index.build(args.out)
@@ -13,7 +13,7 @@ this works identically for issues that were never pushed anywhere.
Downwards is what this draws (what an issue depends on). The other direction is
a grep, not a flag:
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md
"""
import argparse
import os
@@ -65,9 +65,9 @@ def main():
ap.add_argument("ids", nargs="*", help="roots (default: issues nothing depends on)")
ap.add_argument("--depth", type=int, default=6, help="max depth (default: 6)")
ap.add_argument("--write", action="store_true",
help="also write tmp/issues/tree-<slug>.md")
help="also write .tea/issues/tree-<slug>.md")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
problem = issue.store_error(args.out)
+23 -18
View File
@@ -1,11 +1,11 @@
---
name: sync
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
description: Move issues between the local store and Gitea — pull issues into .tea/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
---
# /tea:sync — the bridge between the local store and Gitea
One job: translate between `tmp/issues/<id>.md` and Gitea's JSON, and carry the
One job: translate between `.tea/issues/<id>.md` and Gitea's JSON, and carry the
result over the wire. Everything about **what an issue is** — format, types,
validation, the dependency graph — belongs to `/tea:issue` and is imported from
there, never redefined here.
@@ -43,7 +43,7 @@ there is nothing to pin a second time. No pin anywhere → exit with a pointer t
| Script | What it does |
|---|---|
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `.tea/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
| `evict.py [id…] [--dry-run]` | refresh `state:` from Gitea, then evict the issues it reports closed; `origin: local` is never asked about and never removed |
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
@@ -55,11 +55,16 @@ Key forms for `<key>`: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo
defaults to the current directory's git remote; add `--repo owner/repo` outside
one.
`--out` defaults to `issue.ISSUE_ROOT` on every one of them — the domain layer's
`<repo root>/tmp/issues`, resolved from the scripts' own location rather than
cwd. Both layers therefore address the same store by construction, from any
directory. Pass `--out` to override; a relative one stays relative to cwd. Only
`pull.py` will create a missing store, and it says so on stderr.
`--out` defaults to `issue.ISSUE_ROOT` on every one of them — the domain
layer's `<project root>/.tea/issues`, found by walking up from the working
directory to the nearest `.tea/` marker. Both layers therefore address the same
store by construction, from any directory. Pass `--out` to override; a relative
one stays relative to cwd. Only `pull.py` will create a missing store, and it
says so on stderr.
A project with no marker is not a project these scripts will write into: they
stop and name the directories they searched. Run `/tea:issue`'s
`issue_init.py` in it first.
## Identity mapping
@@ -74,7 +79,7 @@ synced: 2026-08-09T18:40:00Z
```
But the file is deleted on push, so the pair also lives in two places that
outlast it: `tmp/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
outlast it: `.tea/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
marker in the issue body on the Gitea side. See [How the slug comes
back](#how-the-slug-comes-back).
@@ -140,7 +145,7 @@ closed issues included. It writes nothing, so there is no write for a limit to
bound — enumeration is its whole job.
**Comments come with every pull** — there is no flag. An issue that has a
thread gets `tmp/issues/<id>.comments.md` beside it, in key mode and in filter
thread gets `.tea/issues/<id>.comments.md` beside it, in key mode and in filter
mode alike, and the issue's output line says how many. An issue with none
costs nothing: the count arrives in the list payload, so no request is made
and no file is written — and a file left over from a thread that has since
@@ -226,12 +231,12 @@ python3 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick # PATCH
```
**A successful push DELETES the local file**`tmp/issues/<id>.md` and
**A successful push DELETES the local file**`.tea/issues/<id>.md` and
`<id>.comments.md` — and prints the number and URL the issue now lives at:
```
created wire-sqlc-appclick #42 https://git.noodles.cam/claude-skills/tea/issues/42
dropped /repo/tmp/issues/wire-sqlc-appclick.md
dropped /repo/.tea/issues/wire-sqlc-appclick.md
pull.py 42 to work on it again
```
@@ -267,7 +272,7 @@ the durable one is not local:
| where | survives | how |
|---|---|---|
| `<!-- tea:id wire-sqlc-appclick -->` | a rename in the web UI, a lost `.remote.json`, a fresh clone, another machine | first line of the **tracker-side** body; an HTML comment, so Gitea renders nothing |
| `tmp/issues/.remote.json` | the file being deleted | number → slug, written before the delete |
| `.tea/issues/.remote.json` | the file being deleted | number → slug, written before the delete |
`pull.py` consults the ledger first (it is the one that knows about files on
disk right now), then the marker, then falls back to slugifying the title for an
@@ -348,8 +353,8 @@ changed only under `--fix`. Running it twice creates nothing. `tech/*` and
`comp/*` are open-ended by design and stay push-created.
Labels belong to the repository, not to any issue, so this one runs on a
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
and nothing creates it. The request bodies go to `tmp/payload/` (below).
checkout with no store and leaves it that way — nothing here reads `.tea/issues/`
and nothing creates it. The request bodies go to `.tea/payload/` (below).
A milestone must already exist in the repo — push attaches, it does not create.
@@ -515,11 +520,11 @@ precisely so the mechanism this section rules out is not needed.
## Rich payloads for everything else
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
Every body these scripts send is written to `<project>/.tea/payload/<name>.json`
first and passed as `-d @file`, then kept for a retry or a look at what actually
went up. One gitignored directory for all of them, chosen by the transport and
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
and it is never `tmp/issues/` — a command that touches no issue must not leave
and it is never `.tea/issues/` — a command that touches no issue must not leave
an issue store behind.
Comments and issues are wrapped by the scripts above. For **other** entities
@@ -527,7 +532,7 @@ Comments and issues are wrapped by the scripts above. For **other** entities
subcommands like `tea pulls create` hang on a large or formatted body — an
empty-looking positional triggers the `$EDITOR` fallback on a TTY that does not
exist, and the harness eventually kills the process (exit 144 = 128 + SIGURG on
macOS). Write the JSON payload to `$PWD/tmp/` first and POST it with
macOS). Write the JSON payload to `$PWD/.tea/payload/` first and POST it with
`tea api -d @file`. Procedure and endpoint table: `/tea:use`.
## Login
+38 -44
View File
@@ -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
+1 -1
View File
@@ -179,7 +179,7 @@ def main():
help="print what would change; makes no request at all")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
root = args.out
+1 -1
View File
@@ -44,7 +44,7 @@ def main():
help="PATCH an existing comment instead of posting a new one")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
root = args.out
+1 -1
View File
@@ -113,7 +113,7 @@ def main(argv=None):
ap.add_argument("--dry-run", action="store_true",
help="ask the tracker and report; write and delete nothing")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args(argv)
root = args.out
+3 -3
View File
@@ -33,9 +33,9 @@ Only repository labels are read; an organization's own labels sit behind a
different endpoint and are neither read nor written.
The issue store is out of scope too, and not incidentally. A label belongs to
the repository, not to any issue, so this command neither reads tmp/issues/ nor
the repository, not to any issue, so this command neither reads .tea/issues/ nor
creates it — the taxonomy it paints comes from the domain MODULE, and the
request bodies it sends go to the transport's own tmp/payload/.
request bodies it sends go to the transport's own .tea/payload/.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
@@ -173,7 +173,7 @@ def main():
base = _gitea.repo_base(args.repo)
# Read first, always: the plan is decided against the repository itself,
# never against tmp/issues/.labels.json. That cache is what makes
# never against .tea/issues/.labels.json. That cache is what makes
# _gitea.ensure_labels cheap for push.py and wrong for a bootstrap — it
# answers "what did we create last time", and the answer here has to be
# "what does the repository have right now".
+1 -1
View File
@@ -127,7 +127,7 @@ def parse_remote_key(key):
# finds it before the prose rather than buried in it.
#
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
# body is written to disk, so `tmp/issues/<id>.md` holds exactly what the author
# body is written to disk, so `.tea/issues/<id>.md` holds exactly what the author
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
# and the slug is already the file's name, so a copy of it in the body would be
# duplicated state.
+11 -6
View File
@@ -10,7 +10,7 @@ once Gitea has confirmed it, so pulling is not a refresh of a copy you kept —
it is how the copy comes to exist. It lands under the SAME slug it had before,
even after a rename in the web UI and even on a machine that has never seen the
issue: the slug travels in the body as `<!-- tea:id … -->`, and
tmp/issues/.remote.json indexes it by number. See `id_for` for the order those
.tea/issues/.remote.json indexes it by number. See `id_for` for the order those
are consulted in. The marker itself is stripped out of what is written to disk.
Two ways to name what to pull:
@@ -55,7 +55,7 @@ writes nothing at all, so there is no write to bound and its `--limit` means
what it says — how many lines to print.
Comments ride along by default, in both modes and for every issue written:
the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs
the thread lands in .tea/issues/<id>.comments.md, beside the issue. It costs
nothing when there is nothing to fetch — the payload already carries the
comment count, so an issue with none makes no request, and a file left over
from an earlier pull is deleted. An absent file therefore means "no comments",
@@ -218,7 +218,7 @@ def main():
help="skip issues already on disk instead of refetching")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
filtered = bool(args.milestone or args.label or args.query)
@@ -229,9 +229,14 @@ def main():
root = args.out
# A first pull into a fresh checkout has to create the store; it says so,
# and the path is absolute, so it cannot be a stray cwd.
if issue.create_store(root):
sys.stderr.write("created store %s\n" % os.path.abspath(root))
# and the path is absolute, so it cannot be a stray cwd. With no project
# marker anywhere there is nowhere legitimate to put one — pulling into a
# guessed directory is what stranded issues inside the plugin.
try:
if issue.create_store(root):
sys.stderr.write("created store %s\n" % os.path.abspath(root))
except issue.StoreMissing as e:
_gitea.die(str(e))
login = _gitea.require_login()
+2 -2
View File
@@ -2,7 +2,7 @@
"""
push.py — local store -> Gitea, and the local copy goes away.
**A successful push deletes `tmp/issues/<id>.md` and `<id>.comments.md`.** Once
**A successful push deletes `.tea/issues/<id>.md` and `<id>.comments.md`.** Once
the tracker has the issue, the tracker IS the issue: what is left in the store
is only what has not left this machine. Get it back with `pull.py <n>` — it
comes back under the same slug, because the slug travelled up in the body as
@@ -223,7 +223,7 @@ def main():
ap.add_argument("--force", action="store_true", help="push despite format violations")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
root = args.out
+1 -1
View File
@@ -45,7 +45,7 @@ def main():
ap.add_argument("--limit", type=int, default=30)
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args()
login = _gitea.require_login()
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
`issue_init.py` — the statement that makes a directory a project.
python3 -m unittest discover -s tests -v
The marker is the anchor every other script resolves from, so the command that
creates it carries the whole contract: it is idempotent, it never picks a
winner between two versions of one issue, and it migrates the old `tmp/` layout
by MOVING — a store left behind at the old path is a store somebody edits by
accident.
Like the rest of the suite, these run the real script against throwaway
directories: the script stays where it is installed, the project is somewhere
else entirely.
"""
import os
import subprocess
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
INIT = os.path.join(ISSUE_SCRIPTS, "issue_init.py")
sys.path.insert(0, ISSUE_SCRIPTS)
import issue # noqa: E402
def run(*args, **kw):
env = dict(os.environ)
env.pop("PYTHONPATH", None)
env.pop("CLAUDE_PROJECT_DIR", None)
p = subprocess.run([sys.executable, INIT] + list(args),
cwd=kw.pop("cwd"), env=env, capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
def write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(text)
class Dir(object):
def __init__(self):
self._tmp = tempfile.TemporaryDirectory()
self.root = os.path.realpath(self._tmp.name)
def cleanup(self):
self._tmp.cleanup()
def path(self, *parts):
return os.path.join(self.root, *parts)
class TestInit(unittest.TestCase):
def setUp(self):
self.d = Dir()
self.addCleanup(self.d.cleanup)
def test_it_creates_the_marker_and_both_directories(self):
rc, out, err = run(cwd=self.d.root)
self.assertEqual(rc, 0, err)
self.assertTrue(os.path.isdir(self.d.path(issue.MARKER, "issues")))
self.assertTrue(os.path.isdir(self.d.path(issue.MARKER, "payload")))
self.assertEqual(issue.project_root(self.d.root), self.d.root)
def test_the_store_resolves_from_a_subdirectory_afterwards(self):
run(cwd=self.d.root)
deep = self.d.path("a", "b", "c")
os.makedirs(deep)
self.assertEqual(issue.store_root(deep),
self.d.path(*issue.STORE_PARTS))
def test_running_it_twice_changes_nothing(self):
run(cwd=self.d.root)
before = sorted(os.walk(self.d.root))
rc, out, err = run(cwd=self.d.root)
self.assertEqual(rc, 0, err)
self.assertIn("already initialized", out)
self.assertEqual(sorted(os.walk(self.d.root)), before)
def test_a_dry_run_touches_nothing(self):
rc, out, err = run("--dry-run", cwd=self.d.root)
self.assertEqual(rc, 0, err)
self.assertIn("would:", out)
self.assertFalse(os.path.exists(self.d.path(issue.MARKER)))
def test_at_initializes_somewhere_else(self):
other = Dir()
self.addCleanup(other.cleanup)
rc, out, err = run("--at", other.root, cwd=self.d.root)
self.assertEqual(rc, 0, err)
self.assertTrue(os.path.isdir(other.path(issue.MARKER)))
self.assertFalse(os.path.exists(self.d.path(issue.MARKER)))
class TestGitignore(unittest.TestCase):
def setUp(self):
self.d = Dir()
self.addCleanup(self.d.cleanup)
def test_the_marker_is_added(self):
run(cwd=self.d.root)
with open(self.d.path(".gitignore")) as f:
self.assertIn(issue.MARKER + "/", f.read().split())
def test_an_existing_gitignore_keeps_its_contents(self):
write(self.d.path(".gitignore"), "node_modules/\n*.log\n")
run(cwd=self.d.root)
with open(self.d.path(".gitignore")) as f:
lines = f.read().split()
self.assertIn("node_modules/", lines)
self.assertIn("*.log", lines)
self.assertIn(issue.MARKER + "/", lines)
def test_it_is_not_added_twice(self):
write(self.d.path(".gitignore"), issue.MARKER + "\n")
run(cwd=self.d.root)
with open(self.d.path(".gitignore")) as f:
body = f.read()
self.assertEqual(body.count(issue.MARKER), 1, body)
class TestMigration(unittest.TestCase):
"""The old layout moves in. Moves, not copies: two stores is the state this
whole change exists to prevent."""
def setUp(self):
self.d = Dir()
self.addCleanup(self.d.cleanup)
def test_an_old_store_is_moved_in(self):
write(self.d.path("tmp", "issues", "old-work.md"), "id: old-work\n")
write(self.d.path("tmp", "payload", "request.json"), "{}")
rc, out, err = run(cwd=self.d.root)
self.assertEqual(rc, 0, err)
self.assertTrue(os.path.isfile(
self.d.path(issue.MARKER, "issues", "old-work.md")))
self.assertTrue(os.path.isfile(
self.d.path(issue.MARKER, "payload", "request.json")))
self.assertFalse(os.path.exists(self.d.path("tmp", "issues")),
"the old store was left behind for somebody to edit")
self.assertIn("moved 1 file(s)", out)
def test_a_clash_stops_everything_and_moves_nothing(self):
write(self.d.path("tmp", "issues", "same.md"), "old version\n")
write(self.d.path(issue.MARKER, "issues", "same.md"), "new version\n")
rc, out, err = run(cwd=self.d.root)
self.assertNotEqual(rc, 0)
self.assertIn("same.md", out + err)
with open(self.d.path("tmp", "issues", "same.md")) as f:
self.assertEqual(f.read(), "old version\n")
with open(self.d.path(issue.MARKER, "issues", "same.md")) as f:
self.assertEqual(f.read(), "new version\n")
def test_a_dry_run_reports_the_move_without_making_it(self):
write(self.d.path("tmp", "issues", "old-work.md"), "id: old-work\n")
rc, out, err = run("--dry-run", cwd=self.d.root)
self.assertEqual(rc, 0, err)
self.assertIn("moved 1 file(s)", out)
self.assertTrue(os.path.isfile(self.d.path("tmp", "issues", "old-work.md")))
self.assertFalse(os.path.exists(self.d.path(issue.MARKER)))
def test_no_old_layout_is_not_an_error(self):
rc, out, err = run(cwd=self.d.root)
self.assertEqual(rc, 0, err)
self.assertNotIn("moved", out)
class TestNesting(unittest.TestCase):
def setUp(self):
self.d = Dir()
self.addCleanup(self.d.cleanup)
run(cwd=self.d.root)
def test_initializing_inside_a_project_warns(self):
inner = self.d.path("packages", "api")
os.makedirs(inner)
rc, out, err = run(cwd=inner)
self.assertEqual(rc, 0, err)
self.assertIn("already sits inside the project", err)
self.assertIn(self.d.root, err)
def test_the_warning_does_not_stop_it(self):
"""A monorepo package that genuinely wants its own issues is allowed to
say so. The warning is that the nearer marker wins from then on, which
is a consequence worth reading, not an error."""
inner = self.d.path("packages", "api")
os.makedirs(inner)
run(cwd=inner)
self.assertEqual(issue.project_root(inner), inner)
self.assertEqual(issue.project_root(self.d.root), self.d.root)
if __name__ == "__main__":
unittest.main()
+30 -4
View File
@@ -108,7 +108,12 @@ class Worktree(object):
os.path.join(self.main, "skills", layer, "scripts"),
ignore=skip)
shutil.copytree(HOOKS, os.path.join(self.main, "hooks"), ignore=skip)
write(os.path.join(self.main, ".gitignore"), "tmp/\n.claude/\n")
write(os.path.join(self.main, ".gitignore"), ".tea/\n.claude/\n")
# The project marker, in the MAIN checkout only — it is gitignored, so
# a linked worktree never has one, exactly like the pin. One project,
# one store, reached from the worktree by the same hop.
os.makedirs(os.path.join(self.main, ".tea", "issues"))
self.bin = os.path.join(self.root, "fakebin")
os.makedirs(self.bin)
@@ -305,17 +310,38 @@ class TestScriptsInAWorktree(unittest.TestCase):
self.assertNotEqual(rc, 0)
self.assertIn("no login pinned", err)
def test_the_store_resolves_to_the_main_checkout_from_a_worktree(self):
"""The marker is gitignored, so a linked worktree never has one. It is
the same project on another branch and it gets the same store — by the
same hop the pin takes. Initializing in the worktree instead would give
one project two stores, in a directory deleted with the branch."""
code = ("import sys; sys.path.insert(0, %r)\n"
"import issue\nprint(issue.project_root() or '')\n"
"print(issue.store_root() or '')\n"
% os.path.join(self.wt.main, "skills", "issue", "scripts"))
env = self.wt.env()
env.pop("CLAUDE_PROJECT_DIR", None)
p = subprocess.run([sys.executable, "-c", code], cwd=self.wt.tree,
env=env, capture_output=True, text=True)
self.assertEqual(p.returncode, 0, p.stderr)
root, store = p.stdout.strip().splitlines()
self.assertEqual(root, self.wt.main)
self.assertEqual(store, os.path.join(self.wt.main, ".tea", "issues"))
def test_push_from_a_worktree_sends_the_worktree_branch(self):
"""`branch:` -> Gitea `ref`. The workaround this fix removes — run the
worktree's scripts with cwd in the main checkout — sent the main
checkout's branch, which is the one field `branch:` exists for."""
write(os.path.join(self.wt.tree, "tmp", "issues", "pinned-work.md"), ISSUE)
checkout's branch, which is the one field `branch:` exists for.
The store is the main checkout's, reached by the hop; the branch is the
worktree's, read from cwd. Two questions, two answers, one command."""
write(os.path.join(self.wt.main, ".tea", "issues", "pinned-work.md"), ISSUE)
rc, out, err = self.wt.run(self.wt.script("sync", "push.py"),
"pinned-work", "--repo", "fixture/repo")
self.assertEqual(rc, 0, "push.py failed:\n%s%s" % (out, err))
self.assertIn("created pinned-work #101", out)
with open(os.path.join(self.wt.tree, "tmp", "payload",
with open(os.path.join(self.wt.main, ".tea", "payload",
"issue-pinned-work.json")) as f:
payload = json.load(f)
self.assertEqual(payload.get("ref"), "feature")
+57 -35
View File
@@ -49,7 +49,11 @@ sys.stdout.write(json.dumps({"id": 1, "name": "created"})
class FakeRepo(object):
"""A self-contained repository with no store and no tmp/ at all."""
"""An initialized project with no store and nothing under `.tea/` yet.
The scripts are NOT copied in: they stay at their real installed path, so
what these tests exercise is a plugin operating on somebody else's project
— which is every use of it but this repository's own."""
def __init__(self):
self._tmp = tempfile.TemporaryDirectory()
@@ -57,12 +61,7 @@ class FakeRepo(object):
# own cwd would otherwise disagree with the path we handed it.
self.root = os.path.realpath(self._tmp.name)
os.makedirs(os.path.join(self.root, ".git")) # the repo marker
skip = shutil.ignore_patterns("__pycache__")
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
# the transport resolves the login pin through skills/auth/scripts
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip)
os.makedirs(os.path.join(self.root, issue.MARKER)) # the project marker
os.makedirs(self.path("sub", "deeper"))
# the login pin the transport insists on, local to this fixture
@@ -85,18 +84,22 @@ class FakeRepo(object):
@property
def store(self):
return self.path("tmp", "issues")
return self.path(*issue.STORE_PARTS)
@property
def payloads(self):
return self.path("tmp", "payload")
return self.path(*_gitea.PAYLOAD_PARTS)
def script(self, layer, name):
return self.path("skills", layer, "scripts", name)
"""The real script, several directories away from this fixture."""
return os.path.join(REPO, "skills", layer, "scripts", name)
def run(self, script, *args, **kw):
env = dict(os.environ)
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
# the first anchor of both walks: left in place, every fixture would
# resolve to this repository instead of itself
env.pop("CLAUDE_PROJECT_DIR", None)
env["PATH"] = self.bin + os.pathsep + env["PATH"]
env["TEA_CALL_LOG"] = self.root
p = subprocess.run([sys.executable, script] + list(args),
@@ -118,26 +121,43 @@ class FakeRepo(object):
class TestPayloadRoot(unittest.TestCase):
def test_root_is_absolute_and_repo_anchored(self):
self.assertTrue(os.path.isabs(_gitea.PAYLOAD_ROOT), _gitea.PAYLOAD_ROOT)
self.assertEqual(_gitea.PAYLOAD_ROOT, os.path.join(REPO, "tmp", "payload"))
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
def test_root_is_absolute_and_project_anchored(self):
root = _gitea.payload_root(self.repo.path("sub", "deeper"))
self.assertTrue(os.path.isabs(root), root)
self.assertEqual(root, self.repo.payloads)
def test_it_is_not_the_issue_store_and_not_inside_one(self):
"""The acceptance criterion, as a path fact: a request body is not
store content, so it may not live in a store or under one."""
self.assertNotEqual(_gitea.PAYLOAD_ROOT, issue.ISSUE_ROOT)
self.assertFalse(_gitea.PAYLOAD_ROOT.startswith(issue.ISSUE_ROOT + os.sep))
self.assertFalse(issue.ISSUE_ROOT.startswith(_gitea.PAYLOAD_ROOT + os.sep))
start = self.repo.path("sub", "deeper")
payload = _gitea.payload_root(start)
store = issue.store_root(start)
self.assertNotEqual(payload, store)
self.assertFalse(payload.startswith(store + os.sep))
self.assertFalse(store.startswith(payload + os.sep))
def test_the_name_says_what_it_holds(self):
"""Named so the distinction is visible: a top-level directory called
`payload`, not a dotdir hiding among an issue's files."""
self.assertEqual(os.path.basename(_gitea.PAYLOAD_ROOT), "payload")
self.assertFalse(os.path.basename(_gitea.PAYLOAD_ROOT).startswith("."))
"""Named so the distinction is visible: `payload`, a sibling of the
store under the marker, not a dotdir hiding among an issue's files."""
payload = _gitea.payload_root(self.repo.root)
self.assertEqual(os.path.basename(payload), "payload")
self.assertEqual(os.path.dirname(payload),
self.repo.path(issue.MARKER))
def test_no_project_means_no_payload_root(self):
"""Same answer as the store gives: nothing, rather than a directory
picked because it was the only one at hand."""
plain = tempfile.TemporaryDirectory()
self.addCleanup(plain.cleanup)
self.assertIsNone(_gitea.payload_root(os.path.realpath(plain.name)))
def test_gitignore_covers_it(self):
"""The rule is `tmp/` is ignored, not which file says so: this plugin
lives under `plugins/` in a marketplace repo, and git reads every
"""The rule is that the marker is ignored, not which file says so: this
plugin lives under `plugins/` in a marketplace repo, and git reads every
.gitignore on the way up. So walk up the same way git does."""
ignored = set()
d = REPO
@@ -150,15 +170,16 @@ class TestPayloadRoot(unittest.TestCase):
if parent == d or os.path.isdir(os.path.join(d, ".git")):
break
d = parent
self.assertEqual(_gitea.PAYLOAD_PARTS[0], "tmp")
self.assertIn("tmp/", ignored,
self.assertEqual(_gitea.PAYLOAD_PARTS[0], issue.MARKER)
self.assertIn(issue.MARKER + "/", ignored,
"the payload directory is not covered by .gitignore")
def test_resolution_is_anchored_on_the_module_not_on_cwd(self):
repo = FakeRepo()
self.addCleanup(repo.cleanup)
self.assertEqual(_gitea.payload_root(repo.path("sub", "deeper")),
repo.payloads)
def test_resolution_follows_the_project_not_the_module(self):
"""The bug, as a path fact: the scripts live somewhere else entirely,
and the answer is still this project's directory."""
self.assertEqual(_gitea.payload_root(self.repo.path("sub", "deeper")),
self.repo.payloads)
self.assertFalse(self.repo.payloads.startswith(REPO + os.sep))
# --------------------------------------------------------------------------
@@ -211,17 +232,18 @@ class TestLabelsTouchesNoStore(unittest.TestCase):
def test_a_dry_run_writes_nothing_at_all(self):
out, _ = self.bootstrap("--dry-run")
self.assertIn("nothing was written", out)
self.assertFalse(os.path.exists(self.repo.path("tmp")),
"a dry run left something behind in tmp/")
self.assertEqual(os.listdir(self.repo.path(issue.MARKER)), [],
"a dry run left something behind under the marker")
def test_the_directory_does_not_follow_cwd(self):
"""Run from a subdirectory: still one payload root, at the repo root.
A cwd-relative directory is how the store ended up with a second copy
of itself, and this one is resolved the same way to avoid the same
"""Run from a subdirectory: still one payload root, at the project
root. A cwd-relative directory is how the store ended up with a second
copy of itself, and this one is resolved the same way to avoid the same
class of bug."""
self.bootstrap(cwd=self.repo.path("sub", "deeper"))
self.assertTrue(os.path.isdir(self.repo.payloads))
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")))
self.assertFalse(os.path.exists(
self.repo.path("sub", "deeper", issue.MARKER)))
self.assertFalse(os.path.exists(self.repo.store))
+297 -117
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Where the issue store is, and that the answer does not depend on cwd.
Where the issue store is: the project the operator marked, never the plugin.
python3 -m unittest discover -s tests -v
@@ -8,11 +8,22 @@ Stdlib unittest, no third-party anything — the same rule the scripts under tes
live by. `skills/*/scripts/` are not packages, so the domain module is imported
by path.
Most of these tests do not touch this repository at all. They build a throwaway
repo in a temp directory — a `.git` marker, a copy of both script layers, a
These tests build a throwaway project in a temp directory — a `.tea/` marker, a
store with two issues — and run the real scripts inside it as subprocesses with
different working directories. That is the only honest way to test a cwd bug:
importing the module would resolve the store once, against the wrong tree.
different working directories.
**The scripts are deliberately NOT copied into the fixture.** They stay where
they really live, several directories away from the project under test, because
that separation IS the thing being tested: a plugin is installed in one place
and used on projects in another, and the store belongs to the project. The
suite used to copy both script layers in, which made the two locations the same
directory and hid the bug completely — issues written from a project landed in
`~/.claude/plugins/cache/tea/tea/<version>/tmp/issues` and vanished on the next
version bump.
`CLAUDE_PROJECT_DIR` is stripped from the child environment except where a test
is about it: it is the first anchor, so leaving the harness's own value in place
would point every fixture at this repository.
"""
import os
import shutil
@@ -24,7 +35,6 @@ import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
sys.path.insert(0, ISSUE_SCRIPTS)
import issue # noqa: E402
@@ -85,34 +95,38 @@ none
def run(script, *args, **kw):
"""Run one of the plugin's scripts and return (rc, stdout, stderr)."""
"""Run one of the plugin's real scripts and return (rc, stdout, stderr).
`project_dir` sets CLAUDE_PROJECT_DIR for the child; by default the variable
is removed, so cwd alone decides which project answers."""
cwd = kw.pop("cwd")
project_dir = kw.pop("project_dir", None)
env = dict(os.environ)
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
env.pop("CLAUDE_PROJECT_DIR", None)
if project_dir:
env["CLAUDE_PROJECT_DIR"] = project_dir
p = subprocess.run([sys.executable, script] + list(args), cwd=cwd, env=env,
capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
class FakeRepo(object):
"""A self-contained repository in a temp directory.
def script(layer, name):
"""A script at its real installed path — never a copy inside a fixture."""
return os.path.join(REPO, "skills", layer, "scripts", name)
Both script layers are copied in, so `__file__`-anchored resolution lands
inside the fixture and never on the developer's real store.
"""
def __init__(self, with_store=True, issues=(ALPHA, BETA)):
class FakeProject(object):
"""An initialized project in a temp directory, far from the scripts."""
def __init__(self, with_store=True, marker=True, issues=(ALPHA, BETA)):
self._tmp = tempfile.TemporaryDirectory()
# realpath: on macOS $TMPDIR is a symlink, and a child process reporting
# its own cwd would otherwise disagree with the path we handed it.
self.root = os.path.realpath(self._tmp.name)
os.makedirs(os.path.join(self.root, ".git")) # the repo marker
skip = shutil.ignore_patterns("__pycache__")
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
# the transport resolves the login pin through skills/auth/scripts
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip)
if marker:
os.makedirs(os.path.join(self.root, issue.MARKER))
os.makedirs(self.path("sub", "deeper"))
if with_store:
@@ -130,17 +144,14 @@ class FakeRepo(object):
@property
def store(self):
return self.path("tmp", "issues")
def script(self, layer, name):
return self.path("skills", layer, "scripts", name)
return self.path(*issue.STORE_PARTS)
def everywhere(self):
"""Working directories that must all produce the same answer: the repo
root, a plain subdirectory, a deeper one, the script directory itself,
and — the case from the bug report — inside the store."""
"""Working directories that must all produce the same answer: the
project root, a plain subdirectory, a deeper one, and — the case from
the original bug report — inside the store itself."""
return [self.root, self.path("sub"), self.path("sub", "deeper"),
self.path("skills", "issue", "scripts"), self.store]
self.store]
# --------------------------------------------------------------------------
@@ -150,38 +161,99 @@ class FakeRepo(object):
class TestResolution(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
self.project = FakeProject()
self.addCleanup(self.project.cleanup)
def test_repo_root_found_from_any_depth(self):
for start in self.repo.everywhere():
self.assertEqual(issue.repo_root(start), self.repo.root, start)
def test_the_marker_is_found_from_any_depth(self):
for start in self.project.everywhere():
self.assertEqual(issue.project_root(start), self.project.root, start)
def test_agents_md_works_as_a_marker(self):
"""A checkout without .git — the plugin copied out of git — still
resolves, because AGENTS.md marks the root too."""
shutil.rmtree(self.repo.path(".git"))
open(self.repo.path("AGENTS.md"), "w").close()
self.assertEqual(issue.repo_root(self.repo.path("sub", "deeper")),
self.repo.root)
def test_git_alone_is_not_a_marker(self):
"""The whole point of an explicit marker. `.git` is in every clone,
including this plugin's own — inferring the root from one is how the
plugin came to answer with itself. An uninitialized repository is not a
project this tool knows about, and it says so instead of guessing."""
plain = FakeProject(marker=False, with_store=False)
self.addCleanup(plain.cleanup)
os.makedirs(plain.path(".git"))
open(plain.path("AGENTS.md"), "w").close()
self.assertIsNone(issue.project_root(plain.path("sub", "deeper")))
self.assertIsNone(issue.store_root(plain.path("sub", "deeper")))
def test_nearest_marker_wins(self):
"""A repo inside a repo (a worktree, a vendored copy) resolves to the
inner one, not the outer."""
inner = self.repo.path("sub", "inner")
os.makedirs(os.path.join(inner, ".git"))
self.assertEqual(issue.repo_root(inner), inner)
self.assertEqual(issue.repo_root(self.repo.root), self.repo.root)
"""A project inside a project (a vendored copy, a nested checkout)
resolves to the inner one, not the outer."""
inner = self.project.path("sub", "inner")
os.makedirs(os.path.join(inner, issue.MARKER))
self.assertEqual(issue.project_root(inner), inner)
self.assertEqual(issue.project_root(self.project.root), self.project.root)
def test_store_root_is_repo_root_plus_tmp_issues(self):
self.assertEqual(issue.store_root(self.repo.path("sub", "deeper")),
self.repo.store)
def test_store_root_is_project_root_plus_marker(self):
self.assertEqual(issue.store_root(self.project.path("sub", "deeper")),
self.project.store)
self.assertTrue(os.path.isabs(issue.store_root(self.project.root)))
def test_default_root_is_absolute(self):
"""The whole point: a default that cannot mean two directories."""
self.assertTrue(os.path.isabs(issue.ISSUE_ROOT), issue.ISSUE_ROOT)
self.assertEqual(issue.ISSUE_ROOT,
os.path.join(REPO, "tmp", "issues"))
def test_no_marker_anywhere_resolves_to_nothing(self):
"""Not a default, not cwd, not the script's own directory: None. A
wrong directory that looks like it worked is the failure this
replaces."""
plain = FakeProject(marker=False, with_store=False)
self.addCleanup(plain.cleanup)
self.assertIsNone(issue.store_root(plain.path("sub", "deeper")))
def test_the_error_names_the_directories_it_searched(self):
msg = issue.no_project_error("/nowhere-at-all")
self.assertIn(issue.MARKER, msg)
self.assertIn("/nowhere-at-all", msg)
self.assertIn("issue_init.py", msg)
# --------------------------------------------------------------------------
# the regression: an installed plugin never answers with itself
# --------------------------------------------------------------------------
class TestTheStoreIsNeverThePlugin(unittest.TestCase):
"""The bug this contract exists for.
Anchored on `__file__`, every one of these commands resolved the store
inside the plugin — a versioned cache directory — so work written from a
project was invisible from it and disappeared on the next plugin update."""
def setUp(self):
self.project = FakeProject()
self.addCleanup(self.project.cleanup)
self.before = self._plugin_tree()
def _plugin_tree(self):
out = set()
for dirpath, dirnames, filenames in os.walk(REPO):
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for f in filenames:
out.add(os.path.join(dirpath, f))
return out
def test_scripts_run_from_a_project_write_only_into_that_project(self):
for d in self.project.everywhere():
for name in ("issue_index.py", "issue_check.py", "issue_tree.py"):
run(script("issue", name), cwd=d)
rc, out, err = run(script("issue", "issue_new.py"),
"--type", "task", "--title", "Written from a project",
cwd=self.project.path("sub", "deeper"))
self.assertEqual(rc, 0, err)
self.assertTrue(os.path.isfile(
os.path.join(self.project.store, "written-from-a-project.md")))
new = self._plugin_tree() - self.before
self.assertEqual(new, set(),
"these commands wrote into the plugin: %s"
% ", ".join(sorted(new)))
def test_the_plugins_own_marker_does_not_leak_into_a_project(self):
"""Should this repository ever be initialized for its own issues, that
marker must not become the answer for a project that has one."""
for d in self.project.everywhere():
self.assertEqual(issue.project_root(d), self.project.root, d)
# --------------------------------------------------------------------------
@@ -191,17 +263,18 @@ class TestResolution(unittest.TestCase):
class TestSameFromAnywhere(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
self.project = FakeProject()
self.addCleanup(self.project.cleanup)
def assertSameEverywhere(self, layer, name, *args):
"""Run the script from the repo root and from every other directory;
"""Run the script from the project root and from every other directory;
every result must be byte-identical to the one from the root."""
dirs = self.repo.everywhere()
base = run(self.repo.script(layer, name), *args, cwd=dirs[0])
self.assertEqual(base[0], 0, "%s failed at the repo root:\n%s" % (name, base[2]))
dirs = self.project.everywhere()
base = run(script(layer, name), *args, cwd=dirs[0])
self.assertEqual(base[0], 0, "%s failed at the project root:\n%s"
% (name, base[2]))
for d in dirs[1:]:
self.assertEqual(run(self.repo.script(layer, name), *args, cwd=d), base,
self.assertEqual(run(script(layer, name), *args, cwd=d), base,
"%s disagrees when run from %s" % (name, d))
return base
@@ -218,24 +291,111 @@ class TestSameFromAnywhere(unittest.TestCase):
def test_issue_index(self):
_, out, _ = self.assertSameEverywhere("issue", "issue_index.py")
self.assertIn("2 issue(s)", out)
self.assertIn(os.path.join(self.repo.store, "INDEX.md"), out)
self.assertIn(os.path.join(self.project.store, "INDEX.md"), out)
def test_no_second_store_is_ever_created(self):
"""The bug's worst symptom: `issue_index.py` run from inside the store
used to leave tmp/issues/tmp/issues/ behind, silently."""
for d in self.repo.everywhere():
"""The old bug's worst symptom: `issue_index.py` run from inside the
store used to leave tmp/issues/tmp/issues/ behind, silently."""
for d in self.project.everywhere():
for name in ("issue_index.py", "issue_check.py", "issue_tree.py"):
run(self.repo.script("issue", name), cwd=d)
run(script("issue", name), cwd=d)
found = []
for dirpath, dirnames, filenames in os.walk(self.repo.root):
for dirpath, dirnames, filenames in os.walk(self.project.root):
if "__pycache__" in dirnames:
dirnames.remove("__pycache__")
if "INDEX.md" in filenames:
found.append(dirpath)
self.assertEqual(found, [self.repo.store],
self.assertEqual(found, [self.project.store],
"a second store appeared: %s" % found)
def test_a_cd_into_another_project_answers_with_that_project(self):
"""Walking up is not cwd-independence for its own sake: two projects
are two stores, and the one you are standing in is the one you meant."""
other = FakeProject(issues=(ALPHA,))
self.addCleanup(other.cleanup)
_, mine, _ = run(script("issue", "issue_check.py"), cwd=self.project.root)
_, theirs, _ = run(script("issue", "issue_check.py"), cwd=other.root)
self.assertIn("2 issue(s) checked", mine)
self.assertIn("1 issue(s) checked", theirs)
# --------------------------------------------------------------------------
# which anchor wins
# --------------------------------------------------------------------------
class TestAnchorOrder(unittest.TestCase):
def setUp(self):
self.project = FakeProject()
self.other = FakeProject(issues=(ALPHA,))
self.addCleanup(self.project.cleanup)
self.addCleanup(self.other.cleanup)
def test_claude_project_dir_is_asked_before_cwd(self):
"""The editor's project is the project, even when a command happens to
run from somewhere else — the same order the login pin uses, so the two
cannot disagree about which project this is."""
_, out, err = run(script("issue", "issue_check.py"),
cwd=self.other.root, project_dir=self.project.root)
self.assertIn("2 issue(s) checked", out, err)
def test_an_unmarked_claude_project_dir_falls_through_to_cwd(self):
"""First hit wins, not first anchor tried: a project dir with no marker
above it is no answer at all, and cwd still gets its turn."""
plain = FakeProject(marker=False, with_store=False)
self.addCleanup(plain.cleanup)
_, out, err = run(script("issue", "issue_check.py"),
cwd=self.other.root, project_dir=plain.root)
self.assertIn("1 issue(s) checked", out, err)
# --------------------------------------------------------------------------
# no project at all
# --------------------------------------------------------------------------
class TestNoProject(unittest.TestCase):
def setUp(self):
self.plain = FakeProject(marker=False, with_store=False)
self.addCleanup(self.plain.cleanup)
def test_readers_report_it_and_name_where_they_looked(self):
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
rc, out, err = run(script("issue", name), cwd=self.plain.root)
msg = out + err
self.assertNotEqual(rc, 0, "%s should fail with no project" % name)
self.assertIn("no %s/ found" % issue.MARKER, msg, name)
self.assertIn(self.plain.root, msg, name)
def test_no_domain_script_ever_shows_a_traceback(self):
""""No project" is an ordinary answer, not a crash. A TypeError on a
None path is how an unresolved root announced itself while this was
being written — every entry point is swept, so a new one cannot
quietly reintroduce it."""
args = {"issue_ac.py": ["alpha-issue"],
"issue_evict.py": ["--dry-run"],
"issue_new.py": ["--type", "task", "--title", "Nowhere"]}
entries = [n for n in sorted(os.listdir(ISSUE_SCRIPTS))
if n.endswith(".py") and n not in ("issue.py", "issue_init.py")]
self.assertTrue(entries)
for name in entries:
rc, out, err = run(script("issue", name), *args.get(name, []),
cwd=self.plain.path("sub", "deeper"))
self.assertNotIn("Traceback", err, "%s crashed:\n%s" % (name, err))
self.assertNotEqual(rc, 0, name)
self.assertIn("no %s/ found" % issue.MARKER, out + err, name)
def test_a_writer_refuses_to_invent_a_project(self):
rc, out, err = run(script("issue", "issue_new.py"),
"--type", "task", "--title", "Nowhere to put this",
cwd=self.plain.path("sub", "deeper"))
self.assertNotEqual(rc, 0)
self.assertIn("no %s/ found" % issue.MARKER, out + err)
self.assertFalse(os.path.exists(self.plain.path(issue.MARKER)))
self.assertFalse(os.path.exists(self.plain.path("sub", "deeper",
issue.MARKER)))
# --------------------------------------------------------------------------
# missing is not empty
@@ -244,20 +404,20 @@ class TestSameFromAnywhere(unittest.TestCase):
class TestMissingVersusEmpty(unittest.TestCase):
def test_missing_store_says_missing(self):
repo = FakeRepo(with_store=False)
self.addCleanup(repo.cleanup)
project = FakeProject(with_store=False)
self.addCleanup(project.cleanup)
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
rc, out, err = run(script("issue", name), cwd=project.root)
msg = out + err
self.assertNotEqual(rc, 0, "%s should fail on a missing store" % name)
self.assertIn("does not exist", msg, name)
self.assertNotIn("is empty", msg, name)
def test_empty_store_says_empty(self):
repo = FakeRepo(issues=())
self.addCleanup(repo.cleanup)
project = FakeProject(issues=())
self.addCleanup(project.cleanup)
for name in ("issue_check.py", "issue_tree.py"):
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
rc, out, err = run(script("issue", name), cwd=project.root)
msg = out + err
self.assertNotEqual(rc, 0, name)
self.assertIn("is empty", msg, name)
@@ -266,12 +426,12 @@ class TestMissingVersusEmpty(unittest.TestCase):
def test_index_of_an_empty_store_is_legitimate(self):
"""An existing store with nothing in it gets an index saying so. Only a
missing directory is an error."""
repo = FakeRepo(issues=())
self.addCleanup(repo.cleanup)
rc, out, err = run(repo.script("issue", "issue_index.py"), cwd=repo.root)
project = FakeProject(issues=())
self.addCleanup(project.cleanup)
rc, out, err = run(script("issue", "issue_index.py"), cwd=project.root)
self.assertEqual(rc, 0, err)
self.assertIn("0 issue(s)", out)
with open(os.path.join(repo.store, "INDEX.md")) as f:
with open(os.path.join(project.store, "INDEX.md")) as f:
self.assertIn("_empty_", f.read())
@@ -282,39 +442,41 @@ class TestMissingVersusEmpty(unittest.TestCase):
class TestNoSilentCreation(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo(with_store=False)
self.addCleanup(self.repo.cleanup)
self.project = FakeProject(with_store=False)
self.addCleanup(self.project.cleanup)
def test_readers_and_the_indexer_create_nothing(self):
for d in (self.repo.root, self.repo.path("sub")):
for d in (self.project.root, self.project.path("sub")):
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
run(self.repo.script("issue", name), cwd=d)
self.assertFalse(os.path.exists(self.repo.path("tmp")),
run(script("issue", name), cwd=d)
self.assertFalse(os.path.exists(self.project.store),
"the store was created by a read")
self.assertFalse(os.path.exists(self.repo.path("sub", "tmp")),
self.assertFalse(os.path.exists(self.project.path("sub", issue.MARKER)),
"a store was created relative to cwd")
def test_explicit_out_pointing_nowhere_is_an_error_not_a_mkdir(self):
target = self.repo.path("sub", "nowhere")
rc, out, err = run(self.repo.script("issue", "issue_index.py"),
"--out", target, cwd=self.repo.root)
target = self.project.path("sub", "nowhere")
rc, out, err = run(script("issue", "issue_index.py"),
"--out", target, cwd=self.project.root)
self.assertNotEqual(rc, 0)
self.assertIn("does not exist", out + err)
self.assertFalse(os.path.exists(target))
def test_issue_new_creates_the_store_and_says_so(self):
"""Creating the first issue in a fresh checkout must still work — but
out loud, and at the repo root, not below whatever cwd happens to be."""
rc, out, err = run(self.repo.script("issue", "issue_new.py"),
"""Creating the first issue in a fresh project must still work — but
out loud, and at the project root, not below whatever cwd happens to
be."""
rc, out, err = run(script("issue", "issue_new.py"),
"--type", "task", "--title", "Bootstrap the store",
cwd=self.repo.path("sub", "deeper"))
cwd=self.project.path("sub", "deeper"))
self.assertEqual(rc, 0, err)
self.assertIn("created store", err)
self.assertIn(self.repo.store, err)
self.assertIn(self.project.store, err)
self.assertTrue(os.path.isfile(
os.path.join(self.repo.store, "bootstrap-the-store.md")))
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")),
"a store was created relative to cwd")
os.path.join(self.project.store, "bootstrap-the-store.md")))
self.assertFalse(
os.path.exists(self.project.path("sub", "deeper", issue.MARKER)),
"a store was created relative to cwd")
# --------------------------------------------------------------------------
@@ -324,39 +486,38 @@ class TestNoSilentCreation(unittest.TestCase):
class TestExplicitOutWins(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
self.project = FakeProject()
self.addCleanup(self.project.cleanup)
def test_absolute_out_is_honored(self):
other = self.repo.path("sub", "other-store")
other = self.project.path("sub", "other-store")
os.makedirs(other)
shutil.copy(os.path.join(self.repo.store, "alpha-issue.md"), other)
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
"--out", other, cwd=self.repo.root)
shutil.copy(os.path.join(self.project.store, "alpha-issue.md"), other)
rc, out, err = run(script("issue", "issue_check.py"),
"--out", other, cwd=self.project.root)
self.assertEqual(rc, 0, err)
self.assertIn("1 issue(s) checked", out)
def test_relative_out_stays_relative_to_cwd(self):
"""`--out tmp/issues` typed from a subdirectory means that
subdirectory's tmp/issues — which is not there. Auto-resolution must
"""`--out .tea/issues` typed from a subdirectory means that
subdirectory's `.tea/issues` — which is not there. Auto-resolution must
not step in and "fix" what the operator typed."""
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
"--out", os.path.join("tmp", "issues"),
cwd=self.repo.path("sub"))
rel = os.path.join(*issue.STORE_PARTS)
rc, out, err = run(script("issue", "issue_check.py"),
"--out", rel, cwd=self.project.path("sub"))
self.assertNotEqual(rc, 0)
self.assertIn("does not exist", out + err)
# the same relative path from the root does resolve, by cwd alone
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
"--out", os.path.join("tmp", "issues"),
cwd=self.repo.root)
rc, out, err = run(script("issue", "issue_check.py"),
"--out", rel, cwd=self.project.root)
self.assertEqual(rc, 0, err)
self.assertIn("2 issue(s) checked", out)
def test_relative_out_can_climb(self):
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
"--out", os.path.join("..", "tmp", "issues"),
cwd=self.repo.path("sub"))
rc, out, err = run(script("issue", "issue_check.py"),
"--out", os.path.join("..", *issue.STORE_PARTS),
cwd=self.project.path("sub"))
self.assertEqual(rc, 0, err)
self.assertIn("2 issue(s) checked", out)
@@ -368,33 +529,52 @@ class TestExplicitOutWins(unittest.TestCase):
class TestSyncLayerAgrees(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
self.project = FakeProject()
self.addCleanup(self.project.cleanup)
def _probe(self, layer, cwd):
"""Ask one layer, from `cwd`, which module defines the store and where
it lands. The sync scripts put the issue scripts on sys.path themselves
— `import map` is how they do it — so each layer is asked its own way.
"""
scripts = self.repo.path("skills", layer, "scripts")
scripts = SYNC_SCRIPTS if layer == "sync" else ISSUE_SCRIPTS
entry = "import map, issue" if layer == "sync" else "import issue"
code = ("import sys; sys.path.insert(0, %r)\n%s\n"
"print(issue.__file__)\nprint(issue.ISSUE_ROOT)\n") % (scripts, entry)
env = dict(os.environ)
env.pop("PYTHONPATH", None)
env.pop("CLAUDE_PROJECT_DIR", None)
p = subprocess.run([sys.executable, "-c", code], cwd=cwd, env=env,
capture_output=True, text=True)
self.assertEqual(p.returncode, 0, p.stderr)
return p.stdout.strip().splitlines()
def test_both_layers_resolve_the_same_store_from_anywhere(self):
for d in self.repo.everywhere():
for d in self.project.everywhere():
mod_i, root_i = self._probe("issue", d)
mod_s, root_s = self._probe("sync", d)
# sync does not redefine the store; it imports the domain module
self.assertEqual(os.path.realpath(mod_i), os.path.realpath(mod_s), d)
self.assertEqual(root_i, self.repo.store, d)
self.assertEqual(root_s, self.repo.store, d)
self.assertEqual(root_i, self.project.store, d)
self.assertEqual(root_s, self.project.store, d)
def test_the_payload_root_is_a_sibling_of_the_store(self):
"""One marker, one walk: the transport's scratchpad and the domain's
store cannot end up in different projects, and the scratchpad is never
inside the store."""
code = ("import sys; sys.path.insert(0, %r)\n"
"import _gitea\nprint(_gitea.PAYLOAD_ROOT)\n") % SYNC_SCRIPTS
env = dict(os.environ)
env.pop("PYTHONPATH", None)
env.pop("CLAUDE_PROJECT_DIR", None)
for d in self.project.everywhere():
p = subprocess.run([sys.executable, "-c", code], cwd=d, env=env,
capture_output=True, text=True)
self.assertEqual(p.returncode, 0, p.stderr)
payload = p.stdout.strip()
self.assertEqual(payload,
self.project.path(issue.MARKER, "payload"), d)
self.assertFalse(payload.startswith(self.project.store + os.sep), d)
def test_every_out_flag_defers_to_the_domain_layer(self):
"""Both layers agree by construction, not by coincidence: no script