#!/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