Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
111 changes: 108 additions & 3 deletions gitspace.plugin.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions lib/gh
Original file line number Diff line number Diff line change
@@ -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" "$@"
Loading