Created
September 23, 2025 16:14
-
-
Save timhodson/c415d1454d1598e60d05e039b68a3dcd to your computer and use it in GitHub Desktop.
Find status of all repos in a given directory
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
set -euo pipefail | |
usage() { | |
cat <<'EOF' | |
Usage: find_git_repos.sh [--output <file>] <directory> | |
Scans immediate subdirectories of <directory>: | |
- If a subdirectory is a git repo, prints its repo name and remotes | |
- If not, prints a warning that no repo is present | |
Outputs lines like: | |
Directory: /path/project | Repo: project | Remotes: origin, upstream | GitHub URLs: https://github.com/org/repo.git | |
Directory: /path/notes | WARNING: no git repo present | |
Options: | |
-o, --output <file> Write a copy of the output to <file> (also prints to stdout). Default: git_repo_report_YYYYmmdd-HHMMSS.txt | |
-h, --help Show this help and exit | |
EOF | |
} | |
if [[ ${1-} == "-h" || ${1-} == "--help" ]]; then | |
usage | |
exit 0 | |
fi | |
# Parse args | |
output_file="" | |
args=() | |
while [[ $# -gt 0 ]]; do | |
case "$1" in | |
-o|--output) | |
if [[ $# -lt 2 ]]; then | |
echo "Error: missing value for $1" >&2 | |
exit 1 | |
fi | |
output_file="$2" | |
shift 2 | |
;; | |
-h|--help) | |
usage | |
exit 0 | |
;; | |
--) | |
shift; break ;; | |
-*) | |
echo "Error: unknown option: $1" >&2 | |
echo >&2 | |
usage >&2 | |
exit 1 | |
;; | |
*) | |
args+=("$1"); shift ;; | |
esac | |
done | |
if [[ ${#args[@]} -ne 1 ]]; then | |
echo "Error: exactly one directory path is required" >&2 | |
echo >&2 | |
usage >&2 | |
exit 1 | |
fi | |
base_dir=${args[0]} | |
if [[ ! -d "$base_dir" ]]; then | |
echo "Error: '$base_dir' is not a directory" >&2 | |
exit 1 | |
fi | |
# Prepare output capture | |
ts=$(date '+%Y%m%d-%H%M%S') | |
default_out="git_repo_report_${ts}.txt" | |
outfile=${output_file:-$default_out} | |
# Mirror stdout/stderr to both console and file | |
exec > >(tee "$outfile") 2>&1 | |
# Helper: normalize and detect GitHub URLs | |
is_github_url() { | |
local url=$1 | |
# Match common GitHub URL forms: https, http, git, ssh, and scp-like | |
if [[ $url =~ (^https?://([^/]*\.)?github\.com/) || \ | |
$url =~ (^git://([^/]*\.)?github\.com/) || \ | |
$url =~ (^ssh://git@([^/]*\.)?github\.com/) || \ | |
$url =~ (^git@([^:]*\.)?github\.com:) ]]; then | |
return 0 | |
fi | |
return 1 | |
} | |
# Summarize repository cleanliness and upstream push status | |
repo_status_summary() { | |
local repo_dir=$1 | |
local status_parts=() | |
local porcelain | |
porcelain=$(git -C "$repo_dir" status --porcelain 2>/dev/null || true) | |
if [[ -n "$porcelain" ]]; then | |
status_parts+=("dirty") | |
local untracked_count=0 | |
local tracked_changes=0 | |
while IFS= read -r entry; do | |
[[ -z "$entry" ]] && continue | |
if [[ ${entry:0:2} == "??" ]]; then | |
((untracked_count++)) | |
elif [[ ${entry:0:1} == "!" ]]; then | |
continue | |
else | |
((tracked_changes++)) | |
fi | |
done <<< "$porcelain" | |
if (( untracked_count > 0 )); then | |
if (( untracked_count == 1 )); then | |
status_parts+=("1 untracked file") | |
else | |
status_parts+=("${untracked_count} untracked files") | |
fi | |
fi | |
if (( tracked_changes > 0 )); then | |
if (( tracked_changes == 1 )); then | |
status_parts+=("1 file with changes") | |
else | |
status_parts+=("${tracked_changes} files with changes") | |
fi | |
fi | |
fi | |
local status_branch | |
status_branch=$(git -C "$repo_dir" status --porcelain=2 --branch 2>/dev/null || true) | |
local upstream="" | |
while IFS= read -r line; do | |
case "$line" in | |
'# branch.upstream '*) | |
upstream=${line#'# branch.upstream '} | |
;; | |
'# branch.ab '*) | |
local trimmed=${line#'# branch.ab '} | |
local ahead_token=${trimmed%% -*} | |
local ahead=${ahead_token#+} | |
if [[ $ahead =~ ^[0-9]+$ ]] && (( ahead > 0 )); then | |
if [[ -n "$upstream" ]]; then | |
status_parts+=("ahead of $upstream by $ahead") | |
else | |
status_parts+=("ahead of upstream by $ahead") | |
fi | |
fi | |
;; | |
esac | |
done <<< "$status_branch" | |
if [[ ${#status_parts[@]} -eq 0 ]]; then | |
echo "clean" | |
else | |
local joined="${status_parts[0]}" | |
for ((i=1; i<${#status_parts[@]}; i++)); do | |
joined+=", ${status_parts[$i]}" | |
done | |
echo "$joined" | |
fi | |
} | |
# Iterate immediate subdirectories (including those with spaces) | |
while IFS= read -r -d '' dir; do | |
# Skip nested VCS metadata directories like .git | |
name=$(basename "$dir") | |
if [[ "$name" == ".git" ]]; then | |
continue | |
fi | |
# Detect if this directory is inside a git work tree | |
if git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then | |
# Determine repo toplevel and a human-friendly repo name | |
toplevel=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null || true) | |
if [[ -n "${toplevel}" && -d "${toplevel}" ]]; then | |
repo_name=$(basename "${toplevel}") | |
else | |
# Fallback to directory name | |
repo_name="$name" | |
fi | |
repo_root=${toplevel:-$dir} | |
repo_status=$(repo_status_summary "$repo_root") | |
# Collect remote names (if any) | |
# Use mapfile to safely capture lines; fallback if not available | |
if remotes=$(git -C "$dir" remote 2>/dev/null); then | |
if [[ -z "$remotes" ]]; then | |
echo "Directory: $dir | Repo: $repo_name | WARNING: no remote present | Status: $repo_status" | |
else | |
# Join remote names with ', ' | |
remote_list=$(printf '%s' "$remotes" | paste -sd ', ' -) | |
# Collect GitHub remote URLs (all remotes/all URLs) | |
gh_urls=() | |
while IFS= read -r remote; do | |
# get-url --all prints multiple lines for multiple push/fetch URLs | |
while IFS= read -r url; do | |
if is_github_url "$url"; then | |
gh_urls+=("$url") | |
fi | |
done < <(git -C "$dir" remote get-url --all "$remote" 2>/dev/null || true) | |
done <<< "$remotes" | |
if [[ ${#gh_urls[@]} -gt 0 ]]; then | |
# Deduplicate while preserving order | |
uniq_urls=() | |
declare -A seen=() | |
for u in "${gh_urls[@]}"; do | |
if [[ -z "${seen[$u]:-}" ]]; then | |
uniq_urls+=("$u") | |
seen[$u]=1 | |
fi | |
done | |
github_list=$(printf '%s' "${uniq_urls[0]}"; for ((i=1;i<${#uniq_urls[@]};i++)); do printf ', %s' "${uniq_urls[$i]}"; done) | |
echo "Directory: $dir | Repo: $repo_name | Remotes: $remote_list | GitHub URLs: $github_list | Status: $repo_status" | |
else | |
echo "Directory: $dir | Repo: $repo_name | Remotes: $remote_list | GitHub URLs: none | Status: $repo_status" | |
fi | |
fi | |
else | |
echo "Directory: $dir | Repo: $repo_name | Remotes: error reading remotes | Status: $repo_status" | |
fi | |
else | |
echo "Directory: $dir | WARNING: no git repo present" | |
fi | |
done < <(find "$base_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment