sync-all-repos · git:20260601.8072844 · 2026-06-01 · sha256 892e5fc1ed208997

sync-all-repos git:20260601.8072844A

Immutable. This exact content never changes and is served at /api/v1/blob/892e5fc1ed208997.

---
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.

```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
        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

# 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"
```

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

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 `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.