| 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:
REMOTE=$(git -C "$dir" config --get remote.origin.url 2>/dev/null)
case "$REMOTE" in
*github.com*) ;;
*) continue ;;
esac
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 (with timeout + 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):
if command -v gtimeout >/dev/null 2>&1; then T=gtimeout
elif command -v timeout >/dev/null 2>&1; then T=timeout
else
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
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)
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):
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.
OUTDIR=$(mktemp -d)
printf '%s\0' "${REPOS[@]}" | \
xargs -0 -n 1 -P 8 -I{} bash -c 'classify_one "$1" "$2"' _ {} "$OUTDIR"
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 (also parallel + timed)
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.
Push retry helper (transient failures + non-FF recovery)
Lessons from the first real run: (a) ~5% of pushes fail transiently (network blip, slow remote auth) and succeed on the next attempt; (b) between classify and action, another machine may have pushed — so an ahead state's push fails with non-fast-forward. Both are recoverable in-line:
_smart_push() {
local repo=$1
if ${T} 60 git -C "$repo" push --quiet 2>/dev/null; then return 0; fi
sleep 1
if ${T} 60 git -C "$repo" push --quiet 2>/dev/null; then return 0; fi
if ${T} 60 git -C "$repo" pull --rebase --quiet 2>/dev/null; then
${T} 60 git -C "$repo" push --quiet 2>/dev/null && return 0
else
git -C "$repo" rebase --abort 2>/dev/null
fi
return 1
}
export -f _smart_push
Action runner
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
git -C "$repo" rebase --abort 2>/dev/null
echo "$repo|pull-conflict" > "$out"
fi ;;
ahead)
if _smart_push "$repo"; 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 _smart_push "$repo"; 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
ACTION_LIST=$(echo "$RESULTS" | awk -F'|' '$2 ~ /^(behind|ahead|diverged)$/ {print $1"|"$2}')
ACTOUT=$(mktemp -d)
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"
ACTIONS=$(cat "$ACTOUT"/*.out 2>/dev/null | sort)
rm -rf "$ACTOUT"
_smart_push makes up to 3 attempts total (push, push, pull-rebase+push). If all three fail, the repo lands in push-failed / pull-ok-push-failed for manual investigation. This handles the two common in-the-wild causes: transient network and concurrent push from another machine.
Step 4: Build structured report
Two-section output: a one-line discovery summary + the per-state breakdown with aligned columns.
Header — one-line discovery summary
═══ SYNC-ALL: <N> repos discovered (<M> GitHub-backed, <K> ignored, <S> skipped non-github) ═══
Body — categorical breakdown
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.
STATE REPOS DETAIL
───── ───── ──────
✓ Pulled (N)
<repo> was behind by N
<repo> was behind by M
✓ Pushed (M)
<repo> was ahead by N
✓ Reconciled (K)
<repo> pulled N, pushed M
= In-sync (J)
<repo>, <repo>, <repo> (compact list)
⚠ Dirty — DEFERRED to Claude (X)
<repo> [<branch>] <D> files; <B>↓/<A>↑
last: <hash> <subject>
diffstat:
<file1>: +X -Y
<file2>: +X -Y
(… N more)
⚠ 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:
- Run
git -C <repo> diff to see what's changed
- Run
git -C <repo> log -5 --oneline to see recent commits for context
- 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 stage with worktree exclusion (see below), 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.
CRITICAL — Staging exclusions for dotforge-managed repos
Many managed projects have .claude/worktrees/<agent-worktree>/ directories. These are dotforge's internal agent worktrees: each is its own .git (a submodule reference from the outer repo's perspective). If you git add .claude/ or git add -A, git silently adds them as embedded git references, polluting the commit history with submodule pointers that have no meaning.
Always stage with explicit exclusion when targeting .claude/:
git -C "$repo" add ".claude/" ":!.claude/worktrees/"
git -C "$repo" diff --name-only --diff-filter=M | xargs -d '\n' git -C "$repo" add --
git -C "$repo" ls-files --others --exclude-standard | grep '^\.claude/' | grep -v '^\.claude/worktrees/' | xargs -d '\n' git -C "$repo" add --
git -C "$repo" add .claude/
git -C "$repo" add -A
Verify before committing:
git -C "$repo" diff --cached --name-only | grep '\.claude/worktrees/' && {
echo "ERROR: worktree accidentally staged — abort"
git -C "$repo" reset --quiet
return 1
}
Other paths to never auto-stage even when present in .claude/:
.claude/session/ — ephemeral session state (last-compact.md, last-startup.md, startup-history/)
.claude/.forge/runtime/ — v3 behavior state file (per-machine, gitignored normally but sometimes leaks)
Same pattern: pass them as :!.claude/session/ and :!.claude/.forge/runtime/ exclusions.
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: on , on ...
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. 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.
- Stuck Claude-managed worktrees block branch checkout: post-v2.1.x changelog (verify exact version), Claude-managed worktrees auto-unlock on agent finish. Pre-fix, they kept the git lock and broke
git checkout main with 'main' is already used by worktree at .... Detection during sync: for any repo, run git -C "$repo" worktree list --porcelain | awk '/^worktree/ {print $2}' | grep "$repo/.claude/worktrees/" to find Claude-managed worktrees still registered. If a worktree's branch has no unique commits vs origin AND no uncommitted work, propose cleanup: git worktree remove <path> + git branch -D <branch> + git worktree prune. Lived 2026-06-01 — TRADINGBOT's claude/festive-maxwell-a70698 and heuristic-swartz-65163d blocked main checkout mid-sync.
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.