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.
This commit is contained in:
@@ -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-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 |
|
| `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 |
|
| `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 |
|
| `git-status-report` | Recursive git repo status with diff stats | Done |
|
||||||
| `md-to-docx` | Markdown to DOCX conversion | Done |
|
| `md-to-docx` | Markdown to DOCX conversion | Done |
|
||||||
| `mp3-to-mp4` | Convert MP3 to MP4 with a static title card | 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 |
|
| `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 |
|
| `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-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)
|
### Engagement modes (claude-profile)
|
||||||
|
|
||||||
|
|||||||
269
scripts/git-identity
Executable file
269
scripts/git-identity
Executable file
@@ -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:-<unset>} <${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
|
||||||
102
specs/git-identity.spec.md
Normal file
102
specs/git-identity.spec.md
Normal file
@@ -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 <paul@example.net>
|
||||||
|
|
||||||
|
1) personal Paul Example <paul@example.net>
|
||||||
|
2) work Paul Example <paul.example@corp.com>
|
||||||
|
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 <paul@example.net>
|
||||||
|
work Paul Example <paul.example@corp.com>
|
||||||
|
```
|
||||||
120
tests/test-git-identity.sh
Executable file
120
tests/test-git-identity.sh
Executable file
@@ -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 ]]
|
||||||
Reference in New Issue
Block a user