Compare commits
3 Commits
40016e06f2
...
74a0e3b173
| Author | SHA1 | Date | |
|---|---|---|---|
| 74a0e3b173 | |||
| bb964d5a55 | |||
| 1d7abc11ae |
@@ -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
|
▲ offline — no tracker, no network, stdlib imports only
|
||||||
│ imports
|
│ imports
|
||||||
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
|
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
|
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
|
||||||
transport is _gitea.py — there is no second one
|
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
|
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
|
│ calls
|
||||||
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
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
|
## Repo layout
|
||||||
|
|
||||||
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
- `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
|
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
||||||
- `references/format.md` — canonical issue format; single source of truth
|
- `references/format.md` — canonical issue format; single source of truth
|
||||||
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
- `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`
|
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
||||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
- `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/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
||||||
- `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters,
|
- `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids,
|
||||||
label ids, the remote-id map, `tmp/payload/`
|
the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py`
|
||||||
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
||||||
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
|
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
|
||||||
repository; reads the domain taxonomy, never the store
|
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
|
Delegating a single call costs more than running it inline — the win is the
|
||||||
loop, the retry, and the error triage.
|
loop, the retry, and the error triage.
|
||||||
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
||||||
that don't use the pinned login; `agents-sync` keeps every directory canonical
|
that don't use the pinned login (resolving it through `auth/pin.py`);
|
||||||
(`AGENTS.md` real file, `CLAUDE.md` symlink to it)
|
`agents-sync` keeps every directory canonical (`AGENTS.md` real file,
|
||||||
|
`CLAUDE.md` symlink to it)
|
||||||
- `tests/` — stdlib `unittest`, no third-party anything
|
- `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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-gua
|
|||||||
|
|
||||||
## First use
|
## 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
|
/tea:auth
|
||||||
@@ -80,7 +82,7 @@ right skill automatically. `/tea:auth` is only needed for the tracker side;
|
|||||||
|
|
||||||
## How the login guard works
|
## 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:
|
Claude is **blocked** from:
|
||||||
- running `tea` without `--login` at all
|
- running `tea` without `--login` at all
|
||||||
|
|||||||
+29
-30
@@ -12,7 +12,12 @@ checking it:
|
|||||||
|
|
||||||
The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call
|
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
|
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:
|
Rules:
|
||||||
- not a `tea` command ............................. allow (passthrough)
|
- not a `tea` command ............................. allow (passthrough)
|
||||||
@@ -27,6 +32,18 @@ rewrite; exit 2 + stderr to block.
|
|||||||
"""
|
"""
|
||||||
import sys, os, re, json, shlex
|
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}"}
|
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
|
||||||
|
|
||||||
|
|
||||||
@@ -53,30 +70,6 @@ def rewrite(tool_input, new_cmd, note):
|
|||||||
sys.exit(0)
|
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():
|
def main():
|
||||||
try:
|
try:
|
||||||
payload = json.load(sys.stdin)
|
payload = json.load(sys.stdin)
|
||||||
@@ -117,16 +110,22 @@ def main():
|
|||||||
'the operator pinned via /tea:auth. This prevents acting under '
|
'the operator pinned via /tea:auth. This prevents acting under '
|
||||||
'the wrong identity.' % raw_val)
|
'the wrong identity.' % raw_val)
|
||||||
|
|
||||||
start = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
if pin is None:
|
||||||
pin, src = find_pin(start)
|
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
|
||||||
if not pin:
|
'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 '
|
block('no login is pinned. Run /tea:auth to choose one (writes '
|
||||||
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
||||||
'the file at call time, so it takes effect with no restart.')
|
'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,
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+27
-3
@@ -32,13 +32,37 @@ So:
|
|||||||
3. **One login:** propose it; confirm before writing.
|
3. **One login:** propose it; confirm before writing.
|
||||||
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
|
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
|
||||||
`url` so the operator's choice is unambiguous. Never decide for them.
|
`url` so the operator's choice is unambiguous. Never decide for them.
|
||||||
5. Merge the chosen name into `.claude/settings.local.json` under `env`
|
5. Merge the chosen name into the **project root's**
|
||||||
(do not clobber other keys):
|
`.claude/settings.local.json` under `env` (do not clobber other keys):
|
||||||
```json
|
```json
|
||||||
{ "env": { "GITEA_LOGIN": "<chosen-name>" } }
|
{ "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
|
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
|
## Identity-safety rules
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -32,8 +32,13 @@ index.
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
|
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
|
operator's pin from `.claude/settings.local.json` through
|
||||||
the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
|
`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 |
|
| 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
|
one. Reading the branch is the only thing these scripts ask git for — they
|
||||||
never check out, create, or write anything.
|
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
|
## What crosses the boundary, and what does not
|
||||||
|
|
||||||
| domain | Gitea | note |
|
| domain | Gitea | note |
|
||||||
|
|||||||
@@ -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
|
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
|
||||||
lives one layer further out in skills/issue/scripts/issue.py.
|
lives one layer further out in skills/issue/scripts/issue.py.
|
||||||
|
|
||||||
Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up
|
Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
|
||||||
from CWD — the same file /tea:auth writes and the tea-guard hook reads. No
|
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
|
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.
|
they will use. No pin -> exit with a pointer to /tea:auth.
|
||||||
|
|
||||||
@@ -107,29 +109,32 @@ PAYLOAD_ROOT = payload_root()
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# login
|
# 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):
|
_AUTH_SCRIPTS = os.path.abspath(
|
||||||
"""Walk up from start_dir; return the login from the first
|
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
|
||||||
.claude/settings.local.json carrying a non-empty env.GITEA_LOGIN."""
|
if _AUTH_SCRIPTS not in sys.path:
|
||||||
d = os.path.abspath(start_dir or ".")
|
sys.path.append(_AUTH_SCRIPTS)
|
||||||
while True:
|
import pin # noqa: E402
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def require_login():
|
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:
|
if not login:
|
||||||
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
||||||
return login
|
return login
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -329,13 +329,12 @@ class TestImportScript(unittest.TestCase):
|
|||||||
self.tmp = tempfile.TemporaryDirectory()
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
self.root = self.tmp.name
|
self.root = self.tmp.name
|
||||||
os.makedirs(os.path.join(self.root, ".git"))
|
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"),
|
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
||||||
os.path.join(self.root, "skills", layer, "scripts"),
|
os.path.join(self.root, "skills", layer, "scripts"),
|
||||||
ignore=shutil.ignore_patterns("__pycache__"))
|
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.src = build_artifacts(os.path.join(self.root, "artifacts"))
|
||||||
self.scripts = os.path.join(self.root, "skills", "page", "scripts")
|
self.scripts = os.path.join(self.root, "skills", "page", "scripts")
|
||||||
self.space = os.path.join(self.root, "tmp", "wiki", "s")
|
self.space = os.path.join(self.root, "tmp", "wiki", "s")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||||
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "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, SYNC_SCRIPTS)
|
||||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||||
@@ -61,6 +62,8 @@ class FakeRepo(object):
|
|||||||
skip = shutil.ignore_patterns("__pycache__")
|
skip = shutil.ignore_patterns("__pycache__")
|
||||||
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
|
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
|
||||||
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "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"))
|
os.makedirs(self.path("sub", "deeper"))
|
||||||
|
|
||||||
# the login pin the transport insists on, local to this fixture
|
# the login pin the transport insists on, local to this fixture
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import unittest
|
|||||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "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)
|
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||||
import issue # noqa: E402
|
import issue # noqa: E402
|
||||||
@@ -110,6 +111,8 @@ class FakeRepo(object):
|
|||||||
skip = shutil.ignore_patterns("__pycache__")
|
skip = shutil.ignore_patterns("__pycache__")
|
||||||
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
|
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
|
||||||
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "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"))
|
os.makedirs(self.path("sub", "deeper"))
|
||||||
|
|
||||||
if with_store:
|
if with_store:
|
||||||
|
|||||||
Reference in New Issue
Block a user