#!/usr/bin/env bash# Bring the local repository in sync with origin/master.## Every failure is REPORTED, not fatal: this script returns a non-zero code and# leaves the decision to its caller — `git-sync` (packages/solver/solver/utils/scripts.py), which# gates the enc-key pull flow on it, and scripts/setup/user.sh, which treats a# refused sync as a note and carries on provisioning. `set -e` could not serve# either: it exited on the first failed git command, so the caller learned only# "something failed", and — worse — the stash restore below never ran.set-uopipefaildeclaredry_run=0eval_with_dry_run(){# eval_with_dry_run — Execute a command, or print it if dry_run=1.## Usage: eval_with_dry_run <command> [arg ...]# Pass the command and its arguments as separate words, exactly as you# would invoke the command directly. In dry-run mode the expanded words# are printed to stdout with a '[dry-run]' prefix instead of being executed.## Returns:# In normal mode: the exit code of the evaluated command# In dry-run mode: 0 alwayslocalrcif[["${dry_run}"-eq0]];then"$@"rc=$?echo"$* -> ${rc}"return${rc}elseecho"[dry-run] $*"fi}verb_in_progress(){# verb_in_progress — True when a `git <verb>` stopped part-way and still owns the tree.## Usage: verb_in_progress <verb># Reads the state files git itself writes, so it answers for a merge/rebase this# script started OR one it inherited. An atomic failure (a refused --ff-only, say)# leaves none of them and is correctly reported as 'not in progress' — nothing to# roll back, and no spurious `--abort` to fail noisily.localverb="$1"gitdirgitdir=$(gitrev-parse--git-dir2>/dev/null)||return1case"${verb}"inrebase)[[-d"${gitdir}/rebase-merge"||-d"${gitdir}/rebase-apply"]];;merge)[[-f"${gitdir}/MERGE_HEAD"]];;*)return1;;esac}sync_onto_master(){# sync_onto_master — Run `git <verb> [arg ...]`, stashing a dirty tree around it.## Usage: sync_onto_master <has_changes> <verb> [arg ...]# verb is the git subcommand ('merge', 'rebase') — named, not baked into the# command words, because the rollback below has to invoke `git <verb> --abort`.# has_changes only decides whether to ATTEMPT a stash; whether one exists to pop# afterwards is read back from refs/stash, never assumed from it.## NEVER leaves the repository half-synced. A merge/rebase that fails usually stops# MID-WAY, on a conflicted index, so any failure is rolled back with `--abort`: the# tree ends up as it was found, always, whether or not it was dirty. This is a# deliberate trade against git's interactive habit of leaving a conflicted rebase in# progress to resolve — nothing here is interactive. `git-sync` is one step of a# shell command, and scripts/setup/user.sh runs it unattended while provisioning,# where the vault is locked and the smudge filter makes failure the expected case.# A caller that must resolve conflicts by hand can still drive git directly.## The rollback also has to come FIRST, before the stash is restored: `git stash pop`# refuses to write over unmerged entries, so popping onto a conflicted index fails# and strands the changes in a stash nothing told the user about.## Returns:# The exit code of the sync command — the pop's code only when the pop is the# thing that failed, since a completed sync whose stash will not pop is still a# failure the caller must hear about.localhas_changes="$1"verb="$2";shift2localrcstashed=0stash_beforestash_ours=''stash_before=$(gitrev-parse-q--verifyrefs/stash)if[[${has_changes}-eq1]];theneval_with_dry_rungitstashpush-m"refresh: stash before ${verb}"||return$?# What was stashed, not what looked dirty: `git stash push` exits 0 whether or# not it created an entry ('No local changes to save'), so popping on the# strength of has_changes alone failed a sync that had already succeeded.stash_ours=$(gitrev-parse-q--verifyrefs/stash)[["${stash_ours}"!="${stash_before}"]]&&stashed=1fieval_with_dry_rungit"${verb}""$@"rc=$?if[[${rc}-ne0]]&&verb_in_progress"${verb}";thenecho"Error: 'git ${verb}$*' stopped part-way (${rc}) — rolling it back.">&2if!eval_with_dry_rungit"${verb}"--abort;thenecho"Error: 'git ${verb} --abort' FAILED — the repository is still mid-${verb}.">&2echo" Resolve it by hand: 'git ${verb} --abort', then 'git stash pop' if you had changes.">&2return${rc}fifiif[[${stashed}-eq1]];thenif!eval_with_dry_rungitstashpop;then[[${rc}-eq0]]&&rc=1# A pop that conflicts does not fail atomically: it stages what applied cleanly,# marks the rest unmerged (UU), and KEEPS the stash entry. Left there, the clone is# wedged — HEAD is level with origin, so every later sync reports "nothing to do"# over a conflicted index, and the next stash it takes re-captures that half-state.# Everything the pop touched is still in the stash, and before the pop the tracked# tree was exactly HEAD, so resetting to HEAD loses nothing — but only while the# stash entry is still the one this run pushed, which is checked, not assumed.if[["$(gitrev-parse-q--verifyrefs/stash)"=="${stash_ours}"]]&&eval_with_dry_rungitreset--hard-qHEAD;thenecho"Error: synced, but your uncommitted changes conflict with the incoming work.">&2echo" The tree is left clean at HEAD; your changes are safe in stash@{0}.">&2echo" Inspect with 'git stash show -p', re-apply with 'git stash pop' and resolve">&2echo" the conflicts by hand — or discard them with 'git stash drop'.">&2elseecho"Error: your changes are safe but still STASHED, and the tree may be part-applied.">&2echo" Recover them with 'git stash pop' after resolving by hand — 'git-reset --hard'">&2echo" returns to origin/master but DISCARDS the stash along with everything else.">&2fififireturn${rc}}main(){# main — Fetch origin/master and tags, prune dead remote branches, and bring local in sync.## State detection:# up_to_date — local HEAD == origin/master# local_ahead — local has commits not on origin/master (publish first)# local_behind — origin/master has commits not in local HEAD# diverged — both sides have commits the other does not## Sync strategy by state:# up_to_date — nothing to do# local_ahead — warn; no auto-sync (use 'solver git-push')# local_behind — git merge --ff-only origin/master# (stash/unstash uncommitted changes if present)# diverged — git rebase origin/master# (stash/unstash uncommitted changes if present)## Returns:# 0 when the repository is in sync, or was deliberately left alone# (up_to_date, local_ahead — declining to sync is not a failure)# 1 when the state could not be determined, the clone is mid-merge (unmerged# paths, or a merge/rebase in progress), or the sync itself failedlocalaheadbehindhas_changesstateprune_outunmergedlocal-aunmerged_paths# Fetch master. This one must succeed — it is the sync.if!gitfetchoriginmaster1>/dev/null2>&1;thenecho"Error: could not fetch origin/master.">&2return1fi# Then the tags, SEPARATELY and best-effort. The version mechanism reads release tags# — the `version` command's `git describe` and release.sh's last-tag anchor — but a# normal fetch only auto-follows tags pointing at objects downloaded this time, so a# clone that already has the tagged commit never picks up its `vX.Y.Z` tag.## `--force --prune-tags`: origin is the source of truth for release tags, so the clone# mirrors it. This used to be a plain `--tags` on the fetch above, on the reasoning# that release tags are immutable — until a history rewrite moved one. git then# refuses to clobber the local tag, `fetch` exits non-zero, and the whole sync failed# with it: every clone stuck, permanently, on a tag. For a reader that is terminal —# `git-sync` is the only git verb they have. A tag is bookkeeping; it must never be# able to fail the sync, so a failure here is a note, exactly like the prune below.if!gitfetch--tags--force--prune-tagsorigin1>/dev/null2>&1;thenecho"Note: could not refresh tags (version reporting may be stale).">&2fi# Drop remote-tracking refs for branches the remote no longer has — typically a# user/<slug> branch deleted when its pull request merged, which otherwise lingers# as a stale origin/<branch> and a '[gone]' upstream for as long as the clone lives.## `git remote prune`, NOT `--prune` on the fetch above: prune only considers the# refs its refspec covers, and 'master' covers no user branch, so that fetch prunes# nothing. Widening the refspec to prune would drag every other collaborator's# branch into this clone just to notice one of its own is gone; pruning separately# removes the dead ref and adds none.## Housekeeping, not sync: a prune that fails is noted, and the sync carries on.if[[${dry_run}-eq1]];thenecho"[dry-run] git remote prune origin"elifprune_out=$(gitremotepruneorigin2>&1);thengrep-F'[pruned]'<<<"${prune_out}"||true# the 'URL:' heading is noiseelseecho"Note: could not prune stale remote-tracking refs — carrying on.">&2fiif!ahead=$(gitrev-list--countorigin/master..HEAD2>/dev/null)||!behind=$(gitrev-list--countHEAD..origin/master2>/dev/null);thenecho"Error: could not compare HEAD with origin/master.">&2return1fi# `git status`, not `git diff --quiet`: on the filtered tree (solutions/private/**,# filter=solver-crypt) `git diff` calls every one of those files changed — 'Binary# files differ' — on a tree that `git status` calls clean, that `git add -A` stages# nothing from, and that `git diff --cached` agrees is identical. Only git diff's# worktree comparison disagrees, and a preceding `git status` does not settle it.# Reading that as "uncommitted changes present" stashed a clean tree before every# sync. `git status` is both the correct answer and the one `git-status` prints,# so the two commands can no longer contradict each other.if[[-z"$(gitstatus--porcelain--untracked-files=no2>/dev/null)"]];thenhas_changes=0elsehas_changes=1fi# A clone left mid-operation — unmerged (UU) paths from a conflicted stash pop or merge,# or a merge/rebase that never finished — is not "uncommitted changes": a stash refuses# it, and when HEAD happens to be level with origin the states below would report# "nothing to do" over a conflicted index, forever. Name it and stop; the way out# discards local changes, so it is the user's call, not this script's.unmerged=$(gitdiff--name-only--diff-filter=U2>/dev/null)if[[-n"${unmerged}"]]||verb_in_progressmerge||verb_in_progressrebase;thenecho"Error: this clone is mid-merge — a merge, rebase or stash pop stopped part-way.">&2if[[-n"${unmerged}"]];thenmapfile-tunmerged_paths<<<"${unmerged}"printf' unmerged: %s\n'"${unmerged_paths[@]}">&2fiecho" Nothing was synced. To return to origin/master, DISCARDING uncommitted changes and stashes:">&2echo" git-reset --hard">&2echo" Or resolve the conflicts by hand ('git status' shows them), then run git-sync again.">&2return1fiif[[${ahead}-eq0&&${behind}-eq0]];thenstate="up_to_date"elif[[${ahead}-gt0&&${behind}-eq0]];thenstate="local_ahead"elif[[${ahead}-eq0&&${behind}-gt0]];thenstate="local_behind"elsestate="diverged"filocalchanges_msg[[${has_changes}-eq1]]&&changes_msg="uncommitted changes present"||changes_msg="clean worktree"echo"State: ${state} (ahead: ${ahead}, behind: ${behind}, ${changes_msg})"case"${state}"inup_to_date)echo"Action: nothing to do.";;local_ahead)echo"Action: no sync — use 'solver git-push' to publish to origin."echo"Warning: local has ${ahead} commit(s) ahead of origin/master. No sync performed."echo" Use 'solver git-push' to push file changes to origin.";;local_behind)echo"Action: fast-forward merge from origin/master${has_changes:+; stash/unstash around merge}."sync_onto_master"${has_changes}"merge--ff-onlyorigin/master||return$?;;diverged)echo"Action: rebase local commits onto origin/master${has_changes:+; stash/unstash around rebase}."echo"Warning: local has diverged (${ahead} ahead, ${behind} behind)."echo" Replaying local commits on top of origin/master via rebase."sync_onto_master"${has_changes}"rebaseorigin/master||return$?;;esac}if[["${BASH_SOURCE[0]}"=="${0}"]];then[[" $* "==*" --dry-run "*]]&&dry_run=1mainexit$?fi