diff --git a/codex/plugins/ox/.codex-plugin/plugin.json b/codex/plugins/ox/.codex-plugin/plugin.json index 777eebb..6593290 100644 --- a/codex/plugins/ox/.codex-plugin/plugin.json +++ b/codex/plugins/ox/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ox", - "version": "0.0.19", + "version": "0.0.20", "description": "Base plugin — commit command, code quality hooks, auto-format and check hooks for all projects", "author": { "name": "Oxidian" diff --git a/codex/plugins/ox/hooks.json b/codex/plugins/ox/hooks.json index 4920305..c420273 100644 --- a/codex/plugins/ox/hooks.json +++ b/codex/plugins/ox/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; cache_runner=\"$HOME/.codex/plugins/cache/oxidian/ox/0.0.19/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; elif [ -f \"$cache_runner\" ]; then runner=\"$cache_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner, $repo_runner, and $cache_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action fast'", + "command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; cache_runner=\"$HOME/.codex/plugins/cache/oxidian/ox/0.0.20/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; elif [ -f \"$cache_runner\" ]; then runner=\"$cache_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner, $repo_runner, and $cache_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action fast'", "timeout": 30, "statusMessage": "Running fast checks" } @@ -18,7 +18,7 @@ "hooks": [ { "type": "command", - "command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; cache_runner=\"$HOME/.codex/plugins/cache/oxidian/ox/0.0.19/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; elif [ -f \"$cache_runner\" ]; then runner=\"$cache_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner, $repo_runner, and $cache_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action slow'", + "command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; cache_runner=\"$HOME/.codex/plugins/cache/oxidian/ox/0.0.20/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; elif [ -f \"$cache_runner\" ]; then runner=\"$cache_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner, $repo_runner, and $cache_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action slow'", "timeout": 120, "statusMessage": "Running final checks" } diff --git a/codex/plugins/ox/scripts/run_if_changed.py b/codex/plugins/ox/scripts/run_if_changed.py index f700f67..2e75a80 100644 --- a/codex/plugins/ox/scripts/run_if_changed.py +++ b/codex/plugins/ox/scripts/run_if_changed.py @@ -28,6 +28,7 @@ CONFIG_PATH = ".claude/ox-hooks.json" DEFAULT_FAST_EVERY = 5 +DEFAULT_BASE_REF = "origin/main" def _emit(runtime: str, message: str, *, file: TextIO = sys.stdout) -> None: @@ -244,8 +245,7 @@ def get_changed_files(project_dir: str) -> set[str]: """Return the set of changed file paths from git status --porcelain.""" try: result = subprocess.run( - "git status --porcelain", - shell=True, + ["git", "status", "--porcelain"], capture_output=True, text=True, cwd=project_dir, @@ -265,6 +265,32 @@ def get_changed_files(project_dir: str) -> set[str]: return files +def get_branch_changed_files(project_dir: str, base_ref: str) -> set[str]: + """Return committed branch changes relative to base_ref, or empty if unavailable.""" + try: + result = subprocess.run( + ["git", "diff", "--name-only", "--no-renames", f"{base_ref}...HEAD"], + capture_output=True, + text=True, + cwd=project_dir, + ) + except Exception: + return set() + + if result.returncode != 0: + return set() + + return {line for line in result.stdout.splitlines() if line} + + +def get_base_ref(config: dict) -> str: + """Return the configured branch comparison base ref.""" + base_ref = config.get("base_ref", DEFAULT_BASE_REF) + if isinstance(base_ref, str) and base_ref: + return base_ref + return DEFAULT_BASE_REF + + def directory_has_changes(changed_files: set[str], directory: str) -> bool: """Check if any changed file is under the given directory.""" prefix = f"{directory}/" @@ -387,6 +413,8 @@ def main() -> None: sys.exit(SUCCESS_CODE) changed_files = get_changed_files(project_dir) + if args.action == "slow": + changed_files.update(get_branch_changed_files(project_dir, get_base_ref(config))) if not changed_files: _emit(args.runtime, "No files changed, skipping") sys.exit(SUCCESS_CODE) diff --git a/plugins/ox/.claude-plugin/plugin.json b/plugins/ox/.claude-plugin/plugin.json index 0481961..1e9cbbf 100644 --- a/plugins/ox/.claude-plugin/plugin.json +++ b/plugins/ox/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ox", "description": "Base plugin — commit command, code quality hooks, auto-format and check hooks for all projects", - "version": "0.0.19", + "version": "0.0.20", "author": { "name": "Oxidian" } diff --git a/plugins/ox/README.md b/plugins/ox/README.md index 76092a7..4f9b9d9 100644 --- a/plugins/ox/README.md +++ b/plugins/ox/README.md @@ -25,7 +25,7 @@ Runs on `Write|Edit|MultiEdit` — auto-formats changed files using the project' ### Stop -Runs before Claude stops — executes slow checks on modified directories. +Runs before Claude stops — executes slow checks on modified directories, including files already committed on the current branch. ## Project configuration @@ -33,10 +33,13 @@ The PostToolUse and Stop hooks read `.claude/ox-hooks.json` from the project roo Each entry in `checks` defines a `fast` command (run on PostToolUse) and a `slow` command (run on Stop). If `directory` is set, the command only triggers when files under that directory have changed and runs inside that subdirectory. If omitted, the command triggers on any file change and runs at the project root. +Stop checks include both working-tree changes and committed branch changes compared with `base_ref`. The default `base_ref` is `origin/main`; set it for projects that branch from another long-lived branch. + **Whole-project** (e.g. a Python project using ruff): ```json { + "base_ref": "origin/main", "checks": [ { "fast": "uv run ruff format .", "slow": "uv run ruff check ." } ] diff --git a/plugins/ox/ox-hooks.json.example b/plugins/ox/ox-hooks.json.example index 58bcedd..8639c61 100644 --- a/plugins/ox/ox-hooks.json.example +++ b/plugins/ox/ox-hooks.json.example @@ -1,5 +1,6 @@ // For a repository with unified tooling: { + "base_ref": "origin/main", "checks": [ { "fast": "uv run ruff format .", @@ -10,6 +11,7 @@ // For a repository with submodules using different tooling: { + "base_ref": "origin/main", "checks": [ { "directory": "backend", diff --git a/plugins/ox/scripts/run_if_changed.py b/plugins/ox/scripts/run_if_changed.py index f700f67..2e75a80 100644 --- a/plugins/ox/scripts/run_if_changed.py +++ b/plugins/ox/scripts/run_if_changed.py @@ -28,6 +28,7 @@ CONFIG_PATH = ".claude/ox-hooks.json" DEFAULT_FAST_EVERY = 5 +DEFAULT_BASE_REF = "origin/main" def _emit(runtime: str, message: str, *, file: TextIO = sys.stdout) -> None: @@ -244,8 +245,7 @@ def get_changed_files(project_dir: str) -> set[str]: """Return the set of changed file paths from git status --porcelain.""" try: result = subprocess.run( - "git status --porcelain", - shell=True, + ["git", "status", "--porcelain"], capture_output=True, text=True, cwd=project_dir, @@ -265,6 +265,32 @@ def get_changed_files(project_dir: str) -> set[str]: return files +def get_branch_changed_files(project_dir: str, base_ref: str) -> set[str]: + """Return committed branch changes relative to base_ref, or empty if unavailable.""" + try: + result = subprocess.run( + ["git", "diff", "--name-only", "--no-renames", f"{base_ref}...HEAD"], + capture_output=True, + text=True, + cwd=project_dir, + ) + except Exception: + return set() + + if result.returncode != 0: + return set() + + return {line for line in result.stdout.splitlines() if line} + + +def get_base_ref(config: dict) -> str: + """Return the configured branch comparison base ref.""" + base_ref = config.get("base_ref", DEFAULT_BASE_REF) + if isinstance(base_ref, str) and base_ref: + return base_ref + return DEFAULT_BASE_REF + + def directory_has_changes(changed_files: set[str], directory: str) -> bool: """Check if any changed file is under the given directory.""" prefix = f"{directory}/" @@ -387,6 +413,8 @@ def main() -> None: sys.exit(SUCCESS_CODE) changed_files = get_changed_files(project_dir) + if args.action == "slow": + changed_files.update(get_branch_changed_files(project_dir, get_base_ref(config))) if not changed_files: _emit(args.runtime, "No files changed, skipping") sys.exit(SUCCESS_CODE) diff --git a/tests/ox/test_run_if_changed.py b/tests/ox/test_run_if_changed.py index e552428..125ed92 100644 --- a/tests/ox/test_run_if_changed.py +++ b/tests/ox/test_run_if_changed.py @@ -30,6 +30,11 @@ def _command_for_script(script: Path) -> str: return f"{shlex.quote(sys.executable)} {shlex.quote(str(script))}" +def _command_for_script_with_args(script: Path, *args: Path | str) -> str: + quoted_args = " ".join(shlex.quote(str(arg)) for arg in args) + return f"{_command_for_script(script)} {quoted_args}" + + def _init_changed_repo(tmp_path: Path, command: str) -> Path: subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True, text=True) (tmp_path / ".claude").mkdir() @@ -42,6 +47,40 @@ def _init_changed_repo(tmp_path: Path, command: str) -> Path: return subdir +def _init_branch_changed_repo(tmp_path: Path, command: str, *, base_ref: str = "origin/main") -> Path: + subprocess.run( + ["git", "init", "--initial-branch", "main"], cwd=tmp_path, check=True, capture_output=True, text=True + ) + (tmp_path / ".claude").mkdir() + config = {"checks": [{"fast": command, "slow": command}], "fast_every": 1} + if base_ref != "origin/main": + config["base_ref"] = base_ref + (tmp_path / ".claude" / "ox-hooks.json").write_text(json.dumps(config) + "\n") + (tmp_path / "tracked.txt").write_text("base\n") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True, capture_output=True, text=True) + subprocess.run( + ["git", "-c", "user.name=Test User", "-c", "user.email=test@example.com", "commit", "-m", "base"], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ) + subprocess.run(["git", "update-ref", "refs/remotes/origin/main", "HEAD"], cwd=tmp_path, check=True) + subprocess.run(["git", "checkout", "-b", "feature"], cwd=tmp_path, check=True, capture_output=True, text=True) + (tmp_path / "tracked.txt").write_text("branch\n") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True, capture_output=True, text=True) + subprocess.run( + ["git", "-c", "user.name=Test User", "-c", "user.email=test@example.com", "commit", "-m", "branch change"], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ) + subdir = tmp_path / "subdir" + subdir.mkdir() + return subdir + + def _run_codex_hook(cwd: Path, action: str) -> subprocess.CompletedProcess[str]: payload = { "session_id": f"test-{action}", @@ -249,3 +288,116 @@ def test_failure_exits_two_with_feedback_on_stderr(self, tmp_path: Path) -> None assert result.stdout == "" assert "Final checks failed. Fix these issues before finishing." in result.stderr assert "bad check output" in result.stderr + + def test_slow_runs_for_committed_branch_changes(self, tmp_path: Path) -> None: + marker = tmp_path / "marker.txt" + check_script = tmp_path / "check.py" + check_script.write_text("import sys\nfrom pathlib import Path\nPath(sys.argv[1]).write_text('ran')\n") + subdir = _init_branch_changed_repo(tmp_path, _command_for_script_with_args(check_script, marker)) + + result = _run_codex_hook(subdir, "slow") + + assert result.returncode == 0 + assert marker.read_text() == "ran" + + def test_fast_skips_for_committed_branch_changes(self, tmp_path: Path) -> None: + marker = tmp_path / "marker.txt" + check_script = tmp_path / "check.py" + check_script.write_text("import sys\nfrom pathlib import Path\nPath(sys.argv[1]).write_text('ran')\n") + subdir = _init_branch_changed_repo(tmp_path, _command_for_script_with_args(check_script, marker)) + + result = _run_codex_hook(subdir, "fast") + + assert result.returncode == 0 + assert not marker.exists() + + def test_slow_uses_custom_base_ref(self, tmp_path: Path) -> None: + marker = tmp_path / "marker.txt" + check_script = tmp_path / "check.py" + check_script.write_text("import sys\nfrom pathlib import Path\nPath(sys.argv[1]).write_text('ran')\n") + subdir = _init_branch_changed_repo( + tmp_path, + _command_for_script_with_args(check_script, marker), + base_ref="origin/release", + ) + subprocess.run(["git", "update-ref", "refs/remotes/origin/release", "origin/main"], cwd=tmp_path, check=True) + subprocess.run(["git", "update-ref", "-d", "refs/remotes/origin/main"], cwd=tmp_path, check=True) + + result = _run_codex_hook(subdir, "slow") + + assert result.returncode == 0 + assert marker.read_text() == "ran" + + def test_slow_scopes_committed_branch_changes_to_matching_directory(self, tmp_path: Path) -> None: + subprocess.run( + ["git", "init", "--initial-branch", "main"], cwd=tmp_path, check=True, capture_output=True, text=True + ) + backend = tmp_path / "backend" + frontend = tmp_path / "frontend" + backend.mkdir() + frontend.mkdir() + (backend / "file.txt").write_text("base\n") + (frontend / "file.txt").write_text("base\n") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True, capture_output=True, text=True) + subprocess.run( + ["git", "-c", "user.name=Test User", "-c", "user.email=test@example.com", "commit", "-m", "base"], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ) + subprocess.run(["git", "update-ref", "refs/remotes/origin/main", "HEAD"], cwd=tmp_path, check=True) + subprocess.run(["git", "checkout", "-b", "feature"], cwd=tmp_path, check=True, capture_output=True, text=True) + (backend / "file.txt").write_text("branch\n") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True, capture_output=True, text=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test User", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "backend change", + ], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ) + marker = tmp_path / "marker.txt" + check_script = tmp_path / "check.py" + check_script.write_text( + "import sys\nfrom pathlib import Path\nPath(sys.argv[1]).write_text(Path.cwd().name)\n" + ) + (tmp_path / ".claude").mkdir() + command = _command_for_script_with_args(check_script, marker) + (tmp_path / ".claude" / "ox-hooks.json").write_text( + json.dumps( + { + "checks": [ + {"directory": "backend", "slow": command}, + {"directory": "frontend", "slow": command}, + ] + } + ) + + "\n" + ) + + result = _run_codex_hook(frontend, "slow") + + assert result.returncode == 0 + assert marker.read_text() == "backend" + + def test_slow_ignores_missing_base_ref(self, tmp_path: Path) -> None: + marker = tmp_path / "marker.txt" + check_script = tmp_path / "check.py" + check_script.write_text("import sys\nfrom pathlib import Path\nPath(sys.argv[1]).write_text('ran')\n") + subdir = _init_branch_changed_repo(tmp_path, _command_for_script_with_args(check_script, marker)) + subprocess.run(["git", "update-ref", "-d", "refs/remotes/origin/main"], cwd=tmp_path, check=True) + + result = _run_codex_hook(subdir, "slow") + + assert result.returncode == 0 + assert not marker.exists()