diff --git a/lib/memory-commit.sh b/lib/memory-commit.sh index 4e109e4..52b7065 100755 --- a/lib/memory-commit.sh +++ b/lib/memory-commit.sh @@ -10,6 +10,10 @@ # 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 # +# Output contract for `commit`: diagnostics go to stderr; on a real commit the +# short hash of the MEMORY commit is the ONLY thing on stdout (empty on no-op or +# unsafe), so callers can capture it: `mem_hash=$(memory-commit.sh commit "msg")`. +# # Sourceable: `memory_pending` and `commit_memory` for the v2 hook. set -uo pipefail @@ -52,20 +56,21 @@ memory_pending() { } # Surgical commit of the scoped paths only. Returns 0 (ok or no-op), 3 (unsafe). +# On a real commit, prints the memory-commit short hash to stdout (stderr = diag). commit_memory() { local msg="${1:?commit message required}" _in_git_repo || { - echo "memory-commit: not a git repo — skip" + echo "memory-commit: not a git repo — skip" >&2 return 3 } if _unsafe_state; then - echo "memory-commit: detached HEAD or merge/rebase in progress — skip (no commit)" + echo "memory-commit: detached HEAD or merge/rebase in progress — skip (no commit)" >&2 return 3 fi local changed mapfile -t changed < <(_changed_paths) if [ "${#changed[@]}" -eq 0 ]; then - echo "memory-commit: nothing pending — no-op" + echo "memory-commit: nothing pending — no-op" >&2 return 0 fi # Re-stage working-tree content of the scoped paths over any stale index entry, @@ -73,10 +78,13 @@ commit_memory() { # 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" + echo "memory-commit: only ignored/no-op changes — no-op" >&2 return 0 fi - git commit -m "$msg" -- "${changed[@]}" + # Contract: diagnostics go to stderr; on success ONLY the memory-commit short + # hash goes to stdout, so a caller can do `mem_hash=$(... commit "msg")`. + git commit -q -m "$msg" -- "${changed[@]}" + git rev-parse --short HEAD } main() { diff --git a/lib/tests/run-deterministic.sh b/lib/tests/run-deterministic.sh index 25c4c3b..1a5a78a 100755 --- a/lib/tests/run-deterministic.sh +++ b/lib/tests/run-deterministic.sh @@ -110,6 +110,37 @@ 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 "T6 — stdout contract: commit→hash, no-op→empty, unsafe→empty" +R="$(new_repo)" +printf 'CHG\n' >>"$R/.claude/memory/decisions.md" +out="$( (cd "$R" && "$HELPER" commit "chore(memory): T6") 2>/dev/null )" +expected="$(git -C "$R" rev-parse --short HEAD)" +printf ' commit stdout=[%s] HEAD=[%s]\n' "$out" "$expected" +if [ -n "$out" ] && [ "$out" = "$expected" ]; then ok "commit emits the memory-commit hash on stdout"; else ko "hash mismatch [$out] != [$expected]"; fi +out="$( (cd "$R" && "$HELPER" commit "chore(memory): T6-noop") 2>/dev/null )" +printf ' no-op stdout=[%s]\n' "$out" +if [ -z "$out" ]; then ok "no-op emits nothing on stdout"; else ko "no-op leaked stdout [$out]"; fi +printf 'CHG2\n' >>"$R/.claude/memory/decisions.md" +: >"$R/.git/MERGE_HEAD" +out="$( (cd "$R" && "$HELPER" commit "chore(memory): T6-unsafe") 2>/dev/null )" +rc=$? +printf ' unsafe stdout=[%s] exit=%s\n' "$out" "$rc" +if [ -z "$out" ] && [ "$rc" -eq 3 ]; then ok "unsafe emits nothing on stdout, exit 3"; else ko "unsafe leaked stdout [$out] or rc $rc"; fi +rm -rf "$R" + +echo "T7 — double run: at most one commit (real run, not by construction)" +R="$(new_repo)" +printf 'ONCE\n' >>"$R/.claude/memory/decisions.md" +base="$(git -C "$R" rev-list --count HEAD)" +h1="$( (cd "$R" && "$HELPER" commit "chore(memory): T7-run1") 2>/dev/null )" +after1="$(git -C "$R" rev-list --count HEAD)" +h2="$( (cd "$R" && "$HELPER" commit "chore(memory): T7-run2") 2>/dev/null )" +after2="$(git -C "$R" rev-list --count HEAD)" +printf ' counts base=%s after1=%s after2=%s ; h1=[%s] h2=[%s]\n' "$base" "$after1" "$after2" "$h1" "$h2" +if [ "$after1" -eq "$((base + 1))" ] && [ -n "$h1" ]; then ok "run1 created exactly one commit (hash emitted)"; else ko "run1 commit count wrong"; fi +if [ "$after2" -eq "$after1" ] && [ -z "$h2" ]; then ok "run2 is a no-op (no 2nd commit, empty stdout)"; else ko "run2 was not a no-op"; fi +rm -rf "$R" + echo printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ]