git.lucas.co / cce
cce workspace root: member list, lockfile and dependency pins
git clone https://git.lucas.co/cce.git

bump-revs.sh (16K)

  1 #!/bin/sh
  2 # Bump the pinned revs that let a single crate be installed on its own.
  3 #
  4 # Each app declares its workspace-internal dependencies as git dependencies on
  5 # GitHub (the crates' origin), pinned to an exact rev, while the workspace root patches those
  6 # sources back to the local crates. That split is what lets one app be cloned
  7 # and built alone -- and it is also exactly why the pins rot silently: inside
  8 # this workspace the [patch] block always wins, so a stale rev never fails a
  9 # build here. It surfaces only as a standalone build of an app quietly
 10 # compiling an old copy of the toolkit. Nothing warns you. Hence this script.
 11 #
 12 # Run it after pushing a shared crate (cce-ui, cce-window-manager).
 13 #
 14 #   bump-revs.sh [--dry-run] [--commit] [dep...]
 15 #
 16 # With no dep named, every dependency that appears in a git pin is considered.
 17 #
 18 # WHAT IT PINS TO: the head of the dependency's branch on its `origin` remote
 19 # (GitHub, since 2026-09-20 -- the bare repos under ~/git are gone) -- not the
 20 # work tree's HEAD. A rev that exists only in a work tree is fetchable by
 21 # nobody, so pinning it would write manifests that resolve on this machine and
 22 # nowhere else. If the dependency's work tree has uncommitted changes or
 23 # commits not yet on origin, that is reported and the run fails rather than
 24 # pinning something stale; push it first (the post-commit hook normally has).
 25 # An origin ahead of the work tree is fine and is pinned as-is -- it is what
 26 # others can actually fetch.
 27 #
 28 # The pins name GitHub directly (since 2026-09-21; before that git.lucas.co,
 29 # which only mirrors GitHub hourly, so a fresh pin was unfetchable for up to an
 30 # hour), so a rev pinned right after a push resolves at once.
 31 #
 32 # A manifest that should have changed but did not fails the run. Silently
 33 # skipping is what let 21 repos sit unpushed for a day; the same rule applies
 34 # here.
 35 #
 36 # THE LOCKFILE MOVES WITH THE PIN. A crate that commits its Cargo.lock gets it
 37 # re-resolved against the new pin and committed alongside it. Inside the
 38 # workspace cargo never reads a member's own lock (the root lock and the
 39 # [patch] block win), so a stale one rots exactly as a stale pin does, and
 40 # surfaces only as a standalone build rewriting it silently. Until 2026-09-25
 41 # this script touched Cargo.toml only, and all ten tracked locks with git deps
 42 # had drifted -- two still resolving cce-ui as a PATH dependency. The refresh
 43 # is `cargo metadata` in a copy of the crate outside the workspace: a minimal
 44 # update, the one a standalone build would make, moving nothing from
 45 # crates.io that the new pin does not require. A crate whose lock does not
 46 # resolve fails the run before anything is committed.
 47 #
 48 # A PIN MAY CARRY EXTRA KEYS AFTER `rev` (features, optional, ...); the bump
 49 # rewrites the rev alone and keeps them. Until 2026-10-01 the pattern demanded
 50 # nothing after the rev, so cce-notes' and cce-grid's `features = [...]` pins
 51 # of cce-ui and cce-ui's `optional = true` pin of cce-vault were invisible:
 52 # not bumped, not reported, because the drift check only looked at manifests
 53 # the pattern had already matched. Now every `git = "https://github.com/
 54 # lsgalante/..."` line the pattern does not match is reported, and one naming
 55 # a dependency being bumped fails the run before anything is written.
 56 #
 57 # A REPIN TOUCHES ONLY THE REPIN. A crate whose Cargo.toml or Cargo.lock
 58 # already has uncommitted changes blocks the run before anything is written,
 59 # and --commit commits exactly those two paths, leaving anything else staged
 60 # where it was. Until 2026-10-01 it ran `git add` then a bare `git commit`, so
 61 # whatever another session had staged, or half-edited in the manifest, went
 62 # out in the "Repin" commit and was pushed with it. A commit that fails now
 63 # fails the run.
 64 
 65 set -eu
 66 
 67 ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
 68 DRY=""
 69 COMMIT=""
 70 WANT=""
 71 
 72 for arg in "$@"; do
 73     case "$arg" in
 74         --dry-run) DRY=1 ;;
 75         --commit)  COMMIT=1 ;;
 76         -h|--help) sed -n '2,/^$/p' "$0" | sed '/^$/d; s/^# \{0,1\}//'; exit 0 ;;
 77         -*) echo "unknown option: $arg" >&2; exit 2 ;;
 78         *)  WANT="$WANT $arg" ;;
 79     esac
 80 done
 81 
 82 [ -f "$ROOT/Cargo.toml" ] || { echo "no Cargo.toml beside $0" >&2; exit 1; }
 83 grep -q '^\[workspace\]' "$ROOT/Cargo.toml" || {
 84     echo "$ROOT/Cargo.toml is not a workspace root" >&2; exit 1; }
 85 
 86 say() { printf '%s\n' "$*"; }
 87 
 88 # Scratch space for the whole run, removed however the run ends -- an early
 89 # `exit 1` included, which used to leave a predictable /tmp/bump-revs.$$
 90 # behind.
 91 SCRATCH=$(mktemp -d)
 92 trap 'rm -rf "$SCRATCH"' EXIT
 93 
 94 # Every git pin in the workspace, as "crate dep rev". A pin is
 95 #   dep = { git = "https://github.com/lsgalante/dep.git", rev = "<sha>"[, more] }
 96 # -- the git URL and rev first, then any other keys (features, optional, ...),
 97 # which the bump leaves exactly as they are.
 98 PIN_RE='^\([a-z0-9-]*\) = { git = "https://github\.com/lsgalante/\1\.git", rev = "\([0-9a-f]\{40\}\)"\(, [^}]*\)\{0,1\} }$'
 99 pins() {
100     for m in "$ROOT"/*/Cargo.toml; do
101         crate=$(basename "$(dirname "$m")")
102         sed -n "s|$PIN_RE|$crate \\1 \\2|p" "$m"
103     done
104 }
105 
106 # Every line that points a dependency at one of our GitHub repos but is not a
107 # pin in the shape above, as "crate:line: text". These are what used to vanish
108 # silently -- a pin the pattern cannot see is a pin nobody bumps.
109 strays() {
110     for m in "$ROOT"/*/Cargo.toml; do
111         crate=$(basename "$(dirname "$m")")
112         grep -n 'git = "https://github\.com/lsgalante/' "$m" \
113             | grep -v '^[0-9]*:[[:space:]]*#' \
114             | grep -v "^[0-9]*:$(printf '%s' "$PIN_RE" | sed 's/^\^//')" \
115             | sed "s|^|$crate:|"
116     done
117 }
118 
119 ALL_PINS=$(pins)
120 STRAYS=$(strays)
121 if [ -n "$STRAYS" ]; then
122     say "!! not in pin shape -- this script cannot see or bump these:"
123     printf '%s\n' "$STRAYS" | sed 's/^/     /'
124     say "   put each in the shape above (extra keys after rev are fine)"
125 fi
126 [ -n "$ALL_PINS" ] || [ -n "$STRAYS" ] || { say "no git pins found under $ROOT"; exit 0; }
127 
128 DEPS=$(printf '%s\n' "$ALL_PINS" | awk '{print $2}' | sort -u)
129 if [ -n "$WANT" ]; then
130     for w in $WANT; do
131         printf '%s\n' "$DEPS" | grep -qx "$w" || {
132             echo "$w is not pinned by any crate here" >&2; exit 1; }
133     done
134     DEPS=$(printf '%s' "$WANT" | tr ' ' '\n' | sed '/^$/d')
135 fi
136 
137 # A stray naming a dependency this run bumps is a dependent that would be
138 # left behind, so it stops the run; with no dependency named, every one of
139 # our repos counts. Strays naming other dependencies were reported above.
140 if [ -n "$STRAYS" ]; then
141     if [ -n "$WANT" ]; then
142         hit=""
143         for dep in $DEPS; do
144             printf '%s\n' "$STRAYS" | grep -q "lsgalante/$dep\(\.git\)\{0,1\}\"" && hit="$hit $dep"
145         done
146     else
147         hit=" every dependency"
148     fi
149     if [ -n "$hit" ]; then
150         say ""
151         say "blocked: a pin above is out of shape, bumping$hit would leave it behind"
152         say "-- nothing written"
153         exit 1
154     fi
155 fi
156 
157 [ -n "$DRY" ] && say "DRY RUN -- nothing will be written"
158 
159 # Resolve each dependency to the rev that others can actually fetch, refusing
160 # to pin anything that is only local. Collected first, so a blocked dependency
161 # stops the run before any manifest is touched.
162 TARGETS=""
163 BLOCKED=""
164 for dep in $DEPS; do
165     wt="$ROOT/$dep"
166     # -e, not -d: a checkout made with `git worktree add` has a .git FILE.
167     [ -e "$wt/.git" ] || { say "!! $dep: no work tree at $wt"; BLOCKED="$BLOCKED $dep"; continue; }
168     branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD) || {
169         say "!! $dep: detached HEAD -- check out its branch first"; BLOCKED="$BLOCKED $dep"; continue; }
170     url=$(git -C "$wt" remote get-url origin 2>/dev/null) || {
171         say "!! $dep: no origin remote"; BLOCKED="$BLOCKED $dep"; continue; }
172     # Asked of the remote itself, not a possibly stale remote-tracking ref:
173     # what others can fetch is what origin has right now.
174     new=$(git -C "$wt" ls-remote --quiet "$url" "refs/heads/$branch" 2>/dev/null | cut -f1)
175     [ -n "$new" ] || {
176         say "!! $dep: origin ($url) has no branch $branch -- push it first"; BLOCKED="$BLOCKED $dep"; continue; }
177 
178     if [ -n "$(git -C "$wt" status --porcelain --untracked-files=no)" ]; then
179         say "!! $dep: uncommitted changes -- commit and push before pinning"
180         BLOCKED="$BLOCKED $dep"; continue
181     fi
182     wt_head=$(git -C "$wt" rev-parse HEAD)
183     if [ "$wt_head" != "$new" ]; then
184         # Make sure the local history knows the remote rev before comparing;
185         # a fetch is cheap and the ancestor test is meaningless without it.
186         git -C "$wt" fetch --quiet "$url" "refs/heads/$branch" 2>/dev/null || true
187         if git -C "$wt" merge-base --is-ancestor "$new" "$wt_head" 2>/dev/null; then
188             ahead=$(git -C "$wt" rev-list --count "$new..$wt_head")
189             say "!! $dep: work tree is $ahead commit(s) ahead of origin/$branch"
190             say "     push it first, or the pin misses that work:"
191             say "     git -C $wt push origin $branch"
192             BLOCKED="$BLOCKED $dep"; continue
193         elif ! git -C "$wt" merge-base --is-ancestor "$wt_head" "$new" 2>/dev/null; then
194             say "!! $dep: work tree and origin/$branch have diverged -- reconcile first"
195             BLOCKED="$BLOCKED $dep"; continue
196         fi
197     fi
198     TARGETS="$TARGETS $dep=$new"
199 done
200 
201 if [ -n "$BLOCKED" ]; then
202     say ""
203     say "blocked:$BLOCKED -- nothing written"
204     # One blocked name per line: grep reads each line as its own pattern.
205     # (`tr -d ' '` used to glue two blocked names into one pattern that
206     # matched nothing, so the hint suggested re-running with them included.)
207     rest=$(printf '%s\n' "$DEPS" \
208         | grep -vxF "$(printf '%s' "$BLOCKED" | tr ' ' '\n' | sed '/^$/d')" \
209         | tr '\n' ' ' | sed 's/ $//')
210     if [ -n "$rest" ]; then
211         say "name the other dependencies explicitly to bump them anyway, e.g."
212         say "    $(basename "$0") $rest"
213     fi
214     exit 1
215 fi
216 
217 # A crate this run would repin must not already have uncommitted changes in
218 # its manifest or lock: the repin would be committed on top of them -- often
219 # another session's work in progress -- and pushed with it, and the lock
220 # refresh would resolve against them too. Checked before anything is written.
221 DIRTY=""
222 for t in $TARGETS; do
223     dep=${t%%=*}
224     new=${t#*=}
225     for crate in $(printf '%s\n' "$ALL_PINS" | awk -v d="$dep" -v n="$new" '$2 == d && $3 != n { print $1 }'); do
226         case " $DIRTY " in *" $crate "*) continue ;; esac
227         if [ -n "$(git -C "$ROOT/$crate" status --porcelain --untracked-files=no -- Cargo.toml Cargo.lock 2>&1)" ]; then
228             say "!! $crate: Cargo.toml or Cargo.lock has uncommitted changes -- commit or stash them first"
229             DIRTY="$DIRTY $crate"
230         fi
231     done
232 done
233 if [ -n "$DIRTY" ]; then
234     say ""
235     say "blocked: would repin on top of uncommitted changes in$DIRTY -- nothing written"
236     exit 1
237 fi
238 
239 # Apply. A crate already at the target rev is left alone and reported as such,
240 # so the output distinguishes "nothing to do" from "did nothing".
241 CHANGED=""
242 UNCHANGED=0
243 for t in $TARGETS; do
244     dep=${t%%=*}
245     new=${t#*=}
246     say "$dep -> $(printf '%.8s' "$new")"
247     printf '%s\n' "$ALL_PINS" | while read -r crate d old; do
248         [ "$d" = "$dep" ] || continue
249         [ "$old" = "$new" ] && { echo "SAME $crate"; continue; }
250         echo "EDIT $crate $old"
251     done > "$SCRATCH/plan"
252 
253     while read -r verb crate old; do
254         case "$verb" in
255             SAME) UNCHANGED=$((UNCHANGED + 1)) ;;
256             EDIT)
257                 m="$ROOT/$crate/Cargo.toml"
258                 say "    $crate"
259                 if [ -z "$DRY" ]; then
260                     # The rev alone is rewritten; keys after it are kept.
261                     head="$dep = { git = \"https://github\\.com/lsgalante/$dep\\.git\", rev = \""
262                     sed -i "s|^\($head\)$old\"|\1$new\"|" "$m"
263                     grep -q "^$head$new\"" "$m" || {
264                         say "!! $crate: manifest did not change -- pin format drifted?"; exit 1; }
265                 else
266                     printf '    would: %s %s -> %s\n' "$crate" "$(printf '%.8s' "$old")" "$(printf '%.8s' "$new")"
267                 fi
268                 CHANGED="$CHANGED $crate"
269                 ;;
270         esac
271     done < "$SCRATCH/plan"
272 done
273 
274 CHANGED=$(printf '%s' "$CHANGED" | tr ' ' '\n' | sed '/^$/d' | sort -u)
275 COUNT=$(printf '%s' "$CHANGED" | grep -c . || true)
276 
277 # Whether a crate commits its Cargo.lock. An untracked (or absent) lock has
278 # nothing published to go stale, so it is left to whoever builds there.
279 lock_tracked() {
280     git -C "$ROOT/$1" ls-files --error-unmatch Cargo.lock >/dev/null 2>&1
281 }
282 
283 # Re-resolve one crate's Cargo.lock against its edited manifest, the way a
284 # standalone clone would: the committed tree plus the work tree's manifest and
285 # lock, in a directory outside the workspace (inside it cargo would find the
286 # root and ignore this lock entirely).
287 WORK="$SCRATCH/locks"
288 refresh_lock() {
289     crate=$1
290     mkdir -p "$WORK"
291     case "$WORK" in "$ROOT"/*)
292         say "!! temp dir $WORK is inside the workspace -- set TMPDIR elsewhere"; return 1 ;;
293     esac
294     copy="$WORK/$crate"
295     rm -rf "$copy" && mkdir -p "$copy"
296     git -C "$ROOT/$crate" archive HEAD | tar -x -C "$copy"
297     cp "$ROOT/$crate/Cargo.toml" "$ROOT/$crate/Cargo.lock" "$copy/"
298     if ! ( cd "$copy" && cargo metadata --quiet --format-version 1 >/dev/null 2>"$WORK/$crate.err" ); then
299         say "!! $crate: Cargo.lock does not resolve standalone:"
300         tail -n 5 "$WORK/$crate.err" | sed 's/^/     /'
301         return 1
302     fi
303     if cmp -s "$copy/Cargo.lock" "$ROOT/$crate/Cargo.lock"; then
304         say "    $crate (already current)"
305     else
306         cp "$copy/Cargo.lock" "$ROOT/$crate/Cargo.lock"
307         say "    $crate"
308     fi
309 }
310 
311 if [ "$COUNT" != 0 ]; then
312     say ""
313     if [ -n "$DRY" ]; then
314         for crate in $CHANGED; do
315             lock_tracked "$crate" && say "    would refresh: $crate/Cargo.lock"
316         done
317     else
318         say "refreshing lockfiles:"
319         LOCK_FAILED=""
320         for crate in $CHANGED; do
321             lock_tracked "$crate" || continue
322             refresh_lock "$crate" || LOCK_FAILED="$LOCK_FAILED $crate"
323         done
324         if [ -n "$LOCK_FAILED" ]; then
325             say ""
326             say "lock refresh failed:$LOCK_FAILED -- nothing committed; the"
327             say "manifests are edited in place, so fix the resolve and re-run"
328             exit 1
329         fi
330     fi
331 fi
332 
333 say ""
334 if [ "$COUNT" = 0 ]; then
335     say "every pin already current ($UNCHANGED) -- nothing to do"
336     exit 0
337 fi
338 if [ -n "$DRY" ]; then
339     repinned="would be repinned"
340 else
341     repinned="repinned"
342 fi
343 if [ "$UNCHANGED" -gt 0 ]; then
344     say "$COUNT crate(s) $repinned, $UNCHANGED already current"
345 else
346     say "$COUNT crate(s) $repinned"
347 fi
348 
349 if [ -n "$DRY" ]; then
350     say "re-run without --dry-run to write them"
351 elif [ -n "$COMMIT" ]; then
352     say ""
353     say "committing:"
354     FAILED=""
355     for crate in $CHANGED; do
356         files="Cargo.toml"
357         lock_tracked "$crate" && files="$files Cargo.lock"
358         # The paths after `--` are the whole commit: anything else already
359         # staged in the crate stays staged and out of it. A failure is
360         # counted, not swallowed -- `( ... ) && say` here once let a failed
361         # commit pass under set -e and still print "committed".
362         if git -C "$ROOT/$crate" commit --quiet -m "Repin workspace dependencies to their published revs
363 
364 The pinned revs only affect builds outside this workspace, where an app is
365 cloned on its own, so a stale pin never fails a build here. Bumped by
366 bump-revs.sh after the dependency was pushed; Cargo.lock (when committed)
367 is re-resolved to match." -- $files; then
368             say "    $crate"
369         else
370             say "!! $crate: commit failed"
371             FAILED="$FAILED $crate"
372         fi
373     done
374     if [ -n "$FAILED" ]; then
375         say ""
376         say "not committed:$FAILED -- their manifests are edited in place; commit them by hand"
377         exit 1
378     fi
379     say ""
380     say "committed -- each crate's post-commit hook pushes it to origin (GitHub);"
381     say "a crate without the hook still needs: git -C <crate> push origin <branch>"
382 else
383     say "review, then commit in each crate (or re-run with --commit)"
384 fi