fix: resolve the login pin from a git worktree #46

Merged
claude merged 2 commits from fix/worktree-login-pin into main 2026-08-10 13:25:15 +00:00
11 changed files with 774 additions and 68 deletions
+44 -6
View File
@@ -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
`<project root>/.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
+4 -2
View File
@@ -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
+29 -30
View File
@@ -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__":
+27 -3
View File
@@ -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": "<chosen-name>" } }
```
**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
+204
View File
@@ -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:
<project root>/.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: <path>`, and `<path>/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.
`<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.
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
+13 -2
View File
@@ -32,8 +32,13 @@ index.
## Scripts
In `<skill-base-dir>/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 |
|---|---|
@@ -354,6 +359,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 |
+26 -21
View File
@@ -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.
@@ -107,29 +109,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
+418
View File
@@ -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()
+3 -4
View File
@@ -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")
+3
View File
@@ -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
+3
View File
@@ -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: