#!/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 script runs on. Two copies of the order is exactly how a git worktree came to have a working hook and a dead transport in the same directory: `tea` resolved the login, the scripts said "no login pinned", and the error told the operator to pin what was already pinned. Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse hook runs before every Bash call and must not fork a process to answer this. The pin is a file the OPERATOR owns and `/tea:auth` writes: /.claude/settings.local.json -> env.GITEA_LOGIN ## Search order Start directories, in order, first hit wins: 1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set 2. an explicit hint the hook passes the Bash tool's cwd; scripts pass nothing and go straight to 3 3. the current directory Each start directory is searched the same way: a. up the parent chain, from the directory itself to the filesystem root b. then, for each LINKED WORKTREE seen on that chain, up the parent chain of that repository's main working tree (b) is the whole point of this module. A worktree is a *sibling* of the main checkout, not a descendant, so `.claude/settings.local.json` — untracked, and therefore only ever in the main checkout — is not on the parent chain of (a). Git knows the two trees are one repository: a worktree's `.git` is a FILE holding `gitdir: `, and `/commondir` points back at the shared `.git`. `git rev-parse --git-common-dir` answers the same question by forking; this reads the files. ## Why the search does not start at __file__ where does this installation keep its files a fact about the plugin whose login does this project run under a fact about the project which issues does it have 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. This module answered that way first and alone; `issue.store_root` and `_gitea.PAYLOAD_ROOT` were anchored on `__file__` until an installed plugin was found keeping other projects' issues inside its own versioned cache directory. They resolve from the working directory now too, and the walk they share is the one below — `issue.parents`, `gitdir_of`, `main_worktree` moved down into the domain, which is the layer that depends on nothing and so is the only one all three can borrow from. One written copy: the guard, the transport and the store cannot disagree about a directory. 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 import sys # The domain owns the walk (see above). It is stdlib-only and imports nothing, # so the tea-guard hook inherits no new weight by reaching it through here. sys.path.append(os.path.abspath(os.path.join( os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir, "issue", "scripts"))) from issue import parents, gitdir_of, main_worktree # noqa: E402,F401 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 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