Add git-status-report: recursive git repo scanner with status reporting
Scans a directory tree for git repos and produces a concise report showing uncommitted changes (with +/- character counts) and ahead/behind status per remote. Supports --dryrun to preview discovered repos without running checks. Includes OpenSpec, implementation, and 26-assertion test suite. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
295
scripts/git-status-report
Executable file
295
scripts/git-status-report
Executable file
@@ -0,0 +1,295 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# git-status-report — Scan directories for git repos and report status
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# --- Colours ---
|
||||||
|
RED='\033[31m'
|
||||||
|
GREEN='\033[32m'
|
||||||
|
YELLOW='\033[33m'
|
||||||
|
CYAN='\033[36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
DIM='\033[2m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
# --- Defaults ---
|
||||||
|
DRYRUN=false
|
||||||
|
TARGET_DIR="."
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: git-status-report [OPTIONS] [DIRECTORY]
|
||||||
|
|
||||||
|
Recursively scan for git repositories and report uncommitted changes
|
||||||
|
and remote sync status.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
DIRECTORY Root directory to scan (default: current directory)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-n, --dryrun List discovered repos without running status checks
|
||||||
|
-h, --help Show this help message
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Parse args ---
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-n|--dryrun) DRYRUN=true; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
-*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||||
|
*) TARGET_DIR="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Resolve to absolute path for display
|
||||||
|
TARGET_DIR="$(cd "$TARGET_DIR" 2>/dev/null && pwd)" || {
|
||||||
|
echo "Error: cannot access directory '$TARGET_DIR'" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Discovery phase ---
|
||||||
|
discover_repos() {
|
||||||
|
local dir="$1"
|
||||||
|
local repos=()
|
||||||
|
|
||||||
|
while IFS= read -r -d '' gitdir; do
|
||||||
|
repos+=("$(dirname "$gitdir")")
|
||||||
|
done < <(find -L "$dir" -name .git -type d -print0 2>/dev/null | sort -z)
|
||||||
|
|
||||||
|
# Filter out nested repos (a repo whose path is a subdirectory of another)
|
||||||
|
local filtered=()
|
||||||
|
for repo in "${repos[@]}"; do
|
||||||
|
local is_nested=false
|
||||||
|
for other in "${filtered[@]}"; do
|
||||||
|
if [[ "$repo" == "$other"/* ]]; then
|
||||||
|
is_nested=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ! $is_nested; then
|
||||||
|
filtered+=("$repo")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s\n' "${filtered[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
REPOS=()
|
||||||
|
while IFS= read -r repo; do
|
||||||
|
[[ -n "$repo" ]] && REPOS+=("$repo")
|
||||||
|
done < <(discover_repos "$TARGET_DIR")
|
||||||
|
|
||||||
|
REPO_COUNT=${#REPOS[@]}
|
||||||
|
|
||||||
|
if [[ $REPO_COUNT -eq 0 ]]; then
|
||||||
|
echo "No git repositories found in $TARGET_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Dryrun ---
|
||||||
|
if $DRYRUN; then
|
||||||
|
echo "[dryrun] Would check $REPO_COUNT repositories:"
|
||||||
|
for repo in "${REPOS[@]}"; do
|
||||||
|
local_path="${repo#"$TARGET_DIR"}"
|
||||||
|
[[ -z "$local_path" ]] && local_path="."
|
||||||
|
local_path="${local_path#/}"
|
||||||
|
echo " $TARGET_DIR/$local_path"
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Status phase ---
|
||||||
|
DIRTY_REPOS=()
|
||||||
|
DIRTY_OUTPUT=()
|
||||||
|
CLEAN_COUNT=0
|
||||||
|
|
||||||
|
get_char_diff() {
|
||||||
|
local repo="$1"
|
||||||
|
local file="$2"
|
||||||
|
local status="$3"
|
||||||
|
|
||||||
|
if [[ "$status" == "?" ]]; then
|
||||||
|
# Untracked: count all characters as added
|
||||||
|
local target="$repo/$file"
|
||||||
|
if [[ -d "$target" ]]; then
|
||||||
|
# Directory: sum all file sizes recursively
|
||||||
|
local chars
|
||||||
|
chars=$(find "$target" -type f -exec cat {} + 2>/dev/null | wc -c || echo 0)
|
||||||
|
echo "+${chars}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if file "$target" 2>/dev/null | grep -qP "binary|image|archive" && ! file "$target" 2>/dev/null | grep -q "text"; then
|
||||||
|
echo "(binary)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local chars
|
||||||
|
chars=$(wc -c < "$target" 2>/dev/null || echo 0)
|
||||||
|
echo "+${chars}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$status" == "D" ]]; then
|
||||||
|
# Deleted: count from last committed version
|
||||||
|
local chars
|
||||||
|
chars=$(git -C "$repo" show "HEAD:$file" 2>/dev/null | wc -c || echo 0)
|
||||||
|
if [[ "$chars" == "0" ]]; then
|
||||||
|
echo "(binary)"
|
||||||
|
else
|
||||||
|
echo "-${chars}"
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Modified: diff character counts
|
||||||
|
local diff_output
|
||||||
|
diff_output=$(git -C "$repo" diff HEAD -- "$file" 2>/dev/null || git -C "$repo" diff -- "$file" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ -z "$diff_output" ]]; then
|
||||||
|
# Staged but no diff against HEAD — try cached
|
||||||
|
diff_output=$(git -C "$repo" diff --cached -- "$file" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for binary
|
||||||
|
if echo "$diff_output" | grep -q "Binary files"; then
|
||||||
|
echo "(binary)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local added=0 removed=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in
|
||||||
|
+*) added=$((added + ${#line} - 1)) ;; # -1 for the leading +
|
||||||
|
-*) removed=$((removed + ${#line} - 1)) ;;
|
||||||
|
esac
|
||||||
|
done < <(echo "$diff_output" | grep -E '^\+[^+]|^-[^-]' || true)
|
||||||
|
|
||||||
|
echo "+${added} / -${removed}"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_remote_status() {
|
||||||
|
local repo="$1"
|
||||||
|
local output=""
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch=$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$branch" ]]; then
|
||||||
|
# Return empty — detached HEAD alone isn't a divergence.
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local remotes
|
||||||
|
remotes=$(git -C "$repo" remote 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ -z "$remotes" ]]; then
|
||||||
|
# Return empty — "no remotes" is not a divergence.
|
||||||
|
# The caller adds this note only when the repo is already dirty.
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r remote; do
|
||||||
|
[[ -z "$remote" ]] && continue
|
||||||
|
|
||||||
|
# Fetch is too slow for a status report — use local tracking info
|
||||||
|
local remote_branch="${remote}/${branch}"
|
||||||
|
|
||||||
|
if ! git -C "$repo" rev-parse --verify "$remote_branch" &>/dev/null; then
|
||||||
|
output+=" ${DIM}↕ ${remote}/${branch}: (no upstream)${RESET}\n"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
local counts
|
||||||
|
counts=$(git -C "$repo" rev-list --left-right --count "${branch}...${remote_branch}" 2>/dev/null || echo "0 0")
|
||||||
|
local ahead behind
|
||||||
|
ahead=$(echo "$counts" | cut -f1)
|
||||||
|
behind=$(echo "$counts" | cut -f2)
|
||||||
|
|
||||||
|
if [[ "$ahead" -eq 0 && "$behind" -eq 0 ]]; then
|
||||||
|
continue # In sync — nothing to report
|
||||||
|
fi
|
||||||
|
|
||||||
|
local colour="$YELLOW"
|
||||||
|
output+=" ${colour}↕ ${remote}/${branch}: ${ahead} ahead, ${behind} behind${RESET}\n"
|
||||||
|
done <<< "$remotes"
|
||||||
|
|
||||||
|
[[ -n "$output" ]] && echo -e "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
for repo in "${REPOS[@]}"; do
|
||||||
|
rel_path="${repo#"$TARGET_DIR"}"
|
||||||
|
[[ -z "$rel_path" ]] && rel_path="."
|
||||||
|
rel_path="${rel_path#/}"
|
||||||
|
|
||||||
|
# Get porcelain status
|
||||||
|
porcelain=$(git -C "$repo" status --porcelain 2>/dev/null || true)
|
||||||
|
remote_info=$(get_remote_status "$repo")
|
||||||
|
|
||||||
|
if [[ -z "$porcelain" && -z "$remote_info" ]]; then
|
||||||
|
CLEAN_COUNT=$((CLEAN_COUNT + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build output for this repo
|
||||||
|
section=""
|
||||||
|
section+="$(printf "${BOLD}── dirty: %s ──${RESET}" "$rel_path")\n"
|
||||||
|
|
||||||
|
if [[ -n "$porcelain" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
local_status="${line:0:2}"
|
||||||
|
file="${line:3}"
|
||||||
|
|
||||||
|
# Determine primary status character
|
||||||
|
status_char="${local_status:0:1}"
|
||||||
|
[[ "$status_char" == " " ]] && status_char="${local_status:1:1}"
|
||||||
|
[[ "$status_char" == "?" ]] && status_char="?"
|
||||||
|
|
||||||
|
# Format the status display
|
||||||
|
case "$status_char" in
|
||||||
|
M) display_status="${YELLOW} M${RESET}" ;;
|
||||||
|
A) display_status="${GREEN} A${RESET}" ;;
|
||||||
|
D) display_status="${RED} D${RESET}" ;;
|
||||||
|
R) display_status="${CYAN} R${RESET}" ;;
|
||||||
|
?) display_status="${RED}??${RESET}" ;;
|
||||||
|
*) display_status=" ${status_char}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
char_diff=$(get_char_diff "$repo" "$file" "$status_char")
|
||||||
|
section+="$(printf " %b %-30s %s" "$display_status" "$file" "$char_diff")\n"
|
||||||
|
done <<< "$porcelain"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$remote_info" ]]; then
|
||||||
|
section+="$remote_info"
|
||||||
|
elif [[ -n "$porcelain" ]]; then
|
||||||
|
# Show "(no remotes)" only when there are local changes
|
||||||
|
has_remotes=$(git -C "$repo" remote 2>/dev/null || true)
|
||||||
|
if [[ -z "$has_remotes" ]]; then
|
||||||
|
section+=" ${DIM}(no remotes)${RESET}\n"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
DIRTY_REPOS+=("$rel_path")
|
||||||
|
DIRTY_OUTPUT+=("$section")
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Report phase ---
|
||||||
|
echo ""
|
||||||
|
echo "Scanned $REPO_COUNT repositories in $TARGET_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for output in "${DIRTY_OUTPUT[@]}"; do
|
||||||
|
echo -e "$output"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $CLEAN_COUNT -gt 0 ]]; then
|
||||||
|
printf "${GREEN}✓ %d repositories are clean and in sync.${RESET}\n" "$CLEAN_COUNT"
|
||||||
|
elif [[ ${#DIRTY_REPOS[@]} -eq 0 ]]; then
|
||||||
|
printf "${GREEN}✓ All %d repositories are clean and in sync.${RESET}\n" "$REPO_COUNT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Exit 1 if any repo is dirty or out of sync
|
||||||
|
if [[ ${#DIRTY_REPOS[@]} -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
103
specs/git-status-report.spec.md
Normal file
103
specs/git-status-report.spec.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# git-status-report
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Recursively scan a directory tree for git repositories and produce a concise report showing uncommitted changes and remote sync status.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
git-status-report [OPTIONS] [DIRECTORY]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arguments
|
||||||
|
|
||||||
|
| Argument | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DIRECTORY` | `.` (current directory) | Root directory to scan |
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
| Flag | Short | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `--dryrun` | `-n` | List discovered repos without running status checks |
|
||||||
|
| `--help` | `-h` | Show usage information |
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
1. **Discovery phase:** Walk `DIRECTORY` recursively, identifying directories that contain a `.git` folder. Stop descending into a directory once a `.git` is found (don't scan nested repos inside a git worktree).
|
||||||
|
|
||||||
|
2. **Status phase:** For each discovered repo:
|
||||||
|
a. Run `git status --porcelain` to detect uncommitted changes (staged, unstaged, untracked).
|
||||||
|
b. For each changed file, compute the character-level diff: `+N` added, `-N` removed. For untracked files, count all characters as `+N`. For deleted files, count all characters as `-N`.
|
||||||
|
c. Run `git remote` to list remotes. For each remote, run `git rev-list --left-right --count <branch>...<remote>/<remote-branch>` to determine ahead/behind counts.
|
||||||
|
|
||||||
|
3. **Report phase:** Print a grouped report:
|
||||||
|
- **Clean repos** are listed in a single summary line (count only) unless there are none.
|
||||||
|
- **Dirty repos** get a section each, showing:
|
||||||
|
- Repo path (relative to `DIRECTORY`)
|
||||||
|
- Each changed file with its status and `+N / -N` character counts
|
||||||
|
- Ahead/behind status per remote/branch
|
||||||
|
- **Repos with remote divergence** (ahead or behind) but no local changes still get a section showing the ahead/behind status.
|
||||||
|
|
||||||
|
4. **Exit code:**
|
||||||
|
- `0` — all repos clean and in sync
|
||||||
|
- `1` — at least one repo has uncommitted changes or is out of sync
|
||||||
|
|
||||||
|
## Dryrun Behaviour
|
||||||
|
|
||||||
|
When `--dryrun` is passed:
|
||||||
|
- Perform the discovery phase only
|
||||||
|
- Print each discovered repo path (relative to `DIRECTORY`), one per line
|
||||||
|
- Prefix output with `[dryrun] Would check N repositories:`
|
||||||
|
- Do NOT run any git status or remote checks
|
||||||
|
- Exit code is always `0`
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Handling |
|
||||||
|
|----------|----------|
|
||||||
|
| No git repos found | Print "No git repositories found in <dir>" and exit 0 |
|
||||||
|
| Repo has no remotes | Skip remote sync section for that repo, show "(no remotes)" |
|
||||||
|
| Repo has detached HEAD | Show branch as `(detached HEAD)` and skip remote comparison |
|
||||||
|
| Remote branch doesn't exist | Show "(no upstream)" for that remote |
|
||||||
|
| Binary files changed | Show `(binary)` instead of character counts |
|
||||||
|
| Permission denied on subdirectory | Skip with warning to stderr, continue scanning |
|
||||||
|
| Nested git repos (submodules) | Stop at the outermost `.git` — don't descend further |
|
||||||
|
| Symlinked directories | Follow symlinks during discovery |
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Clean repos
|
||||||
|
```
|
||||||
|
Scanned 5 repositories in ~/dev
|
||||||
|
|
||||||
|
✓ All 5 repositories are clean and in sync.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mixed status
|
||||||
|
```
|
||||||
|
Scanned 5 repositories in ~/dev
|
||||||
|
|
||||||
|
── dirty: project-alpha ──
|
||||||
|
M src/main.py +42 / -17
|
||||||
|
M README.md +5 / -0
|
||||||
|
?? TODO.txt +120
|
||||||
|
↕ origin/main: 2 ahead, 0 behind
|
||||||
|
|
||||||
|
── dirty: infra-configs ──
|
||||||
|
D old-config.yaml -89
|
||||||
|
↕ origin/main: 0 ahead, 3 behind
|
||||||
|
|
||||||
|
✓ 3 repositories are clean and in sync.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dryrun
|
||||||
|
```
|
||||||
|
[dryrun] Would check 5 repositories:
|
||||||
|
~/dev/project-alpha
|
||||||
|
~/dev/project-beta
|
||||||
|
~/dev/infra-configs
|
||||||
|
~/dev/scripts
|
||||||
|
~/dev/docs
|
||||||
|
```
|
||||||
241
tests/test-git-status-report.sh
Executable file
241
tests/test-git-status-report.sh
Executable file
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test script for git-status-report using --dryrun and live checks
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
SCRIPT="$REPO_ROOT/scripts/git-status-report"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
TMPDIR=""
|
||||||
|
|
||||||
|
# --- Colours ---
|
||||||
|
RED='\033[31m'
|
||||||
|
GREEN='\033[32m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[[ -n "$TMPDIR" && -d "$TMPDIR" ]] && rm -rf "$TMPDIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
setup_tmpdir() {
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
printf " ${GREEN}✓${RESET} %s\n" "$1"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf " ${RED}✗${RESET} %s\n" "$1"
|
||||||
|
[[ -n "${2:-}" ]] && printf " %s\n" "$2"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local output="$1" expected="$2" label="$3"
|
||||||
|
if echo "$output" | grep -qF -- "$expected"; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label" "Expected to find: $expected"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_not_contains() {
|
||||||
|
local output="$1" unexpected="$2" label="$3"
|
||||||
|
if echo "$output" | grep -qF -- "$unexpected"; then
|
||||||
|
fail "$label" "Did not expect to find: $unexpected"
|
||||||
|
else
|
||||||
|
pass "$label"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_exit_code() {
|
||||||
|
local actual="$1" expected="$2" label="$3"
|
||||||
|
if [[ "$actual" -eq "$expected" ]]; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label" "Expected exit $expected, got $actual"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
echo "=== git-status-report tests ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- Test: --help flag ---
|
||||||
|
echo "-- help flag --"
|
||||||
|
output=$(bash "$SCRIPT" --help 2>&1) || true
|
||||||
|
assert_contains "$output" "Usage:" "--help shows usage"
|
||||||
|
assert_contains "$output" "--dryrun" "--help mentions dryrun"
|
||||||
|
|
||||||
|
# --- Test: no repos found ---
|
||||||
|
echo "-- no repos found --"
|
||||||
|
setup_tmpdir
|
||||||
|
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) || true
|
||||||
|
assert_contains "$output" "No git repositories found" "empty dir reports no repos"
|
||||||
|
|
||||||
|
# --- Test: dryrun discovers repos ---
|
||||||
|
echo "-- dryrun discovery --"
|
||||||
|
setup_tmpdir
|
||||||
|
mkdir -p "$TMPDIR/repo-a"
|
||||||
|
git -C "$TMPDIR/repo-a" init -q
|
||||||
|
mkdir -p "$TMPDIR/subdir/repo-b"
|
||||||
|
git -C "$TMPDIR/subdir/repo-b" init -q
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" --dryrun "$TMPDIR" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
assert_exit_code "$rc" 0 "dryrun exits 0"
|
||||||
|
assert_contains "$output" "[dryrun] Would check 2 repositories:" "dryrun header with count"
|
||||||
|
assert_contains "$output" "repo-a" "dryrun lists repo-a"
|
||||||
|
assert_contains "$output" "repo-b" "dryrun lists repo-b"
|
||||||
|
|
||||||
|
# --- Test: dryrun with -n short flag ---
|
||||||
|
echo "-- dryrun short flag --"
|
||||||
|
output=$(bash "$SCRIPT" -n "$TMPDIR" 2>&1)
|
||||||
|
assert_contains "$output" "[dryrun]" "-n flag works as dryrun"
|
||||||
|
|
||||||
|
# --- Test: clean repo ---
|
||||||
|
echo "-- clean repo --"
|
||||||
|
setup_tmpdir
|
||||||
|
mkdir -p "$TMPDIR/clean-repo"
|
||||||
|
git -C "$TMPDIR/clean-repo" init -q
|
||||||
|
git -C "$TMPDIR/clean-repo" commit --allow-empty -m "init" -q
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
assert_exit_code "$rc" 0 "clean repo exits 0"
|
||||||
|
assert_contains "$output" "clean and in sync" "clean repo shows clean message"
|
||||||
|
|
||||||
|
# --- Test: dirty repo with uncommitted changes ---
|
||||||
|
echo "-- dirty repo --"
|
||||||
|
setup_tmpdir
|
||||||
|
mkdir -p "$TMPDIR/dirty-repo"
|
||||||
|
git -C "$TMPDIR/dirty-repo" init -q
|
||||||
|
echo "hello world" > "$TMPDIR/dirty-repo/file.txt"
|
||||||
|
git -C "$TMPDIR/dirty-repo" add file.txt
|
||||||
|
git -C "$TMPDIR/dirty-repo" commit -m "init" -q
|
||||||
|
echo "hello world modified with extra content" > "$TMPDIR/dirty-repo/file.txt"
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) && rc=$? || rc=$?
|
||||||
|
assert_exit_code "$rc" 1 "dirty repo exits 1"
|
||||||
|
assert_contains "$output" "dirty: dirty-repo" "dirty repo header shown"
|
||||||
|
assert_contains "$output" "file.txt" "changed file listed"
|
||||||
|
|
||||||
|
# --- Test: untracked files ---
|
||||||
|
echo "-- untracked files --"
|
||||||
|
setup_tmpdir
|
||||||
|
mkdir -p "$TMPDIR/untracked-repo"
|
||||||
|
git -C "$TMPDIR/untracked-repo" init -q
|
||||||
|
git -C "$TMPDIR/untracked-repo" commit --allow-empty -m "init" -q
|
||||||
|
echo "new file content" > "$TMPDIR/untracked-repo/newfile.txt"
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) && rc=$? || rc=$?
|
||||||
|
assert_exit_code "$rc" 1 "untracked file makes repo dirty"
|
||||||
|
assert_contains "$output" "newfile.txt" "untracked file listed"
|
||||||
|
assert_contains "$output" "??" "untracked status shown"
|
||||||
|
|
||||||
|
# --- Test: deleted files ---
|
||||||
|
echo "-- deleted files --"
|
||||||
|
setup_tmpdir
|
||||||
|
mkdir -p "$TMPDIR/del-repo"
|
||||||
|
git -C "$TMPDIR/del-repo" init -q
|
||||||
|
echo "content to delete" > "$TMPDIR/del-repo/gone.txt"
|
||||||
|
git -C "$TMPDIR/del-repo" add gone.txt
|
||||||
|
git -C "$TMPDIR/del-repo" commit -m "init" -q
|
||||||
|
rm "$TMPDIR/del-repo/gone.txt"
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) || true
|
||||||
|
assert_contains "$output" "gone.txt" "deleted file listed"
|
||||||
|
|
||||||
|
# --- Test: repo with no remotes ---
|
||||||
|
echo "-- no remotes --"
|
||||||
|
setup_tmpdir
|
||||||
|
mkdir -p "$TMPDIR/no-remote"
|
||||||
|
git -C "$TMPDIR/no-remote" init -q
|
||||||
|
echo "change" > "$TMPDIR/no-remote/file.txt"
|
||||||
|
git -C "$TMPDIR/no-remote" add file.txt
|
||||||
|
git -C "$TMPDIR/no-remote" commit -m "init" -q
|
||||||
|
echo "modified" > "$TMPDIR/no-remote/file.txt"
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) || true
|
||||||
|
assert_contains "$output" "no remotes" "no remotes noted on dirty repo"
|
||||||
|
|
||||||
|
# --- Test: ahead of remote ---
|
||||||
|
echo "-- ahead of remote --"
|
||||||
|
setup_tmpdir
|
||||||
|
# Create a bare "remote"
|
||||||
|
git init -q --bare "$TMPDIR/remote.git"
|
||||||
|
mkdir -p "$TMPDIR/ahead-repo"
|
||||||
|
git -C "$TMPDIR/ahead-repo" init -q
|
||||||
|
git -C "$TMPDIR/ahead-repo" remote add origin "$TMPDIR/remote.git"
|
||||||
|
echo "first" > "$TMPDIR/ahead-repo/file.txt"
|
||||||
|
git -C "$TMPDIR/ahead-repo" add file.txt
|
||||||
|
git -C "$TMPDIR/ahead-repo" commit -m "first" -q
|
||||||
|
git -C "$TMPDIR/ahead-repo" push -u origin main -q 2>/dev/null || git -C "$TMPDIR/ahead-repo" push -u origin master -q 2>/dev/null
|
||||||
|
# Make a local-only commit
|
||||||
|
echo "second" > "$TMPDIR/ahead-repo/file.txt"
|
||||||
|
git -C "$TMPDIR/ahead-repo" add file.txt
|
||||||
|
git -C "$TMPDIR/ahead-repo" commit -m "second" -q
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" "$TMPDIR/ahead-repo" 2>&1) || true
|
||||||
|
assert_contains "$output" "1 ahead" "ahead count shown"
|
||||||
|
|
||||||
|
# --- Test: mixed clean and dirty ---
|
||||||
|
echo "-- mixed repos --"
|
||||||
|
setup_tmpdir
|
||||||
|
mkdir -p "$TMPDIR/clean"
|
||||||
|
git -C "$TMPDIR/clean" init -q
|
||||||
|
git -C "$TMPDIR/clean" commit --allow-empty -m "init" -q
|
||||||
|
|
||||||
|
mkdir -p "$TMPDIR/dirty"
|
||||||
|
git -C "$TMPDIR/dirty" init -q
|
||||||
|
echo "content" > "$TMPDIR/dirty/file.txt"
|
||||||
|
git -C "$TMPDIR/dirty" add file.txt
|
||||||
|
git -C "$TMPDIR/dirty" commit -m "init" -q
|
||||||
|
echo "changed" > "$TMPDIR/dirty/file.txt"
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) || true
|
||||||
|
assert_contains "$output" "Scanned 2 repositories" "scanned count correct"
|
||||||
|
assert_contains "$output" "dirty: dirty" "dirty repo listed"
|
||||||
|
assert_contains "$output" "1 repositories are clean" "clean count shown"
|
||||||
|
|
||||||
|
# --- Test: nested repos not double-counted ---
|
||||||
|
echo "-- nested repos --"
|
||||||
|
setup_tmpdir
|
||||||
|
mkdir -p "$TMPDIR/outer"
|
||||||
|
git -C "$TMPDIR/outer" init -q
|
||||||
|
git -C "$TMPDIR/outer" commit --allow-empty -m "init" -q
|
||||||
|
mkdir -p "$TMPDIR/outer/inner"
|
||||||
|
git -C "$TMPDIR/outer/inner" init -q
|
||||||
|
git -C "$TMPDIR/outer/inner" commit --allow-empty -m "init" -q
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" --dryrun "$TMPDIR" 2>&1)
|
||||||
|
assert_contains "$output" "Would check 1 repositories:" "nested repo filtered out"
|
||||||
|
assert_contains "$output" "outer" "outer repo found"
|
||||||
|
assert_not_contains "$output" "inner" "inner repo excluded"
|
||||||
|
|
||||||
|
# --- Test: permission denied ---
|
||||||
|
echo "-- permission denied --"
|
||||||
|
setup_tmpdir
|
||||||
|
mkdir -p "$TMPDIR/accessible"
|
||||||
|
git -C "$TMPDIR/accessible" init -q
|
||||||
|
git -C "$TMPDIR/accessible" commit --allow-empty -m "init" -q
|
||||||
|
mkdir -p "$TMPDIR/blocked"
|
||||||
|
chmod 000 "$TMPDIR/blocked"
|
||||||
|
|
||||||
|
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
# Should still find the accessible repo
|
||||||
|
assert_contains "$output" "Scanned 1 repositories" "survives permission denied"
|
||||||
|
chmod 755 "$TMPDIR/blocked" # Cleanup
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
if [[ $FAIL -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf "${GREEN}All tests passed.${RESET}\n"
|
||||||
Reference in New Issue
Block a user