From 6a89acf5c59b0a160e22169f6791664ff9660b47 Mon Sep 17 00:00:00 2001 From: SoftSpark Date: Thu, 3 Sep 2026 14:51:04 +0200 Subject: [PATCH 1/2] feat: the gh account follows the directory outside interactive zsh too Binding the account on chpwd only ever worked in an interactive zsh. A script, an editor or a coding agent got whatever account was last made active -- and because that is global state shared by every terminal, switching accounts to open a pull request in one window silently repointed the others. Observed, not theorised: an agent working in one workspace had the account changed under it by a terminal sitting in another. ~/.config/git/bin/gh decides at the point of use and switches nothing. gh is handed the bound account's token through GH_TOKEN for the life of one process, so nothing global changes and a shell that never sourced the plugin still gets the right identity. gh auth is excluded: switch refuses to run while GH_TOKEN is set, and status would report the token's account as the active one. install wires the directory onto PATH through both ~/.zshenv and ~/.zprofile. .zshenv is the only startup file a non-interactive zsh reads; macOS then rebuilds PATH in /etc/zprofile, which would leave the wrapper behind the gh it shadows. doctor checks what actually matters -- whether gh resolves to the wrapper in this shell. Resolution moved to lib/resolve.sh. Sourcing guard.sh runs git and exits on a mismatch, which is right for a hook and useless to anything else. Two defects surfaced on the way. --sign wrote gpg.format = ssh without checking that git understands it: on 2.23 every commit in the workspace then died on "bad config variable" pointing at a file the user never wrote, so add refuses and doctor reports it. And `skip` was called in three places and defined in none, which nothing had reached until the signing tests learned to take the other path. --- CHANGELOG.md | 45 ++++++++++++++ CLAUDE.md | 13 ++++ README.md | 17 +++--- gitspace.plugin.zsh | 111 +++++++++++++++++++++++++++++++++- lib/gh | 97 ++++++++++++++++++++++++++++++ lib/guard.sh | 50 ++-------------- lib/resolve.sh | 54 +++++++++++++++++ package.json | 4 +- tests/run.sh | 142 +++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 473 insertions(+), 60 deletions(-) create mode 100755 lib/gh create mode 100644 lib/resolve.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b4d19..e2280d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## v1.2.0 -- the gh account follows the directory everywhere (2026-09-03) + +### Added +- **A `gh` wrapper, so the account is right in shells the hook cannot reach.** + Binding the account on `chpwd` only ever worked in an interactive zsh. A + script, an editor or a coding agent got whatever account was last made active + — and because that is global state shared by every terminal, switching + accounts to open a pull request in one window silently repointed the others. + + `~/.config/git/bin/gh` decides at the point of use and switches nothing: gh is + handed the bound account's token through `GH_TOKEN` for one process. Two + terminals in two workspaces stop fighting, and a shell that never sourced the + plugin still gets the right identity. + + `gitspace install` puts the directory on `$PATH` through **both** `~/.zshenv` + and `~/.zprofile`. Both are needed: `.zshenv` is the only file a + non-interactive zsh reads, and macOS rebuilds `$PATH` in `/etc/zprofile` + afterwards, which would otherwise leave the wrapper behind the gh it shadows. + `doctor` checks the thing that actually matters — whether `gh` resolves to the + wrapper in this shell. + + Every uncertainty passes the call through untouched: no config, no workspace, + no account bound, no token, a `gh auth` subcommand, or a `GH_TOKEN` the caller + set. `gh auth` is excluded deliberately — `switch` refuses to run while + `GH_TOKEN` is set, and `status` would report the token's account as the active + one, turning the command people use to check their identity into a lie. + +### Fixed +- **`--sign` no longer bricks a workspace on git older than 2.34.** ssh signing + arrived in 2.34; before it, `gpg.format = ssh` is rejected outright. The + setting lands in the workspace's include file, so it did not fail at signing + time but at *every* commit, with `fatal: bad config variable` pointing at a + file the user never wrote. `add --sign` now refuses and names the version it + found, and `doctor` reports a workspace whose signing this git cannot honour — + the case where it was registered on another machine. +- `skip` was called in three places and defined in none. Nothing reached it + until the signing tests learned to take the other path. + +### Changed +- Workspace resolution moved to `lib/resolve.sh`, which has no side effects. + Sourcing `guard.sh` runs git and exits on a mismatch, which is right for a + hook and useless to anything else needing the same answer. + +--- + ## v1.1.1 -- doctor notices stale hooks (2026-08-28) ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index be45add..8d67517 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,19 @@ npm test # full suite in an isolated HOME to a real array first, or an alias like `github-acme` silently becomes `g` - A check that flags everything flags nothing: `audit` compares against the operator's own addresses, never against every author in history +- The `chpwd` hook reaches an interactive zsh and nothing else. Anything that + must also hold for scripts, editors and agents belongs in `lib/gh` or in git + config, not in the plugin +- A wrapper must not shell out to find itself. `lib/gh` takes its directory from + `${0%/*}`, never `dirname`: a stripped `$PATH` has neither, and self-detection + that fails turns `exec` into an infinite loop. Bound any test that can hit it — + a hanging test reports nothing and has to be killed by hand +- macOS rebuilds `$PATH` in `/etc/zprofile` via `path_helper`, which runs AFTER + `.zshenv`. An entry that must win goes in `.zprofile` too, and re-prepends + rather than skipping when already present: after path_helper it is present, + just too late to matter +- Never write a git config a git might reject. `gpg.format = ssh` on git < 2.34 + fails every commit in the workspace, not just the signing ## Layout ``` diff --git a/README.md b/README.md index ff4a8ba..c7f2bf3 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,17 @@ [![npm](https://img.shields.io/npm/v/@softspark/gitspace)](https://www.npmjs.com/package/@softspark/gitspace) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -## What's New in v1.1.1 +## What's New in v1.2.0 -- **`doctor` notices stale hooks** — the plugin may be a symlink and always - current, while the installed hooks are copies. A drifted copy is now reported - instead of passing silently +- **The `gh` account now follows the directory outside interactive zsh too** — + a `gh` wrapper on `$PATH` hands gh the bound account's token for one process + instead of switching the account globally, so scripts, editors and agents get + the right identity and two terminals stop repointing each other +- **`--sign` refuses on git older than 2.34** rather than writing a config that + makes every commit in the workspace fail -Earlier in 1.1.0: `--sign`, `gitspace audit`, and key-existence checks in -`doctor`. See [CHANGELOG.md](CHANGELOG.md). - -See [CHANGELOG.md](CHANGELOG.md). +Earlier: stale-hook detection in 1.1.1; `--sign`, `gitspace audit` and +key-existence checks in 1.1.0. See [CHANGELOG.md](CHANGELOG.md). ## Table of Contents diff --git a/gitspace.plugin.zsh b/gitspace.plugin.zsh index 0430b2c..bed4aa0 100644 --- a/gitspace.plugin.zsh +++ b/gitspace.plugin.zsh @@ -12,6 +12,7 @@ typeset -g GITSPACE_CONF="${GITSPACE_CONF:-$HOME/.config/git/workspaces.conf}" typeset -g GITSPACE_LIB="${GITSPACE_LIB:-$HOME/.config/git/hooks}" +typeset -g GITSPACE_BIN="${GITSPACE_BIN:-$HOME/.config/git/bin}" typeset -g GITSPACE_SRC="${0:A:h}" autoload -Uz add-zsh-hook @@ -90,6 +91,17 @@ _gs_has_include() { # never ends up with two entries after --sign is re-run. _gs_allowed_signers() { print -r -- "${GITSPACE_CONF:h}/allowed_signers"; } +_gs_git_version() { command git --version 2>/dev/null | awk '{print $3}'; } + +# ssh signing arrived in git 2.34. Compared numerically rather than as a string, +# because "2.9" sorts above "2.34" the moment anyone does it lexically. +_gs_git_can_sign_ssh() { + local v=$(_gs_git_version) maj min + [[ -n "$v" ]] || return 1 + maj=${v%%.*}; min=${${v#*.}%%.*} + (( maj > 2 || (maj == 2 && min >= 34) )) +} + _gs_register_signer() { local mail="$1" pub="$2" f=$(_gs_allowed_signers) tmp [[ -r "$pub" ]] || return 1 @@ -224,17 +236,58 @@ wclone() { fi } +# --- gh wrapper on PATH ------------------------------------------------------ + +typeset -g _GS_PATH_MARKER='# gitspace: the gh wrapper must resolve before the real gh' + +# Put $GITSPACE_BIN in front of the real gh, for every zsh rather than only the +# interactive one. +# +# Two files, and both are needed. ~/.zshenv is the only startup file a +# non-interactive zsh reads, which is what a script or a coding agent gets. +# ~/.zprofile is needed because macOS rebuilds PATH from scratch in +# /etc/zprofile — `eval $(/usr/libexec/path_helper -s)` puts /usr/local/bin +# first and appends whatever .zshenv had set, so in a login shell the wrapper +# ends up BEHIND the gh it is meant to shadow. Restoring the order afterwards is +# the only way the entry survives. +# +# The line removes existing occurrences before prepending, rather than skipping +# when the directory is already somewhere in PATH: after path_helper it IS in +# PATH, just too late to matter. +_gs_wire_path() { + local f + for f in "$HOME/.zshenv" "$HOME/.zprofile"; do + if [[ -f "$f" ]] && grep -qF -- "$_GS_PATH_MARKER" "$f"; then + print -P "%F{8}=%f ${f/#$HOME/~} already wires it" + continue + fi + [[ -f "$f" ]] && cp "$f" "$f.bak-gitspace" + { + print -r -- + print -r -- "$_GS_PATH_MARKER" + print -r -- "path=( ${(q)GITSPACE_BIN} \${path:#${(q)GITSPACE_BIN}} )" + } >> "$f" + print -P "%F{green}v%f ${f/#$HOME/~} puts it first" + done + # And in the shell running the install, so it does not need a new terminal. + path=( "$GITSPACE_BIN" ${path:#$GITSPACE_BIN} ) +} + # --- gitspace command -------------------------------------------------------- _gs_install() { - mkdir -p "${GITSPACE_CONF:h}" "$GITSPACE_LIB" + mkdir -p "${GITSPACE_CONF:h}" "$GITSPACE_LIB" "$GITSPACE_BIN" local f - for f in guard.sh pre-commit pre-push; do + for f in resolve.sh guard.sh pre-commit pre-push; do install -m 0755 "$GITSPACE_SRC/lib/$f" "$GITSPACE_LIB/$f" done print -P "%F{green}v%f hooks -> $GITSPACE_LIB" + install -m 0755 "$GITSPACE_SRC/lib/gh" "$GITSPACE_BIN/gh" + print -P "%F{green}v%f gh wrapper -> $GITSPACE_BIN" + _gs_wire_path + if [[ ! -f "$GITSPACE_CONF" ]]; then cp "$GITSPACE_SRC/templates/workspaces.conf" "$GITSPACE_CONF" print -P "%F{green}v%f created $GITSPACE_CONF" @@ -315,6 +368,18 @@ _gs_add() { # --sign signs with the workspace's own SSH key, so it needs exactly one. local signkey="" if [[ -n "$sign" ]]; then + # git learned ssh signing in 2.34. An older one rejects `gpg.format = ssh` + # outright — and because the setting lands in the workspace's include file, + # it does not fail at signing time but at EVERY commit, with + # "fatal: bad config variable" pointing at a file the user never wrote. + # Refusing here costs one check; the alternative bricks the workspace. + if ! _gs_git_can_sign_ssh; then + print -P "%F{red}x this git cannot sign with ssh: $(_gs_git_version) (needs 2.34)%f" + print -P " Enabling it would write gpg.format = ssh, and every commit in" + print -P " this workspace would then fail with 'bad config variable'." + print -P " Upgrade git, or register the workspace without --sign." + return 1 + fi # Split into a real array first. ${${(s:,:)x}[1]} indexes the first # CHARACTER of the joined result, not the first element, so an alias # like "github-acme" silently becomes "g". @@ -441,7 +506,7 @@ _gs_doctor() { # are running code from an earlier release. Compare them. print -P "%F{cyan}hooks%f" local stale=0 - for x in guard.sh pre-commit pre-push; do + for x in resolve.sh guard.sh pre-commit pre-push; do if [[ ! -f "$GITSPACE_LIB/$x" ]]; then print -P " %F{red}x%f $x missing - run: gitspace install"; (( problems++ )) elif [[ ! -f "$GITSPACE_SRC/lib/$x" ]]; then @@ -454,6 +519,38 @@ _gs_doctor() { done (( stale )) && print -P " %F{yellow}run 'gitspace install' to refresh the installed hooks%f" + # The wrapper is the only part of this tool that reaches a shell which never + # sourced the plugin, and it reaches it through $PATH alone. Every check below + # exists because the wrapper can be perfectly installed and still never run. + print -P "\n%F{cyan}gh wrapper%f" + if [[ ! -x "$GITSPACE_BIN/gh" ]]; then + print -P " %F{red}x%f $GITSPACE_BIN/gh missing - run: gitspace install"; (( problems++ )) + elif [[ -f "$GITSPACE_SRC/lib/gh" ]] && ! cmp -s "$GITSPACE_SRC/lib/gh" "$GITSPACE_BIN/gh"; then + print -P " %F{red}x%f gh wrapper differs from the plugin source - run: gitspace install"; (( problems++ )) + else + print -P " %F{green}v%f ${GITSPACE_BIN/#$HOME/~}/gh" + fi + + for x in "$HOME/.zshenv" "$HOME/.zprofile"; do + if [[ -f "$x" ]] && grep -qF -- "$_GS_PATH_MARKER" "$x"; then + print -P " %F{green}v%f ${x/#$HOME/~} wires it onto PATH" + else + print -P " %F{red}x%f ${x/#$HOME/~} does not - run: gitspace install"; (( problems++ )) + fi + done + + # The one that matters. Everything above can be right while macOS path_helper, + # or another tool prepending to PATH, still leaves the real gh in front. + local resolved=$(command -v gh 2>/dev/null) + if [[ -z "$resolved" ]]; then + print -P " %F{8}=%f gh is not installed - the wrapper has nothing to wrap" + elif [[ "${resolved:A}" == "${GITSPACE_BIN:A}/gh" ]]; then + print -P " %F{green}v%f gh resolves to the wrapper in this shell" + else + print -P " %F{red}x%f gh resolves to $resolved, not the wrapper"; (( problems++ )) + print -P " %F{8}PATH puts it first; open a new shell, or check what prepends after ~/.zprofile%f" + fi + print -P "\n%F{cyan}global settings%f" if [[ "$(command git config --global user.useConfigOnly)" == "true" ]]; then print -P " %F{green}v%f user.useConfigOnly = true" @@ -530,6 +627,14 @@ _gs_doctor() { # three must agree, or `git log --show-signature` reports an unknown signer. local want_sign=$f[6] cfg="$HOME/.gitconfig-$n" signers=$(_gs_allowed_signers) if [[ -n "$want_sign" ]]; then + # A workspace registered on a machine with a newer git, then used on this + # one, signs nothing and breaks every commit here. The config is right; + # the binary cannot read it. + if ! _gs_git_can_sign_ssh; then + print -P " %F{red}x%f signing requested but git $(_gs_git_version) cannot sign with ssh (needs 2.34)" + print -P " %F{8}every commit in this workspace fails with 'bad config variable'%f" + (( problems++ )) + fi if grep -qE '^[[:space:]]*gpgsign[[:space:]]*=[[:space:]]*true' "$cfg" 2>/dev/null; then print -P " %F{green}v%f signing enabled" else diff --git a/lib/gh b/lib/gh new file mode 100755 index 0000000..24aa0e6 --- /dev/null +++ b/lib/gh @@ -0,0 +1,97 @@ +#!/bin/sh +# gitspace - run `gh` as the account the current workspace binds. +# +# The chpwd hook can only reach an interactive zsh. Everything else that runs +# gh -- a script, an editor, a coding agent, cron -- gets whatever account was +# last made active, which is global mutable state shared by every terminal on +# the machine. That is not a theoretical race: switching accounts to open a pull +# request in one window silently repoints every other window. +# +# So the account is decided here, at the point of use, and WITHOUT switching +# anything: gh is handed the token of the bound account through GH_TOKEN for the +# duration of one process. Nothing global changes, two terminals in two +# workspaces stop fighting, and a shell that never sourced the plugin still gets +# the right identity. +# +# Every uncertainty passes the call through untouched. A wrapper around a tool +# this widely used must never be the reason gh stops working. +set -u + +# Re-entry means the directory comparison below failed and this process is about +# to exec itself. Stop, loudly. A wrapper that spins forever is worse than one +# that is simply absent, and an infinite exec loop takes the terminal with it. +if [ -n "${_GITSPACE_GH:-}" ]; then + printf 'gitspace: gh wrapper re-entered itself - refusing to loop\n' >&2 + exit 127 +fi +_GITSPACE_GH=1 +export _GITSPACE_GH + +# The real gh is the first one on $PATH that is not this file. Resolved rather +# than hard-coded: a hard-coded path breaks on the first machine that installs +# gh somewhere else, and comparing directories rather than filenames is what +# keeps this from exec'ing itself forever. +# +# The directory comes from parameter expansion, never from `dirname`: with a +# stripped $PATH there is no dirname to run, and a wrapper whose self-detection +# depends on the PATH it is inspecting will fail exactly when it matters. +case "$0" in + */*) _gs_self_dir=${0%/*} ;; + *) _gs_self_dir=. ;; +esac +_gs_self_dir=$(CDPATH='' cd -- "$_gs_self_dir" 2>/dev/null && pwd -P) || _gs_self_dir="" +_gs_real_gh="" +_gs_ifs_saved=$IFS +IFS=: +for _gs_d in $PATH; do + [ -n "$_gs_d" ] || _gs_d=. + _gs_dd=$(CDPATH='' cd -- "$_gs_d" 2>/dev/null && pwd -P) || continue + [ "$_gs_dd" = "$_gs_self_dir" ] && continue + if [ -x "$_gs_dd/gh" ]; then _gs_real_gh="$_gs_dd/gh"; break; fi +done +IFS=$_gs_ifs_saved + +if [ -z "$_gs_real_gh" ]; then + printf 'gitspace: gh is not installed (only this wrapper is on PATH)\n' >&2 + exit 127 +fi + +# `gh auth` is the one family that must never see GH_TOKEN: `switch` refuses to +# run while it is set, and `status` reports the token's account as the active +# one, which turns the command people use to check their identity into a lie. +case "${1:-}" in + auth|"") exec "$_gs_real_gh" "$@" ;; +esac + +# A token the caller chose beats one this wrapper would pick. CI sets GH_TOKEN +# deliberately; overriding it would be the wrapper deciding it knows better. +if [ -n "${GH_TOKEN:-}${GITHUB_TOKEN:-}" ]; then + exec "$_gs_real_gh" "$@" +fi + +_gs_conf="${GITSPACE_CONF:-$HOME/.config/git/workspaces.conf}" +_gs_lib="${GITSPACE_LIB:-$HOME/.config/git/hooks}" +[ -f "$_gs_lib/resolve.sh" ] || exec "$_gs_real_gh" "$@" +[ -f "$_gs_conf" ] || exec "$_gs_real_gh" "$@" + +# shellcheck source=lib/resolve.sh +. "$_gs_lib/resolve.sh" + +# $PWD rather than the repository root: gh is run outside a repository often +# enough (`gh repo clone`, `gh api`) that anchoring on git would give up exactly +# where the account still matters. +_gs_row=$(_gs_row_for "$(pwd -P)") +[ -n "$_gs_row" ] || exec "$_gs_real_gh" "$@" + +# Field 4, by expansion rather than `cut`: see the note on dirname above. The +# row is name|path|email|account|aliases, so strip three fields then keep one. +_gs_acct=${_gs_row#*|}; _gs_acct=${_gs_acct#*|}; _gs_acct=${_gs_acct#*|} +_gs_acct=${_gs_acct%%|*} +[ -n "$_gs_acct" ] || exec "$_gs_real_gh" "$@" + +# Not logged in as that account, or gh too old for --user: say nothing and let +# gh run as it would have. The wrapper is an improvement, not a precondition. +_gs_token=$("$_gs_real_gh" auth token --user "$_gs_acct" 2>/dev/null) || _gs_token="" +[ -n "$_gs_token" ] || exec "$_gs_real_gh" "$@" + +GH_TOKEN="$_gs_token" exec "$_gs_real_gh" "$@" diff --git a/lib/guard.sh b/lib/guard.sh index 8d73125..4a36b9e 100644 --- a/lib/guard.sh +++ b/lib/guard.sh @@ -1,59 +1,19 @@ #!/bin/sh # gitspace - shared identity guard, sourced by the git hooks. # -# Format of ~/.config/git/workspaces.conf (fields separated by |): -# name|path|email|gh-account|ssh-alias[,ssh-alias...] -# -# The workspace is resolved BY PATH, not by directory name, so a workspace may -# live anywhere. Nested paths are settled by the longest matching prefix. +# Resolution lives in resolve.sh, which has no side effects; this file is the +# part that judges what it finds and stops the operation. Sourcing it IS the +# check, which is why the `gh` wrapper reads resolve.sh instead. set -e -GITSPACE_CONF="${GITSPACE_CONF:-$HOME/.config/git/workspaces.conf}" +# shellcheck source=lib/resolve.sh +. "${GITSPACE_LIB:-$HOME/.config/git/hooks}/resolve.sh" die() { printf '\n\033[31m✗ IDENTITY BLOCKED (%s)\033[0m\n %s\n\n' "$1" "$2" >&2 exit 1 } -# Resolve symlinks. `git rev-parse --show-toplevel` always returns a physical -# path, so a configured path that traverses a symlink (/var -> /private/var on -# macOS) would never match without this. -_gs_real() { - if [ -d "$1" ]; then (cd "$1" 2>/dev/null && pwd -P) || printf '%s' "$1" - else printf '%s' "$1"; fi -} - -# Expand a leading tilde against $HOME, without eval. -# shellcheck disable=SC2088 # "~/" here is a case PATTERN matching a literal -# tilde in the config file, not a path we want the shell to expand. Expansion is -# exactly what this function performs by hand, deliberately avoiding eval. -_gs_expand() { - case "$1" in - "~/"*) printf '%s' "$HOME/${1#\~/}" ;; - "~") printf '%s' "$HOME" ;; - *) printf '%s' "$1" ;; - esac -} - -# Row of the workspace containing the given path; empty when none matches. -_gs_row_for() { - _target="$1" _best="" _bestlen=0 - [ -f "$GITSPACE_CONF" ] || return 0 - while IFS='|' read -r _n _p _m _a _s; do - case "$_n" in ''|\#*) continue ;; esac - _p=$(_gs_real "$(_gs_expand "$_p")") - case "$_target/" in - "$_p"/*) - _len=${#_p} - if [ "$_len" -gt "$_bestlen" ]; then - _best="$_n|$_p|$_m|$_a|$_s"; _bestlen=$_len - fi - ;; - esac - done < "$GITSPACE_CONF" - printf '%s' "$_best" -} - TOP=$(_gs_real "$(git rev-parse --show-toplevel)") ROW=$(_gs_row_for "$TOP") diff --git a/lib/resolve.sh b/lib/resolve.sh new file mode 100644 index 0000000..1d017e4 --- /dev/null +++ b/lib/resolve.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# gitspace - workspace resolution, with no side effects. +# +# Split out of guard.sh so that something other than a git hook can ask which +# workspace a path belongs to. guard.sh runs git and exits on a mismatch the +# moment it is sourced, which is right for a hook and useless for anything else; +# the `gh` wrapper needs the answer without the verdict. +# +# Format of ~/.config/git/workspaces.conf (fields separated by |): +# name|path|email|gh-account|ssh-alias[,ssh-alias...]|[sign] +# +# The workspace is resolved BY PATH, not by directory name, so a workspace may +# live anywhere. Nested paths are settled by the longest matching prefix. + +GITSPACE_CONF="${GITSPACE_CONF:-$HOME/.config/git/workspaces.conf}" + +# Resolve symlinks. `git rev-parse --show-toplevel` always returns a physical +# path, so a configured path that traverses a symlink (/var -> /private/var on +# macOS) would never match without this. +_gs_real() { + if [ -d "$1" ]; then (cd "$1" 2>/dev/null && pwd -P) || printf '%s' "$1" + else printf '%s' "$1"; fi +} + +# Expand a leading tilde against $HOME, without eval. +# shellcheck disable=SC2088 # "~/" here is a case PATTERN matching a literal +# tilde in the config file, not a path we want the shell to expand. Expansion is +# exactly what this function performs by hand, deliberately avoiding eval. +_gs_expand() { + case "$1" in + "~/"*) printf '%s' "$HOME/${1#\~/}" ;; + "~") printf '%s' "$HOME" ;; + *) printf '%s' "$1" ;; + esac +} + +# Row of the workspace containing the given path; empty when none matches. +_gs_row_for() { + _target="$1" _best="" _bestlen=0 + [ -f "$GITSPACE_CONF" ] || return 0 + while IFS='|' read -r _n _p _m _a _s; do + case "$_n" in ''|\#*) continue ;; esac + _p=$(_gs_real "$(_gs_expand "$_p")") + case "$_target/" in + "$_p"/*) + _len=${#_p} + if [ "$_len" -gt "$_bestlen" ]; then + _best="$_n|$_p|$_m|$_a|$_s"; _bestlen=$_len + fi + ;; + esac + done < "$GITSPACE_CONF" + printf '%s' "$_best" +} diff --git a/package.json b/package.json index d5cde20..dffb699 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@softspark/gitspace", - "version": "1.1.1", + "version": "1.2.0", "description": "Oh My Zsh plugin binding git identity to workspace directories: per-directory commit e-mail, gh account and SSH key, optional SSH commit signing, and an audit that finds repositories already holding the wrong identity.", "keywords": [ "zsh", @@ -45,7 +45,7 @@ "node": ">=18.0.0" }, "scripts": { - "lint": "shellcheck lib/guard.sh lib/pre-commit lib/pre-push", + "lint": "shellcheck lib/resolve.sh lib/guard.sh lib/gh lib/pre-commit lib/pre-push", "typecheck": "node --check bin/gitspace-install.mjs", "test": "./tests/run.sh" } diff --git a/tests/run.sh b/tests/run.sh index 25e318c..27688d8 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -12,6 +12,10 @@ FAIL=0 ok() { PASS=$((PASS + 1)); printf ' \033[32mok\033[0m %s\n' "$1"; } bad() { FAIL=$((FAIL + 1)); printf ' \033[31mFAIL\033[0m %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; } head_() { printf '\n\033[36m%s\033[0m\n' "$1"; } +# Called from branches that only run on a machine missing zsh or carrying an +# older git, which is why it went years without existing: nothing here reached +# it until the signing tests learned to take the other path. +skip() { printf ' \033[33mskip\033[0m %s\n' "$1"; } # Physical path on purpose: git's own `includeIf gitdir:` does not resolve # symlinks either, and on macOS mktemp hands back /var/... while git reports @@ -35,7 +39,8 @@ Host gitlab-acme User git SSHCFG -install -m 0755 "$REPO/lib/guard.sh" "$REPO/lib/pre-commit" "$REPO/lib/pre-push" "$GITSPACE_LIB/" +install -m 0755 "$REPO/lib/resolve.sh" "$REPO/lib/guard.sh" \ + "$REPO/lib/pre-commit" "$REPO/lib/pre-push" "$GITSPACE_LIB/" WS="$HOME/ws/Acme" NESTED="$WS/nested" @@ -174,7 +179,35 @@ Host github-signed IdentityFile $HOME/.ssh/id_ed25519_acme SSHCFG -if command -v zsh >/dev/null 2>&1; then +# git learned ssh signing in 2.34, and this suite has to run on whatever git the +# machine has. On an older one the correct behaviour is a refusal, not a signed +# commit: writing gpg.format = ssh there makes EVERY commit in the workspace die +# on "bad config variable". So the branch is chosen here and both are asserted. +GITV=$(git --version | awk '{print $3}') +GITMAJ=${GITV%%.*}; GITMIN=${GITV#*.}; GITMIN=${GITMIN%%.*} +if [ "$GITMAJ" -gt 2 ] || { [ "$GITMAJ" -eq 2 ] && [ "$GITMIN" -ge 34 ]; }; then + GIT_CAN_SIGN=1 +else + GIT_CAN_SIGN=0 +fi + +if command -v zsh >/dev/null 2>&1 && [ "$GIT_CAN_SIGN" -eq 0 ]; then + out=$(zsh -c "GITSPACE_CONF='$GITSPACE_CONF' GITSPACE_LIB='$GITSPACE_LIB' + source '$REPO/gitspace.plugin.zsh' + gitspace add '$HOME/ws/OldGit' --email s@acme.test --name S \ + --alias github-signed --sign" 2>&1) + case "$out" in + *"cannot sign with ssh"*) ok "--sign is refused on git $GITV (needs 2.34)" ;; + *) bad "--sign was accepted on a git that cannot do it" "$out" ;; + esac + if [ -f "$HOME/.gitconfig-OldGit" ]; then + bad "the refused workspace was written anyway - commits there would break" + else + ok "nothing was written for the refused workspace" + fi +fi + +if command -v zsh >/dev/null 2>&1 && [ "$GIT_CAN_SIGN" -eq 1 ]; then SIGNED="$HOME/ws/Signed" zsh -c "GITSPACE_CONF='$GITSPACE_CONF' GITSPACE_LIB='$GITSPACE_LIB' source '$REPO/gitspace.plugin.zsh' @@ -316,6 +349,111 @@ else bad "wrong package directory" "got $out" fi +# -------------------------------------------------------------------------- +head_ "gh wrapper" + +# The wrapper exists for the shells the chpwd hook cannot reach, so it is tested +# the way they use it: through $PATH, with no plugin sourced anywhere. +# +# Two directories: the wrapper goes in the first, a stub standing in for the +# real gh in the second. The stub answers `auth token --user X` with a token +# naming the account, and otherwise reports the GH_TOKEN it was handed — which +# is the whole contract in one line of output. +GHBIN="$SANDBOX/ghbin" +GHREAL="$SANDBOX/ghreal" +mkdir -p "$GHBIN" "$GHREAL" +install -m 0755 "$REPO/lib/gh" "$GHBIN/gh" +cat > "$GHREAL/gh" <<'STUBGH' +#!/bin/sh +if [ "${1:-}" = auth ] && [ "${2:-}" = token ]; then + printf 'tok-for-%s\n' "${4:-}" + exit 0 +fi +printf 'ran=%s GH_TOKEN=%s\n' "${1:-}" "${GH_TOKEN:-}" +STUBGH +chmod +x "$GHREAL/gh" + +wrapped() { ( cd "$1" && PATH="$GHBIN:$GHREAL:/usr/bin:/bin" gh "${@:2}" 2>&1 ); } + +out=$(wrapped "$WS" api user) +case "$out" in + *"GH_TOKEN=tok-for-acme-bot"*) ok "the workspace account is used without switching" ;; + *) bad "wrapper did not supply the workspace account" "$out" ;; +esac + +# Nested workspaces resolve by longest prefix here as everywhere else, and this +# one binds no account: an empty field must mean "leave gh alone", not "". +out=$(wrapped "$NESTED" api user) +case "$out" in + *"GH_TOKEN="*) ok "a workspace with no gh account is left alone" ;; + *) bad "wrapper invented an account for a workspace that binds none" "$out" ;; +esac + +out=$(wrapped "$SANDBOX" api user) +case "$out" in + *"GH_TOKEN="*) ok "outside every workspace the call is untouched" ;; + *) bad "wrapper acted outside a workspace" "$out" ;; +esac + +# `gh auth switch` refuses to run while GH_TOKEN is set, and `gh auth status` +# reports the token's account as the active one. Handing a token to the command +# people use to inspect their identity would make it lie. +out=$(wrapped "$WS" auth status) +case "$out" in + *"GH_TOKEN="*) ok "gh auth is never given a token" ;; + *) bad "wrapper injected a token into gh auth" "$out" ;; +esac + +# CI sets GH_TOKEN on purpose. A wrapper that overrides it decides it knows +# better than the caller. +out=$( cd "$WS" && PATH="$GHBIN:$GHREAL:/usr/bin:/bin" GH_TOKEN=caller-token gh api user 2>&1 ) +case "$out" in + *"GH_TOKEN=caller-token"*) ok "a token the caller set is not overridden" ;; + *) bad "wrapper overrode the caller's GH_TOKEN" "$out" ;; +esac + +# With only the wrapper on $PATH there is no other gh to find — and no dirname, +# cut or grep either, which is how this went wrong the first time: self-detection +# that shells out cannot run when $PATH is the thing being stripped, so the +# wrapper failed to recognise itself and exec'd itself forever. +# +# Bounded on purpose. A regression here is an infinite exec loop, and a test +# that hangs reports nothing, blocks CI and has to be killed by hand; five +# seconds and a verdict is worth more than a perfect diagnosis that never +# arrives. +bounded() { + local secs=$1; shift + ( "$@" ) >"$SANDBOX/bounded.out" 2>&1 & + local pid=$! + ( sleep "$secs"; kill -9 "$pid" 2>/dev/null ) >/dev/null 2>&1 & + local killer=$! + wait "$pid"; local rc=$? + { kill -9 "$killer"; wait "$killer"; } 2>/dev/null + return $rc +} + +bounded 5 env -i HOME="$HOME" GITSPACE_CONF="$GITSPACE_CONF" GITSPACE_LIB="$GITSPACE_LIB" \ + PATH="$GHBIN" "$GHBIN/gh" api user +rc=$? +out=$(cat "$SANDBOX/bounded.out") +if [ "$rc" -eq 137 ]; then + bad "the wrapper recursed instead of reporting a missing gh (killed after 5s)" "$out" +elif [ "$rc" -eq 127 ] && printf '%s' "$out" | grep -q 'gitspace:'; then + ok "with no real gh the wrapper reports it instead of recursing" +else + bad "wrapper did not handle a missing real gh" "rc=$rc $out" +fi + +# The point of the whole design is that no terminal changes another one's +# account. Asserted on the source rather than on hosts.yml, which other tests in +# this suite write for their own fixtures: the property is "this file never +# switches", and that is exactly what it reads. +if grep -q 'auth switch' "$REPO/lib/gh"; then + bad "the wrapper switches accounts - that is the global state it exists to avoid" +else + ok "the wrapper never switches the active account" +fi + # -------------------------------------------------------------------------- head_ "zsh syntax" From 3cbf428ec82ba885b886d13279218247b0df9803 Mon Sep 17 00:00:00 2001 From: SoftSpark Date: Thu, 3 Sep 2026 14:52:55 +0200 Subject: [PATCH 2/2] ci: lint through npm so the file list cannot drift The lint job carried its own copy of the shellcheck arguments. Adding lib/resolve.sh to package.json therefore left CI linting guard.sh without the file it sources, and SC1091 turned a green change red. One list, in package.json. tests/run.sh joins it, which CI was checking and npm was not. --- .github/workflows/ci.yml | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f5d949..6da7328 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + # Through npm rather than a second file list: this job carried its own, + # and adding lib/resolve.sh to package.json left CI linting guard.sh + # without the file it sources — a red build for a green change. - name: ShellCheck (POSIX sh hooks) - run: shellcheck lib/guard.sh lib/pre-commit lib/pre-push tests/run.sh + run: npm run lint typecheck: runs-on: ubuntu-latest diff --git a/package.json b/package.json index dffb699..ee72edb 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "node": ">=18.0.0" }, "scripts": { - "lint": "shellcheck lib/resolve.sh lib/guard.sh lib/gh lib/pre-commit lib/pre-push", + "lint": "shellcheck lib/resolve.sh lib/guard.sh lib/gh lib/pre-commit lib/pre-push tests/run.sh", "typecheck": "node --check bin/gitspace-install.mjs", "test": "./tests/run.sh" }