#!/usr/bin/env bash # gstack-ff-update — fast-forward the gstack install at to # without ever leaving conflict markers in it and without losing a # local edit. The only code that moves an install's tree: the session-start # auto-updater, /gstack-upgrade Step 4 and the held-install recovery commands # (docs/troubleshooting.md#auto-update-held-install-recovery) call it. # # Self-contained on purpose: the recovery commands fetch this one file from # the new revision (git show FETCH_HEAD:bin/gstack-ff-update) and run it in a # private temp directory, so it sources nothing. The lock and the # flatten-and-strip sanitizer are inlined; test/gstack-ff-update.test.ts pins # them to their twins in bin/gstack-session-update. # # Inside an install-scoped lock (/gstack-ff.lock): # 1. The working tree is captured as-is through a private index (tracked # edits, unmerged and marker-laden files), and the real index's staged # state separately; the real index is never touched while building them. # 2. Edits to files the update does not change ride over it, as # `merge --ff-only --autostash` did. Regenerable footprint files # (SKILL.md, */SKILL.md, */sections/*.md, rewritten by prefix mode and # gbrain blocks) ride over when the update leaves them alone and are # replaced by the update's version otherwise; callers re-render them. # 3. Any other edit that conflicts with the update, any staged change and # any unmerged file is shelved into a rescue commit # refs/gstack/rescue/: tree = the working tree as captured, first # parent = the pre-update HEAD (so `diff ^ ` is only the # person's delta), second parent = the staged state. It is stash-shaped, # so `git stash show -p ` reads it too. # 4. The tree moves with a two-way `read-tree -m -u ` on # the private index, so git refuses to overwrite a file changed after the # capture or an untracked file in the way; HEAD moves compare-and-swap. # # stdout, one line: already | ok | rescued unmerged=<0|1> # refusals (stdout): : , and nothing in the install changed # exit 2 usage: / target-unresolvable: / not-a-checkout: # exit 3 autostash-unrecoverable: (edits kept where they are) # exit 4 not-fast-forward: # exit 5 index-unreadable: # exit 75 ff-busy: (another update holds this install's lock) # A rescue also prints the inspect, export and delete commands on stderr. set +e export LC_ALL=C GIT_TERMINAL_PROMPT=0 # Twin: ff_flatten in bin/gstack-session-update (test-pinned). One line, no # control characters or escape sequences, at most bytes. ff_flatten() { _ff_esc=$(printf '\033') printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}" } ff_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" 300)"; exit "$1"; } [ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update " DIR=$1 REV=$2 cd -- "$DIR" 2>/dev/null || refuse 2 not-a-checkout "cannot enter $DIR" DIR=$(pwd -P) g() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; } [ "$(g rev-parse --is-inside-work-tree 2>/dev/null)" = true ] && [ -z "$(g rev-parse --show-prefix 2>/dev/null)" ] \ || refuse 2 not-a-checkout "$DIR is not the top of a git checkout" TARGET=$(g rev-parse -q --verify "$REV^{commit}" 2>/dev/null) || refuse 2 target-unresolvable "$REV does not name a commit in $DIR" COMMON=$(g rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \ || COMMON=$(cd -- "$(g rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) \ || refuse 2 not-a-checkout "cannot locate the git directory of $DIR" INDEX=$(g rev-parse --path-format=absolute --git-path index 2>/dev/null) \ || INDEX="$(cd -- "$(dirname "$(g rev-parse --git-path index)")" && pwd -P)/index" # ── Install-scoped lock (twin of bin/gstack-session-update's lock) ── # Staleness: a dead holder PID, or a heartbeat older than the TTL (a live # holder freshens it every GSTACK_FF_HEARTBEAT_SECONDS, default 300 s). LOCK_DIR="$COMMON/gstack-ff.lock" LOCK_TTL_MINUTES=30 MYPID=$$ OWN_INDEX_LOCK="" TMP="" HB_PID="" lock_is_expired() { _hb="$LOCK_DIR/pid" [ -f "$_hb" ] || _hb="$LOCK_DIR" [ -n "$(find "$_hb" -maxdepth 0 -mmin +$LOCK_TTL_MINUTES 2>/dev/null)" ] } lock_try() { mkdir "$LOCK_DIR" 2>/dev/null && return 0 _lp=$(cat "$LOCK_DIR/pid" 2>/dev/null) if lock_is_expired || { [ "${_lp:-0}" -gt 0 ] 2>/dev/null && ! kill -0 "$_lp" 2>/dev/null; }; then mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null || return 1 rm -rf "$LOCK_DIR.reap.$$" 2>/dev/null mkdir "$LOCK_DIR" 2>/dev/null && return 0 fi return 1 } cleanup_ff_update() { for heartbeat in ${HB_PID:-$(jobs -p)}; do kill "$heartbeat" 2>/dev/null wait "$heartbeat" 2>/dev/null done [ -n "$OWN_INDEX_LOCK" ] && rm -f "$OWN_INDEX_LOCK" 2>/dev/null [ -n "$TMP" ] && rm -rf "$TMP" 2>/dev/null [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null } _wait=${GSTACK_FF_LOCK_WAIT:-15} case "$_wait" in ''|*[!0-9]*) _wait=15 ;; esac until lock_try; do [ "$_wait" -gt 0 ] || refuse 75 ff-busy "another gstack update is moving $DIR (lock $LOCK_DIR held by pid $(cat "$LOCK_DIR/pid" 2>/dev/null || echo unknown)); nothing was changed, retry in a minute" sleep 1; _wait=$((_wait - 1)) done echo "$MYPID" > "$LOCK_DIR/pid" 2>/dev/null trap cleanup_ff_update EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM _beat=${GSTACK_FF_HEARTBEAT_SECONDS:-300} case "$_beat" in ''|*[!0-9]*|0) _beat=300 ;; esac ( cleanup_heartbeat() { for sleeper in $(jobs -p); do kill "$sleeper" 2>/dev/null wait "$sleeper" 2>/dev/null done } trap cleanup_heartbeat EXIT trap 'exit 0' HUP INT TERM while :; do sleep "$_beat" & wait "$!" || exit 0 [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0 touch "$LOCK_DIR/pid" 2>/dev/null done ) & HB_PID=$! # ── Inside the lock ── HEAD_OID=$(g rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null) || refuse 2 not-a-checkout "$DIR has no HEAD commit" TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-ff-XXXXXX" 2>/dev/null) || refuse 3 autostash-unrecoverable "cannot create a temp directory under ${TMPDIR:-/tmp}; nothing was changed" g ls-files -u -z > "$TMP/unmerged.raw" 2>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR ($(head -c 200 "$TMP/err")); nothing was changed" UNMERGED=$(tr '\0' '\n' < "$TMP/unmerged.raw") # Nothing to move: an up-to-date install keeps its edits and staged state as they are. if [ "$HEAD_OID" = "$TARGET" ] && [ -z "$UNMERGED" ]; then echo "already $TARGET"; exit 0; fi g merge-base --is-ancestor "$HEAD_OID" "$TARGET" 2>/dev/null \ || refuse 4 not-fast-forward "$TARGET is not a fast-forward of HEAD $HEAD_OID in $DIR (local commits or a diverged history); nothing was changed" # Exclusive real-index lock for the whole move: git commands wait or fail # instead of racing it, and the result is installed by rename. ( set -C; : > "$INDEX.lock" ) 2>/dev/null \ || refuse 3 autostash-unrecoverable "$INDEX.lock exists (another git command is running in $DIR); nothing was changed" OWN_INDEX_LOCK="$INDEX.lock" is_footprint() { case "$1" in SKILL.md|*/SKILL.md|*/sections/*.md) return 0 ;; esac; return 1; } # capture — the working tree as-is into a private index; prints its tree. capture() { if [ -f "$INDEX" ]; then cp "$INDEX" "$1" || return 1; else GIT_INDEX_FILE=$1 g read-tree "$HEAD_OID" || return 1; fi GIT_INDEX_FILE=$1 g add -u -- . 2>>"$TMP/err" || return 1 GIT_INDEX_FILE=$1 g update-index -q --refresh >/dev/null 2>&1 GIT_INDEX_FILE=$1 g write-tree 2>>"$TMP/err" } # paths — changed paths between two trees, split into # .user and .foot (NUL-separated). paths() { : > "$3.user"; : > "$3.foot" g diff-tree -r -z --no-renames --name-only "$1" "$2" 2>/dev/null | while IFS= read -r -d '' _p; do if is_footprint "$_p"; then printf '%s\0' "$_p" >> "$3.foot"; else printf '%s\0' "$_p" >> "$3.user"; fi done } # entries — index-info lines giving 's entry for # each listed path that differs (mode 0 removes it). entries() { [ -s "$3" ] || return 0 xargs -0 git --literal-pathspecs diff-tree -r -z --no-renames "$1" "$2" -- < "$3" 2>/dev/null \ | while IFS= read -r -d '' _meta && IFS= read -r -d '' _p; do set -- $_meta printf '%s %s\t%s\0' "$2" "$4" "$_p" done } apply_entries() { [ -s "$2" ] || return 0; GIT_INDEX_FILE=$1 g update-index -z --index-info < "$2"; } tree_without_footprint() { paths "$HEAD_OID" "$2" "$TMP/fp-$1" cp "$TMP/$1.idx" "$TMP/$1-user.idx" && entries "$2" "$HEAD_OID" "$TMP/fp-$1.foot" > "$TMP/fp-$1.info" \ && apply_entries "$TMP/$1-user.idx" "$TMP/fp-$1.info" && GIT_INDEX_FILE="$TMP/$1-user.idx" g write-tree } : > "$TMP/err" WTREE=$(capture "$TMP/w.idx") || refuse 3 autostash-unrecoverable "cannot capture the working tree of $DIR ($(head -c 200 "$TMP/err")); nothing was changed" # The staged state: stage 0 entries as they are; an unmerged path keeps its # "ours" stage (else theirs, else base) so the tree can be written. g ls-files -s -z > "$TMP/s.raw" 2>>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR; nothing was changed" TAB=$'\t' : > "$TMP/s.info" _cp="" _cl="" _cr=9 while IFS= read -r -d '' _e; do _p=${_e#*"$TAB"} set -- ${_e%%"$TAB"*} case "$3" in 0) _r=0 ;; 2) _r=1 ;; 3) _r=2 ;; *) _r=3 ;; esac if [ "$_p" != "$_cp" ]; then [ -n "$_cp" ] && printf '%s\0' "$_cl" >> "$TMP/s.info"; _cp=$_p _cr=9; fi [ "$_r" -lt "$_cr" ] && _cr=$_r _cl="$1 $2$TAB$_p" done < "$TMP/s.raw" [ -n "$_cp" ] && printf '%s\0' "$_cl" >> "$TMP/s.info" GIT_INDEX_FILE="$TMP/s.idx" g read-tree --empty 2>/dev/null apply_entries "$TMP/s.idx" "$TMP/s.info" 2>>"$TMP/err" || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed" STREE=$(GIT_INDEX_FILE="$TMP/s.idx" g write-tree 2>>"$TMP/err") || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed" cp "$TMP/w.idx" "$TMP/wu.idx"; WUSER=$(tree_without_footprint wu "$WTREE") || refuse 3 autostash-unrecoverable "cannot capture the working tree of $DIR; nothing was changed" SUSER=$(tree_without_footprint s "$STREE") || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed" HTREE=$(g rev-parse "$HEAD_OID^{tree}") UNMERGED_USER=0 UNMERGED_FOOT="" while IFS= read -r _u; do [ -n "$_u" ] || continue _p=$(printf '%s' "$_u" | cut -f2-) if is_footprint "$_p"; then UNMERGED_FOOT=1; else UNMERGED_USER=1; fi done < — " " of in , empty when absent. lsentry() { git --literal-pathspecs ls-tree -r -z "$1" -- "$2" 2>/dev/null | tr '\0' '\n' | awk -F '\t' 'NR == 1 { split($1, a, " "); print a[1] " " a[3] }'; } # carry_line — the index-info line that carries as captured; # when the update also changed it, a clean three-way content merge of the # edit onto the target (as an autostash pop would make), else return 1. carry_line() { _w=$(lsentry "$WTREE" "$1") if git --literal-pathspecs diff-tree --quiet -r "$HEAD_OID" "$TARGET" -- "$1" >/dev/null 2>&1; then printf '%s\t%s\0' "${_w:-0 $ZERO}" "$1"; return 0 fi _t=$(lsentry "$TARGET" "$1"); _h=$(lsentry "$HEAD_OID" "$1") [ "$_w" = "$_t" ] && return 0 for _e in "$_h" "$_w" "$_t"; do case "${_e%% *}" in 100644|100755) ;; *) return 1 ;; esac; done g cat-file blob "${_h#* }" > "$TMP/m.base" && g cat-file blob "${_w#* }" > "$TMP/m.ours" && g cat-file blob "${_t#* }" > "$TMP/m.theirs" || return 1 g merge-file -p -q "$TMP/m.ours" "$TMP/m.base" "$TMP/m.theirs" > "$TMP/m.out" 2>/dev/null || return 1 _o=$(g hash-object -w "$TMP/m.out") || return 1 _m=${_t%% *}; [ "${_w%% *}" = "${_h%% *}" ] || _m=${_w%% *} printf '%s %s\t%s\0' "$_m" "$_o" "$1" } paths "$HEAD_OID" "$WTREE" "$TMP/delta" SHELVE=0 [ "$UNMERGED_USER" = 1 ] && SHELVE=1 [ "$SUSER" = "$HTREE" ] || SHELVE=1 : > "$TMP/carry.user"; : > "$TMP/carry.foot" if [ "$SHELVE" = 0 ]; then while IFS= read -r -d '' _p; do carry_line "$_p" >> "$TMP/carry.user" || { SHELVE=1; break; } done < "$TMP/delta.user" fi # A footprint edit rides over when it merges cleanly; otherwise, or when it # is unmerged, the target's version wins (callers re-render it). while IFS= read -r -d '' _p; do [ -n "$UNMERGED_FOOT" ] && printf '%s\n' "$UNMERGED" | cut -f2- | grep -qxF -- "$_p" && continue carry_line "$_p" >> "$TMP/carry.foot" done < "$TMP/delta.foot" [ "$SHELVE" = 1 ] && [ "$WUSER" = "$HTREE" ] && [ "$SUSER" = "$HTREE" ] && [ "$UNMERGED_USER" = 0 ] && SHELVE=0 # The result: the target, plus the edits that ride over it. GIT_INDEX_FILE="$TMP/r.idx" g read-tree "$TARGET" 2>>"$TMP/err" || refuse 3 autostash-unrecoverable "cannot read $TARGET; nothing was changed" { [ "$SHELVE" = 0 ] && cat "$TMP/carry.user"; cat "$TMP/carry.foot"; } > "$TMP/carry.info" apply_entries "$TMP/r.idx" "$TMP/carry.info" || refuse 3 autostash-unrecoverable "cannot build the updated tree; nothing was changed" RTREE=$(GIT_INDEX_FILE="$TMP/r.idx" g write-tree 2>>"$TMP/err") || refuse 3 autostash-unrecoverable "cannot build the updated tree; nothing was changed" RESCUE="" REF="" if [ "$SHELVE" = 1 ]; then _id() { GIT_AUTHOR_NAME='gstack rescue' GIT_AUTHOR_EMAIL='gstack@localhost' GIT_COMMITTER_NAME='gstack rescue' GIT_COMMITTER_EMAIL='gstack@localhost' "$@"; } _label="" [ "$UNMERGED_USER" = 1 ] && _label=" Needs manual resolution: the install had unmerged files; their captured content may contain conflict markers." SCOMMIT=$(printf 'gstack rescue: staged state before updating to %s\n' "$TARGET" \ | _id g commit-tree --no-gpg-sign "$SUSER" -p "$HEAD_OID" 2>>"$TMP/err") \ || refuse 3 autostash-unrecoverable "cannot write the rescue commit ($(head -c 200 "$TMP/err")); nothing was changed" RESCUE=$(printf 'gstack rescue: local edits that conflicted with %s\n\nPre-update HEAD: %s\nTarget: %s\nRestore out of the live install: git diff ^ > patch, then git apply -3 in a scratch checkout.\n%s\n' \ "$TARGET" "$HEAD_OID" "$TARGET" "$_label" \ | _id g commit-tree --no-gpg-sign "$WUSER" -p "$HEAD_OID" -p "$SCOMMIT" 2>>"$TMP/err") \ || refuse 3 autostash-unrecoverable "cannot write the rescue commit ($(head -c 200 "$TMP/err")); nothing was changed" REF="refs/gstack/rescue/$RESCUE" if ! g update-ref -m "gstack-ff-update: rescue before $TARGET" "$REF" "$RESCUE" "" 2>/dev/null; then [ "$(g rev-parse -q --verify "$REF" 2>/dev/null)" = "$RESCUE" ] \ || refuse 3 autostash-unrecoverable "rescue ref $REF already exists and names another commit; nothing was changed" fi fi unrescue() { [ -n "$REF" ] && g update-ref -d "$REF" "$RESCUE" 2>/dev/null; } # The working tree must still be exactly what was captured. : > "$TMP/err" RECHECK=$(capture "$TMP/w2.idx") if [ "$RECHECK" != "$WTREE" ] || [ "$(g rev-parse -q --verify HEAD 2>/dev/null)" != "$HEAD_OID" ]; then unrescue refuse 3 autostash-unrecoverable "files in $DIR changed while the update ran; nothing was changed, your edits are where they were" fi if ! GIT_INDEX_FILE="$TMP/w.idx" g read-tree -m -u "$WTREE" "$RTREE" 2>"$TMP/err"; then unrescue refuse 3 autostash-unrecoverable "git refused to update $DIR: $(head -c 200 "$TMP/err"); nothing was changed, your edits are where they were" fi if ! g update-ref -m "gstack-ff-update: fast-forward to $TARGET" HEAD "$TARGET" "$HEAD_OID" 2>"$TMP/err"; then GIT_INDEX_FILE="$TMP/w.idx" g read-tree -m -u "$RTREE" "$WTREE" 2>/dev/null unrescue refuse 3 autostash-unrecoverable "HEAD of $DIR moved during the update; the working tree was put back" fi # The real index becomes the target with the carried edits unstaged. cp "$TMP/w.idx" "$TMP/f.idx" paths "$RTREE" "$TARGET" "$TMP/unstage" cat "$TMP/unstage.user" "$TMP/unstage.foot" > "$TMP/unstage.all" entries "$RTREE" "$TARGET" "$TMP/unstage.all" > "$TMP/unstage.info" apply_entries "$TMP/f.idx" "$TMP/unstage.info" GIT_INDEX_FILE="$TMP/f.idx" g update-index -q --refresh >/dev/null 2>&1 cat "$TMP/f.idx" > "$INDEX.lock" && mv -f "$INDEX.lock" "$INDEX" && OWN_INDEX_LOCK="" if [ "$SHELVE" = 1 ] && [ -n "$REF" ]; then _q=$(ff_quote "$DIR") { echo "gstack-ff-update: your local edits in $DIR conflicted with the update; they are saved in $REF and the install now runs $TARGET." [ "$UNMERGED_USER" = 1 ] && echo " They include unmerged files: this rescue needs manual resolution." echo " Inspect: git -C $_q show --stat '$REF'" echo " Export: git -C $_q diff '$REF^' '$REF' > ~/gstack-local-edits.patch (then git apply -3 it in a scratch checkout, not in the install)" echo " Delete: git -C $_q update-ref -d '$REF'" } >&2 echo "rescued $HEAD_OID $TARGET $REF unmerged=$UNMERGED_USER" else echo "ok $HEAD_OID $TARGET" fi exit 0