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.
Scanned 9/1/2026
Install to Claude Code
npx -y skills add luiseiman/dotforge --skill sync-all-repos --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Sync All Repos?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/luiseiman-sync-all-repos)More formats (shields.io, HTML) on the badges page.
---
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 (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`):
```bash
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
```
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):
```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 (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:
```bash
_smart_push() {
local repo=$1
# First attempt
if ${T} 60 git -C "$repo" push --quiet 2>/dev/null; then return 0; fi
# Retry once (transient network failure)
sleep 1
if ${T} 60 git -C "$repo" push --quiet 2>/dev/null; then return 0; fi
# Still failing — likely non-fast-forward. Try pull-rebase, push.
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
```bash
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
# 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"
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:
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 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/`:
```bash
# Right — pathspec exclusion preserves the intent of "everything .claude except worktrees"
git -C "$repo" add ".claude/" ":!.claude/worktrees/"
# Alternative — stage modified, then add untracked excluding 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 --
# Wrong — adds worktrees as embedded git refs
git -C "$repo" add .claude/ # ← causes "warning: adding embedded git repository"
git -C "$repo" add -A # ← same
```
Verify before committing:
```bash
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: <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.
- **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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!