Skip to content

Commit aea1471

Browse files
authored
chore(hooks): make a destructive restore reversible and give each worktree one owning session (#1425)
1 parent 72a5a5f commit aea1471

8 files changed

Lines changed: 509 additions & 0 deletions

.claude/hooks/pr-title-prefix-scope-gate.test.sh

100644100755
File mode changed.

.claude/hooks/restore-backup.sh

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env bash
2+
# Smoke test for restore-backup.sh.
3+
#
4+
# Runs against a REAL throwaway git repo (not fixtures-in-a-string) so the
5+
# snapshot + recovery path is exercised end to end — the whole value of the
6+
# hook is that `git apply` of its output actually restores wiped work, and
7+
# only a real repo proves that.
8+
9+
set -u
10+
HOOK="$(cd "$(dirname "$0")" && pwd)/restore-backup.sh"
11+
pass=0; fail=0
12+
ok(){ echo " ok: $1"; pass=$((pass+1)); }
13+
no(){ echo " FAIL: $1"; fail=$((fail+1)); }
14+
chk(){ [ "$1" = "$2" ] && ok "$3" || no "$3 (rc=$1 want $2)"; }
15+
16+
tmp=$(mktemp -d)
17+
trap 'rm -rf "$tmp"' EXIT
18+
repo="$tmp/repo"
19+
mkdir -p "$repo"
20+
git -C "$repo" init -q
21+
git -C "$repo" config user.email t@t; git -C "$repo" config user.name t
22+
# Repo opt-in marker is NOT required by this hook (it protects any repo), but
23+
# create a realistic tree.
24+
echo "base" > "$repo/f.txt"
25+
git -C "$repo" add f.txt; git -C "$repo" commit -qm init
26+
27+
payload(){ python3 -c "
28+
import json,sys
29+
print(json.dumps({'tool_input':{'command':sys.argv[1]},'cwd':sys.argv[2]}))" "$1" "$repo"; }
30+
gd(){ git -C "$repo" rev-parse --absolute-git-dir; }
31+
snapcount(){ ls "$(gd)/wipe-backups" 2>/dev/null | wc -l | tr -d ' '; }
32+
33+
echo "== matching =="
34+
echo "dirty" >> "$repo/f.txt"
35+
36+
payload "git checkout -- f.txt" | bash "$HOOK" >/dev/null 2>&1; chk $? 0 "checkout -- : non-blocking"
37+
[ "$(snapcount)" -ge 1 ] && ok "checkout -- : snapshot taken" || no "checkout -- : no snapshot"
38+
39+
payload "git restore f.txt" | bash "$HOOK" >/dev/null 2>&1
40+
[ "$(snapcount)" -ge 2 ] && ok "restore: snapshot taken" || no "restore: no snapshot"
41+
42+
payload "git reset --hard" | bash "$HOOK" >/dev/null 2>&1
43+
[ "$(snapcount)" -ge 3 ] && ok "reset --hard: snapshot taken" || no "reset --hard: no snapshot"
44+
45+
payload "git stash" | bash "$HOOK" >/dev/null 2>&1
46+
[ "$(snapcount)" -ge 4 ] && ok "stash: snapshot taken" || no "stash: no snapshot"
47+
48+
echo "== NON-matching (must not snapshot) =="
49+
before=$(snapcount)
50+
payload "git checkout -b feat/x" | bash "$HOOK" >/dev/null 2>&1
51+
chk "$(snapcount)" "$before" "branch create is not a restore"
52+
payload "git checkout main" | bash "$HOOK" >/dev/null 2>&1
53+
chk "$(snapcount)" "$before" "branch switch is not a restore"
54+
payload "git status" | bash "$HOOK" >/dev/null 2>&1
55+
chk "$(snapcount)" "$before" "read-only command"
56+
# Quoted-body false positive (cdkd#563 convention).
57+
payload 'gh pr create --body "do not run git checkout -- . in main"' | bash "$HOOK" >/dev/null 2>&1
58+
chk "$(snapcount)" "$before" "quoted body does not trigger"
59+
payload 'echo "git reset --hard is dangerous"' | bash "$HOOK" >/dev/null 2>&1
60+
chk "$(snapcount)" "$before" "echoed text does not trigger"
61+
62+
echo "== clean tree =="
63+
git -C "$repo" checkout -q -- f.txt
64+
rm -rf "$(gd)/wipe-backups"
65+
payload "git checkout -- f.txt" | bash "$HOOK" >/dev/null 2>&1
66+
chk "$(snapcount)" "0" "nothing uncommitted => no snapshot"
67+
68+
echo "== end-to-end recovery (the point of the hook) =="
69+
printf 'PRECIOUS\n' >> "$repo/f.txt"
70+
payload "git checkout -- f.txt" | bash "$HOOK" >/dev/null 2>&1
71+
git -C "$repo" checkout -- f.txt # the destructive command really runs
72+
grep -q PRECIOUS "$repo/f.txt" && no "wipe did not happen (test is meaningless)" || ok "work was wiped"
73+
d=$(ls -dt "$(gd)"/wipe-backups/*checkout | head -1)
74+
git -C "$repo" apply --include=f.txt "$d/tracked.patch" 2>/dev/null
75+
grep -q PRECIOUS "$repo/f.txt" && ok "work RECOVERED from the snapshot" || no "recovery failed"
76+
77+
echo "== git clean also archives untracked =="
78+
git -C "$repo" checkout -q -- f.txt
79+
echo "scratch" > "$repo/untracked.txt"
80+
payload "git clean -fd" | bash "$HOOK" >/dev/null 2>&1
81+
d=$(ls -dt "$(gd)"/wipe-backups/*clean 2>/dev/null | head -1)
82+
[ -n "$d" ] && tar -tf "$d/untracked.tar" 2>/dev/null | grep -q untracked.txt \
83+
&& ok "untracked file archived" || no "untracked file not archived"
84+
85+
echo ""
86+
echo "restore-backup.test: $pass passed, $fail failed"
87+
[ "$fail" -eq 0 ]
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env bash
2+
# worktree-owner-gate.sh
3+
#
4+
# PreToolUse hook. Gives each LINKED git worktree a single owning
5+
# session. The first session to edit a file in a worktree claims it;
6+
# a different session editing the same worktree is blocked with the
7+
# owner's id and the exact release command.
8+
#
9+
# WHY (incident 2026-08-09): the repo convention is "one lane, one
10+
# worktree", which nothing enforced at the SESSION level. Two sessions
11+
# both `cd`-ed into `.claude/worktrees/fix-1387-globaltable-gsi-throughput`
12+
# and worked the same PR. Session B saw ~228 lines of uncommitted
13+
# changes it had not written, could not attribute them, and reverted
14+
# them; they were session A's tested fix. Both sessions then serialized
15+
# their remaining lanes behind each other to avoid a repeat.
16+
#
17+
# The convention was never the problem — the collision was invisible.
18+
# `git worktree list` shows which worktrees EXIST, not which are being
19+
# actively driven by another agent, and a session that starts (or
20+
# resumes, or has its cwd silently reset) inside an existing worktree
21+
# has no signal that it is trespassing.
22+
#
23+
# Scope: file-writing tools only (Edit / Write / NotebookEdit). Bash is
24+
# deliberately NOT gated — a shell command's write targets cannot be
25+
# resolved statically, and the read-only commands that dominate Bash
26+
# usage (git status, ls, grep) must never be blocked. The reactive
27+
# `main-tree-dirty-detector.sh` PostToolUse hook covers the Bash-write
28+
# case in the same spirit.
29+
#
30+
# Ownership record: `<worktree git dir>/session-owner` holding
31+
# `<session id> <UTC claim time>`. Per-worktree by construction, so it
32+
# cannot collide across lanes.
33+
#
34+
# TTL: an owner older than OWNER_TTL_HOURS is treated as abandoned and
35+
# silently taken over. Sessions end without cleanup all the time, and a
36+
# stale lock that requires manual clearing is a worse failure than the
37+
# one being prevented.
38+
#
39+
# Release / takeover: delete the file the message names.
40+
#
41+
# Failure policy: fail OPEN. Anything unresolvable (no session id in
42+
# the payload, path outside a worktree, unreadable sentinel) passes
43+
# through. This hook exists to catch an honest mistake, not to be a
44+
# security boundary — a false block on a legitimate edit costs more
45+
# than the rare miss.
46+
47+
set -u
48+
49+
OWNER_TTL_HOURS=${CDKD_WORKTREE_OWNER_TTL_HOURS:-12}
50+
51+
input=$(cat 2>/dev/null || true)
52+
53+
session=$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || echo "")
54+
hook_cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || echo "")
55+
file_path=$(printf '%s' "$input" | jq -r '
56+
.tool_input.file_path // .tool_input.notebook_path // ""
57+
' 2>/dev/null || echo "")
58+
59+
# No session id => the payload shape is not what this hook assumes.
60+
# Pass through rather than guess; the hook is inert instead of wrong.
61+
[ -n "$session" ] || exit 0
62+
63+
# Escape hatch for a deliberate hand-off.
64+
[ "${CDKD_SKIP_WORKTREE_OWNER_GATE:-}" = "1" ] && exit 0
65+
66+
target="${file_path:-$hook_cwd}"
67+
[ -n "$target" ] || exit 0
68+
69+
# Resolve the directory to ask git about (the file may not exist yet on
70+
# a Write, so use its parent).
71+
if [ -d "$target" ]; then
72+
probe_dir="$target"
73+
else
74+
probe_dir=$(dirname "$target")
75+
fi
76+
[ -d "$probe_dir" ] || exit 0
77+
78+
git_dir=$(git -C "$probe_dir" rev-parse --absolute-git-dir 2>/dev/null || echo "")
79+
[ -n "$git_dir" ] || exit 0
80+
81+
# Only LINKED worktrees are gated. A linked worktree's git dir is
82+
# `<common>/worktrees/<name>`; the main tree's is plain `.git` and is
83+
# already covered by main-tree-edit-gate.sh / main-tree-branch-gate.sh.
84+
case "$git_dir" in
85+
*/worktrees/*) : ;;
86+
*) exit 0 ;;
87+
esac
88+
89+
# Repo opt-in, matching branch-gate.sh: only repos carrying the
90+
# markgate convention participate.
91+
top=$(git -C "$probe_dir" rev-parse --show-toplevel 2>/dev/null || echo "")
92+
[ -n "$top" ] && [ -f "$top/.markgate.yml" ] || exit 0
93+
94+
sentinel="$git_dir/session-owner"
95+
worktree_name=$(basename "$git_dir")
96+
97+
claim() {
98+
printf '%s %s\n' "$session" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$sentinel" 2>/dev/null || true
99+
}
100+
101+
if [ ! -f "$sentinel" ]; then
102+
claim
103+
exit 0
104+
fi
105+
106+
owner=$(awk '{print $1}' "$sentinel" 2>/dev/null || echo "")
107+
claimed_at=$(awk '{print $2}' "$sentinel" 2>/dev/null || echo "")
108+
109+
# Unreadable / malformed sentinel: reclaim rather than block.
110+
[ -n "$owner" ] || { claim; exit 0; }
111+
112+
[ "$owner" = "$session" ] && exit 0
113+
114+
# Stale owner => take over silently.
115+
if [ -n "$claimed_at" ]; then
116+
now_epoch=$(date -u +%s)
117+
# BSD date (macOS) and GNU date disagree on parse flags; try both and
118+
# treat an unparseable stamp as "not stale" (conservative: keep the
119+
# lock rather than silently stealing it).
120+
claim_epoch=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$claimed_at" +%s 2>/dev/null \
121+
|| date -u -d "$claimed_at" +%s 2>/dev/null || echo "")
122+
if [ -n "$claim_epoch" ]; then
123+
age_h=$(( (now_epoch - claim_epoch) / 3600 ))
124+
if [ "$age_h" -ge "$OWNER_TTL_HOURS" ]; then
125+
claim
126+
echo "worktree-owner-gate: took over '$worktree_name' from an owner idle ${age_h}h." >&2
127+
exit 0
128+
fi
129+
fi
130+
fi
131+
132+
echo "Blocked by worktree-owner-gate: '$worktree_name' is owned by another session." >&2
133+
echo " worktree: $top" >&2
134+
echo " owner session: $owner (claimed $claimed_at)" >&2
135+
echo " your session: $session" >&2
136+
echo " target: $target" >&2
137+
echo "" >&2
138+
echo "Two sessions editing one worktree is how uncommitted work gets destroyed" >&2
139+
echo "(2026-08-09: a session reverted another's finished, tested fix because it" >&2
140+
echo "could not attribute the diff)." >&2
141+
echo "" >&2
142+
echo "Use your OWN worktree for this lane:" >&2
143+
echo " git worktree add .claude/worktrees/<branch> -b <branch> origin/main" >&2
144+
echo "" >&2
145+
echo "If the other session is genuinely finished, take ownership explicitly:" >&2
146+
echo " rm \"$sentinel\"" >&2
147+
exit 2

0 commit comments

Comments
 (0)