Files
marketplace/plugins/tea/tests/test_login_pin.py
T
naudachu fb5445915f 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>
2026-08-11 13:38:39 +05:00

445 lines
19 KiB
Python

#!/usr/bin/env python3
"""
Where the login pin is found, and that a git worktree is not a dead zone.
python3 -m unittest discover -s tests -v
Stdlib unittest, no third-party anything, and not one real network call: every
run here is against a throwaway repository with a FAKE `tea` first on PATH.
The bug: the pin was searched for by walking up from CWD only. A worktree is a
*sibling* of the main checkout, and `.claude/settings.local.json` is untracked,
so it lives in the main checkout and nowhere else — the whole sync layer died
inside any worktree with "no login pinned", while `tea` in the same directory
worked, because the tea-guard hook had a second, different copy of the search.
So these tests hold two lines at once: the pin is reachable from a worktree,
and the hook and the scripts get their answer from the same function.
"""
import json
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
HOOKS = os.path.join(REPO, "hooks")
sys.path.insert(0, AUTH_SCRIPTS)
import pin # noqa: E402
HAVE_GIT = shutil.which("git") is not None
LOGIN = "fixture/user"
ENV_KEY = pin.ENV_KEY
# A `tea` that answers without a network: an empty list for every GET, a
# created object for every write. It records its own argv, which is how a test
# reads back the login the call actually ran under.
FAKE_TEA = '''#!%s
import json, os, sys
argv = sys.argv[1:]
with open(os.environ["TEA_CALL_LOG"], "a") as f:
f.write("\\t".join(argv) + "\\n")
sys.stdout.write(json.dumps({"id": 1, "number": 101, "name": "created",
"html_url": "https://example.invalid/issues/101",
"labels": []})
if "-X" in argv else "[]")
'''
ISSUE = """\
---
id: pinned-work
state: open
labels: [type/task]
assignees: []
milestone: none
depends: []
origin: local
---
# Pinned work
## Summary
Issue фикстуры, живёт в сторе worktree.
## Spec
none
## Motivation
Нужен, чтобы push.py было что отправить.
## Acceptance criteria
- [ ] проверяемое условие
"""
def write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(text)
class Worktree(object):
"""A repository with a pin, and a linked worktree beside it.
Beside, not below: `main/` and `worktrees/feature/` are siblings, which is
the entire shape of the bug. The pin is written after the clone is
committed and is covered by .gitignore, so it exists in the main checkout
only — exactly as `/tea:auth` leaves it."""
def __init__(self, pinned=LOGIN):
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-")
# realpath: on macOS $TMPDIR is a symlink, and a child reporting its
# own cwd would otherwise disagree with the path we handed it.
self.root = os.path.realpath(self._tmp.name)
self.main = os.path.join(self.root, "main")
self.tree = os.path.join(self.root, "worktrees", "feature")
self.calls = os.path.join(self.root, "calls.txt")
skip = shutil.ignore_patterns("__pycache__")
for layer in ("auth", "issue", "sync"):
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
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"), ".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)
tea = os.path.join(self.bin, "tea")
write(tea, FAKE_TEA % sys.executable)
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
self.git("init", cwd=self.main)
self.git("add", "-A", cwd=self.main)
self.git("commit", "-m", "fixture", cwd=self.main)
self.git("worktree", "add", "-b", "feature", self.tree, cwd=self.main)
if pinned:
write(os.path.join(self.main, ".claude", "settings.local.json"),
json.dumps({"env": {ENV_KEY: pinned}}))
def cleanup(self):
self._tmp.cleanup()
def env(self):
env = dict(os.environ)
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
# The start of the search order, cleared: this fixture is about the
# steps *after* it, and the developer's own project must not answer.
env.pop(pin.PROJECT_DIR_ENV, None)
env["PATH"] = self.bin + os.pathsep + env["PATH"]
env["TEA_CALL_LOG"] = self.calls
env["HOME"] = self.root # keep the developer's git config out
env["GIT_CONFIG_NOSYSTEM"] = "1"
env["GIT_CONFIG_GLOBAL"] = os.devnull
return env
def git(self, *args, **kw):
cmd = ["git", "-c", "user.email=fixture@example.invalid",
"-c", "user.name=fixture", "-c", "commit.gpgsign=false"] + list(args)
p = subprocess.run(cmd, cwd=kw.pop("cwd", self.tree), env=self.env(),
capture_output=True, text=True)
if p.returncode != 0:
raise AssertionError("%s failed:\n%s%s" % (" ".join(cmd), p.stdout, p.stderr))
return p.stdout.strip()
def script(self, layer, name):
"""A script as the WORKTREE sees it — the copy the operator would run."""
return os.path.join(self.tree, "skills", layer, "scripts", name)
def run(self, script, *args, **kw):
p = subprocess.run([sys.executable, script] + list(args),
cwd=kw.pop("cwd", self.tree), env=self.env(),
capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
def tea_calls(self):
if not os.path.isfile(self.calls):
return []
with open(self.calls) as f:
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
def logins_used(self):
return [a[a.index("--login") + 1] for a in self.tea_calls() if "--login" in a]
# --------------------------------------------------------------------------
# the search itself
# --------------------------------------------------------------------------
class TestSearch(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-unit-")
self.root = os.path.realpath(self._tmp.name)
self.addCleanup(self._tmp.cleanup)
def path(self, *parts):
return os.path.join(self.root, *parts)
def pin_at(self, root, login=LOGIN):
write(os.path.join(root, ".claude", "settings.local.json"),
json.dumps({"env": {ENV_KEY: login}}))
def test_the_parent_chain_is_searched(self):
self.pin_at(self.root)
os.makedirs(self.path("a", "b"))
self.assertEqual(pin.search(self.path("a", "b"))[0], LOGIN)
def test_no_pin_is_no_pin(self):
os.makedirs(self.path("a"))
self.assertEqual(pin.search(self.path("a")), (None, None))
def test_an_unreadable_pin_is_not_a_login(self):
write(self.path(".claude", "settings.local.json"), "{ not json")
self.assertEqual(pin.search(self.root), (None, None))
def test_an_empty_pin_is_not_a_login(self):
write(self.path(".claude", "settings.local.json"),
json.dumps({"env": {ENV_KEY: " "}}))
self.assertEqual(pin.search(self.root), (None, None))
def test_a_git_file_pointing_at_a_worktree_reaches_the_main_checkout(self):
"""The hop, built by hand from the two files git writes — no git
needed to state what the layout means."""
main, tree = self.path("main"), self.path("elsewhere", "feature")
gitdir = os.path.join(main, ".git", "worktrees", "feature")
os.makedirs(gitdir)
os.makedirs(tree)
write(os.path.join(gitdir, "commondir"), "../..\n")
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
self.pin_at(main)
self.assertEqual(pin.main_worktree(tree), main)
login, src = pin.search(tree)
self.assertEqual(login, LOGIN)
self.assertEqual(src, pin.settings_path(main))
def test_an_ordinary_clone_is_not_a_worktree(self):
os.makedirs(self.path("clone", ".git"))
self.assertIsNone(pin.main_worktree(self.path("clone")))
def test_a_submodule_pointer_is_not_a_worktree(self):
"""`.git` is a file there too, but it points into .git/modules/… and
the tree it belongs to is already on the parent chain."""
sub = self.path("super", "lib")
gitdir = self.path("super", ".git", "modules", "lib")
os.makedirs(gitdir)
os.makedirs(sub)
write(os.path.join(sub, ".git"), "gitdir: %s\n" % gitdir)
self.assertIsNone(pin.main_worktree(sub))
def test_the_chain_wins_over_the_hop(self):
"""The worktree branch may only find a pin the walk up would have
missed entirely — it never overrides a nearer one."""
main, tree = self.path("main"), self.path("elsewhere", "feature")
gitdir = os.path.join(main, ".git", "worktrees", "feature")
os.makedirs(gitdir)
os.makedirs(tree)
write(os.path.join(gitdir, "commondir"), "../..\n")
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
self.pin_at(main, "main/login")
self.pin_at(tree, "worktree/login")
self.assertEqual(pin.search(tree)[0], "worktree/login")
def test_start_dirs_are_ordered_and_deduplicated(self):
with mock.patch.dict(os.environ, {pin.PROJECT_DIR_ENV: self.path("p")}):
self.assertEqual(pin.start_dirs(self.path("h")),
[self.path("p"), self.path("h"),
os.path.abspath(os.getcwd())])
with mock.patch.dict(os.environ, {}, clear=True):
self.assertEqual(pin.start_dirs(), [os.path.abspath(os.getcwd())])
# --------------------------------------------------------------------------
# a script run from a worktree
# --------------------------------------------------------------------------
@unittest.skipUnless(HAVE_GIT, "git is not installed")
class TestScriptsInAWorktree(unittest.TestCase):
def setUp(self):
self.wt = Worktree()
self.addCleanup(self.wt.cleanup)
def test_a_sync_script_run_from_the_worktree_finds_the_login(self):
"""The acceptance criterion, run for real: cwd inside the worktree,
the pin in the main checkout, and the call goes out under it."""
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
"--repo", "fixture/repo", "--state", "all")
self.assertEqual(rc, 0, "remote.py failed:\n%s%s" % (out, err))
self.assertNotIn("no login pinned", err)
self.assertEqual(self.wt.logins_used(), [LOGIN])
def test_it_does_not_pin_a_second_login_in_the_worktree(self):
"""Nothing here writes a settings file, and the worktree is the last
place one should appear: it is deleted with the worktree."""
self.wt.run(self.wt.script("sync", "remote.py"), "--repo", "fixture/repo")
self.assertFalse(os.path.exists(pin.settings_path(self.wt.tree)),
"a second settings.local.json appeared in the worktree")
def test_with_no_pin_anywhere_it_still_says_so(self):
wt = Worktree(pinned=None)
self.addCleanup(wt.cleanup)
rc, out, err = wt.run(wt.script("sync", "remote.py"), "--repo", "fixture/repo")
self.assertNotEqual(rc, 0)
self.assertIn("no login pinned", err)
self.assertEqual(wt.logins_used(), [])
def test_the_scripts_own_directory_is_not_a_pin_source(self):
"""Run the worktree's script from a directory that is in no pinned
tree. The script sits inside a repository that has a pin — and it must
still refuse, because the pin belongs to the project being worked on,
not to the installation."""
outside = os.path.join(self.wt.root, "outside")
os.makedirs(outside)
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
"--repo", "fixture/repo", cwd=outside)
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.
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.main, ".tea", "payload",
"issue-pinned-work.json")) as f:
payload = json.load(f)
self.assertEqual(payload.get("ref"), "feature")
self.assertEqual(self.wt.git("rev-parse", "--abbrev-ref", "HEAD"), "feature")
self.assertNotEqual(
self.wt.git("rev-parse", "--abbrev-ref", "HEAD", cwd=self.wt.main),
"feature", "the fixture's two trees are on the same branch")
# --------------------------------------------------------------------------
# one order, one copy of it
# --------------------------------------------------------------------------
@unittest.skipUnless(HAVE_GIT, "git is not installed")
class TestTheHookAndTheScriptsAgree(unittest.TestCase):
def setUp(self):
self.wt = Worktree()
self.addCleanup(self.wt.cleanup)
def guard(self, cwd):
"""The hook, as the harness calls it: payload on stdin, decision on
stdout."""
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
"cwd": cwd}
p = subprocess.run([sys.executable, os.path.join(self.wt.tree, "hooks",
"tea-guard.sh")],
input=json.dumps(payload), cwd=cwd, env=self.wt.env(),
capture_output=True, text=True)
return p
def test_the_hook_resolves_the_pin_from_the_worktree_too(self):
p = self.guard(self.wt.tree)
self.assertEqual(p.returncode, 0, p.stderr)
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
self.assertIn(LOGIN, got)
self.assertNotIn("GITEA_LOGIN", got)
def test_the_hook_and_a_script_answer_the_same_directory_alike(self):
"""The regression that started this: in one directory the hook
resolved the login and every script said there was none."""
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
"--repo", "fixture/repo")
self.assertEqual(rc, 0, err)
script_login = self.wt.logins_used()[0]
hook_login = json.loads(self.guard(self.wt.tree).stdout)[
"hookSpecificOutput"]["updatedInput"]["command"].split("--login ")[1].split()[0]
self.assertEqual(hook_login, script_login)
def test_the_hook_still_blocks_when_nothing_is_pinned(self):
wt = Worktree(pinned=None)
self.addCleanup(wt.cleanup)
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
"cwd": wt.tree}
p = subprocess.run([sys.executable, os.path.join(wt.tree, "hooks", "tea-guard.sh")],
input=json.dumps(payload), cwd=wt.tree, env=wt.env(),
capture_output=True, text=True)
self.assertEqual(p.returncode, 2)
self.assertIn("no login is pinned", p.stderr)
class TestNobodyKeepsASecondCopy(unittest.TestCase):
"""Mechanical: the search order is written in pin.py, and the two callers
spell neither the path nor the walk."""
CALLERS = (os.path.join(HOOKS, "tea-guard.sh"),
os.path.join(SYNC_SCRIPTS, "_gitea.py"))
def source(self, path):
with open(path) as f:
return f.read()
def test_the_path_is_spelled_once(self):
self.assertEqual(pin.SETTINGS_PARTS, (".claude", "settings.local.json"))
for path in self.CALLERS:
body = self.source(path)
for literal in ('".claude"', "'.claude'"):
self.assertNotIn(literal, body,
"%s builds the settings path itself" % path)
def test_both_callers_go_through_the_module(self):
for path in self.CALLERS:
self.assertIn("import pin", self.source(path),
"%s does not resolve the pin through pin.py" % path)
def test_the_domain_layer_never_learns_what_a_login_is(self):
"""The layer rule, unchanged by this: the identity module is imported
by the bridge and by the hook, never by a domain."""
for layer in ("issue", "page"):
d = os.path.join(REPO, "skills", layer, "scripts")
for name in sorted(os.listdir(d)):
if not name.endswith(".py"):
continue
body = self.source(os.path.join(d, name))
for banned in ("import pin", "GITEA_LOGIN", "settings.local.json"):
self.assertNotIn(banned, body, "%s/%s: %s" % (layer, name, banned))
if __name__ == "__main__":
unittest.main()