#!/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
]