Sync Repos — bulk fetch + fast-forward across many local clones
Update a folder full of git clones in one pass: fetch each, fast-forward the
selected default or current branch when safe, then
report exactly what advanced, what was skipped, and what needs attention.
This is deliberately safe. It only ever runs git fetch and
git merge --ff-only / git pull --ff-only. It never force-pushes, resets,
checks out over dirty state, stashes, or discards anything. Repos with local
changes or diverged history are reported, not touched.
When to use
- "walk through the repos and pull latest" / "sync all repos from main"
- "update all my git repos" / "fetch everything under ~/git"
- Any request to refresh a directory of clones before starting work.
Do not use this to reconcile a fork with heavy local divergence, resolve
merge conflicts, or rebase — those need a per-repo interactive decision. Flag
such repos in the report and stop.
Inputs
- root (optional): directory to scan. Default order:
- An explicit path the user gave.
- The current working directory if it contains 2+ git clones.
~/git as a fallback.
If none of these clearly applies, ask the user for the root before scanning.
- scope (optional):
default-branch (default) or current-branch.
default-branch — update each repo's main/default branch (matches "sync
from main"). Does not switch away from a dirty or feature branch.
current-branch — fast-forward whatever branch is checked out (matches
"pull latest").
Procedure
Discover clones. A clone is root itself, or a direct child of root,
that contains a .git directory — nothing deeper is scanned. Linked
worktrees and submodules, where .git is a file, are skipped (see Edge
cases), and node_modules is pruned explicitly because a depth cap alone
still matches <root>/node_modules/.git. Pointing at a single clone
therefore syncs that clone; pointing at a folder of clones syncs each of
them. If root is itself a clone and holds nested clones, both levels are
in scope and every one of them appears in the report — nothing is updated
invisibly, and every update is still fast-forward-only.
Read local state and fetch only needed remotes. Read the current branch,
HEAD commit, and dirty flag (git status --porcelain). Confirm an origin
remote exists. Everything here resolves through origin, so check
git remote get-url origin: a clone whose remote is named upstream
would otherwise pass a bare "some remote exists" guard and then be
misreported as no default branch. Then git fetch --prune --quiet origin,
plus the current branch's tracking remote when needed for current-branch
scope or a dirty checkout's behind-count. Read branch.<current>.remote
directly: remote names can contain /, and . means a local upstream
branch, not a remote to fetch. A clean default-branch update does not need
the feature branch's remote.
Do not use fetch --all: it exits non-zero when any remote fails, so a
single unrelated broken remote would mark a perfectly healthy repo
error: fetch failed. Record error: no origin remote when origin is
missing, and error: fetch failed when a remote this run genuinely needs is
unreachable.
Resolve the server's default branch when needed, using
git ls-remote --symref origin HEAD after fetching. Cached origin/HEAD can
retain the old default after a rename; do not guess from main or master.
Record both its branch name and advertised HEAD commit for verification.
Missing symbolic HEAD means no default branch; a lookup failure or an
unfetched advertised branch is an explicit error. A clean current-branch
update does not depend on discovering a default branch.
Verify the selected commit before using it. Resolve the selected
tracking ref to a commit. For the default branch, compare it with the
advertised HEAD commit. For a configured remote upstream, compare it with
the exact branch.<current>.merge ref returned by
git ls-remote --exit-code <tracking-remote> <merge-ref>.
A local upstream (branch.<current>.remote = .) needs no remote check.
Preserve configured fetch refspecs: never override an exclusion or fetch
an excluded branch separately to make verification pass. A missing,
unmapped, stale, or unverifiable selected ref is error: <reason>, before
any update or behind-count. Use the verified commit ID for every later
count, ancestry check, and update, not a tracking ref that could move.
Fast-forward safely. Every result must be distinguishable — always capture
the branch tip before and after the operation and derive the result
from the difference. Never report success on exit code alone: merge --ff-only
and fetch <b>:<b> both exit 0 when nothing moved, so an exit-code-only check
cannot tell advanced from up-to-date.
After remote reads, require the checkout branch, HEAD, and porcelain state
to match the captured values before reporting checkout-dependent results.
Recheck that same condition immediately before any branch update; only
initially clean checkouts reach an update. A mismatch is
checkout changed (skipped) without retrying that repo.
- Dirty working tree → do not pull. Record
dirty (skipped), N behind,
counting with git rev-list --count <captured-HEAD>..<verified-commit> for its
upstream, or the default branch when no upstream is configured. Report a
verification/counting failure as an error rather than inventing a count.
scope=current-branch: if the current branch has no upstream, record
no upstream (skipped) — do not silently fall back to the default
branch. Otherwise fast-forward to the fetched upstream commit.
scope=default-branch, default branch is checked out:
fast-forward to the fetched origin/<default> commit.
scope=default-branch, default branch is not checked out: update it
without checkout by fetching the verified commit from the local
repository (git fetch . <verified-commit>:refs/heads/<default>).
This avoids a second network fetch racing the ancestry check. A branch
checked out in another worktree is in use by another worktree (skipped).
- Classify history before updating. Use
git merge-base --is-ancestor
in both directions. If the target already contains the upstream commit,
report up-to-date and retain local commits. Only two non-ancestor tips
mean diverged. An ancestry error, lock failure, permission failure, or
failed update is error: <reason>, with diagnostic stderr preserved —
never infer divergence from an operation's exit code alone.
Never run git reset, git checkout -f, git stash, git rebase, or
any push. Never pass --force.
Report (table + one-line summary). The full result vocabulary is:
advanced N commits · up-to-date · created local <default> ·
dirty (skipped), N behind · diverged (needs manual merge) ·
in use by another worktree (skipped) · detached (skipped) ·
no upstream (skipped) · checkout changed (skipped) ·
no default branch · error: <reason>.
When the default branch was updated while another branch is checked out,
suffix (on <current>).
| repo |
branch |
result |
| api-service |
main |
advanced 6 commits |
| web-client |
feature/x |
dirty (skipped), 2 behind |
| shared-lib |
main |
up-to-date |
| infra-tools |
main |
diverged (needs manual merge) |
| legacy-svc |
main |
error: fetch failed |
End with: N repos: X advanced, Y up-to-date, Z skipped, W diverged, V errors.
List the diverged/error/dirty repos again as an explicit "needs attention"
line so nothing important scrolls off.
Ready-to-run reference
Adapt the root as needed. This is read-only except for fetch + ff-only.
ROOT="${1:-$PWD}"; SCOPE="${2:-default-branch}"
# Never block on a credential prompt — one private repo must not hang the run.
export GIT_TERMINAL_PROMPT=0
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes}"
export LC_ALL=C
case "$SCOPE" in default-branch|current-branch) ;;
*) printf 'error: unsupported scope: %s\n' "$SCOPE" >&2; exit 2;; esac
r() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; }
error() {
r "$name" "$target" "error: $1"
[ -z "$2" ] || printf '%s: %s\n' "$name" "$2" >&2
}
checkout_unchanged() {
local branch head state
if branch="$(git -C "$repo" branch --show-current)"; then :;
else error "cannot revalidate checkout branch" ""; return 1; fi
if head="$(git -C "$repo" rev-parse --verify HEAD)"; then :;
else error "cannot revalidate checkout HEAD" ""; return 1; fi
if state="$(git -C "$repo" status --porcelain)"; then :;
else error "cannot revalidate working tree" ""; return 1; fi
if [ "$branch" != "$cur" ] || [ "$head" != "$initial_head" ] || [ "$state" != "$dirty" ]; then
r "$name" "$target" "checkout changed (skipped)"; return 1
fi
return 0
}
find "$ROOT" -maxdepth 2 -name node_modules -prune -o -name .git -type d -print | while IFS= read -r g; do
repo="$(dirname "$g")"; name="$(basename "$repo")"
cur="$(git -C "$repo" branch --show-current 2>/dev/null)"
[ -z "$cur" ] && { r "$name" "detached" "detached (skipped)"; continue; }
target="$cur"
git -C "$repo" remote get-url origin >/dev/null 2>&1 || { r "$name" "$cur" "error: no origin remote"; continue; }
if initial_head="$(git -C "$repo" rev-parse --verify HEAD)"; then :;
else error "cannot read checkout HEAD" ""; continue; fi
# Git diagnostics stay on stderr, never inside values parsed as data.
if dirty="$(git -C "$repo" status --porcelain)"; then :;
else error "cannot read working-tree state" ""; continue; fi
if err="$(git -C "$repo" fetch --prune --quiet origin 2>&1)"; then :;
else error "fetch failed (origin)" "$err"; continue; fi
upr="$(git -C "$repo" config --get "branch.$cur.remote")"
if { [ "$SCOPE" = current-branch ] || [ -n "$dirty" ]; } &&
[ -n "$upr" ] && [ "$upr" != origin ] && [ "$upr" != . ]; then
if err="$(git -C "$repo" fetch --prune --quiet "$upr" 2>&1)"; then :;
else error "fetch failed ($upr)" "$err"; continue; fi
fi
upstream="$(git -C "$repo" rev-parse --verify --quiet --symbolic-full-name '@{u}' 2>/dev/null)"
merge_ref="$(git -C "$repo" config --get "branch.$cur.merge")"
if { [ "$SCOPE" = current-branch ] || [ -n "$dirty" ]; } &&
[ -z "$upstream" ] && { [ -n "$upr" ] || [ -n "$merge_ref" ]; }; then
error "configured upstream ref unavailable (check fetch filters)" ""; continue
fi
ref="$upstream"; remote="$upr"; expected=""
if { [ "$SCOPE" = default-branch ] && [ -z "$dirty" ]; } ||
{ [ -n "$dirty" ] && [ -z "$upstream" ]; }; then
if advertised="$(git -C "$repo" ls-remote --symref origin HEAD)"; then :;
else error "default branch lookup failed" ""; continue; fi
def="$(printf '%s\n' "$advertised" | sed -n 's#^ref: refs/heads/\([^[:space:]]*\)[[:space:]]HEAD$#\1#p')"
[ -n "$def" ] || { r "$name" "$cur" "no default branch"; continue; }
ref="refs/remotes/origin/$def"; remote=origin
expected="$(printf '%s\n' "$advertised" | awk '$2 == "HEAD" {print $1}')"
[ -n "$dirty" ] || target="$def"
else
[ -n "$ref" ] || { r "$name" "$cur" "no upstream (skipped)"; continue; }
if [ "$remote" != . ]; then
[ -n "$remote" ] && [ -n "$merge_ref" ] ||
{ error "cannot identify configured upstream" ""; continue; }
if advertised="$(git -C "$repo" ls-remote --exit-code "$remote" "$merge_ref")"; then :;
else error "upstream lookup failed ($remote $merge_ref)" ""; continue; fi
expected="$(printf '%s\n' "$advertised" | awk -v ref="$merge_ref" '$2 == ref {print $1}')"
fi
fi
if desired="$(git -C "$repo" rev-parse --verify "$ref^{commit}")"; then :;
else error "selected ref unavailable ($ref); check fetch filters" ""; continue; fi
if [ "$remote" != . ]; then
[ -n "$expected" ] || { error "selected remote commit was not advertised" ""; continue; }
[ "$desired" = "$expected" ] ||
{ error "stale tracking ref ($ref); check fetch filters or retry" ""; continue; }
fi
checkout_unchanged || continue
if [ -n "$dirty" ]; then
if behind="$(git -C "$repo" rev-list --count "$initial_head..$desired")"; then
r "$name" "$cur" "dirty (skipped), $behind behind"
else error "cannot count commits behind" ""; fi
continue
fi
suffix=""; [ "$target" = "$cur" ] || suffix=" (on $cur)"
if [ "$target" = "$cur" ]; then before="$initial_head"
else before="$(git -C "$repo" rev-parse --verify --quiet "refs/heads/$target")"; fi
if [ -n "$before" ]; then
if git -C "$repo" merge-base --is-ancestor "$desired" "$before"; then
r "$name" "$target" "up-to-date$suffix"; continue
else
rc=$?; [ "$rc" -eq 1 ] || { error "cannot compare history" ""; continue; }
fi
if git -C "$repo" merge-base --is-ancestor "$before" "$desired"; then :;
else
rc=$?
if [ "$rc" -eq 1 ]; then r "$name" "$target" "diverged (needs manual merge)"
else error "cannot compare history" ""; fi
continue
fi
fi
checkout_unchanged || continue
if [ "$target" = "$cur" ]; then
if err="$(git -C "$repo" merge --ff-only "$desired" --quiet 2>&1)"; then :;
else error "merge failed" "$err"; continue; fi
else
if err="$(git -C "$repo" fetch --quiet . "$desired:refs/heads/$target" 2>&1)"; then :;
else
case "$err" in
*"checked out at"*|*"current branch"*) r "$name" "$target" "in use by another worktree (skipped)";;
*) error "local branch update failed" "$err";;
esac
continue
fi
fi
if after="$(git -C "$repo" rev-parse --verify "refs/heads/$target")"; then :;
else error "cannot read updated branch" ""; continue; fi
if [ -z "$before" ]; then r "$name" "$target" "created local $target$suffix"
elif [ "$before" = "$after" ]; then r "$name" "$target" "up-to-date$suffix"
elif count="$(git -C "$repo" rev-list --count "$before..$after")"; then
r "$name" "$target" "advanced $count commits$suffix"
else error "cannot count advanced commits" ""; fi
done
Prefer running the loop and then presenting a clean table to the user rather
than dumping raw tab output. If there are many repos, run the fetches and
summarize; do not narrate each repo.
Edge cases
- Worktrees / submodules: discovery matches only a
.git directory, so
linked worktrees and submodules (where .git is a file) are skipped by
construction. Do not auto-update submodules unless asked.
- Branch checked out elsewhere: a needed local ref update refuses when the
branch is checked out in another worktree. Report
in use by another worktree (skipped), not diverged. If the branch already
contains the upstream tip, no update is needed and it is up-to-date.
- No
origin remote: everything here resolves through origin, so a repo
whose only remote is named something else (upstream on a fork, a renamed
remote) must be reported, not silently misread as no default branch. Guard
with git remote get-url origin. Report error: no origin remote.
- Several remotes: fetch only
origin and, when needed for a current-branch
merge or dirty behind-count, the configured tracking remote. . is local;
names containing / are used intact. fetch --all couples success to remotes it never reads —
one broken remote fails the whole fetch and the repo is reported as
error: fetch failed while origin is perfectly healthy.
- Stale/missing
origin/HEAD: use the server's advertised symbolic HEAD,
not the cached alias. Missing server HEAD is no default branch; a network
failure is an error. Current-branch scope needs neither when clean.
- Ahead-only history: preserve the extra local commits and report
up-to-date — nothing from the upstream is missing.
- Locks or permissions: report the operational error and its diagnostic.
Do not remove lock files or suggest a manual merge for non-divergent history.
- Detached HEAD: report
detached (skipped), never fast-forward.
- No upstream (current-branch scope): report
no upstream (skipped) rather
than quietly switching to the default branch — a silent scope change is worse
than a skip.
- Auth prompts: if a fetch would block on credentials, record it as an error
and move on — never hang the whole run on one repo.
- Large trees: cap discovery depth at 2; if the user points at a huge root,
confirm scope before scanning thousands of directories.
Stop condition
Done when every discovered repo has a recorded result and the summary line is
produced. Anything requiring a human decision (diverged, dirty with important
changes, auth error) is surfaced in the "needs attention" line — do not attempt
to resolve it automatically.
1---2name: sync-repos3description: Use when the user wants to update or sync many local git repositories at once — "pull latest for all repos", "sync all repos from main", "walk through the repos and pull", "update all my git repos", "fetch all clones", or bulk fast-forward across a folder of git checkouts. Non-destructive, fast-forward-only; never force/reset/stash/push.4---56# Sync Repos — bulk fetch + fast-forward across many local clones78Update a folder full of git clones in one pass: fetch each, fast-forward the9selected default or current branch when safe, then10report exactly what advanced, what was skipped, and what needs attention.1112This is deliberately **safe**. It only ever runs `git fetch` and13`git merge --ff-only` / `git pull --ff-only`. It never force-pushes, resets,14checks out over dirty state, stashes, or discards anything. Repos with local15changes or diverged history are reported, not touched.1617## When to use1819- "walk through the repos and pull latest" / "sync all repos from main"20- "update all my git repos" / "fetch everything under ~/git"21- Any request to refresh a directory of clones before starting work.2223Do **not** use this to reconcile a fork with heavy local divergence, resolve24merge conflicts, or rebase — those need a per-repo interactive decision. Flag25such repos in the report and stop.2627## Inputs2829- **root** (optional): directory to scan. Default order:30 1. An explicit path the user gave.31 2. The current working directory if it contains 2+ git clones.32 3. `~/git` as a fallback.33 If none of these clearly applies, ask the user for the root before scanning.34- **scope** (optional): `default-branch` (default) or `current-branch`.35 - `default-branch` — update each repo's main/default branch (matches "sync36 from main"). Does not switch away from a dirty or feature branch.37 - `current-branch` — fast-forward whatever branch is checked out (matches38 "pull latest").3940## Procedure41421. **Discover clones.** A clone is `root` itself, or a direct child of `root`,43 that contains a `.git` **directory** — nothing deeper is scanned. Linked44 worktrees and submodules, where `.git` is a file, are skipped (see Edge45 cases), and `node_modules` is pruned explicitly because a depth cap alone46 still matches `<root>/node_modules/.git`. Pointing at a single clone47 therefore syncs that clone; pointing at a folder of clones syncs each of48 them. If `root` is itself a clone *and* holds nested clones, both levels are49 in scope and every one of them appears in the report — nothing is updated50 invisibly, and every update is still fast-forward-only.512. **Read local state and fetch only needed remotes.** Read the current branch,52 HEAD commit, and dirty flag (`git status --porcelain`). Confirm an `origin`53 remote exists. Everything here resolves through `origin`, so check54 `git remote get-url origin`: a clone whose remote is named `upstream`55 would otherwise pass a bare "some remote exists" guard and then be56 misreported as `no default branch`. Then `git fetch --prune --quiet origin`,57 plus the current branch's tracking remote when needed for `current-branch`58 scope or a dirty checkout's behind-count. Read `branch.<current>.remote`59 directly: remote names can contain `/`, and `.` means a local upstream60 branch, not a remote to fetch. A clean `default-branch` update does not need61 the feature branch's remote.62 Do **not** use `fetch --all`: it exits non-zero when *any* remote fails, so a63 single unrelated broken remote would mark a perfectly healthy repo64 `error: fetch failed`. Record `error: no origin remote` when `origin` is65 missing, and `error: fetch failed` when a remote this run genuinely needs is66 unreachable.673. **Resolve the server's default branch when needed**, using68 `git ls-remote --symref origin HEAD` after fetching. Cached `origin/HEAD` can69 retain the old default after a rename; do not guess from `main` or `master`.70 Record both its branch name and advertised HEAD commit for verification.71 Missing symbolic HEAD means `no default branch`; a lookup failure or an72 unfetched advertised branch is an explicit error. A clean `current-branch`73 update does not depend on discovering a default branch.744. **Verify the selected commit before using it.** Resolve the selected75 tracking ref to a commit. For the default branch, compare it with the76 advertised HEAD commit. For a configured remote upstream, compare it with77 the exact `branch.<current>.merge` ref returned by78 `git ls-remote --exit-code <tracking-remote> <merge-ref>`.79 A local upstream (`branch.<current>.remote = .`) needs no remote check.80 Preserve configured fetch refspecs: never override an exclusion or fetch81 an excluded branch separately to make verification pass. A missing,82 unmapped, stale, or unverifiable selected ref is `error: <reason>`, before83 any update or behind-count. Use the verified commit ID for every later84 count, ancestry check, and update, not a tracking ref that could move.855. **Fast-forward safely.** Every result must be distinguishable — always capture86 the branch tip **before** and **after** the operation and derive the result87 from the difference. Never report success on exit code alone: `merge --ff-only`88 and `fetch <b>:<b>` both exit 0 when nothing moved, so an exit-code-only check89 cannot tell `advanced` from `up-to-date`.90 After remote reads, require the checkout branch, HEAD, and porcelain state91 to match the captured values before reporting checkout-dependent results.92 Recheck that same condition immediately before any branch update; only93 initially clean checkouts reach an update. A mismatch is94 `checkout changed (skipped)` without retrying that repo.95 - **Dirty** working tree → do not pull. Record `dirty (skipped), N behind`,96 counting with `git rev-list --count <captured-HEAD>..<verified-commit>` for its97 upstream, or the default branch when no upstream is configured. Report a98 verification/counting failure as an error rather than inventing a count.99 - `scope=current-branch`: if the current branch has no upstream, record100 `no upstream (skipped)` — do **not** silently fall back to the default101 branch. Otherwise fast-forward to the fetched upstream commit.102 - `scope=default-branch`, default branch **is** checked out:103 fast-forward to the fetched `origin/<default>` commit.104 - `scope=default-branch`, default branch **is not** checked out: update it105 without checkout by fetching the verified commit from the local106 repository (`git fetch . <verified-commit>:refs/heads/<default>`).107 This avoids a second network fetch racing the ancestry check. A branch108 checked out in another worktree is `in use by another worktree (skipped)`.109 - **Classify history before updating.** Use `git merge-base --is-ancestor`110 in both directions. If the target already contains the upstream commit,111 report `up-to-date` and retain local commits. Only two non-ancestor tips112 mean `diverged`. An ancestry error, lock failure, permission failure, or113 failed update is `error: <reason>`, with diagnostic stderr preserved —114 never infer divergence from an operation's exit code alone.1156. **Never** run `git reset`, `git checkout -f`, `git stash`, `git rebase`, or116 any push. Never pass `--force`.1177. **Report** (table + one-line summary). The full result vocabulary is:118 `advanced N commits` · `up-to-date` · `created local <default>` ·119 `dirty (skipped), N behind` · `diverged (needs manual merge)` ·120 `in use by another worktree (skipped)` · `detached (skipped)` ·121 `no upstream (skipped)` · `checkout changed (skipped)` ·122 `no default branch` · `error: <reason>`.123 When the default branch was updated while another branch is checked out,124 suffix `(on <current>)`.125126 | repo | branch | result |127 |------|--------|--------|128 | api-service | main | advanced 6 commits |129 | web-client | feature/x | dirty (skipped), 2 behind |130 | shared-lib | main | up-to-date |131 | infra-tools | main | diverged (needs manual merge) |132 | legacy-svc | main | error: fetch failed |133134 End with: `N repos: X advanced, Y up-to-date, Z skipped, W diverged, V errors.`135 List the diverged/error/dirty repos again as an explicit "needs attention"136 line so nothing important scrolls off.137138## Ready-to-run reference139140Adapt the root as needed. This is read-only except for fetch + ff-only.141142```bash143ROOT="${1:-$PWD}"; SCOPE="${2:-default-branch}"144# Never block on a credential prompt — one private repo must not hang the run.145export GIT_TERMINAL_PROMPT=0146export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes}"147export LC_ALL=C148case "$SCOPE" in default-branch|current-branch) ;;149 *) printf 'error: unsupported scope: %s\n' "$SCOPE" >&2; exit 2;; esac150r() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; }151error() {152 r "$name" "$target" "error: $1"153 [ -z "$2" ] || printf '%s: %s\n' "$name" "$2" >&2154}155checkout_unchanged() {156 local branch head state157 if branch="$(git -C "$repo" branch --show-current)"; then :;158 else error "cannot revalidate checkout branch" ""; return 1; fi159 if head="$(git -C "$repo" rev-parse --verify HEAD)"; then :;160 else error "cannot revalidate checkout HEAD" ""; return 1; fi161 if state="$(git -C "$repo" status --porcelain)"; then :;162 else error "cannot revalidate working tree" ""; return 1; fi163 if [ "$branch" != "$cur" ] || [ "$head" != "$initial_head" ] || [ "$state" != "$dirty" ]; then164 r "$name" "$target" "checkout changed (skipped)"; return 1165 fi166 return 0167}168find "$ROOT" -maxdepth 2 -name node_modules -prune -o -name .git -type d -print | while IFS= read -r g; do169 repo="$(dirname "$g")"; name="$(basename "$repo")"170 cur="$(git -C "$repo" branch --show-current 2>/dev/null)"171 [ -z "$cur" ] && { r "$name" "detached" "detached (skipped)"; continue; }172 target="$cur"173 git -C "$repo" remote get-url origin >/dev/null 2>&1 || { r "$name" "$cur" "error: no origin remote"; continue; }174 if initial_head="$(git -C "$repo" rev-parse --verify HEAD)"; then :;175 else error "cannot read checkout HEAD" ""; continue; fi176 # Git diagnostics stay on stderr, never inside values parsed as data.177 if dirty="$(git -C "$repo" status --porcelain)"; then :;178 else error "cannot read working-tree state" ""; continue; fi179 if err="$(git -C "$repo" fetch --prune --quiet origin 2>&1)"; then :;180 else error "fetch failed (origin)" "$err"; continue; fi181 upr="$(git -C "$repo" config --get "branch.$cur.remote")"182 if { [ "$SCOPE" = current-branch ] || [ -n "$dirty" ]; } &&183 [ -n "$upr" ] && [ "$upr" != origin ] && [ "$upr" != . ]; then184 if err="$(git -C "$repo" fetch --prune --quiet "$upr" 2>&1)"; then :;185 else error "fetch failed ($upr)" "$err"; continue; fi186 fi187 upstream="$(git -C "$repo" rev-parse --verify --quiet --symbolic-full-name '@{u}' 2>/dev/null)"188 merge_ref="$(git -C "$repo" config --get "branch.$cur.merge")"189 if { [ "$SCOPE" = current-branch ] || [ -n "$dirty" ]; } &&190 [ -z "$upstream" ] && { [ -n "$upr" ] || [ -n "$merge_ref" ]; }; then191 error "configured upstream ref unavailable (check fetch filters)" ""; continue192 fi193 ref="$upstream"; remote="$upr"; expected=""194 if { [ "$SCOPE" = default-branch ] && [ -z "$dirty" ]; } ||195 { [ -n "$dirty" ] && [ -z "$upstream" ]; }; then196 if advertised="$(git -C "$repo" ls-remote --symref origin HEAD)"; then :;197 else error "default branch lookup failed" ""; continue; fi198 def="$(printf '%s\n' "$advertised" | sed -n 's#^ref: refs/heads/\([^[:space:]]*\)[[:space:]]HEAD$#\1#p')"199 [ -n "$def" ] || { r "$name" "$cur" "no default branch"; continue; }200 ref="refs/remotes/origin/$def"; remote=origin201 expected="$(printf '%s\n' "$advertised" | awk '$2 == "HEAD" {print $1}')"202 [ -n "$dirty" ] || target="$def"203 else204 [ -n "$ref" ] || { r "$name" "$cur" "no upstream (skipped)"; continue; }205 if [ "$remote" != . ]; then206 [ -n "$remote" ] && [ -n "$merge_ref" ] ||207 { error "cannot identify configured upstream" ""; continue; }208 if advertised="$(git -C "$repo" ls-remote --exit-code "$remote" "$merge_ref")"; then :;209 else error "upstream lookup failed ($remote $merge_ref)" ""; continue; fi210 expected="$(printf '%s\n' "$advertised" | awk -v ref="$merge_ref" '$2 == ref {print $1}')"211 fi212 fi213 if desired="$(git -C "$repo" rev-parse --verify "$ref^{commit}")"; then :;214 else error "selected ref unavailable ($ref); check fetch filters" ""; continue; fi215 if [ "$remote" != . ]; then216 [ -n "$expected" ] || { error "selected remote commit was not advertised" ""; continue; }217 [ "$desired" = "$expected" ] ||218 { error "stale tracking ref ($ref); check fetch filters or retry" ""; continue; }219 fi220 checkout_unchanged || continue221 if [ -n "$dirty" ]; then222 if behind="$(git -C "$repo" rev-list --count "$initial_head..$desired")"; then223 r "$name" "$cur" "dirty (skipped), $behind behind"224 else error "cannot count commits behind" ""; fi225 continue226 fi227 suffix=""; [ "$target" = "$cur" ] || suffix=" (on $cur)"228 if [ "$target" = "$cur" ]; then before="$initial_head"229 else before="$(git -C "$repo" rev-parse --verify --quiet "refs/heads/$target")"; fi230 if [ -n "$before" ]; then231 if git -C "$repo" merge-base --is-ancestor "$desired" "$before"; then232 r "$name" "$target" "up-to-date$suffix"; continue233 else234 rc=$?; [ "$rc" -eq 1 ] || { error "cannot compare history" ""; continue; }235 fi236 if git -C "$repo" merge-base --is-ancestor "$before" "$desired"; then :;237 else238 rc=$?239 if [ "$rc" -eq 1 ]; then r "$name" "$target" "diverged (needs manual merge)"240 else error "cannot compare history" ""; fi241 continue242 fi243 fi244 checkout_unchanged || continue245 if [ "$target" = "$cur" ]; then246 if err="$(git -C "$repo" merge --ff-only "$desired" --quiet 2>&1)"; then :;247 else error "merge failed" "$err"; continue; fi248 else249 if err="$(git -C "$repo" fetch --quiet . "$desired:refs/heads/$target" 2>&1)"; then :;250 else251 case "$err" in252 *"checked out at"*|*"current branch"*) r "$name" "$target" "in use by another worktree (skipped)";;253 *) error "local branch update failed" "$err";;254 esac255 continue256 fi257 fi258 if after="$(git -C "$repo" rev-parse --verify "refs/heads/$target")"; then :;259 else error "cannot read updated branch" ""; continue; fi260 if [ -z "$before" ]; then r "$name" "$target" "created local $target$suffix"261 elif [ "$before" = "$after" ]; then r "$name" "$target" "up-to-date$suffix"262 elif count="$(git -C "$repo" rev-list --count "$before..$after")"; then263 r "$name" "$target" "advanced $count commits$suffix"264 else error "cannot count advanced commits" ""; fi265done266```267268Prefer running the loop and then presenting a clean table to the user rather269than dumping raw tab output. If there are many repos, run the fetches and270summarize; do not narrate each repo.271272## Edge cases273274- **Worktrees / submodules:** discovery matches only a `.git` **directory**, so275 linked worktrees and submodules (where `.git` is a file) are skipped by276 construction. Do not auto-update submodules unless asked.277- **Branch checked out elsewhere:** a needed local ref update refuses when the278 branch is checked out in another worktree. Report279 `in use by another worktree (skipped)`, not `diverged`. If the branch already280 contains the upstream tip, no update is needed and it is `up-to-date`.281- **No `origin` remote:** everything here resolves through `origin`, so a repo282 whose only remote is named something else (`upstream` on a fork, a renamed283 remote) must be reported, not silently misread as `no default branch`. Guard284 with `git remote get-url origin`. Report `error: no origin remote`.285- **Several remotes:** fetch only `origin` and, when needed for a current-branch286 merge or dirty behind-count, the configured tracking remote. `.` is local;287 names containing `/` are used intact. `fetch --all` couples success to remotes it never reads —288 one broken remote fails the whole fetch and the repo is reported as289 `error: fetch failed` while `origin` is perfectly healthy.290- **Stale/missing `origin/HEAD`:** use the server's advertised symbolic HEAD,291 not the cached alias. Missing server HEAD is `no default branch`; a network292 failure is an error. Current-branch scope needs neither when clean.293- **Ahead-only history:** preserve the extra local commits and report294 `up-to-date` — nothing from the upstream is missing.295- **Locks or permissions:** report the operational error and its diagnostic.296 Do not remove lock files or suggest a manual merge for non-divergent history.297- **Detached HEAD:** report `detached (skipped)`, never fast-forward.298- **No upstream (current-branch scope):** report `no upstream (skipped)` rather299 than quietly switching to the default branch — a silent scope change is worse300 than a skip.301- **Auth prompts:** if a fetch would block on credentials, record it as an error302 and move on — never hang the whole run on one repo.303- **Large trees:** cap discovery depth at 2; if the user points at a huge root,304 confirm scope before scanning thousands of directories.305306## Stop condition307308Done when every discovered repo has a recorded result and the summary line is309produced. Anything requiring a human decision (diverged, dirty with important310changes, auth error) is surfaced in the "needs attention" line — do not attempt311to resolve it automatically.