feat(lib): surgical memory-commit helper + deterministic scope tests (T1/T2/T2-bis)
Foundation for the coupled-capitalize invariant (Frame 2): commit ONLY .claude/memory + .claude/tasks, never `git add -A`. Safety lives in the pathspec because automation removes the human diff review. Proven on real git behavior, not assumed: - T1/T2: dangling code (untracked or pre-staged) never embarked. - T2-bis: `git commit -- pathspec` takes the working tree, not a stale index. - T3 idempotent, T4 fail-closed on broken state, T5 TODO.md in scope. _changed_paths filters to paths with real changes: `git commit -- pathspec` aborts the whole commit on a no-match pathspec (e.g. empty .claude/tasks), unlike `git add` which tolerates it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W9sqAwZxBMZSynZoVrEJhd
This commit is contained in:
parent
8536c733ec
commit
58cb91d2b7
100
lib/memory-commit.sh
Executable file
100
lib/memory-commit.sh
Executable file
@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# memory-commit.sh — surgically commit ONLY .claude/memory + .claude/tasks.
|
||||||
|
#
|
||||||
|
# Used by the dev-flow capitalize step (and, later, the v2 Stop hook) to couple
|
||||||
|
# the memory commit to the flow. Safety lives in the PATHSPEC, never in a human
|
||||||
|
# diff review — automation removes that review, so the scope must be airtight:
|
||||||
|
# code that happens to be dirty or staged is NEVER embarked.
|
||||||
|
#
|
||||||
|
# Usage (CLI):
|
||||||
|
# memory-commit.sh pending # exit 0 if memory/tasks have changes, 1 if clean
|
||||||
|
# memory-commit.sh commit "<message>" # surgical commit; exit 0 ok/no-op, 3 unsafe state
|
||||||
|
#
|
||||||
|
# Sourceable: `memory_pending` and `commit_memory` for the v2 hook.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
MC_PATHS=(".claude/memory" ".claude/tasks")
|
||||||
|
|
||||||
|
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
# True (0) when the repo is in a state where we must NOT auto-commit:
|
||||||
|
# detached HEAD, or a merge/rebase/cherry-pick in progress.
|
||||||
|
_unsafe_state() {
|
||||||
|
local gitdir
|
||||||
|
gitdir="$(git rev-parse --git-dir 2>/dev/null)" || return 0
|
||||||
|
if [ -e "$gitdir/MERGE_HEAD" ] || [ -e "$gitdir/rebase-merge" ] ||
|
||||||
|
[ -e "$gitdir/rebase-apply" ] || [ -e "$gitdir/CHERRY_PICK_HEAD" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
git symbolic-ref -q HEAD >/dev/null 2>&1 || return 0 # detached HEAD
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scoped paths that have actual pending changes. A bare/empty path (e.g. an
|
||||||
|
# empty .claude/tasks dir) is excluded: `git commit -- <pathspec>` aborts the
|
||||||
|
# WHOLE commit on a pathspec that matches no known file, even though `git add`
|
||||||
|
# tolerates it. So scope = only paths git would accept.
|
||||||
|
_changed_paths() {
|
||||||
|
local p
|
||||||
|
for p in "${MC_PATHS[@]}"; do
|
||||||
|
[ -e "$p" ] || continue
|
||||||
|
[ -n "$(git status --porcelain -- "$p" 2>/dev/null)" ] && printf '%s\n' "$p"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 0 if something is pending under the scoped paths, 1 if clean / absent.
|
||||||
|
memory_pending() {
|
||||||
|
_in_git_repo || return 1
|
||||||
|
local changed
|
||||||
|
mapfile -t changed < <(_changed_paths)
|
||||||
|
[ "${#changed[@]}" -gt 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Surgical commit of the scoped paths only. Returns 0 (ok or no-op), 3 (unsafe).
|
||||||
|
commit_memory() {
|
||||||
|
local msg="${1:?commit message required}"
|
||||||
|
_in_git_repo || {
|
||||||
|
echo "memory-commit: not a git repo — skip"
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if _unsafe_state; then
|
||||||
|
echo "memory-commit: detached HEAD or merge/rebase in progress — skip (no commit)"
|
||||||
|
return 3
|
||||||
|
fi
|
||||||
|
local changed
|
||||||
|
mapfile -t changed < <(_changed_paths)
|
||||||
|
if [ "${#changed[@]}" -eq 0 ]; then
|
||||||
|
echo "memory-commit: nothing pending — no-op"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Re-stage working-tree content of the scoped paths over any stale index entry,
|
||||||
|
# then commit ONLY those paths. The pathspec on `git commit` makes it a partial
|
||||||
|
# commit: other staged files (dangling code) are not recorded.
|
||||||
|
git add -- "${changed[@]}"
|
||||||
|
if git diff --cached --quiet -- "${changed[@]}"; then
|
||||||
|
echo "memory-commit: only ignored/no-op changes — no-op"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
git commit -m "$msg" -- "${changed[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local cmd="${1:-}"
|
||||||
|
case "$cmd" in
|
||||||
|
pending) memory_pending ;;
|
||||||
|
commit)
|
||||||
|
shift
|
||||||
|
commit_memory "${1:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: memory-commit.sh {pending | commit <message>}" >&2
|
||||||
|
return 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main only when executed, not when sourced.
|
||||||
|
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
||||||
|
main "$@"
|
||||||
|
fi
|
||||||
115
lib/tests/run-deterministic.sh
Executable file
115
lib/tests/run-deterministic.sh
Executable file
@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deterministic tests for lib/memory-commit.sh.
|
||||||
|
#
|
||||||
|
# Proves the surgical-scope safety contract on REAL git behavior (not assumed):
|
||||||
|
# - dangling code (untracked OR pre-staged) is NEVER embarked in a memory commit
|
||||||
|
# - stale-staged memory (version A) yields the WORKING-TREE version (B) that
|
||||||
|
# capitalize just wrote — `git add --` re-stage neutralizes the stale index
|
||||||
|
# - clean tree → no-op ; broken git state → skip ; TODO.md is in scope
|
||||||
|
#
|
||||||
|
# No -e: run every test and report, even after a failure.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
HELPER="$HERE/../memory-commit.sh"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; PASS=$((PASS + 1)); }
|
||||||
|
ko() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAIL=$((FAIL + 1)); }
|
||||||
|
|
||||||
|
# Fresh throwaway repo with a baseline commit (.claude/memory tracked, src/ tracked).
|
||||||
|
new_repo() {
|
||||||
|
local d
|
||||||
|
d="$(mktemp -d)"
|
||||||
|
git -C "$d" init -q
|
||||||
|
git -C "$d" config user.email t@t.t
|
||||||
|
git -C "$d" config user.name tester
|
||||||
|
mkdir -p "$d/.claude/memory" "$d/.claude/tasks" "$d/src"
|
||||||
|
printf 'baseline\n' >"$d/.claude/memory/decisions.md"
|
||||||
|
printf 'src baseline\n' >"$d/src/app.txt"
|
||||||
|
git -C "$d" add -A
|
||||||
|
git -C "$d" commit -qm baseline
|
||||||
|
printf '%s' "$d"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Files recorded in HEAD (no diff noise).
|
||||||
|
head_files() { git -C "$1" diff-tree --no-commit-id --name-only -r HEAD; }
|
||||||
|
|
||||||
|
echo "T1 — untracked dangling code is NOT embarked"
|
||||||
|
R="$(new_repo)"
|
||||||
|
printf 'NEW DECISION\n' >>"$R/.claude/memory/decisions.md"
|
||||||
|
printf 'junk\n' >"$R/src/dangling.txt"
|
||||||
|
(cd "$R" && "$HELPER" commit "chore(memory): T1") >/dev/null 2>&1
|
||||||
|
committed="$(head_files "$R")"
|
||||||
|
status="$(git -C "$R" status --porcelain)"
|
||||||
|
printf ' committed: [%s]\n' "$committed"
|
||||||
|
printf ' status : [%s]\n' "$status"
|
||||||
|
if [ "$committed" = ".claude/memory/decisions.md" ]; then ok "only memory committed"; else ko "expected only memory, got [$committed]"; fi
|
||||||
|
if printf '%s\n' "$status" | grep -q '^?? src/dangling.txt$'; then ok "dangling code still untracked"; else ko "dangling code not left untracked"; fi
|
||||||
|
rm -rf "$R"
|
||||||
|
|
||||||
|
echo "T2 — PRE-STAGED dangling code is NOT embarked, stays staged"
|
||||||
|
R="$(new_repo)"
|
||||||
|
printf 'NEW DECISION\n' >>"$R/.claude/memory/decisions.md"
|
||||||
|
printf 'junk\n' >"$R/src/dangling.txt"
|
||||||
|
git -C "$R" add src/dangling.txt
|
||||||
|
(cd "$R" && "$HELPER" commit "chore(memory): T2") >/dev/null 2>&1
|
||||||
|
committed="$(head_files "$R")"
|
||||||
|
status="$(git -C "$R" status --porcelain)"
|
||||||
|
printf ' committed: [%s]\n' "$committed"
|
||||||
|
printf ' status : [%s]\n' "$status"
|
||||||
|
if [ "$committed" = ".claude/memory/decisions.md" ]; then ok "only memory committed"; else ko "expected only memory, got [$committed]"; fi
|
||||||
|
if printf '%s\n' "$status" | grep -q '^A src/dangling.txt$'; then ok "pre-staged code remained staged, not embarked"; else ko "pre-staged code state wrong"; fi
|
||||||
|
rm -rf "$R"
|
||||||
|
|
||||||
|
echo "T2-bis — stale-staged memory (A) vs working tree (B): commit must take B"
|
||||||
|
R="$(new_repo)"
|
||||||
|
printf 'STALE STAGED VERSION A\n' >>"$R/.claude/memory/decisions.md"
|
||||||
|
git -C "$R" add .claude/memory/decisions.md # index = A
|
||||||
|
printf 'baseline\nFRESH WORKING B\n' >"$R/.claude/memory/decisions.md" # working = B (no 'A' line)
|
||||||
|
(cd "$R" && "$HELPER" commit "chore(memory): T2bis") >/dev/null 2>&1
|
||||||
|
blob="$(git -C "$R" show HEAD:.claude/memory/decisions.md)"
|
||||||
|
printf ' committed blob:\n'
|
||||||
|
printf '%s\n' "$blob" | sed 's/^/ | /'
|
||||||
|
if printf '%s' "$blob" | grep -q 'FRESH WORKING B' && ! printf '%s' "$blob" | grep -q 'STALE STAGED VERSION A'; then
|
||||||
|
ok "working-tree (B) committed, stale staged (A) discarded"
|
||||||
|
else
|
||||||
|
ko "stale staged version (A) leaked into the commit"
|
||||||
|
fi
|
||||||
|
rm -rf "$R"
|
||||||
|
|
||||||
|
echo "T3 — clean tree → no-op, exit 0, HEAD unchanged"
|
||||||
|
R="$(new_repo)"
|
||||||
|
before="$(git -C "$R" rev-parse HEAD)"
|
||||||
|
(cd "$R" && "$HELPER" commit "chore(memory): T3")
|
||||||
|
rc=$?
|
||||||
|
after="$(git -C "$R" rev-parse HEAD)"
|
||||||
|
printf ' exit=%s HEAD %s\n' "$rc" "$([ "$before" = "$after" ] && echo unchanged || echo CHANGED)"
|
||||||
|
if [ "$rc" -eq 0 ] && [ "$before" = "$after" ]; then ok "no-op, exit 0, HEAD unchanged"; else ko "expected no-op exit 0, got exit $rc"; fi
|
||||||
|
rm -rf "$R"
|
||||||
|
|
||||||
|
echo "T4 — broken git state (mid-merge) → skip, exit 3, no commit"
|
||||||
|
R="$(new_repo)"
|
||||||
|
printf 'CHANGE\n' >>"$R/.claude/memory/decisions.md"
|
||||||
|
: >"$R/.git/MERGE_HEAD"
|
||||||
|
before="$(git -C "$R" rev-parse HEAD)"
|
||||||
|
(cd "$R" && "$HELPER" commit "chore(memory): T4")
|
||||||
|
rc=$?
|
||||||
|
after="$(git -C "$R" rev-parse HEAD)"
|
||||||
|
printf ' exit=%s HEAD %s\n' "$rc" "$([ "$before" = "$after" ] && echo unchanged || echo CHANGED)"
|
||||||
|
if [ "$rc" -eq 3 ] && [ "$before" = "$after" ]; then ok "unsafe state skipped, exit 3, no commit"; else ko "expected skip exit 3, got exit $rc"; fi
|
||||||
|
rm -rf "$R"
|
||||||
|
|
||||||
|
echo "T5 — .claude/tasks/TODO.md is in scope"
|
||||||
|
R="$(new_repo)"
|
||||||
|
printf -- '- [ ] task\n' >"$R/.claude/tasks/TODO.md"
|
||||||
|
(cd "$R" && "$HELPER" commit "chore(memory): T5") >/dev/null 2>&1
|
||||||
|
committed="$(head_files "$R")"
|
||||||
|
printf ' committed: [%s]\n' "$committed"
|
||||||
|
if printf '%s\n' "$committed" | grep -q '^.claude/tasks/TODO.md$'; then ok "TODO.md embarked"; else ko "TODO.md not embarked"; fi
|
||||||
|
rm -rf "$R"
|
||||||
|
|
||||||
|
echo
|
||||||
|
printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
Loading…
Reference in New Issue
Block a user