From f21506af1924eaee82c475dd55cd2254c5bafce1 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Tue, 18 Aug 2026 20:20:45 +1200 Subject: [PATCH] Add git-identity: switch git author identity from a saved menu Identities live in ~/.git-identities (alias|Name|email). Interactive menu to select, add, or remove identities; direct mode via alias argument; --global/-g scope flag (defaults to local inside a repo, global fallback outside); --list/--current helpers. Full --dryrun support and a 27-assertion test suite driven through dryrun. GIT_IDENTITIES_FILE env override for testability. --- README.md | 2 + scripts/git-identity | 269 +++++++++++++++++++++++++++++++++++++ specs/git-identity.spec.md | 102 ++++++++++++++ tests/test-git-identity.sh | 120 +++++++++++++++++ 4 files changed, 493 insertions(+) create mode 100755 scripts/git-identity create mode 100644 specs/git-identity.spec.md create mode 100755 tests/test-git-identity.sh diff --git a/README.md b/README.md index 9245930..246863d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This project uses **spec-driven development** (OpenSpec) as a testbed for agent- | `claude-profile` | Claude Code profile + engagement-mode launcher | Done | | `claude-tmux` | Spawn a detached tmux Claude Code session with Remote Control, named after a project (wraps `claude-profile`) | Done | | `gen-secret` | Generate bash/YAML/JSON-safe random strings | Done | +| `git-identity` | Switch git user.name/email from a saved identity menu (`~/.git-identities`) | Done | | `git-status-report` | Recursive git repo status with diff stats | Done | | `md-to-docx` | Markdown to DOCX conversion | Done | | `mp3-to-mp4` | Convert MP3 to MP4 with a static title card | Done | @@ -27,6 +28,7 @@ This project uses **spec-driven development** (OpenSpec) as a testbed for agent- | `validate-skill` | Validate SKILL.md files against known Claude Code restrictions | Done | | `semver-ci` | Compute `MAJOR.MINOR.PATCH.BUILD` from commit-message tokens, maintain `VERSION.md`, and (on main) tag/push/create a Gitea release — see [`docs/semver-ci.md`](docs/semver-ci.md) and template at [`templates/gitea-workflow-version.yml`](templates/gitea-workflow-version.yml) | Done | | `wait-for-release` | Poll a Gitea repo's releases until a glob pattern matches or a timeout expires (default 600s) | Done | +| `wait-for-version` | Poll a Gitea repo's Actions runs until the named CI workflow completes on HEAD of main, then print the latest release tag — solves the push-then-update race condition for semver dependencies | Done | ### Engagement modes (claude-profile) diff --git a/scripts/git-identity b/scripts/git-identity new file mode 100755 index 0000000..a1d8aec --- /dev/null +++ b/scripts/git-identity @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +set -uo pipefail + +# git-identity — switch git author identity from a saved menu. +# Spec: specs/git-identity.spec.md + +IDENTITIES_FILE="${GIT_IDENTITIES_FILE:-$HOME/.git-identities}" + +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +usage() { + cat <<'EOF' +Usage: git-identity [OPTIONS] [ALIAS] + +Switch git user.name/user.email from identities saved in ~/.git-identities. +Run with no arguments for an interactive menu (select, add, or remove). + +Arguments: + ALIAS Apply this saved identity directly + +Options: + -g, --global Apply to global git config (default: local when in a repo) + -l, --list List saved identities + -c, --current Show the identity in effect here + -n, --dryrun Preview actions without changing anything + -h, --help Show this help + +File format (~/.git-identities): alias|Name|email, one per line, # comments. +EOF +} + +dryrun=false +scope_global=false +action="" +alias_arg="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -n|--dryrun) dryrun=true; shift ;; + -g|--global) scope_global=true; shift ;; + -l|--list) action="list"; shift ;; + -c|--current) action="current"; shift ;; + -*) echo "Error: Unknown option: $1" >&2; exit 1 ;; + *) + if [[ -n "$alias_arg" ]]; then + echo "Error: Too many arguments" >&2; exit 1 + fi + alias_arg="$1"; shift ;; + esac +done + +in_repo() { git rev-parse --is-inside-work-tree >/dev/null 2>&1; } + +scope_desc() { + if $scope_global; then + echo "--global" + else + echo "--local in $(git rev-parse --show-toplevel)" + fi +} + +# Load identities into parallel arrays. +aliases=() names=() emails=() +load_identities() { + aliases=() names=() emails=() + [[ -f "$IDENTITIES_FILE" ]] || return 0 + while IFS='|' read -r a n e; do + [[ -z "$a" || "$a" == \#* ]] && continue + [[ -z "$n" || -z "$e" ]] && continue + aliases+=("$a"); names+=("$n"); emails+=("$e") + done < "$IDENTITIES_FILE" +} + +show_current() { + local scope="global" name email + if in_repo && ! $scope_global; then scope="local"; fi + name=$(git config user.name 2>/dev/null || true) + email=$(git config user.email 2>/dev/null || true) + echo -e "Current identity (${scope}): ${CYAN}${name:-} <${email:-unset}>${NC}" +} + +apply_identity() { + local name="$1" email="$2" + if ! $scope_global && ! in_repo; then + echo -e "${YELLOW}Note: not inside a git repository — applying globally${NC}" + scope_global=true + fi + local desc; desc=$(scope_desc) + if $dryrun; then + echo "[dryrun] Would set user.name '$name' and user.email '$email' ($desc)" + return 0 + fi + local flag="--local" + $scope_global && flag="--global" + git config "$flag" user.name "$name" && git config "$flag" user.email "$email" || { + echo "Error: git config failed" >&2; exit 1 + } + echo -e "${GREEN}Set user.name '$name' and user.email '$email' ($desc)${NC}" +} + +apply_alias() { + local wanted="$1" i + for i in "${!aliases[@]}"; do + if [[ "${aliases[$i]}" == "$wanted" ]]; then + apply_identity "${names[$i]}" "${emails[$i]}" + return 0 + fi + done + echo "Error: No identity '$wanted' in $IDENTITIES_FILE" >&2 + if [[ ${#aliases[@]} -gt 0 ]]; then + echo "Available: ${aliases[*]}" >&2 + fi + exit 1 +} + +list_identities() { + local i + for i in "${!aliases[@]}"; do + printf '%-14s %s <%s>\n' "${aliases[$i]}" "${names[$i]}" "${emails[$i]}" + done +} + +ensure_file() { + [[ -f "$IDENTITIES_FILE" ]] && return 0 + local gname gemail + gname=$(git config --global user.name 2>/dev/null || true) + gemail=$(git config --global user.email 2>/dev/null || true) + echo "No identities file at $IDENTITIES_FILE." + local seed="" + if [[ -n "$gname" && -n "$gemail" ]]; then + seed="default|$gname|$gemail" + echo "Create it seeded with your global identity ($gname <$gemail>)? [Y/n]" + else + echo "Create an empty one? [Y/n]" + fi + read -r reply + [[ "$reply" =~ ^[Nn] ]] && exit 0 + if $dryrun; then + echo "[dryrun] Would create $IDENTITIES_FILE${seed:+ seeded with '$seed'}" + return 0 + fi + { echo "# alias|Name|email"; [[ -n "$seed" ]] && echo "$seed"; } > "$IDENTITIES_FILE" + echo -e "${GREEN}Created $IDENTITIES_FILE${NC}" +} + +add_identity() { + local a n e + while true; do + read -rp "Alias: " a + [[ -z "$a" ]] && { echo "Cancelled."; return 0; } + if [[ "$a" == *"|"* ]]; then echo "Alias must not contain '|'"; continue; fi + local i dup=false + for i in "${!aliases[@]}"; do + [[ "${aliases[$i]}" == "$a" ]] && dup=true + done + $dup && { echo "Alias '$a' already exists"; continue; } + break + done + read -rp "Name: " n + [[ -z "$n" ]] && { echo "Cancelled."; return 0; } + while true; do + read -rp "Email: " e + [[ -z "$e" ]] && { echo "Cancelled."; return 0; } + [[ "$e" == *@* ]] && break + echo "Email must contain '@'" + done + if $dryrun; then + echo "[dryrun] Would append '$a|$n|$e' to $IDENTITIES_FILE" + else + echo "$a|$n|$e" >> "$IDENTITIES_FILE" + echo -e "${GREEN}Added '$a'${NC}" + fi + read -rp "Apply it now? [y/N] " reply + if [[ "$reply" =~ ^[Yy] ]]; then + apply_identity "$n" "$e" + fi +} + +remove_identity() { + if [[ ${#aliases[@]} -eq 0 ]]; then + echo "No identities to remove." + return 0 + fi + local i + for i in "${!aliases[@]}"; do + printf ' %d) %-14s %s <%s>\n' "$((i+1))" "${aliases[$i]}" "${names[$i]}" "${emails[$i]}" + done + read -rp "Remove which? [1-${#aliases[@]}/q]: " reply + [[ -z "$reply" || "$reply" == "q" ]] && { echo "Cancelled."; return 0; } + if ! [[ "$reply" =~ ^[0-9]+$ ]] || (( reply < 1 || reply > ${#aliases[@]} )); then + echo "Error: Invalid selection" >&2; return 1 + fi + local victim="${aliases[$((reply-1))]}" + if $dryrun; then + echo "[dryrun] Would remove identity '$victim' from $IDENTITIES_FILE" + return 0 + fi + local tmp; tmp=$(mktemp) + awk -F'|' -v a="$victim" '$1 != a' "$IDENTITIES_FILE" > "$tmp" && mv "$tmp" "$IDENTITIES_FILE" + echo -e "${GREEN}Removed '$victim'${NC}" +} + +something_else() { + echo " a) Add identity" + echo " r) Remove identity" + echo " q) Cancel" + read -rp "Select [a/r/q]: " reply + case "$reply" in + a) add_identity ;; + r) remove_identity ;; + *) echo "Cancelled." ;; + esac +} + +interactive_menu() { + ensure_file + load_identities + show_current + echo "" + local i + for i in "${!aliases[@]}"; do + printf ' %d) %-14s %s <%s>\n' "$((i+1))" "${aliases[$i]}" "${names[$i]}" "${emails[$i]}" + done + echo " s) Something else..." + echo "" + local prompt="Select [s/q]: " + [[ ${#aliases[@]} -gt 0 ]] && prompt="Select [1-${#aliases[@]}/s/q]: " + read -rp "$prompt" reply + if [[ -z "$reply" || "$reply" == "q" ]]; then + echo "Cancelled." + exit 0 + elif [[ "$reply" == "s" ]]; then + something_else + elif [[ "$reply" =~ ^[0-9]+$ ]] && (( reply >= 1 && reply <= ${#aliases[@]} )); then + apply_identity "${names[$((reply-1))]}" "${emails[$((reply-1))]}" + else + echo "Error: Invalid selection" >&2 + exit 1 + fi +} + +case "$action" in + current) + show_current + exit 0 ;; + list) + if [[ ! -f "$IDENTITIES_FILE" ]]; then + echo "Error: No identities file at $IDENTITIES_FILE (run git-identity to create one)" >&2 + exit 1 + fi + load_identities + list_identities + exit 0 ;; +esac + +if [[ -n "$alias_arg" ]]; then + if [[ ! -f "$IDENTITIES_FILE" ]]; then + echo "Error: No identities file at $IDENTITIES_FILE (run git-identity to create one)" >&2 + exit 1 + fi + load_identities + apply_alias "$alias_arg" +else + interactive_menu +fi diff --git a/specs/git-identity.spec.md b/specs/git-identity.spec.md new file mode 100644 index 0000000..f9f6e46 --- /dev/null +++ b/specs/git-identity.spec.md @@ -0,0 +1,102 @@ +# git-identity + +## Purpose + +Switch the git author identity (user.name + user.email) for the current repo or globally, from a menu of identities saved in `~/.git-identities`. + +## Usage + +``` +git-identity [OPTIONS] [ALIAS] +``` + +### Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| `ALIAS` | — | Apply this saved identity directly (non-interactive). Omit for the interactive menu. | + +### Flags + +| Flag | Description | +|------|-------------| +| `--help`, `-h` | Show usage information | +| `--dryrun`, `-n` | Preview all actions without changing config or the identities file | +| `--global`, `-g` | Apply to global git config instead of the current repo | +| `--list`, `-l` | List saved identities and exit | +| `--current`, `-c` | Show the identity in effect for the current directory and exit | + +### Environment + +| Variable | Description | +|----------|-------------| +| `GIT_IDENTITIES_FILE` | Override the identities file path (default `~/.git-identities`). Used by tests. | + +## Identities file format + +One identity per line: `alias|Name|email`. Blank lines and lines starting with `#` are ignored. + +``` +# alias|Name|email +personal|Paul Example|paul@example.net +work|Paul Example|paul.example@corp.com +``` + +## Behaviour + +1. Determine scope: `--global` → global config; otherwise local config if inside a git repository, else fall back to global (with a note). +2. **Direct mode** (`ALIAS` given): look up the alias in the identities file; apply `git config user.name` + `user.email` in the chosen scope; print confirmation showing scope, name, and email. +3. **Interactive mode** (no alias): print the identity currently in effect, then a numbered menu of saved identities plus a final option `s) Something else...`. + - Selecting a number applies that identity (as in direct mode). + - `s` opens a submenu: `a) Add identity`, `r) Remove identity`, `q) Cancel`. + - **Add**: prompts for alias, name, email; validates alias is unique and contains no `|`; validates email contains `@`; appends to the file; then offers to apply it now. + - **Remove**: numbered menu of identities; selected line is deleted from the file. Never touches git config. + - `q` or empty input cancels with exit 0. +4. Applying an identity never modifies the identities file; add/remove never modify git config (except the post-add "apply now" offer). + +## Dryrun Behaviour + +Every mutating action prints a `[dryrun]` line instead of acting: + +- Apply: `[dryrun] Would set user.name 'NAME' and user.email 'EMAIL' (--local in /path/to/repo)` (or `(--global)`) +- Add: `[dryrun] Would append 'alias|NAME|EMAIL' to FILE` +- Remove: `[dryrun] Would remove identity 'alias' from FILE` + +Menus and prompts still function in dryrun so a full flow can be rehearsed. + +## Edge Cases + +| Case | Handling | +|------|----------| +| Identities file missing (interactive) | Offer to create it seeded with the current global identity, then continue to menu | +| Identities file missing (direct/`--list`) | Error: "No identities file at FILE (run git-identity to create one)", exit 1 | +| Unknown alias in direct mode | Error: "No identity 'ALIAS' in FILE", exit 1; list available aliases | +| Empty identities file | Menu shows only "Something else..." | +| Duplicate alias on add | Error: "Alias 'X' already exists", re-prompt | +| Alias containing `\|` | Error: "Alias must not contain '\|'", re-prompt | +| Not in a git repo, no `--global` | Note "not inside a git repository — applying globally", apply to global | +| Malformed line in file | Skipped silently (comment/blank handling covers this) | + +## Examples + +```bash +# Interactive menu +$ git-identity +Current identity (local): Paul Example + + 1) personal Paul Example + 2) work Paul Example + s) Something else... + +Select [1-2/s/q]: 2 +Set user.name 'Paul Example' and user.email 'paul.example@corp.com' (--local in /home/paul/dev/myrepo) + +# Direct, dryrun +$ git-identity -n work +[dryrun] Would set user.name 'Paul Example' and user.email 'paul.example@corp.com' (--local in /home/paul/dev/myrepo) + +# List +$ git-identity --list +personal Paul Example +work Paul Example +``` diff --git a/tests/test-git-identity.sh b/tests/test-git-identity.sh new file mode 100755 index 0000000..43b25ea --- /dev/null +++ b/tests/test-git-identity.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +GIT_IDENTITY="$SCRIPT_DIR/scripts/git-identity" + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +pass=0 +fail=0 + +assert_match() { + local desc="$1" pattern="$2" actual="$3" + if [[ "$actual" =~ $pattern ]]; then + echo -e "${GREEN}PASS${NC}: $desc" + ((pass++)) + else + echo -e "${RED}FAIL${NC}: $desc" + echo " pattern: $pattern" + echo " actual: $actual" + ((fail++)) + fi +} + +assert_exit() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo -e "${GREEN}PASS${NC}: $desc" + ((pass++)) + else + echo -e "${RED}FAIL${NC}: $desc (expected exit $expected, got $actual)" + ((fail++)) + fi +} + +# Fixture: temp identities file and a temp git repo +TMPDIR_T=$(mktemp -d) +trap 'rm -rf "$TMPDIR_T"' EXIT +IDFILE="$TMPDIR_T/identities" +cat > "$IDFILE" <<'EOF' +# alias|Name|email +personal|Test Person|test@example.net +work|Test Person|test.person@corp.example +EOF +REPO="$TMPDIR_T/repo" +mkdir -p "$REPO" && git -C "$REPO" init -q + +run() { GIT_IDENTITIES_FILE="$IDFILE" "$GIT_IDENTITY" "$@"; } + +# --help +out=$(run --help); rc=$? +assert_exit "--help exits 0" 0 "$rc" +assert_match "--help shows usage" "Usage: git-identity" "$out" + +# --list +out=$(run --list); rc=$? +assert_exit "--list exits 0" 0 "$rc" +assert_match "--list shows personal" "personal.*test@example\.net" "$out" +assert_match "--list shows work" "work.*test\.person@corp\.example" "$out" + +# direct apply, dryrun, inside repo -> local scope +out=$(cd "$REPO" && run -n work); rc=$? +assert_exit "dryrun apply exits 0" 0 "$rc" +assert_match "dryrun apply previews local set" \ + "\[dryrun\] Would set user.name 'Test Person' and user.email 'test.person@corp.example' \(--local in " "$out" + +# dryrun apply did not change config +name=$(git -C "$REPO" config --local user.name 2>/dev/null || echo "UNSET") +assert_match "dryrun did not write config" "UNSET" "$name" + +# direct apply, dryrun, outside repo -> global fallback note +out=$(cd "$TMPDIR_T" && run -n personal); rc=$? +assert_exit "dryrun global fallback exits 0" 0 "$rc" +assert_match "notes global fallback" "not inside a git repository" "$out" +assert_match "previews global set" "\(--global\)" "$out" + +# --global flag, dryrun +out=$(cd "$REPO" && run -n -g personal); rc=$? +assert_match "dryrun --global previews global scope" "\(--global\)" "$out" + +# unknown alias +out=$(cd "$REPO" && run -n nosuch 2>&1); rc=$? +assert_exit "unknown alias exits 1" 1 "$rc" +assert_match "unknown alias error names file" "No identity 'nosuch'" "$out" +assert_match "unknown alias lists available" "Available: personal work" "$out" + +# missing file, direct mode +out=$(GIT_IDENTITIES_FILE="$TMPDIR_T/absent" "$GIT_IDENTITY" -n work 2>&1); rc=$? +assert_exit "missing file exits 1" 1 "$rc" +assert_match "missing file error" "No identities file" "$out" + +# interactive menu: select identity 2 via stdin, dryrun +out=$(cd "$REPO" && printf '2\n' | run -n); rc=$? +assert_exit "interactive select exits 0" 0 "$rc" +assert_match "interactive shows menu" "1\) personal" "$out" +assert_match "interactive select previews apply" "\[dryrun\] Would set user.name 'Test Person' and user.email 'test.person@corp.example'" "$out" + +# interactive: something else -> add, dryrun +out=$(cd "$REPO" && printf 's\na\nnewalias\nNew Name\nnew@example.net\nn\n' | run -n); rc=$? +assert_exit "interactive add exits 0" 0 "$rc" +assert_match "add previews append" "\[dryrun\] Would append 'newalias\|New Name\|new@example.net'" "$out" +grep -q "newalias" "$IDFILE" && added=yes || added=no +assert_match "dryrun add did not write file" "no" "$added" + +# interactive: something else -> remove, dryrun +out=$(cd "$REPO" && printf 's\nr\n1\n' | run -n); rc=$? +assert_exit "interactive remove exits 0" 0 "$rc" +assert_match "remove previews deletion" "\[dryrun\] Would remove identity 'personal'" "$out" +grep -q "^personal|" "$IDFILE" && still=yes || still=no +assert_match "dryrun remove did not write file" "yes" "$still" + +# duplicate alias rejected during add (then cancel with empty alias) +out=$(cd "$REPO" && printf 's\na\nwork\n\n' | run -n); rc=$? +assert_match "duplicate alias rejected" "Alias 'work' already exists" "$out" + +echo "" +echo "Results: $pass passed, $fail failed" +[[ $fail -eq 0 ]]