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:
@@ -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()
|
||||
Reference in New Issue
Block a user