Claude Code transcript - page 1/2

I'd like a bash utility to help with managing the scenario where many git repositories are cloned into subdirectories under some parent directory. I have a project where each server's config is managed in a separate git repository like that. What I've found is that as changes are made locally to this collection of cloned repos, it can be hard to keep track of which ones now have local changes and hence need to be pushed to the remote. Could you write such a utility please? I've called it "git-recursive" and created an empty directory for it. I'd structure it as a command that takes a subcommand, but for now the only subcommand required to be implemented is "status". That subcommand should recursively look in directories from either a specified parent directory or the current working directory. Any directory that is a cloned git repository should be checked for current status. Output "local changes" or "pending commit" as appropriate. It is not necessary to look in subdirectories of any discovered git repository as repos are never cloned into other cloned repos.

Thinking
$ Bash
List git-recursive dir and parent utilities dir
ls -la /home/david/projects/code-associates/utilities/git-recursive && echo "---PARENT---" && ls -la /home/david/projects/code-associates/utilities
total 8
drwxr-xr-x 2 david david 4096 Jun 24 11:13 .
drwxr-xr-x 5 david david 4096 Jun 24 11:13 ..
---PARENT---
total 24
drwxr-xr-x 5 david david 4096 Jun 24 11:13 .
drwxr-xr-x 7 david david 4096 Feb 18 10:37 ..
drwxr-xr-x 8 david david 4096 Jun 24 11:12 .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 24 11:13 git-recursive
Thinking
$ Bash
List git-identity directory contents
ls -la /home/david/projects/code-associates/utilities/git-identity
total 32
drwxr-xr-x 2 david david  4096 Dec  7  2025 .
drwxr-xr-x 5 david david  4096 Jun 24 11:13 ..
-rw-r--r-- 1 david david  6232 Dec  7  2025 README.md
-rwx--x--x 1 david david 12932 Dec  7  2025 git-identity
Read
{
  "file_path": "/home/david/projects/code-associates/utilities/git-identity/git-identity"
}
1	#!/bin/bash
2	
3	# git-identity - A CLI utility for managing git identities in local repositories
4	# Sets user.name and user.email for the current repository based on an email address
5	# Also manages SSH keys for GitHub with per-identity key selection
6	
7	set -e
8	
9	SCRIPT_NAME="git-identity"
10	VERSION="2.0.0"
11	
12	# Prefix for SSH key files to differentiate from other keys
13	SSH_KEY_PREFIX="gitid_"
14	SSH_DIR="$HOME/.ssh"
15	SSH_CONFIG="$SSH_DIR/config"
16	
17	# Extract username from email address
18	# e.g., "john.doe@example.com" -> "John Doe"
19	extract_name_from_email() {
20	    local email="$1"
21	    local local_part="${email%%@*}"
22	
23	    # Replace common separators with spaces
24	    local name="${local_part//[._-]/ }"
25	
26	    # Capitalize each word
27	    local result=""
28	    for word in $name; do
29	        # Capitalize first letter, lowercase rest
30	        local capitalized="$(echo "${word:0:1}" | tr '[:lower:]' '[:upper:]')$(echo "${word:1}" | tr '[:upper:]' '[:lower:]')"
31	        if [[ -z "$result" ]]; then
32	            result="$capitalized"
33	        else
34	            result="$result $capitalized"
35	        fi
36	    done
37	
38	    echo "$result"
39	}
40	
41	# Extract local part from email (before @)
42	extract_local_part() {
43	    local email="$1"
44	    echo "${email%%@*}"
45	}
46	
47	# Generate hostname alias from email
48	# e.g., "john.doe@example.com" -> "john.doe.github.com"
49	generate_host_alias() {
50	    local email="$1"
51	    local local_part=$(extract_local_part "$email")
52	    # Replace underscores with hyphens for valid hostname
53	    local sanitized="${local_part//_/-}"
54	    echo "${sanitized}.github.com"
55	}
56	
57	# Generate SSH key filename from email
58	generate_key_filename() {
59	    local email="$1"
60	    local local_part=$(extract_local_part "$email")
61	    # Sanitize for filename: replace special chars with underscores
62	    local sanitized="${local_part//[^a-zA-Z0-9]/_}"
63	    echo "${SSH_KEY_PREFIX}${sanitized}"
64	}
65	
66	# Validate email format
67	validate_email() {
68	    local email="$1"
69	    if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
70	        return 0
71	    else
72	        return 1
73	    fi
74	}
75	
76	# Check if we're in a git repository
77	check_git_repo() {
78	    if ! git rev-parse --git-dir > /dev/null 2>&1; then
79	        echo "Error: Not in a git repository" >&2
80	        exit 1
81	    fi
82	}
83	
84	# Ensure SSH directory exists
85	ensure_ssh_dir() {
86	    if [[ ! -d "$SSH_DIR" ]]; then
87	        mkdir -p "$SSH_DIR"
88	        chmod 700 "$SSH_DIR"
89	    fi
90	}
91	
92	# Show current git identity
93	show_current() {
94	    check_git_repo
95	
96	    local name=$(git config --local user.name 2>/dev/null || echo "(not set)")
97	    local email=$(git config --local user.email 2>/dev/null || echo "(not set)")
98	
99	    echo "Current local git identity:"
100	    echo "  Name:  $name"
101	    echo "  Email: $email"
102	
103	    if [[ "$email" != "(not set)" ]]; then
104	        local host_alias=$(generate_host_alias "$email")
105	        local key_name=$(generate_key_filename "$email")
106	        local key_path="$SSH_DIR/$key_name"
107	
108	        echo ""
109	        echo "SSH configuration:"
110	        echo "  Host alias: $host_alias"
111	        if [[ -f "$key_path" ]]; then
112	            echo "  SSH key:    $key_path (exists)"
113	        else
114	            echo "  SSH key:    $key_path (not created)"
115	        fi
116	    fi
117	}
118	
119	# Set git identity
120	set_identity() {
121	    local email="$1"
122	
123	    check_git_repo
124	
125	    if ! validate_email "$email"; then
126	        echo "Error: Invalid email format: $email" >&2
127	        exit 1
128	    fi
129	
130	    local name=$(extract_name_from_email "$email")
131	
132	    git config --local user.name "$name"
133	    git config --local user.email "$email"
134	
135	    local host_alias=$(generate_host_alias "$email")
136	    local key_name=$(generate_key_filename "$email")
137	    local key_path="$SSH_DIR/$key_name"
138	
139	    echo "Git identity set for this repository:"
140	    echo "  Name:  $name"
141	    echo "  Email: $email"
142	    echo ""
143	    echo "GitHub SSH host alias: $host_alias"
144	
145	    if [[ -f "$key_path" ]]; then
146	        echo "SSH key exists: $key_path"
147	    else
148	        echo ""
149	        echo "Note: SSH key not found for this identity."
150	        echo "Run '$SCRIPT_NAME create $email' to create one."
151	    fi
152	}
153	
154	# Clear local git identity
155	clear_identity() {
156	    check_git_repo
157	
158	    git config --local --unset user.name 2>/dev/null || true
159	    git config --local --unset user.email 2>/dev/null || true
160	
161	    echo "Local git identity cleared."
162	}
163	
164	# Add or update SSH config entry for a host alias
165	update_ssh_config() {
166	    local host_alias="$1"
167	    local key_path="$2"
168	
169	    ensure_ssh_dir
170	
171	    # Create config file if it doesn't exist
172	    if [[ ! -f "$SSH_CONFIG" ]]; then
173	        touch "$SSH_CONFIG"
174	        chmod 600 "$SSH_CONFIG"
175	    fi
176	
177	    # Check if entry already exists
178	    if grep -q "^Host ${host_alias}$" "$SSH_CONFIG" 2>/dev/null; then
179	        echo "Updating existing SSH config entry for $host_alias"
180	        # Remove existing entry (from Host line to next Host line or end of file)
181	        local temp_file=$(mktemp)
182	        awk -v host="$host_alias" '
183	            BEGIN { skip = 0 }
184	            /^Host / {
185	                if ($2 == host) {
186	                    skip = 1
187	                    next
188	                } else {
189	                    skip = 0
190	                }
191	            }
192	            !skip { print }
193	        ' "$SSH_CONFIG" > "$temp_file"
194	        mv "$temp_file" "$SSH_CONFIG"
195	    fi
196	
197	    # Append new entry
198	    cat >> "$SSH_CONFIG" << EOF
199	
200	# Git identity: $host_alias (managed by git-identity)
201	Host $host_alias
202	    HostName github.com
203	    User git
204	    IdentityFile $key_path
205	    IdentitiesOnly yes
206	EOF
207	
208	    echo "SSH config updated for host alias: $host_alias"
209	}
210	
211	# Remove SSH config entry for a host alias
212	remove_ssh_config() {
213	    local host_alias="$1"
214	
215	    if [[ ! -f "$SSH_CONFIG" ]]; then
216	        return
217	    fi
218	
219	    if grep -q "^Host ${host_alias}$" "$SSH_CONFIG" 2>/dev/null; then
220	        local temp_file=$(mktemp)
221	        awk -v host="$host_alias" '
222	            BEGIN { skip = 0 }
223	            /^# Git identity:.*\(managed by git-identity\)$/ {
224	                getline next_line
225	                if (next_line ~ "^Host " host "$") {
226	                    skip = 1
227	                    next
228	                } else {
229	                    print
230	                    print next_line
231	                    next
232	                }
233	            }
234	            /^Host / {
235	                if ($2 == host) {
236	                    skip = 1
237	                    next
238	                } else {
239	                    skip = 0
240	                }
241	            }
242	            !skip { print }
243	        ' "$SSH_CONFIG" > "$temp_file"
244	        mv "$temp_file" "$SSH_CONFIG"
245	        echo "Removed SSH config entry for $host_alias"
246	    fi
247	}
248	
249	# Create SSH key for an identity
250	create_key() {
251	    local email="$1"
252	
253	    if ! validate_email "$email"; then
254	        echo "Error: Invalid email format: $email" >&2
255	        exit 1
256	    fi
257	
258	    ensure_ssh_dir
259	
260	    local key_name=$(generate_key_filename "$email")
261	    local key_path="$SSH_DIR/$key_name"
262	    local host_alias=$(generate_host_alias "$email")
263	
264	    if [[ -f "$key_path" ]]; then
265	        echo "Error: SSH key already exists: $key_path" >&2
266	        echo "Use '$SCRIPT_NAME delete $email' to remove it first." >&2
267	        exit 1
268	    fi
269	
270	    echo "Creating SSH key for identity: $email"
271	    echo "Key file: $key_path"
272	    echo ""
273	
274	    # Generate ED25519 key (recommended for GitHub)
275	    ssh-keygen -t ed25519 -C "$email" -f "$key_path"
276	
277	    # Update SSH config
278	    update_ssh_config "$host_alias" "$key_path"
279	
280	    echo ""
281	    echo "SSH key created successfully!"
282	    echo ""
283	    echo "Host alias for git remotes: $host_alias"
284	    echo ""
285	    echo "Public key (add this to GitHub):"
286	    echo "----------------------------------------"
287	    cat "${key_path}.pub"
288	    echo "----------------------------------------"
289	    echo ""
290	    echo "To use this identity with a repository, update the remote URL:"
291	    echo "  git remote set-url origin git@${host_alias}:<user>/<repo>.git"
292	    echo ""
293	    echo "Or clone using the host alias:"
294	    echo "  git clone git@${host_alias}:<user>/<repo>.git"
295	}
296	
297	# List all managed SSH keys
298	list_keys() {
299	    ensure_ssh_dir
300	
301	    echo "Managed SSH keys (prefix: ${SSH_KEY_PREFIX}):"
302	    echo ""
303	
304	    local found=0
305	    for key_file in "$SSH_DIR"/${SSH_KEY_PREFIX}*; do
306	        # Skip if no matches (glob returns pattern if no matches)
307	        [[ -e "$key_file" ]] || continue
308	        # Skip public keys, only show private keys
309	        [[ "$key_file" == *.pub ]] && continue
310	
311	        found=1
312	        local key_name=$(basename "$key_file")
313	        local pub_key="${key_file}.pub"
314	
315	        echo "Key: $key_name"
316	        echo "  Path: $key_file"
317	
318	        if [[ -f "$pub_key" ]]; then
319	            # Extract email from public key comment
320	            local comment=$(awk '{print $3}' "$pub_key" 2>/dev/null)
321	            if [[ -n "$comment" ]]; then
322	                echo "  Email: $comment"
323	                local host_alias=$(generate_host_alias "$comment")
324	                echo "  Host alias: $host_alias"
325	            fi
326	            echo "  Public key: $pub_key"
327	        fi
328	        echo ""
329	    done
330	
331	    if [[ $found -eq 0 ]]; then
332	        echo "No managed SSH keys found."
333	        echo "Use '$SCRIPT_NAME create <email>' to create one."
334	    fi
335	}
336	
337	# Delete SSH key for an identity
338	delete_key() {
339	    local email="$1"
340	
341	    if ! validate_email "$email"; then
342	        echo "Error: Invalid email format: $email" >&2
343	        exit 1
344	    fi
345	
346	    local key_name=$(generate_key_filename "$email")
347	    local key_path="$SSH_DIR/$key_name"
348	    local host_alias=$(generate_host_alias "$email")
349	
350	    if [[ ! -f "$key_path" ]]; then
351	        echo "Error: SSH key not found: $key_path" >&2
352	        exit 1
353	    fi
354	
355	    echo "This will delete the SSH key for: $email"
356	    echo "  Private key: $key_path"
357	    echo "  Public key:  ${key_path}.pub"
358	    echo ""
359	    read -p "Are you sure? (y/N) " -n 1 -r
360	    echo ""
361	
362	    if [[ $REPLY =~ ^[Yy]$ ]]; then
363	        rm -f "$key_path" "${key_path}.pub"
364	        remove_ssh_config "$host_alias"
365	        echo "SSH key deleted."
366	    else
367	        echo "Cancelled."
368	    fi
369	}
370	
371	# Show public key for an identity
372	show_pubkey() {
373	    local email="$1"
374	
375	    if ! validate_email "$email"; then
376	        echo "Error: Invalid email format: $email" >&2
377	        exit 1
378	    fi
379	
380	    local key_name=$(generate_key_filename "$email")
381	    local pub_key="$SSH_DIR/${key_name}.pub"
382	
383	    if [[ ! -f "$pub_key" ]]; then
384	        echo "Error: Public key not found: $pub_key" >&2
385	        echo "Use '$SCRIPT_NAME create $email' to create a key." >&2
386	        exit 1
387	    fi
388	
389	    cat "$pub_key"
390	}
391	
392	# Show usage
393	usage() {
394	    cat << EOF
395	$SCRIPT_NAME v$VERSION - Manage git identities and SSH keys for GitHub
396	
397	Usage:
398	    $SCRIPT_NAME <email>           Set git identity for current repository
399	    $SCRIPT_NAME show              Show current local git identity
400	    $SCRIPT_NAME clear             Clear local git identity
401	
402	SSH Key Management:
403	    $SCRIPT_NAME create <email>    Create SSH key for an identity
404	    $SCRIPT_NAME list              List all managed SSH keys
405	    $SCRIPT_NAME delete <email>    Delete SSH key for an identity
406	    $SCRIPT_NAME pubkey <email>    Show public key for an identity
407	
408	    $SCRIPT_NAME help              Show this help message
409	
410	Examples:
411	    # Set identity for current repository
412	    $SCRIPT_NAME john.doe@example.com
413	
414	    # Create SSH key for a new identity
415	    $SCRIPT_NAME create john.doe@example.com
416	
417	    # List all managed SSH keys
418	    $SCRIPT_NAME list
419	
420	The username is automatically derived from the email address:
421	    john.doe@example.com    -> John Doe
422	    jane_smith@company.org  -> Jane Smith
423	
424	SSH Key Scheme:
425	    Each identity gets a unique hostname alias for GitHub:
426	    john.doe@example.com    -> john.doe.github.com
427	
428	    Use this alias in your git remote URLs to automatically select
429	    the correct SSH key for each identity:
430	    git@john.doe.github.com:user/repo.git
431	
432	    The SSH config (~/.ssh/config) is automatically updated to map
433	    these aliases to github.com with the appropriate identity key.
434	EOF
435	}
436	
437	# Main
438	case "${1:-}" in
439	    "")
440	        usage
441	        exit 1
442	        ;;
443	    help|--help|-h)
444	        usage
445	        exit 0
446	        ;;
447	    show|--show|-s)
448	        show_current
449	        ;;
450	    clear|--clear|-c)
451	        clear_identity
452	        ;;
453	    list|--list|-l)
454	        list_keys
455	        ;;
456	    create)
457	        if [[ -z "${2:-}" ]]; then
458	            echo "Error: Email address required" >&2
459	            echo "Usage: $SCRIPT_NAME create <email>" >&2
460	            exit 1
461	        fi
462	        create_key "$2"
463	        ;;
464	    delete)
465	        if [[ -z "${2:-}" ]]; then
466	            echo "Error: Email address required" >&2
467	            echo "Usage: $SCRIPT_NAME delete <email>" >&2
468	            exit 1
469	        fi
470	        delete_key "$2"
471	        ;;
472	    pubkey)
473	        if [[ -z "${2:-}" ]]; then
474	            echo "Error: Email address required" >&2
475	            echo "Usage: $SCRIPT_NAME pubkey <email>" >&2
476	            exit 1
477	        fi
478	        show_pubkey "$2"
479	        ;;
480	    *)
481	        set_identity "$1"
482	        ;;
483	esac
484	
Thinking

The sibling git-identity shows me the house style. Let me write git-recursive to match it.

📝 Write git-recursive
/home/david/projects/code-associates/utilities/git-recursive/git-recursive
#!/bin/bash

# git-recursive - Operate on many git repositories cloned under a parent directory
# Discovers cloned repositories in subdirectories and reports their status, so it
# is easy to see which ones have uncommitted work or commits that need pushing.

set -e

SCRIPT_NAME="git-recursive"
VERSION="1.0.0"

# Run a git command against a specific repository directory
git_in() {
    local dir="$1"
    shift
    git -C "$dir" "$@" 2>/dev/null
}

# Determine and print the status of a single discovered repository.
# Reports "local changes" if the working tree is dirty, "pending commit"
# if there are commits not yet pushed to the upstream, "no upstream" if the
# branch has nothing to push to, or "clean" if there is nothing outstanding.
report_repo() {
    local dir="$1"
    local rel="${dir#"$ROOT"/}"
    [[ "$rel" == "$dir" ]] && rel="."

    local states=()

    # Uncommitted work in the working tree (staged, modified or untracked)
    if [[ -n "$(git_in "$dir" status --porcelain)" ]]; then
        states+=("local changes")
    fi

    # Commits that exist locally but have not been pushed
    if git_in "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null; then
        local ahead
        ahead=$(git_in "$dir" rev-list --count '@{upstream}..HEAD')
        if [[ "${ahead:-0}" -gt 0 ]]; then
            states+=("pending commit")
        fi
    elif git_in "$dir" rev-parse HEAD >/dev/null; then
        # Branch has commits but no upstream to push them to
        states+=("no upstream")
    fi

    local status
    if [[ ${#states[@]} -eq 0 ]]; then
        status="clean"
    else
        local IFS=", "
        status="${states[*]}"
    fi

    printf '%-50s %s\n' "$rel" "$status"
}

# Recursively walk a directory tree. When a cloned repository is found it is
# reported and not descended into, since repos are never cloned inside repos.
scan_dir() {
    local dir="$1"

    # A cloned repository has a .git entry (directory, or a file for worktrees)
    if [[ -e "$dir/.git" ]]; then
        report_repo "$dir"
        return
    fi

    local entry
    for entry in "$dir"/*/; do
        [[ -d "$entry" ]] || continue
        scan_dir "${entry%/}"
    done
}

# "status" subcommand entry point
cmd_status() {
    local parent="${1:-$PWD}"

    if [[ ! -d "$parent" ]]; then
        echo "Error: Not a directory: $parent" >&2
        exit 1
    fi

    # Normalise to an absolute path so relative output is stable
    ROOT="$(cd "$parent" && pwd)"

    scan_dir "$ROOT"
}

# Show usage
usage() {
    cat << EOF
$SCRIPT_NAME v$VERSION - Status across many cloned git repositories

Usage:
    $SCRIPT_NAME status [parent-dir]   Report the status of every cloned
                                       repository found under parent-dir
                                       (defaults to the current directory)

    $SCRIPT_NAME help                  Show this help message

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.

Example:
    # Check every server config repo cloned under ~/servers
    $SCRIPT_NAME status ~/servers
EOF
}

# Main
case "${1:-}" in
    "")
        usage
        exit 1
        ;;
    help|--help|-h)
        usage
        exit 0
        ;;
    status)
        shift
        cmd_status "$@"
        ;;
    *)
        echo "Error: Unknown subcommand: $1" >&2
        echo "Run '$SCRIPT_NAME help' for usage." >&2
        exit 1
        ;;
esac
File created successfully at: /home/david/projects/code-associates/utilities/git-recursive/git-recursive (file state is current in your context — no need to Read it back)
$ Bash
Create test repo tree and exercise the status subcommand
chmod +x /home/david/projects/code-associates/utilities/git-recursive/git-recursive
# Build a small test tree of cloned repos to exercise the script
TMP=$(mktemp -d)
cd "$TMP"
mkdir -p servers
git init -q servers/clean-repo   && (cd servers/clean-repo   && git commit -q --allow-empty -m init)
git init -q servers/dirty-repo   && (cd servers/dirty-repo   && git commit -q --allow-empty -m init && echo hi > newfile)
git init -q servers/nested/deep-repo && (cd servers/nested/deep-repo && git commit -q --allow-empty -m init)
mkdir -p servers/not-a-repo/plain
echo "=== run from parent ==="
/home/david/projects/code-associates/utilities/git-recursive/git-recursive status "$TMP/servers"
echo "=== help ==="
/home/david/projects/code-associates/utilities/git-recursive/git-recursive help
echo "=== bad subcommand exit code ==="
/home/david/projects/code-associates/utilities/git-recursive/git-recursive bogus; echo "exit=$?"
rm -rf "$TMP"
Exit code 1
=== run from parent ===
clean-repo                                         no upstream
dirty-repo                                         local changes,no upstream
nested/deep-repo                                   no upstream
=== help ===
git-recursive v1.0.0 - Status across many cloned git repositories

Usage:
    git-recursive status [parent-dir]   Report the status of every cloned
                                       repository found under parent-dir
                                       (defaults to the current directory)

    git-recursive help                  Show this help message

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.

Example:
    # Check every server config repo cloned under ~/servers
    git-recursive status ~/servers
=== bad subcommand exit code ===
Error: Unknown subcommand: bogus
Run 'git-recursive help' for usage.
exit=1
pwd: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
Thinking

Works. One cosmetic bug: the state separator rendered as local changes,no upstream with no space. The ${array[*]} join only uses the first char of IFS. Let me fix that.

✏️ Edit git-recursive
/home/david/projects/code-associates/utilities/git-recursive/git-recursive
    local status
    if [[ ${#states[@]} -eq 0 ]]; then
        status="clean"
    else
        local IFS=", "
        status="${states[*]}"
    fi
+
    local status
    if [[ ${#states[@]} -eq 0 ]]; then
        status="clean"
    else
        printf -v status '%s, ' "${states[@]}"
        status="${status%, }"
    fi
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)
$ Bash
Test pending commit and up-to-date detection with a real upstream
TMP=$(mktemp -d); cd "$TMP"; mkdir -p servers
git init -q servers/dirty   && (cd servers/dirty && git commit -q --allow-empty -m init && echo hi > f)
# simulate a repo with an upstream that is ahead (pending commit)
git init -q --bare remote.git
git clone -q remote.git servers/ahead && (cd servers/ahead && git commit -q --allow-empty -m init && git push -q -u origin HEAD && git commit -q --allow-empty -m local)
git clone -q remote.git servers/upToDate && (cd servers/upToDate && git fetch -q)
echo "=== status ==="
/home/david/projects/code-associates/utilities/git-recursive/git-recursive status "$TMP/servers"
cd /; rm -rf "$TMP"
warning: You appear to have cloned an empty repository.
=== status ===
ahead                                              pending commit
dirty                                              local changes, no upstream
upToDate                                           clean
Shell cwd was reset to /home/david/projects/code-associates/utilities/git-recursive

All four states detect correctly:

  • aheadpending commit (commit not yet pushed)
  • dirtylocal changes, no upstream
  • upToDateclean

What I built

git-recursive at /home/david/projects/code-associates/utilities/git-recursive/git-recursive (executable), styled to match the sibling git-identity utility.

Usage: git-recursive status [parent-dir] — defaults to the current directory if no parent is given.

It recursively descends from the parent directory, and for each cloned repo it finds (a directory with a .git entry) it reports one or more states, then stops descending into that repo:

State Meaning
local changes working tree is dirty (staged/modified/untracked)
pending commit commits exist locally but aren't pushed upstream
no upstream branch has commits but no upstream to push to
clean nothing outstanding

Notes on a couple of judgment calls:

  • I split your "needs pushing" concern into two distinct states — uncommitted work (local changes) vs. committed-but-unpushed (pending commit) — since a repo can be in either or both, and they call for different actions (commit vs. push). A repo with both shows local changes, pending commit.
  • I added no upstream because a freshly-cloned-but-never-pushed branch otherwise looks clean even though it has unpushed work. In your scenario (config repos cloned from remotes) most repos will have an upstream, so this mainly flags anomalies.
  • Clean repos are listed too, so you get a full inventory of what was scanned rather than silence. Easy to filter to only-interesting repos later if you'd prefer — just say the word.

The subcommand dispatch is in place, so adding pull, push, etc. later is a matter of dropping in another cmd_* function. Want me to add a README.md to match git-identity, or wire up any further subcommands?

Thanks! Can you also test on the production repos cloned here? /home/david/projects/nuevasync/Deployment/config_repos

$ Bash
Run status on production config_repos
/home/david/projects/code-associates/utilities/git-recursive/git-recursive status /home/david/projects/nuevasync/Deployment/config_repos
cluster_cluster_cfg_c1                             clean
cluster_cluster_cfg_c2                             local changes
cluster_cluster_cfg_cs                             clean
cluster_general_cfg_c1                             clean
cluster_general_cfg_c2                             clean
cluster_general_cfg_cs                             clean
server_cfg_c1_1                                    clean
server_cfg_c1_2                                    clean
server_cfg_c1_20                                   clean
server_cfg_c1_21                                   clean
server_cfg_c1_80                                   clean
server_cfg_c2_22                                   clean
server_cfg_c2_5                                    clean
server_cfg_c2_50                                   clean
server_cfg_c2_6                                    clean
server_cfg_c2_7                                    clean
server_cfg_c2_8                                    clean
server_cfg_cs_99                                   clean
Thinking

Worked against the production set. It discovered 18 cloned repos under config_repos, all reporting cleanly. One stands out:

  • cluster_cluster_cfg_c2local changes — has uncommitted work in its working tree.

Everything else is clean (committed and pushed, nothing pending). So that one repo is the only thing currently needing attention — you'd want to commit and push it.

Want me to peek at what the uncommitted changes are in cluster_cluster_cfg_c2 (a git status/git diff there), or leave it for you to handle?

No, I just wanted to make sure the utility works on a "from the trenches" example.

Sounds good — it handled the real set cleanly: 18 repos discovered, correctly flagged the one with uncommitted work, and the rest clean. The utility's ready to use.

Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.

/exit exit