git:20260601.f371476 to git:20260601.8072844

188 added, 52 removed. Audit A to A.

---
name: sync-all-repos
description: Sync all GitHub-backed git repos on this machine with origin. Pulls behind repos, pushes ahead repos, reports dirty/non-main/conflict cases for Claude to resolve.
---
# Sync All Repos
Discover every GitHub-backed git repo on the current machine, classify each by sync state, auto-execute the obvious cases (clean repo behind/ahead/diverged from origin), and report the ambiguous cases (uncommitted changes, non-main branches, rebase conflicts) for Claude to resolve case by case.
Use this when:
- You sit down at the Mac or VPS and want to catch up with whatever the OTHER machine pushed to GitHub since last session
- You're about to walk away and want to ensure all committed-but-not-pushed work is on GitHub
- You suspect drift between machines and want a single-shot reconciliation
This skill does NOT:
- Coordinate Mac↔VPS directly (each machine syncs with GitHub independently)
- Auto-stash, auto-commit, or auto-resolve conflicts (Claude decides per case)
- Touch non-GitHub repos (gitlab, bitbucket, self-hosted git all skipped)
- Run on a timer (explicit invocation only)
## Step 1: Discover repos
Search roots:
- `~/Documents/GitHub/` (depth ≤2)
- `~/Documents/jira nbch/` (cds-dashboard, jira-nbch)
- `~/Documents/crm/`
- Any additional path in `$DOTFORGE_DIR/registry/projects.local.yml` not covered above
For each candidate directory at depth ≤2 with a `.git/` subdirectory:
```bash
REMOTE=$(git -C "$dir" config --get remote.origin.url 2>/dev/null)
case "$REMOTE" in
*github.com*) ;;
*) continue ;; # non-GitHub: skip silently
esac
# Opt-out marker: project declares itself out of sync scope
if [[ -f "$dir/.dotforge-sync-ignore" ]]; then
reason=$(head -1 "$dir/.dotforge-sync-ignore" 2>/dev/null)
reason=${reason:-"opt-out (no reason given)"}
echo "IGNORED|$dir|$reason"
continue
fi
echo "$dir"
```
Skip silently:
- Directories without `.git/`
- Repos with no `origin` remote
- Repos with `origin` not pointing at GitHub
Skip with note (reported under "Ignored" in the final summary):
- Repos with a `.dotforge-sync-ignore` file at root. The first line of that file is shown as the reason. Use this for archived projects, vendored repos, or any GitHub-backed repo you deliberately don't want auto-synced. The marker is in the project's own working tree — version-controlled by that project, not by dotforge.
- ## Step 2: Classify each repo
+ ## Step 2: Classify each repo (with timeout + parallel)
- For each discovered repo, gather state (all read-only — safe to run in parallel):
+ **Critical**: wrap every git network operation (`fetch`) and tree-scanning operation (`status`) in a timeout. Without this, a single hung repo (DNS lag, large worktree, stale lock) blocks the whole sync. Process repos in parallel for speed.
+ Helper to detect the right timeout binary (macOS ships `gtimeout` via coreutils; Linux ships `timeout`):
+
```bash
- cd "$repo"
- BRANCH=$(git branch --show-current)
- DIRTY=$(git status --porcelain | wc -l | tr -d ' ')
- git fetch origin --quiet 2>/dev/null || FETCH_FAIL=1
- BEHIND=$(git rev-list --count HEAD..@{u} 2>/dev/null || echo "?")
- AHEAD=$(git rev-list --count @{u}..HEAD 2>/dev/null || echo "?")
- LAST_COMMIT=$(git log -1 --format='%h %s' 2>/dev/null)
+ if command -v gtimeout >/dev/null 2>&1; then T=gtimeout
+ elif command -v timeout >/dev/null 2>&1; then T=timeout
+ else
+ # Fallback for systems without either — pure-bash timeout
+ T() {
+ local secs=$1; shift
+ ( "$@" ) & local pid=$!
+ ( sleep "$secs"; kill -TERM "$pid" 2>/dev/null ) & local watcher=$!
+ wait "$pid" 2>/dev/null; local rc=$?
+ kill "$watcher" 2>/dev/null
+ return $rc
+ }
+ fi
```
- Classification table:
+ Use `${T} 30 git fetch ...` for every potentially-slow git call. Recommended timeouts:
+ - `git fetch`: 30s (network)
+ - `git status --porcelain`: 20s (large worktrees)
+ - `git rev-list --count`: 5s (cheap, just in case)
- | State | Trigger | Action |
- |-------|---------|--------|
- | `unreachable` | `FETCH_FAIL` set | Skip with warning. GitHub down or no network. Don't fail the rest. |
- | `non-main` | `BRANCH` is not `main`/`master` | Report only. User may be on a feature branch deliberately. |
- | `dirty` | `DIRTY > 0` | Report with branch + `git diff --stat` + last commit. **NO automatic action.** Defer to Claude. |
- | `in-sync` | `DIRTY==0 && BEHIND==0 && AHEAD==0` | No-op. Report as clean. |
- | `behind` | `DIRTY==0 && BEHIND>0 && AHEAD==0` | Auto: `git pull --rebase` |
- | `ahead` | `DIRTY==0 && BEHIND==0 && AHEAD>0` | Auto: `git push` |
- | `diverged` | `DIRTY==0 && BEHIND>0 && AHEAD>0` | Auto: `git pull --rebase` then `git push`. If rebase fails, `git rebase --abort` and report. |
+ ### Per-repo classification function
+
+ Run as a subshell so a single hung repo can't poison shared state, and each invocation writes its result to a unique temp file (avoids interleaved stdout from parallel runs):
+
+ ```bash
+ classify_one() {
+ local repo=$1 outdir=$2
+ local out="$outdir/$(echo "$repo" | tr '/' '_').out"
+ local name=$(basename "$repo")
+
+ local branch dirty fetch_rc behind ahead last
+ branch=$(git -C "$repo" branch --show-current 2>/dev/null)
+ branch=${branch:-?}
+
+ if ! ${T} 20 git -C "$repo" status --porcelain >/dev/null 2>&1; then
+ printf '%s|stalled|%s|status timeout\n' "$repo" "$branch" > "$out"; return
+ fi
+ dirty=$(${T} 20 git -C "$repo" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
+
+ if ! ${T} 30 git -C "$repo" fetch origin --quiet 2>/dev/null; then
+ printf '%s|unreachable|%s|fetch failed or timeout\n' "$repo" "$branch" > "$out"; return
+ fi
+
+ behind=$(${T} 5 git -C "$repo" rev-list --count HEAD..@{u} 2>/dev/null || echo 0)
+ ahead=$(${T} 5 git -C "$repo" rev-list --count @{u}..HEAD 2>/dev/null || echo 0)
+ last=$(${T} 5 git -C "$repo" log -1 --format='%h %s' 2>/dev/null)
+
+ local state detail
+ if [[ "$dirty" -gt 0 ]]; then
+ state=dirty; detail="${dirty} files; ${behind}↓/${ahead}↑"
+ elif [[ "$branch" != "main" && "$branch" != "master" ]]; then
+ state=non-main; detail="${behind}↓/${ahead}↑"
+ elif [[ "$behind" -eq 0 && "$ahead" -eq 0 ]]; then
+ state=in-sync; detail="—"
+ elif [[ "$behind" -gt 0 && "$ahead" -eq 0 ]]; then
+ state=behind; detail="${behind} commits"
+ elif [[ "$behind" -eq 0 && "$ahead" -gt 0 ]]; then
+ state=ahead; detail="${ahead} commits"
+ else
+ state=diverged; detail="${behind}↓/${ahead}↑"
+ fi
+ printf '%s|%s|%s|%s|%s\n' "$repo" "$state" "$branch" "$detail" "$last" > "$out"
+ }
+ export -f classify_one
+ export T
+ ```
+
+ ### Parallel execution
+
+ **Critical**: paths can contain spaces (e.g., `~/Documents/jira nbch/cds-dashboard`). Always pipe **null-delimited** input to `xargs -0` — newline-delimited splits on whitespace and corrupts paths with spaces.
+
+ ```bash
+ OUTDIR=$(mktemp -d)
+ # REPOS array comes from Step 1 (filtered, github-only, not ignored)
+ printf '%s\0' "${REPOS[@]}" | \
+ xargs -0 -n 1 -P 8 -I{} bash -c 'classify_one "$1" "$2"' _ {} "$OUTDIR"
+
+ # Collect results — sort alphabetically for stable output
+ RESULTS=$(cat "$OUTDIR"/*.out 2>/dev/null | sort)
+ rm -rf "$OUTDIR"
+ ```
+
+ The `-P 8` runs up to 8 repos concurrently. Adjust `-P` based on the machine (Mac M-series: 8; VPS Oracle ARM: 4 to leave headroom). On a 15-repo workload, parallel finishes in ~30-60s (Mac M, mixed cold/warm caches) vs 3+ min sequential.
+
+ ### State table
+
+ | State | Trigger | Action in Step 3 |
+ |-------|---------|------------------|
+ | `unreachable` | fetch timeout or fail | Skip with warning. Don't fail the rest. |
+ | `stalled` | status timeout | Skip with warning. Repo has a huge worktree or stuck git lock. |
+ | `non-main` | `branch` not main/master | Report only. User may be on a feature branch deliberately. |
+ | `dirty` | `dirty > 0` | Report with branch + `git diff --stat` + last commit. **NO automatic action.** Defer to Claude. |
+ | `in-sync` | clean + main + 0/0 | No-op. |
+ | `behind` | clean + main + N↓/0↑ | Auto: `${T} 60 git pull --rebase` |
+ | `ahead` | clean + main + 0↓/M↑ | Auto: `${T} 60 git push` |
+ | `diverged` | clean + main + N↓/M↑ | Auto: `${T} 60 git pull --rebase` then push. If rebase fails, `git rebase --abort` and report. |
| `conflict` | rebase failed | Already aborted in `diverged` flow. Report and let Claude decide. |
- ## Step 3: Execute auto-actions
+ ## Step 3: Execute auto-actions (also parallel + timed)
- For each repo classified as `behind`, `ahead`, or `diverged`, execute the action.
+ For each repo classified as `behind`, `ahead`, or `diverged`, run the action with timeout. Pattern mirrors Step 2: subshell per repo, write result to a tempfile, parallelize via xargs, collect at the end.
```bash
- # behind
- git -C "$repo" pull --rebase --quiet
+ act_one() {
+ local repo=$1 state=$2 outdir=$3
+ local out="$outdir/$(echo "$repo" | tr '/' '_').out"
+ case "$state" in
+ behind)
+ if ${T} 60 git -C "$repo" pull --rebase --quiet 2>&1; then
+ echo "$repo|pulled" > "$out"
+ else
+ echo "$repo|pull-failed" > "$out"
+ fi ;;
+ ahead)
+ if ${T} 60 git -C "$repo" push --quiet 2>&1; then
+ echo "$repo|pushed" > "$out"
+ else
+ echo "$repo|push-failed" > "$out"
+ fi ;;
+ diverged)
+ if ${T} 60 git -C "$repo" pull --rebase --quiet 2>&1; then
+ if ${T} 60 git -C "$repo" push --quiet 2>&1; then
+ echo "$repo|reconciled" > "$out"
+ else
+ echo "$repo|pull-ok-push-failed" > "$out"
+ fi
+ else
+ git -C "$repo" rebase --abort 2>/dev/null
+ echo "$repo|rebase-conflict" > "$out"
+ fi ;;
+ esac
+ }
+ export -f act_one
- # ahead
- git -C "$repo" push --quiet
+ # Build action list from Step 2 RESULTS, filter to actionable states
+ ACTION_LIST=$(echo "$RESULTS" | awk -F'|' '$2 ~ /^(behind|ahead|diverged)$/ {print $1"|"$2}')
+ ACTOUT=$(mktemp -d)
+ # Null-delimited to survive paths with spaces
+ printf '%s\0' $ACTION_LIST | xargs -0 -n 1 -P 8 -I{} bash -c '
+ IFS="|" read -r repo state <<< "$1"
+ act_one "$repo" "$state" "$2"
+ ' _ {} "$ACTOUT"
- # diverged
- git -C "$repo" pull --rebase --quiet || git -C "$repo" rebase --abort
- [ rebase succeeded ] && git -C "$repo" push --quiet
+ ACTIONS=$(cat "$ACTOUT"/*.out 2>/dev/null | sort)
+ rm -rf "$ACTOUT"
```
- Capture exit code per repo. Don't stop the loop on failure — collect all results and report at the end.
+ If a `push` fails (typically because someone else pushed first), that repo flips to "diverged" — you'd re-run sync-all to reconcile. Don't auto-retry within a single run.
## Step 4: Build structured report
- Emit a markdown table summarizing every repo touched:
+ Two-section output: a one-line discovery summary + the per-state breakdown with aligned columns.
+ ### Header — one-line discovery summary
+
```
- ═══ SYNC-ALL RESULTS ═══
+ ═══ SYNC-ALL: <N> repos discovered (<M> GitHub-backed, <K> ignored, <S> skipped non-github) ═══
+ ```
- Pulled (N):
- - <repo> (was behind by N commits)
+ ### Body — categorical breakdown
- Pushed (M):
- - <repo> (was ahead by N commits)
+ Use a fixed-width table format (40-char repo column + 14-char state + 16-char branch + rest for detail). Stable column widths across categories make it scannable.
- Diverged + reconciled (K):
- - <repo> (pulled N, pushed M)
+ ```
+ STATE REPOS DETAIL
+ ───── ───── ──────
- In-sync (J):
- - <repo>, <repo>, ...
+ ✓ Pulled (N)
+ <repo> was behind by N
+ <repo> was behind by M
- Dirty — DEFERRED to Claude (X):
- - <repo>
- branch: <branch>
- last commit: <hash> <msg>
- changes: <diff --stat first 3 lines>
+ ✓ Pushed (M)
+ <repo> was ahead by N
- Non-main branches — skipped (Y):
- - <repo> on <branch>
+ ✓ Reconciled (K)
+ <repo> pulled N, pushed M
- Conflicts — manual intervention (Z):
- - <repo>: rebase aborted; pull and push diverged manually
+ = In-sync (J)
+ <repo>, <repo>, <repo> (compact list)
- Unreachable (W):
- - <repo>: fetch failed (GitHub or network)
+ ⚠ Dirty — DEFERRED to Claude (X)
+ <repo> [<branch>] <D> files; <B>↓/<A>↑
+ last: <hash> <subject>
+ diffstat:
+ <file1>: +X -Y
+ <file2>: +X -Y
+ (… N more)
- Ignored (V):
- - <repo>: <reason from .dotforge-sync-ignore>
+ ⚠ Non-main — skipped (Y)
+ <repo> on <branch>; <B>↓/<A>↑
+
+ ⚠ Conflicts — manual intervention (Z)
+ <repo> rebase aborted, diverged remained
+
+ ✗ Unreachable (W)
+ <repo> fetch timeout/failed
+
+ ✗ Stalled (T)
+ <repo> git status hung — repo may be corrupt or worktree huge
+
+ ⊘ Ignored (V)
+ <repo> <reason from .dotforge-sync-ignore>
```
+
+ ### Counter line at the end
+
+ ```
+ ═══ TOTAL: pulled=N pushed=M reconciled=K in-sync=J dirty=X non-main=Y conflicts=Z unreachable=W stalled=T ignored=V ═══
+ ```
+
+ Categories with count 0 are omitted from the body but still appear in the counter line for completeness.
## Step 5: Claude resolves dirty cases
For each repo in the `Dirty` section, Claude decides what to do based on the diff and session context:
1. Run `git -C <repo> diff` to see what's changed
2. Run `git -C <repo> log -5 --oneline` to see recent commits for context
3. Decide:
- **Skip**: user is mid-edit on something they haven't finished. Leave it alone, note in summary.
- **Commit WIP**: changes look like a coherent unit. Compose a descriptive commit message and `git add -A && git commit -m "<msg>"`, then push.
- **Stash**: changes don't fit a clean commit but need to clear the tree to pull. `git stash push -m "sync-all auto-stash <date>"`, pull, optionally `git stash pop`.
- **Ask user**: if intent is genuinely unclear, surface via AskUserQuestion with the diff.
Apply the decision per repo using Bash directly. Do NOT loop back through the skill — this phase is Claude's orchestration.
## Step 6: Handle non-main branches
For each repo on a non-main branch (reported in step 4), ask one question collectively rather than per-repo:
> Found N repos on non-main branches: <repo1> on <branch1>, <repo2> on <branch2>...
> Want me to sync these branches with their tracking origin (same logic: pull/push/report)?
If yes, re-run steps 2-4 on those repos. If no, skip.
## Step 7: Final summary
After all dirty cases and non-main branches are resolved, emit the final counter:
```
═══ FINAL ═══
Total repos discovered: T
Pulled: N
Pushed: M
Synced: K (in-sync, no action)
Dirty resolved: J (X committed, Y stashed, Z skipped)
Conflicts remaining: C
Unreachable: U
```
## Edge cases
- **`~/Documents/GitHub/dotforge` is included**: sync it LAST, after every other repo is done. If the pull brought new commits, surface:
> dotforge updated to v<new-version>. The new skills/hooks are on disk but the current Claude session is still using the old version. Restart Claude to pick up the update.
Do NOT auto-restart.
- **Repo doesn't exist on this machine**: search-roots-based discovery skips it silently. No error.
- **Repo is a submodule**: detect via `.git` being a file (not a directory). Skip submodules — they're managed by their parent.
- **Repo has a non-`origin` GitHub remote**: only `origin` is considered. If you push to multiple GitHubs, you're outside the scope of this skill.
## Constraints
- NEVER `git push --force` from this skill, even after a rebase. If push fails, report and stop on that repo.
- NEVER auto-commit changes to `.env`, `*.key`, `*.pem`, `**/secrets*` files. If a dirty repo's diff includes those paths, force "Skip" or "AskUserQuestion" — never WIP-commit.
- NEVER touch repos under `.claude/worktrees/` — those are dotforge's internal agent worktrees, not real project state.
- Always run `git fetch` BEFORE classifying. Without an up-to-date `@{u}`, behind/ahead counts are stale.