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
+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()