#!/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__ Deliberate asymmetry with `issue.store_root` and `_gitea.PAYLOAD_ROOT`, which *are* anchored on their own module's location. Two different questions: where does this installation keep its files a fact about the plugin whose login does this project run under a fact about the project A plugin installed outside any repository and pointed at somebody else's tree must answer the second one from the tree it was pointed at. Anchoring the pin on `__file__` would make the plugin's own directory an identity source, which is how a checkout ends up acting under a login nobody chose for it. So the search runs from the working directory upward — and reaches a worktree's main checkout by asking git, not by walking somewhere else. Finding nothing is a real answer: `(None, None)` means there is no pin, and the caller says so. This module never guesses a login. """ import json import os SETTINGS_PARTS = (".claude", "settings.local.json") ENV_KEY = "GITEA_LOGIN" PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR" def settings_path(root): """The pin file for a project root. The one place this path is spelled.""" return os.path.join(root, *SETTINGS_PARTS) def read_pin(path): """The login in a settings file, or None. Unreadable, not JSON, no `env`, empty string — all the same answer. A broken file is not a login and is not worth a traceback in a hook.""" try: with open(path) as f: value = (json.load(f).get("env") or {}).get(ENV_KEY) except Exception: return None if isinstance(value, str) and value.strip(): return value.strip() return None def parents(start): """`start` and every ancestor of it, up to the filesystem root.""" d = os.path.abspath(start) while True: yield d parent = os.path.dirname(d) if parent == d: return d = parent def gitdir_of(d): """The private git directory `d/.git` points at, or None. Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a directory and there is nothing to follow.""" p = os.path.join(d, ".git") if not os.path.isfile(p): return None try: with open(p) as f: head = f.read(4096) except OSError: return None for line in head.splitlines(): line = line.strip() if line.startswith("gitdir:"): target = line[len("gitdir:"):].strip() if not target: return None if not os.path.isabs(target): target = os.path.join(d, target) return os.path.abspath(target) return None def main_worktree(d): """If `d` is a linked worktree, the main working tree of its repository. `/.git` -> `
/.git/worktrees/`, whose `commondir` file holds a path to `
/.git`; the main working tree is its parent. The `.git` basename check keeps this to worktrees: a submodule's `.git` is a pointer too, but it points into `/.git/modules/…`, and the tree it belongs to is already on the parent chain.""" gitdir = gitdir_of(d) if not gitdir or not os.path.isdir(gitdir): return None common = gitdir marker = os.path.join(gitdir, "commondir") if os.path.isfile(marker): try: with open(marker) as f: rel = f.read().strip() except OSError: rel = "" if rel: common = os.path.abspath(os.path.join(gitdir, rel)) if os.path.basename(common) != ".git": return None root = os.path.dirname(common) if root and os.path.isdir(root) and root != os.path.abspath(d): return root return None def search(start): """(login, path) for one start directory: the parent chain, then the main checkout of any worktree met on it. (None, None) when there is no pin. The chain comes first and always wins, so the worktree branch can only ever find a pin that walking up would not have found at all.""" hops = [] for d in parents(start): login = read_pin(settings_path(d)) if login: return login, settings_path(d) root = main_worktree(d) if root and root not in hops: hops.append(root) for root in hops: # One level of indirection, never two: a main checkout is not itself a # linked worktree, so this loop cannot chain and cannot cycle. for d in parents(root): login = read_pin(settings_path(d)) if login: return login, settings_path(d) return None, None def start_dirs(hint=None): """The ordered, deduplicated start directories. `hint` is the caller's own idea of where the work is happening — the hook passes the `cwd` from its payload, which is the directory the Bash command will actually run in. A script has no payload and passes nothing.""" try: cwd = os.getcwd() except OSError: # cwd deleted out from under us cwd = None out = [] for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd): if not d: continue d = os.path.abspath(d) if d not in out: out.append(d) return out def find_pin(hint=None): """(login, path) for the first start directory that has a pin, else (None, None). The entry point; everything above is its parts.""" for start in start_dirs(hint): login, path = search(start) if login: return login, path return None, None