Please add a new feature to the git-recursive utility in this project. The new feature should print the repo's current branch along with its status. This allows easy identification of cloned repositories that have been switched to a non-default branch.
ls -la && echo "---" && find . -path ./.git -prune -o -type f -print
total 24 drwxr-xr-x 5 david david 4096 Jun 25 09:59 . drwxr-xr-x 7 david david 4096 Feb 18 10:37 .. drwxr-xr-x 8 david david 4096 Jun 25 09:59 .git -rw-r--r-- 1 david david 11 Dec 5 2025 README.md drwxr-xr-x 2 david david 4096 Dec 7 2025 git-identity drwxr-xr-x 2 david david 4096 Jun 25 09:59 git-recursive --- ./README.md ./git-identity/git-identity ./git-identity/README.md ./git-recursive/git-recursive
{
"file_path": "/home/david/projects/code-associates/utilities/git-recursive/git-recursive"
}1 #!/bin/bash
2
3 # git-recursive - Operate on many git repositories cloned under a parent directory
4 # Discovers cloned repositories in subdirectories and reports their status, so it
5 # is easy to see which ones have uncommitted work or commits that need pushing.
6
7 set -e
8
9 SCRIPT_NAME="git-recursive"
10 VERSION="1.0.0"
11
12 # Run a git command against a specific repository directory
13 git_in() {
14 local dir="$1"
15 shift
16 git -C "$dir" "$@" 2>/dev/null
17 }
18
19 # Determine and print the status of a single discovered repository.
20 # Reports "local changes" if the working tree is dirty, "pending commit"
21 # if there are commits not yet pushed to the upstream, "no upstream" if the
22 # branch has nothing to push to, or "clean" if there is nothing outstanding.
23 report_repo() {
24 local dir="$1"
25 local rel="${dir#"$ROOT"/}"
26 [[ "$rel" == "$dir" ]] && rel="."
27
28 local states=()
29
30 # Uncommitted work in the working tree (staged, modified or untracked)
31 if [[ -n "$(git_in "$dir" status --porcelain)" ]]; then
32 states+=("local changes")
33 fi
34
35 # Commits that exist locally but have not been pushed
36 if git_in "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null; then
37 local ahead
38 ahead=$(git_in "$dir" rev-list --count '@{upstream}..HEAD')
39 if [[ "${ahead:-0}" -gt 0 ]]; then
40 states+=("pending commit")
41 fi
42 elif git_in "$dir" rev-parse HEAD >/dev/null; then
43 # Branch has commits but no upstream to push them to
44 states+=("no upstream")
45 fi
46
47 local status
48 if [[ ${#states[@]} -eq 0 ]]; then
49 status="clean"
50 else
51 printf -v status '%s, ' "${states[@]}"
52 status="${status%, }"
53 fi
54
55 printf '%-50s %s\n' "$rel" "$status"
56 }
57
58 # Recursively walk a directory tree. When a cloned repository is found it is
59 # reported and not descended into, since repos are never cloned inside repos.
60 scan_dir() {
61 local dir="$1"
62
63 # A cloned repository has a .git entry (directory, or a file for worktrees)
64 if [[ -e "$dir/.git" ]]; then
65 report_repo "$dir"
66 return
67 fi
68
69 local entry
70 for entry in "$dir"/*/; do
71 [[ -d "$entry" ]] || continue
72 scan_dir "${entry%/}"
73 done
74 }
75
76 # "status" subcommand entry point
77 cmd_status() {
78 local parent="${1:-$PWD}"
79
80 if [[ ! -d "$parent" ]]; then
81 echo "Error: Not a directory: $parent" >&2
82 exit 1
83 fi
84
85 # Normalise to an absolute path so relative output is stable
86 ROOT="$(cd "$parent" && pwd)"
87
88 scan_dir "$ROOT"
89 }
90
91 # Show usage
92 usage() {
93 cat << EOF
94 $SCRIPT_NAME v$VERSION - Status across many cloned git repositories
95
96 Usage:
97 $SCRIPT_NAME status [parent-dir] Report the status of every cloned
98 repository found under parent-dir
99 (defaults to the current directory)
100
101 $SCRIPT_NAME help Show this help message
102
103 Each repository found under the parent directory is reported with one or
104 more of the following states:
105
106 local changes The working tree has uncommitted changes
107 pending commit There are commits that have not been pushed upstream
108 no upstream The branch has commits but no upstream to push to
109 clean Nothing outstanding
110
111 Subdirectories of a discovered repository are not searched, as repositories
112 are never cloned inside other repositories.
113
114 Example:
115 # Check every server config repo cloned under ~/servers
116 $SCRIPT_NAME status ~/servers
117 EOF
118 }
119
120 # Main
121 case "${1:-}" in
122 "")
123 usage
124 exit 1
125 ;;
126 help|--help|-h)
127 usage
128 exit 0
129 ;;
130 status)
131 shift
132 cmd_status "$@"
133 ;;
134 *)
135 echo "Error: Unknown subcommand: $1" >&2
136 echo "Run '$SCRIPT_NAME help' for usage." >&2
137 exit 1
138 ;;
139 esac
140 {
"file_path": "/home/david/projects/code-associates/utilities/git-recursive/README.md"
}File does not exist. Note: your current working directory is /home/david/projects/code-associates/utilities.
local dir="$1"
local rel="${dir#"$ROOT"/}"
[[ "$rel" == "$dir" ]] && rel="."
local states=() local dir="$1"
local rel="${dir#"$ROOT"/}"
[[ "$rel" == "$dir" ]] && rel="."
# Current branch (or a short commit description for a detached HEAD)
local branch
branch=$(git_in "$dir" symbolic-ref --short HEAD)
if [[ -z "$branch" ]]; then
branch="$(git_in "$dir" rev-parse --short HEAD)"
branch="detached@${branch:-?}"
fi
local states=()The file /home/david/projects/code-associates/utilities/git-recursive/git-recursive has been updated successfully. (file state is current in your context — no need to Read it back)
printf '%-50s %s\n' "$rel" "$status"
printf '%-50s %-20s %s\n' "$rel" "$branch" "$status"
The file /home/david/projects/code-associates/utilities/git-recursive/git-recursive has been updated successfully. (file state is current in your context — no need to Read it back)
Each repository found under the parent directory is reported with one or
more of the following states:
local changes The working tree has uncommitted changes
pending commit There are commits that have not been pushed upstream
no upstream The branch has commits but no upstream to push to
clean Nothing outstanding
Subdirectories of a discovered repository are not searched, as repositories
are never cloned inside other repositories.Each repository found under the parent directory is reported with its
currently checked-out branch (or "detached@<commit>" for a detached HEAD,
making it easy to spot repos switched away from their default branch) and
one or more of the following states:
local changes The working tree has uncommitted changes
pending commit There are commits that have not been pushed upstream
no upstream The branch has commits but no upstream to push to
clean Nothing outstanding
Subdirectories of a discovered repository are not searched, as repositories
are never cloned inside other repositories.The file /home/david/projects/code-associates/utilities/git-recursive/git-recursive has been updated successfully. (file state is current in your context — no need to Read it back)
cd /home/david/projects/code-associates && bash utilities/git-recursive/git-recursive status .
brand main local changes conversations-with-claude main clean occam2go main clean utilities dboreham/print-repo-branch local changes, no upstream Shell cwd was reset to /home/david/projects/code-associates/utilities
Works exactly as intended — utilities is clearly flagged as being on the non-default dboreham/print-repo-branch branch. Let me verify the detached-HEAD path too:
cd /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad && rm -rf t && mkdir t && cd t && git init -q r && cd r && git -C . commit -q --allow-empty -m one && git -C . commit -q --allow-empty -m two && git -C . checkout -q HEAD~1 && cd .. && bash /home/david/projects/code-associates/utilities/git-recursive/git-recursive status .
cd /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad && rm -rf t && mkdir t && cd t && git init -q r && git -C r commit -q --allow-empty -m one && git -C r commit -q --allow-empty -m two && git -C r checkout -q HEAD~1 && bash /home/david/projects/code-associates/utilities/git-recursive/git-recursive status .
cd /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad && rm -rf t && mkdir t && cd t && git init -q r 2>&1; git -C r -c user.email=a@b.c -c user.name=a commit -q --allow-empty -m one 2>&1; git -C r -c user.email=a@b.c -c user.name=a commit -q --allow-empty -m two 2>&1; git -C r checkout -q HEAD~1 2>&1; bash /home/david/projects/code-associates/utilities/git-recursive/git-recursive status .
cd /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad && rm -rf t && mkdir -p t && cd t && git init -q r && echo "init exit=$?"
cd /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r && git -c user.email=a@b.c -c user.name=a commit --allow-empty -m one; echo "c1=$?"; git -c user.email=a@b.c -c user.name=a commit --allow-empty -m two; echo "c2=$?"; git checkout HEAD~1 2>&1; echo "co=$?"
[master (root-commit) 975cd7f] one c1=0
c2=0 Note: switching to 'HEAD~1'. You are in 'detached HEAD' state. You can look around, make experimental changes and commit them, and you can discard any commits you make in this state without impacting any branches by switching back to a branch. If you want to create a new branch to retain commits you create, you may do so (now or later) by using -c with the switch command. Example: git switch -c <new-branch-name> Or undo this operation with: git switch - Turn off this advice by setting config variable advice.detachedHead to false HEAD is now at 975cd7f one co=0 Shell cwd was reset to /home/david/projects/code-associates/utilities
cd /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t && bash /home/david/projects/code-associates/utilities/git-recursive/git-recursive status .
cd /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t && bash -x /home/david/projects/code-associates/utilities/git-recursive/git-recursive status . 2>&1 | tail -30
+ set -e
+ SCRIPT_NAME=git-recursive
+ VERSION=1.0.0
+ case "${1:-}" in
+ shift
+ cmd_status .
+ local parent=.
+ [[ ! -d . ]]
++ cd .
++ pwd
+ ROOT=/tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t
+ scan_dir /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t
+ local dir=/tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t
+ [[ -e /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/.git ]]
+ local entry
+ for entry in "$dir"/*/
+ [[ -d /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r/ ]]
+ scan_dir /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r
+ local dir=/tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r
+ [[ -e /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r/.git ]]
+ report_repo /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r
+ local dir=/tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r
+ local rel=r
+ [[ r == \/\t\m\p\/\c\l\a\u\d\e\-\1\0\0\0\/\-\h\o\m\e\-\d\a\v\i\d\-\p\r\o\j\e\c\t\s\-\c\o\d\e\-\a\s\s\o\c\i\a\t\e\s\-\u\t\i\l\i\t\i\e\s\/\a\a\e\0\5\5\b\e\-\c\3\6\c\-\4\4\0\1\-\8\a\0\9\-\9\b\0\6\e\9\0\1\6\5\5\b\/\s\c\r\a\t\c\h\p\a\d\/\t\/\r ]]
+ local branch
++ git_in /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r symbolic-ref --short HEAD
++ local dir=/tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r
++ shift
++ git -C /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t/r symbolic-ref --short HEAD
+ branch=
Shell cwd was reset to /home/david/projects/code-associates/utilitiesFound a bug: under set -e, the standalone assignment branch=$(...) exits the script when symbolic-ref fails (detached HEAD). Let me fix it:
# Current branch (or a short commit description for a detached HEAD)
local branch
branch=$(git_in "$dir" symbolic-ref --short HEAD)
if [[ -z "$branch" ]]; then
branch="$(git_in "$dir" rev-parse --short HEAD)"
branch="detached@${branch:-?}"
fi # Current branch (or a short commit description for a detached HEAD).
# symbolic-ref exits non-zero on a detached HEAD; tolerate that under set -e.
local branch
branch=$(git_in "$dir" symbolic-ref --short HEAD) || branch=""
if [[ -z "$branch" ]]; then
local commit
commit=$(git_in "$dir" rev-parse --short HEAD) || commit=""
branch="detached@${commit:-?}"
fiThe file /home/david/projects/code-associates/utilities/git-recursive/git-recursive has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-1000/-home-david-projects-code-associates-utilities/aae055be-c36c-4401-8a09-9b06e901655b/scratchpad/t && bash /home/david/projects/code-associates/utilities/git-recursive/git-recursive status . ; echo "exit=$?"; echo "---"; cd /home/david/projects/code-associates && bash utilities/git-recursive/git-recursive status .
r detached@975cd7f no upstream exit=0 --- brand main local changes conversations-with-claude main clean occam2go main clean utilities dboreham/print-repo-branch local changes, no upstream Shell cwd was reset to /home/david/projects/code-associates/utilities
Both paths work. Done.
I added branch reporting to the git-recursive status output. Each repository now shows its currently checked-out branch between the path and the status, so repos switched to a non-default branch stand out at a glance (e.g. utilities on dboreham/print-repo-branch above).
Changes in git-recursive/git-recursive:
- report_repo: determines the current branch via symbolic-ref --short HEAD, falling back to detached@<short-commit> for a detached HEAD, and prints it as a new middle column.
- usage: documents the branch column.
Along the way I caught a set -e bug: the standalone branch=$(…) assignment would have aborted the script on a detached HEAD (where symbolic-ref exits non-zero), so I guarded both substitutions with || branch="" / || commit="". Verified against a normal repo, a non-default branch, and a detached HEAD.