diff --git a/lib/memory-commit.sh b/lib/memory-commit.sh new file mode 100755 index 0000000..4e109e4 --- /dev/null +++ b/lib/memory-commit.sh @@ -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 "" # 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 -- ` 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 }" >&2 + return 2 + ;; + esac +} + +# Run main only when executed, not when sourced. +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" +fi diff --git a/lib/tests/run-deterministic.sh b/lib/tests/run-deterministic.sh new file mode 100755 index 0000000..25c4c3b --- /dev/null +++ b/lib/tests/run-deterministic.sh @@ -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 ]