diff --git a/AGENTS.md b/AGENTS.md index 519e867..b5bfb68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,11 +24,17 @@ skills/page DOMAIN what a page tree is: title <-> path, order, the index ▲ offline — no tracker, no network, stdlib imports only │ imports skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O - _gitea.py login pin, tea api, pagination, filters + _gitea.py tea api, pagination, filters, payloads skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O transport is _gitea.py — there is no second one -skills/use REFERENCE tea CLI docs for everything that is not an issue + │ imports + ▼ skills/auth IDENTITY pin the login the whole tracker side runs under + ▲ pin.py where the pin is and how it is found — + │ imports imported by _gitea.py AND by hooks/tea-guard.sh +hooks/tea-guard so `tea` and the scripts cannot disagree + +skills/use REFERENCE tea CLI docs for everything that is not an issue ▲ │ calls agents/ EXECUTION tea-runner: runs the scripts, reports a receipt @@ -51,6 +57,8 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`, ## Repo layout - `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`) + - `scripts/pin.py` — the one written copy of the pin's location and search + order (see "The login pin" below); stdlib, no subprocess, no network - `skills/issue` — issues as units of work (`/tea:issue`), entirely offline - `references/format.md` — canonical issue format; single source of truth - `scripts/issue.py` — domain module: slug identity, parse/render, validation, @@ -63,8 +71,8 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`, - `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md` - `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: login pin, `tea api`, pagination, filters, - label ids, the remote-id map, `tmp/payload/` + - `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids, + the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py` - `scripts/pull.py`, `push.py`, `remote.py`, `comment.py` - `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a repository; reads the domain taxonomy, never the store @@ -91,10 +99,40 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`, Delegating a single call costs more than running it inline — the win is the loop, the retry, and the error triage. - `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations - that don't use the pinned login; `agents-sync` keeps every directory canonical - (`AGENTS.md` real file, `CLAUDE.md` symlink to it) + that don't use the pinned login (resolving it through `auth/pin.py`); + `agents-sync` keeps every directory canonical (`AGENTS.md` real file, + `CLAUDE.md` symlink to it) - `tests/` — stdlib `unittest`, no third-party anything +## The login pin + +`/.claude/settings.local.json` → `env.GITEA_LOGIN`, written by +`/tea:auth` and read at call time. **The search order is written once, in +`skills/auth/scripts/pin.py`**, and both callers import it: the transport +(`_gitea.require_login`) and the `tea-guard` hook. Neither spells the path or +the walk itself, and a test asserts they don't. + +Start directories, first hit wins: `$CLAUDE_PROJECT_DIR`, then a hint the +caller supplies (the hook passes the Bash payload's `cwd`; a script passes +nothing), then the current directory. Each one is searched up its parent chain, +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`/`page.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. + +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. + ## Tests ```bash diff --git a/README.md b/README.md index b913bda..146ba69 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,9 @@ The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-gua ## First use -Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to `.claude/settings.local.json` and takes effect immediately — no restart needed. +Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to the project root's `.claude/settings.local.json` and takes effect immediately — no restart needed. + +Once per *project*, not once per checkout: a `git worktree` shares its main checkout's pin. Both the hook and the scripts find it from inside a worktree, so don't run `/tea:auth` there — it would leave a second pin in a directory that disappears with the branch. ``` /tea:auth @@ -80,7 +82,7 @@ right skill automatically. `/tea:auth` is only needed for the tracker side; ## How the login guard works -Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. +Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. The hook and the scripts look it up the same way — one search order, in `skills/auth/scripts/pin.py`. Claude is **blocked** from: - running `tea` without `--login` at all diff --git a/hooks/tea-guard.sh b/hooks/tea-guard.sh index 1943383..7c490d9 100755 --- a/hooks/tea-guard.sh +++ b/hooks/tea-guard.sh @@ -12,7 +12,12 @@ checking it: The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call time — from the FILE, not the environment — so a freshly pinned login works in -the same session with no restart. +the same session with no restart. WHERE that file is looked for is not decided +here: skills/auth/scripts/pin.py holds the search order, and the sync and wiki +scripts resolve the pin through the same module. One order, one copy of it. The +guard and the scripts disagreeing about a directory is a bug by construction, +and was one: in a git worktree `tea` worked and every script said "no login +pinned". Rules: - not a `tea` command ............................. allow (passthrough) @@ -27,6 +32,18 @@ rewrite; exit 2 + stderr to block. """ import sys, os, re, json, shlex +# The identity layer, reached by the plugin's own layout — the one thing a hook +# may assume about where it lives. Import failure is not fatal on its own: a +# command that is not `tea` still passes through untouched (see main), and only +# a command that needs a login is blocked. +sys.path.append(os.path.abspath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + os.pardir, "skills", "auth", "scripts"))) +try: + import pin +except Exception: + pin = None + PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"} @@ -53,30 +70,6 @@ def rewrite(tool_input, new_cmd, note): sys.exit(0) -def find_pin(start_dir): - """Walk up from start_dir; return (login, path) from the first - .claude/settings.local.json that carries a non-empty env.GITEA_LOGIN.""" - try: - d = os.path.abspath(start_dir or ".") - except Exception: - return None, None - while True: - p = os.path.join(d, ".claude", "settings.local.json") - if os.path.isfile(p): - try: - with open(p) as f: - data = json.load(f) - v = (data.get("env") or {}).get("GITEA_LOGIN") - if isinstance(v, str) and v.strip(): - return v.strip(), p - except Exception: - pass - parent = os.path.dirname(d) - if parent == d: - return None, None - d = parent - - def main(): try: payload = json.load(sys.stdin) @@ -117,16 +110,22 @@ def main(): 'the operator pinned via /tea:auth. This prevents acting under ' 'the wrong identity.' % raw_val) - start = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd() - pin, src = find_pin(start) - if not pin: + if pin is None: + block('cannot import skills/auth/scripts/pin.py, so the pinned login ' + 'cannot be resolved. The plugin tree is incomplete; reinstall it.') + + # The hint is the directory the Bash command will run in; the rest of the + # order (CLAUDE_PROJECT_DIR first, cwd last, and the worktree branch of the + # search) is pin.py's, and is the same order the scripts get. + login, src = pin.find_pin(payload.get("cwd")) + if not login: block('no login is pinned. Run /tea:auth to choose one (writes ' '.claude/settings.local.json env.GITEA_LOGIN). The guard reads ' 'the file at call time, so it takes effect with no restart.') - new_cmd = cmd[:m.start(3)] + shlex.quote(pin) + cmd[m.end(3):] + new_cmd = cmd[:m.start(3)] + shlex.quote(login) + cmd[m.end(3):] rewrite(tool_input, new_cmd, - 'tea-guard: resolved --login -> %s (pinned in %s)' % (pin, src)) + 'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src)) if __name__ == "__main__": diff --git a/skills/auth/SKILL.md b/skills/auth/SKILL.md index c861057..a0223de 100644 --- a/skills/auth/SKILL.md +++ b/skills/auth/SKILL.md @@ -32,13 +32,37 @@ So: 3. **One login:** propose it; confirm before writing. 4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and `url` so the operator's choice is unambiguous. Never decide for them. -5. Merge the chosen name into `.claude/settings.local.json` under `env` - (do not clobber other keys): +5. Merge the chosen name into the **project root's** + `.claude/settings.local.json` under `env` (do not clobber other keys): ```json { "env": { "GITEA_LOGIN": "" } } ``` + **In a git worktree, write it to the main checkout, never to the worktree.** + A worktree is deleted when the branch is done, taking a pin written into it + with it, and one repository with two pins is one repository with two + identities. Both the guard and the scripts already reach the main checkout's + pin from inside any worktree — so there is nothing to pin a second time. + `git rev-parse --path-format=absolute --git-common-dir` names the `.git` to + write beside. 6. Done — it is live. The guard resolves the pin from the file on the next - `tea` call; no restart needed. Tell the operator which login is now pinned. + `tea` call; no restart needed. Tell the operator which login is now pinned, + and which file it went in. + +## Where the pin is looked for + +One search order, written once in `scripts/pin.py` and imported by both the +`tea-guard` hook and the sync/wiki transport — they cannot disagree about a +directory, and a test asserts neither keeps a copy of the walk. + +`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's +`cwd`), then the current directory. Each is searched up its parent chain; only +if that finds nothing does the search cross into the main working tree of a +linked worktree, via `gitdir:` in the `.git` file. The plugin's own directory +is never a source — a plugin pointed at somebody else's project must take the +identity from that project, not from where it happens to be installed. + +If a script reports "no login pinned", that is the honest answer: nothing was +found anywhere on that order. Pin one — at the project root. ## Identity-safety rules diff --git a/skills/auth/scripts/pin.py b/skills/auth/scripts/pin.py new file mode 100644 index 0000000..9165b38 --- /dev/null +++ b/skills/auth/scripts/pin.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +pin.py — where the operator's Gitea login pin is, and how it is found. + +**The search order lives here and nowhere else.** The `tea-guard` hook imports +this module; so does the transport every sync and wiki script runs on. Two +copies of the order is exactly how a git worktree came to have a working hook +and a dead transport in the same directory: `tea` resolved the login, the +scripts said "no login pinned", and the error told the operator to pin what was +already pinned. + +Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse +hook runs before every Bash call and must not fork a process to answer this. + +The pin is a file the OPERATOR owns and `/tea:auth` writes: + + /.claude/settings.local.json -> env.GITEA_LOGIN + +## Search order + +Start directories, in order, first hit wins: + + 1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set + 2. an explicit hint the hook passes the Bash tool's cwd; scripts pass + nothing and go straight to 3 + 3. the current directory + +Each start directory is searched the same way: + + a. up the parent chain, from the directory itself to the filesystem root + b. then, for each LINKED WORKTREE seen on that chain, up the parent chain + of that repository's main working tree + +(b) is the whole point of this module. A worktree is a *sibling* of the main +checkout, not a descendant, so `.claude/settings.local.json` — untracked, and +therefore only ever in the main checkout — is not on the parent chain of (a). +Git knows the two trees are one repository: a worktree's `.git` is a FILE +holding `gitdir: `, and `/commondir` points back at the shared +`.git`. `git rev-parse --git-common-dir` answers the same question by forking; +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 + +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 +on `__file__` would make the plugin's own directory an identity source, which +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. + +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 + +SETTINGS_PARTS = (".claude", "settings.local.json") +ENV_KEY = "GITEA_LOGIN" +PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR" + + +def settings_path(root): + """The pin file for a project root. The one place this path is spelled.""" + return os.path.join(root, *SETTINGS_PARTS) + + +def read_pin(path): + """The login in a settings file, or None. + + Unreadable, not JSON, no `env`, empty string — all the same answer. A + broken file is not a login and is not worth a traceback in a hook.""" + try: + with open(path) as f: + value = (json.load(f).get("env") or {}).get(ENV_KEY) + except Exception: + return None + if isinstance(value, str) and value.strip(): + return value.strip() + 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. + + `/.git` -> `
/.git/worktrees/`, whose `commondir` + file holds a path to `
/.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 `/.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. + + The chain comes first and always wins, so the worktree branch can only + ever find a pin that walking up would not have found at all.""" + hops = [] + for d in parents(start): + login = read_pin(settings_path(d)) + if login: + return login, settings_path(d) + root = main_worktree(d) + if root and root not in hops: + hops.append(root) + for root in hops: + # One level of indirection, never two: a main checkout is not itself a + # linked worktree, so this loop cannot chain and cannot cycle. + for d in parents(root): + login = read_pin(settings_path(d)) + if login: + return login, settings_path(d) + return None, None + + +def start_dirs(hint=None): + """The ordered, deduplicated start directories. + + `hint` is the caller's own idea of where the work is happening — the hook + passes the `cwd` from its payload, which is the directory the Bash command + will actually run in. A script has no payload and passes nothing.""" + try: + cwd = os.getcwd() + except OSError: # cwd deleted out from under us + cwd = None + out = [] + for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd): + if not d: + continue + d = os.path.abspath(d) + if d not in out: + out.append(d) + return out + + +def find_pin(hint=None): + """(login, path) for the first start directory that has a pin, else + (None, None). The entry point; everything above is its parts.""" + for start in start_dirs(hint): + login, path = search(start) + if login: + return login, path + return None, None diff --git a/skills/sync/SKILL.md b/skills/sync/SKILL.md index 130202e..84219c5 100644 --- a/skills/sync/SKILL.md +++ b/skills/sync/SKILL.md @@ -32,8 +32,13 @@ index. ## Scripts In `/scripts/`. None of them take `--login`: they resolve the -operator's pin from `.claude/settings.local.json` themselves, the same source -the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`. +operator's pin from `.claude/settings.local.json` through +`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls, +not merely the same file, so a directory where `tea` works is a directory where +these work. That includes a **git worktree**, whose untracked pin sits in the +main checkout: the search crosses to it through the `gitdir:` in `.git`, and +there is nothing to pin a second time. No pin anywhere → exit with a pointer to +`/tea:auth`. | Script | What it does | |---|---| @@ -312,6 +317,12 @@ a git repo no `ref` is sent and a warning names the issues that went up without one. Reading the branch is the only thing these scripts ask git for — they never check out, create, or write anything. +The branch comes from the **current directory**, so run `push.py` from the tree +the work is on. In a git worktree that is the worktree, and it is now also +where the pin resolves from: the old workaround for the pin — run the scripts +with cwd in the main checkout — sent the main checkout's branch as `ref`, which +is the one thing `branch:` exists to record. + ## What crosses the boundary, and what does not | domain | Gitea | note | diff --git a/skills/sync/scripts/_gitea.py b/skills/sync/scripts/_gitea.py index 5325d1d..2cbbf3f 100644 --- a/skills/sync/scripts/_gitea.py +++ b/skills/sync/scripts/_gitea.py @@ -7,8 +7,10 @@ query quirks. It does NOT know what an issue is: no sections, no acceptance criteria, no type taxonomy. Payload shapes come from map.py; the domain model lives one layer further out in skills/issue/scripts/issue.py. -Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up -from CWD — the same file /tea:auth writes and the tea-guard hook reads. No +Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN). +Where that file is searched for is NOT written here — skills/auth/scripts/pin.py +owns the search order, and the tea-guard hook imports the same module, so `tea` +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. @@ -100,29 +102,32 @@ PAYLOAD_ROOT = payload_root() # -------------------------------------------------------------------------- # login # -------------------------------------------------------------------------- +# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the +# single written copy of the search order, and the tea-guard hook calls the +# 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. -def find_pin(start_dir=None): - """Walk up from start_dir; return the login from the first - .claude/settings.local.json carrying a non-empty env.GITEA_LOGIN.""" - d = os.path.abspath(start_dir or ".") - while True: - p = os.path.join(d, ".claude", "settings.local.json") - if os.path.isfile(p): - try: - with open(p) as f: - v = (json.load(f).get("env") or {}).get("GITEA_LOGIN") - if isinstance(v, str) and v.strip(): - return v.strip() - except Exception: - pass - parent = os.path.dirname(d) - if parent == d: - return None - d = parent +_AUTH_SCRIPTS = os.path.abspath( + os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts")) +if _AUTH_SCRIPTS not in sys.path: + sys.path.append(_AUTH_SCRIPTS) +import pin # noqa: E402 def require_login(): - login = find_pin(os.getcwd()) + """The operator's pinned login, or exit pointing at /tea:auth. + + No pin found is reported as exactly that. It stays a truthful message: the + fix for "the pin is somewhere this search does not reach" belongs in + pin.py, never in a hint here that sends the operator to pin it twice.""" + login, _ = pin.find_pin() if not login: die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.") return login diff --git a/tests/test_login_pin.py b/tests/test_login_pin.py new file mode 100644 index 0000000..060ede9 --- /dev/null +++ b/tests/test_login_pin.py @@ -0,0 +1,418 @@ +#!/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"), "tmp/\n.claude/\n") + + 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_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) + 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", + "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() diff --git a/tests/test_page_tree.py b/tests/test_page_tree.py index 032e4f7..792ad9a 100644 --- a/tests/test_page_tree.py +++ b/tests/test_page_tree.py @@ -329,13 +329,12 @@ class TestImportScript(unittest.TestCase): self.tmp = tempfile.TemporaryDirectory() self.root = self.tmp.name os.makedirs(os.path.join(self.root, ".git")) - for layer in ("page", "wiki"): + # auth is in the list because the transport resolves the login pin + # through skills/auth/scripts/pin.py — one search order, one module. + for layer in ("page", "wiki", "sync", "auth"): shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"), os.path.join(self.root, "skills", layer, "scripts"), ignore=shutil.ignore_patterns("__pycache__")) - shutil.copytree(SYNC_SCRIPTS, - os.path.join(self.root, "skills", "sync", "scripts"), - ignore=shutil.ignore_patterns("__pycache__")) self.src = build_artifacts(os.path.join(self.root, "artifacts")) self.scripts = os.path.join(self.root, "skills", "page", "scripts") self.space = os.path.join(self.root, "tmp", "wiki", "s") diff --git a/tests/test_payload_root.py b/tests/test_payload_root.py index 1d44134..f89e634 100644 --- a/tests/test_payload_root.py +++ b/tests/test_payload_root.py @@ -28,6 +28,7 @@ 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") WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts") +AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts") sys.path.insert(0, SYNC_SCRIPTS) sys.path.insert(0, ISSUE_SCRIPTS) @@ -61,6 +62,8 @@ class FakeRepo(object): 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(self.path("sub", "deeper")) # the login pin the transport insists on, local to this fixture diff --git a/tests/test_store_path.py b/tests/test_store_path.py index 736d214..20f31f1 100644 --- a/tests/test_store_path.py +++ b/tests/test_store_path.py @@ -24,6 +24,7 @@ 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 @@ -110,6 +111,8 @@ class FakeRepo(object): 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(self.path("sub", "deeper")) if with_store: