Files
claude-mem/tests/test_hooks.py
John Reilly Pospos 408d4019b3 Fix git commits re-populating dirty-files in default mode (#22)
In default mode, after the memory-updater processes files and SubagentStop
clears dirty-files, a git commit via Bash would re-trigger PostToolUse,
extract committed files via git diff-tree, and re-add them to dirty-files.
This caused the Stop hook to fire again for already-processed files.

Add early-return guard so default mode skips git commit processing (files
are already tracked via Edit/Write hooks). Also fix existing test to run
commit context enrichment in gitmode where it belongs.
2026-02-14 12:57:33 +10:00

808 lines
30 KiB
Python

"""Tests for hook scripts."""
import json
import os
import subprocess
import sys
from pathlib import Path
# Add scripts directory to path
SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
class TestPostToolUseHook:
"""Tests for post-tool-use.py hook."""
def _make_tool_input(self, file_path: str, tool_name: str = "Edit") -> str:
"""Create JSON input for post-tool-use hook (Edit/Write tools)."""
return json.dumps(
{
"tool_name": tool_name,
"tool_input": {"file_path": file_path},
}
)
def _make_bash_input(self, command: str) -> str:
"""Create JSON input for Bash tool."""
return json.dumps(
{
"tool_name": "Bash",
"tool_input": {"command": command},
}
)
def test_creates_dirty_file(self, tmp_path):
"""Hook creates .claude/auto-memory/dirty-files if it doesn't exist."""
file_path = str(tmp_path / "file.py")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_tool_input(file_path),
capture_output=True,
text=True,
)
assert result.returncode == 0
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert dirty_file.exists()
def test_appends_paths(self, tmp_path):
"""Hook appends file paths to dirty file."""
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.parent.mkdir(parents=True)
existing_file = str(tmp_path / "existing" / "file.py")
dirty_file.write_text(existing_file + "\n")
new_file = str(tmp_path / "new" / "file.py")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_tool_input(new_file),
capture_output=True,
text=True,
)
content = dirty_file.read_text()
assert existing_file in content
assert new_file in content
def test_no_output(self, tmp_path):
"""Hook produces no output (zero token cost)."""
file_path = str(tmp_path / "file.py")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_tool_input(file_path),
capture_output=True,
text=True,
)
assert result.stdout == ""
assert result.stderr == ""
def test_handles_missing_input(self):
"""Hook exits gracefully when input is missing."""
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={},
input="{}",
capture_output=True,
text=True,
)
assert result.returncode == 0
def test_excludes_claude_directory(self, tmp_path):
"""Hook excludes files in .claude/ directory."""
file_path = str(tmp_path / ".claude" / "state.json")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_tool_input(file_path),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert not dirty_file.exists()
def test_excludes_claude_md(self, tmp_path):
"""Hook excludes CLAUDE.md files."""
file_path = str(tmp_path / "CLAUDE.md")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_tool_input(file_path),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert not dirty_file.exists()
def test_excludes_files_outside_project(self, tmp_path):
"""Hook excludes files outside project directory."""
file_path = "/outside/project/file.py"
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_tool_input(file_path),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert not dirty_file.exists()
# Bash command tracking tests
def test_tracks_rm_command(self, tmp_path):
"""Hook tracks files from rm command."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("rm file.py"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert dirty_file.exists()
content = dirty_file.read_text()
assert "file.py" in content
def test_tracks_rm_with_flags(self, tmp_path):
"""Hook tracks files from rm -rf command."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("rm -rf src/old_module"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert dirty_file.exists()
content = dirty_file.read_text()
assert "old_module" in content
def test_tracks_rm_multiple_files(self, tmp_path):
"""Hook tracks multiple files from rm command."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("rm file1.py file2.py file3.py"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert dirty_file.exists()
content = dirty_file.read_text()
assert "file1.py" in content
assert "file2.py" in content
assert "file3.py" in content
def test_tracks_git_rm_command(self, tmp_path):
"""Hook tracks files from git rm command."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("git rm obsolete.py"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert dirty_file.exists()
content = dirty_file.read_text()
assert "obsolete.py" in content
def test_tracks_mv_source(self, tmp_path):
"""Hook tracks source file from mv command."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("mv old_name.py new_name.py"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert dirty_file.exists()
content = dirty_file.read_text()
assert "old_name.py" in content
# Should NOT track destination
assert content.count("new_name.py") == 0 or "old_name.py" in content
def test_tracks_unlink_command(self, tmp_path):
"""Hook tracks files from unlink command."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("unlink temp.txt"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert dirty_file.exists()
content = dirty_file.read_text()
assert "temp.txt" in content
def test_ignores_non_file_bash_commands(self, tmp_path):
"""Hook ignores Bash commands that don't modify files."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
# Test various non-file commands
non_file_commands = [
"git status",
"ls -la",
"cat file.py",
"npm install",
"python --version",
"echo hello",
"grep pattern file.py",
]
for cmd in non_file_commands:
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input(cmd),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert not dirty_file.exists()
def test_bash_no_output(self, tmp_path):
"""Hook produces no output for Bash commands (zero token cost)."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("rm file.py"),
capture_output=True,
text=True,
)
assert result.stdout == ""
assert result.stderr == ""
def test_stops_at_shell_operators(self, tmp_path):
"""Hook stops parsing at shell operators like && || ; |."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
# Test && operator - should only track file.py, not 'echo' or 'done'
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("rm file.py && echo done"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert dirty_file.exists()
content = dirty_file.read_text()
assert "file.py" in content
assert "echo" not in content
assert "done" not in content
assert "&&" not in content
def test_stops_at_semicolon(self, tmp_path):
"""Hook stops parsing at semicolon operator."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("rm old.py ; ls -la"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
content = dirty_file.read_text()
assert "old.py" in content
assert "ls" not in content
assert "-la" not in content
def test_stops_at_pipe(self, tmp_path):
"""Hook stops parsing at pipe operator."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("rm -rf build | tee log.txt"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
content = dirty_file.read_text()
assert "build" in content
assert "tee" not in content
assert "log.txt" not in content
def test_stops_at_redirect(self, tmp_path):
"""Hook stops parsing at redirect operators."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("rm deleted.py > /dev/null"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
content = dirty_file.read_text()
assert "deleted.py" in content
assert "/dev/null" not in content
def test_skip_git_commit_in_default_mode(self, tmp_path):
"""Hook skips git commit commands in default mode (files tracked via Edit/Write)."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("git commit -m 'Add feature'"),
capture_output=True,
text=True,
)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
assert not dirty_file.exists()
class TestStopHook:
"""Tests for trigger.py Stop hook behavior."""
def test_passes_when_empty(self, tmp_path):
"""Hook passes through when no dirty files exist."""
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input="{}",
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
def test_passes_when_active(self, tmp_path):
"""Hook passes through when stop_hook_active is true."""
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.parent.mkdir(parents=True)
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input='{"stop_hook_active": true}',
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
def test_blocks_with_files(self, tmp_path):
"""Hook blocks and outputs JSON when dirty files exist."""
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.parent.mkdir(parents=True)
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input="{}",
capture_output=True,
text=True,
)
assert result.returncode == 0
output = json.loads(result.stdout)
assert output["decision"] == "block"
assert "memory-updater" in output["reason"]
def test_json_format(self, tmp_path):
"""Hook output is valid JSON with required fields."""
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.parent.mkdir(parents=True)
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input="{}",
capture_output=True,
text=True,
)
output = json.loads(result.stdout)
assert "decision" in output
assert "reason" in output
def test_deduplicates_files(self, tmp_path):
"""Hook deduplicates file paths in output."""
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.parent.mkdir(parents=True)
dirty_file.write_text("/file.py\n/file.py\n/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input="{}",
capture_output=True,
text=True,
)
output = json.loads(result.stdout)
# Should only mention file once
assert output["reason"].count("/file.py") == 1
def test_limits_file_count(self, tmp_path):
"""Hook limits file list to 20 files max."""
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.parent.mkdir(parents=True)
files = [f"/file{i}.py" for i in range(30)]
dirty_file.write_text("\n".join(files) + "\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input="{}",
capture_output=True,
text=True,
)
output = json.loads(result.stdout)
# Extract the file list portion and count files
reason = output["reason"]
# Files are listed after "changed files: " and before the next sentence
files_part = reason.split("changed files: ")[1].split("'.")[0]
file_count = files_part.count(",") + 1
assert file_count <= 20
def test_handles_invalid_json_input(self, tmp_path):
"""Hook handles invalid JSON input gracefully."""
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.parent.mkdir(parents=True)
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input="not valid json",
capture_output=True,
text=True,
)
# Should still work, treating input as empty
assert result.returncode == 0
output = json.loads(result.stdout)
assert output["decision"] == "block"
def test_output_includes_task_params(self, tmp_path):
"""Stop output includes run_in_background and bypassPermissions instructions."""
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.parent.mkdir(parents=True)
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input="{}",
capture_output=True,
text=True,
)
output = json.loads(result.stdout)
assert "run_in_background" in output["reason"]
assert "bypassPermissions" in output["reason"]
class TestPreToolUseHook:
"""Tests for trigger.py PreToolUse hook behavior."""
def _make_pre_tool_input(self, command: str) -> str:
"""Create JSON input simulating PreToolUse for a Bash command."""
return json.dumps(
{
"hook_event_name": "PreToolUse",
"tool_input": {"command": command},
}
)
def _setup_gitmode(self, tmp_path):
"""Set up gitmode configuration."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.json"
config_file.write_text(json.dumps({"triggerMode": "gitmode"}))
def test_passthrough_in_default_mode(self, tmp_path):
"""PreToolUse passes through in default trigger mode."""
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.parent.mkdir(parents=True, exist_ok=True)
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input=self._make_pre_tool_input("git commit -m 'test'"),
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
def test_passthrough_non_git_commit_in_gitmode(self, tmp_path):
"""PreToolUse passes through for non-git-commit commands in gitmode."""
self._setup_gitmode(tmp_path)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input=self._make_pre_tool_input("git status"),
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
def test_denies_git_commit_with_dirty_files_in_gitmode(self, tmp_path):
"""PreToolUse denies git commit in gitmode when dirty files exist."""
self._setup_gitmode(tmp_path)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input=self._make_pre_tool_input("git commit -m 'test'"),
capture_output=True,
text=True,
)
assert result.returncode == 0
output = json.loads(result.stdout)
hook_output = output["hookSpecificOutput"]
assert hook_output["hookEventName"] == "PreToolUse"
assert hook_output["permissionDecision"] == "deny"
assert "memory-updater" in hook_output["permissionDecisionReason"]
def test_passthrough_git_commit_no_dirty_files_in_gitmode(self, tmp_path):
"""PreToolUse passes through git commit in gitmode when no dirty files."""
self._setup_gitmode(tmp_path)
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input=self._make_pre_tool_input("git commit -m 'test'"),
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
def test_deny_output_includes_task_params(self, tmp_path):
"""PreToolUse deny output includes run_in_background and bypassPermissions."""
self._setup_gitmode(tmp_path)
dirty_file = tmp_path / ".claude" / "auto-memory" / "dirty-files"
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input=self._make_pre_tool_input("git commit -m 'test'"),
capture_output=True,
text=True,
)
output = json.loads(result.stdout)
reason = output["hookSpecificOutput"]["permissionDecisionReason"]
assert "run_in_background" in reason
assert "bypassPermissions" in reason
class TestSubagentStopHook:
"""Tests for trigger.py SubagentStop hook behavior."""
def test_clears_dirty_files_with_config(self, tmp_path):
"""Clears dirty-files when config.json and dirty-files both present."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "default"}))
dirty_file = config_dir / "dirty-files"
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input=json.dumps({"hook_event_name": "SubagentStop"}),
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
assert dirty_file.read_text() == ""
def test_noop_when_no_config(self, tmp_path):
"""Does nothing when config.json is missing (plugin not active)."""
dirty_dir = tmp_path / ".claude" / "auto-memory"
dirty_dir.mkdir(parents=True)
dirty_file = dirty_dir / "dirty-files"
dirty_file.write_text("/path/to/file.py\n")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input=json.dumps({"hook_event_name": "SubagentStop"}),
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
# dirty-files should remain unchanged
assert dirty_file.read_text() == "/path/to/file.py\n"
def test_noop_when_dirty_files_empty(self, tmp_path):
"""Does nothing when dirty-files is empty."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "default"}))
dirty_file = config_dir / "dirty-files"
dirty_file.write_text("")
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
result = subprocess.run(
[sys.executable, SCRIPTS_DIR / "trigger.py"],
env={**os.environ, **env},
input=json.dumps({"hook_event_name": "SubagentStop"}),
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
assert dirty_file.read_text() == ""
class TestGitCommitContext:
"""Tests for git commit context enrichment."""
def _make_bash_input(self, command: str) -> str:
"""Create JSON input for Bash tool."""
return json.dumps(
{
"tool_name": "Bash",
"tool_input": {"command": command},
}
)
def _init_git_repo(self, tmp_path):
"""Initialize a git repo with an initial commit.
Creates an initial commit so subsequent commits have a parent
for git diff-tree to compare against.
"""
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=tmp_path,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=tmp_path,
capture_output=True,
)
# Create initial commit so later commits have a parent
init_file = tmp_path / ".gitkeep"
init_file.write_text("")
subprocess.run(["git", "add", ".gitkeep"], cwd=tmp_path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=tmp_path,
capture_output=True,
)
def test_handle_git_commit_non_git_directory(self, tmp_path):
"""handle_git_commit returns empty when not a git repo."""
# Import the function directly
sys.path.insert(0, str(SCRIPTS_DIR))
from importlib import import_module
post_tool_use = import_module("post-tool-use")
files, context = post_tool_use.handle_git_commit(str(tmp_path))
assert files == []
assert context is None
# Cleanup
sys.path.pop(0)
sys.modules.pop("post-tool-use", None)
def test_handle_git_commit_extracts_files_and_context(self, tmp_path):
"""handle_git_commit extracts files and commit context from git."""
# Initialize git repo
self._init_git_repo(tmp_path)
# Create and commit a file
test_file = tmp_path / "feature.py"
test_file.write_text("print('hello')")
subprocess.run(["git", "add", "feature.py"], cwd=tmp_path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Add feature"],
cwd=tmp_path,
capture_output=True,
)
# Import the function directly
sys.path.insert(0, str(SCRIPTS_DIR))
from importlib import import_module
post_tool_use = import_module("post-tool-use")
files, context = post_tool_use.handle_git_commit(str(tmp_path))
# Verify files list contains our file
assert len(files) == 1
assert "feature.py" in files[0]
# Verify context has hash and message
assert context is not None
assert "hash" in context
assert len(context["hash"]) == 7 # Short hash
assert context["message"] == "Add feature"
# Cleanup
sys.path.pop(0)
sys.modules.pop("post-tool-use", None)
def test_commit_enriches_dirty_files_with_context(self, tmp_path):
"""Git commit command enriches dirty files with inline context in gitmode."""
# Initialize git repo
self._init_git_repo(tmp_path)
# Set up gitmode config (commit enrichment only applies in gitmode)
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "config.json").write_text(json.dumps({"triggerMode": "gitmode"}))
# Create and commit a file
test_file = tmp_path / "module.py"
test_file.write_text("# module")
subprocess.run(["git", "add", "module.py"], cwd=tmp_path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Add module"],
cwd=tmp_path,
capture_output=True,
)
# Run hook with git commit command
env = {"CLAUDE_PROJECT_DIR": str(tmp_path)}
subprocess.run(
[sys.executable, SCRIPTS_DIR / "post-tool-use.py"],
env={**os.environ, **env},
input=self._make_bash_input("git commit -m 'Add module'"),
capture_output=True,
text=True,
)
# Check dirty files contain commit context
dirty_file = config_dir / "dirty-files"
assert dirty_file.exists()
content = dirty_file.read_text()
# Should have file path with inline commit context
assert "module.py" in content
assert "[" in content # Context marker
assert ":" in content # hash: message separator
assert "Add module" in content