Skip to content

Instantly share code, notes, and snippets.

@timhodson
Created September 23, 2025 16:14
Show Gist options
  • Save timhodson/c415d1454d1598e60d05e039b68a3dcd to your computer and use it in GitHub Desktop.
Save timhodson/c415d1454d1598e60d05e039b68a3dcd to your computer and use it in GitHub Desktop.
Find status of all repos in a given directory
#!/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