|
| 1 | +#!/usr/bin/env bash |
| 2 | +# restore-backup.sh |
| 3 | +# |
| 4 | +# PreToolUse hook. NON-BLOCKING. Before a command that can destroy |
| 5 | +# uncommitted work (`git checkout -- <path>`, `git restore <path>`, |
| 6 | +# `git reset --hard`, `git clean -f`, `git stash`), snapshot whatever |
| 7 | +# the working tree currently holds into `.git/wipe-backups/` and let |
| 8 | +# the command proceed. |
| 9 | +# |
| 10 | +# WHY this exists (incident 2026-08-09): two sessions were working in |
| 11 | +# ONE worktree. Session B found ~228 lines of uncommitted changes it |
| 12 | +# did not recognize, could not attribute them, and ran |
| 13 | +# `git checkout -- <2 files>` to get back to a known state. Those lines |
| 14 | +# were session A's finished, tested bug fix plus its regression tests. |
| 15 | +# Nothing in git recorded them — `git checkout --` leaves no reflog |
| 16 | +# entry and creates no stash — so the work was simply gone. It was |
| 17 | +# recoverable only because session B happened to have saved a diff by |
| 18 | +# hand first. |
| 19 | +# |
| 20 | +# The collision (two sessions, one worktree) is the separate concern of |
| 21 | +# worktree-owner-gate.sh. THIS hook targets the second, independent |
| 22 | +# failure: a destructive restore is irreversible for uncommitted work. |
| 23 | +# Making it reversible is cheap, so nothing here blocks or prompts — |
| 24 | +# blocking a legitimate `git checkout --` would be constant friction, |
| 25 | +# and the whole point is that the operator does not know the changes |
| 26 | +# are precious at the moment they run it. |
| 27 | +# |
| 28 | +# Deliberately NOT limited to multi-session setups: the same command in |
| 29 | +# a single session (a revert-proof probe restore, an "undo my scratch |
| 30 | +# edits" reflex) destroys work the same way. Related memory rule: |
| 31 | +# feedback_revert_proof_restore_must_not_git_checkout. |
| 32 | +# |
| 33 | +# Output: `.git/wipe-backups/<UTC timestamp>-<verb>/` containing |
| 34 | +# tracked.patch — `git diff HEAD` (staged + unstaged, tracked files) |
| 35 | +# untracked.tar — every untracked, non-ignored file (only when the |
| 36 | +# command can delete untracked files, i.e. clean) |
| 37 | +# COMMAND — the command line that triggered the snapshot |
| 38 | +# Restore with: git apply .git/wipe-backups/<dir>/tracked.patch |
| 39 | +# |
| 40 | +# Failure policy: fail OPEN and SILENT on anything unexpected. A backup |
| 41 | +# helper that blocks the user's command when the snapshot fails would |
| 42 | +# be worse than no helper at all. |
| 43 | + |
| 44 | +set -u |
| 45 | + |
| 46 | +input=$(cat 2>/dev/null || true) |
| 47 | + |
| 48 | +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "") |
| 49 | +hook_cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || echo "") |
| 50 | + |
| 51 | +[ -n "$cmd" ] || exit 0 |
| 52 | + |
| 53 | +# ---------------------------------------------------------------- match |
| 54 | +# Only the shapes that can DESTROY uncommitted work. |
| 55 | +# |
| 56 | +# git checkout -- <path> / git checkout . (worktree overwrite) |
| 57 | +# git restore <path> (worktree overwrite) |
| 58 | +# git reset --hard (worktree + index) |
| 59 | +# git clean -f / -fd / -xf (deletes untracked) |
| 60 | +# git stash / git stash push (moves work aside) |
| 61 | +# |
| 62 | +# `git checkout <branch>` (no `--`, no pathspec) is a branch switch, not |
| 63 | +# a restore, and is covered by main-tree-branch-gate.sh — matching it |
| 64 | +# here would snapshot on every ordinary switch. `git restore --staged` |
| 65 | +# alone only unstages (worktree untouched), but it is cheap to include |
| 66 | +# and a combined `--staged --worktree` IS destructive, so it stays in. |
| 67 | +# |
| 68 | +# Line-start anchored per feedback_hook_command_match_line_start: a |
| 69 | +# `git checkout --` mentioned inside a quoted PR body must not trigger. |
| 70 | +prefix='^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+' |
| 71 | +verb="" |
| 72 | +if printf '%s' "$cmd" | grep -qE "${prefix}checkout([[:space:]]+-[^[:space:]]+)*[[:space:]]+(--|\.)([[:space:]]|$)"; then |
| 73 | + verb="checkout" |
| 74 | +elif printf '%s' "$cmd" | grep -qE "${prefix}restore([[:space:]]|$)"; then |
| 75 | + verb="restore" |
| 76 | +elif printf '%s' "$cmd" | grep -qE "${prefix}reset([[:space:]]+-[^[:space:]]+)*[[:space:]]+--hard([[:space:]]|$)"; then |
| 77 | + verb="reset-hard" |
| 78 | +elif printf '%s' "$cmd" | grep -qE "${prefix}clean([[:space:]]+-[^[:space:]]*f[^[:space:]]*)"; then |
| 79 | + verb="clean" |
| 80 | +elif printf '%s' "$cmd" | grep -qE "${prefix}stash([[:space:]]|$)"; then |
| 81 | + verb="stash" |
| 82 | +fi |
| 83 | +[ -n "$verb" ] || exit 0 |
| 84 | + |
| 85 | +# ------------------------------------------------------- resolve target |
| 86 | +# Same resolution order as branch-gate.sh: `git -C <path>` wins, then a |
| 87 | +# leading `cd <path> &&`, then the Bash tool's persisted cwd. |
| 88 | +target_dir="${hook_cwd:-$PWD}" |
| 89 | + |
| 90 | +if [[ "$cmd" =~ ^[[:space:]]*cd[[:space:]]+([^[:space:]\&\;\|]+) ]]; then |
| 91 | + cd_target="${BASH_REMATCH[1]}" |
| 92 | + cd_target="${cd_target%\"}"; cd_target="${cd_target#\"}" |
| 93 | + cd_target="${cd_target%\'}"; cd_target="${cd_target#\'}" |
| 94 | + [[ "$cd_target" == /* ]] || cd_target="$target_dir/$cd_target" |
| 95 | + target_dir="$cd_target" |
| 96 | +fi |
| 97 | + |
| 98 | +remaining="$cmd" |
| 99 | +while [[ "$remaining" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; do |
| 100 | + c_target="${BASH_REMATCH[1]}" |
| 101 | + remaining="${remaining#*"${BASH_REMATCH[0]}"}" |
| 102 | + c_target="${c_target%\"}"; c_target="${c_target#\"}" |
| 103 | + c_target="${c_target%\'}"; c_target="${c_target#\'}" |
| 104 | + [[ "$c_target" == /* ]] || c_target="$target_dir/$c_target" |
| 105 | + target_dir="$c_target" |
| 106 | +done |
| 107 | + |
| 108 | +git -C "$target_dir" rev-parse --git-dir >/dev/null 2>&1 || exit 0 |
| 109 | + |
| 110 | +# --------------------------------------------------------------- snapshot |
| 111 | +# Nothing uncommitted and nothing untracked => nothing to lose. Note |
| 112 | +# `--porcelain` covers both, so a `git clean` against a pristine tree |
| 113 | +# correctly writes no snapshot. |
| 114 | +status=$(git -C "$target_dir" status --porcelain 2>/dev/null || echo "") |
| 115 | +[ -n "$status" ] || exit 0 |
| 116 | + |
| 117 | +# Per-worktree git dir, so a snapshot taken in a linked worktree lands |
| 118 | +# beside that worktree's own git metadata rather than in the shared |
| 119 | +# common dir. (markgate resolves its marker store the same way — see |
| 120 | +# memory feedback_markgate_markers_are_per_worktree.) |
| 121 | +git_dir=$(git -C "$target_dir" rev-parse --absolute-git-dir 2>/dev/null || echo "") |
| 122 | +[ -n "$git_dir" ] || exit 0 |
| 123 | + |
| 124 | +ts=$(date -u +%Y%m%dT%H%M%SZ) |
| 125 | +dest="$git_dir/wipe-backups/${ts}-${verb}" |
| 126 | +mkdir -p "$dest" 2>/dev/null || exit 0 |
| 127 | + |
| 128 | +printf '%s\n' "$cmd" > "$dest/COMMAND" 2>/dev/null || true |
| 129 | + |
| 130 | +# `git diff HEAD` captures staged AND unstaged changes to tracked files |
| 131 | +# in one applyable patch. On a repo with no commits yet HEAD does not |
| 132 | +# resolve; fall back to the plain worktree diff rather than emitting an |
| 133 | +# empty file. |
| 134 | +if ! git -C "$target_dir" diff HEAD --binary > "$dest/tracked.patch" 2>/dev/null; then |
| 135 | + git -C "$target_dir" diff --binary > "$dest/tracked.patch" 2>/dev/null || true |
| 136 | +fi |
| 137 | + |
| 138 | +# Untracked files are only at risk from `git clean`; archiving them on |
| 139 | +# every stash/checkout would copy build output on a large tree for no |
| 140 | +# reason. |
| 141 | +if [ "$verb" = "clean" ]; then |
| 142 | + ( cd "$target_dir" 2>/dev/null && |
| 143 | + git ls-files --others --exclude-standard -z 2>/dev/null | |
| 144 | + tar -cf "$dest/untracked.tar" --null -T - 2>/dev/null ) || true |
| 145 | +fi |
| 146 | + |
| 147 | +# Drop an empty snapshot so the directory does not fill with noise. |
| 148 | +if [ ! -s "$dest/tracked.patch" ] && [ ! -s "$dest/untracked.tar" ]; then |
| 149 | + rm -rf "$dest" 2>/dev/null || true |
| 150 | + exit 0 |
| 151 | +fi |
| 152 | + |
| 153 | +echo "restore-backup: snapshotted the working tree before '$verb'." >&2 |
| 154 | +echo " $dest" >&2 |
| 155 | +# The patch covers the WHOLE tree, so a plain `git apply` fails once any |
| 156 | +# other change in it is still present ("patch does not apply"). Both |
| 157 | +# forms below were verified against a real wipe-and-recover replay: |
| 158 | +# --include re-applies exactly one path and exits 0; --3way restores |
| 159 | +# everything recoverable and reports the hunks already in place. |
| 160 | +echo " recover ONE file: git -C \"$target_dir\" apply --include=<path> \"$dest/tracked.patch\"" >&2 |
| 161 | +echo " recover the tree: git -C \"$target_dir\" apply --3way \"$dest/tracked.patch\"" >&2 |
| 162 | + |
| 163 | +exit 0 |
0 commit comments