#!/usr/bin/env python3 """Exercise aicommit routing and plan validation without invoking an AI CLI.""" import json import os from pathlib import Path import subprocess import tempfile import unittest SOURCE = Path(__file__).resolve().parent / "func" ROUTE = ''' source "$1" shift _aicommit_engine() { printf 'ENGINE %s %s\n' "$1" "$2"; } _aicommit_modules() { printf 'MODULES %s %s\n' "$1" "$2"; return 7; } aicommit "$@" ''' class AicommitTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix="aicommit-test-") self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.repo = self.root / "repo" self.repo.mkdir() self.home = self.root / "home" config = self.home / ".config" config.mkdir(parents=True) for name in ("ai-commit-prompt.txt", "ai-commit-split-prompt.txt"): (config / name).write_text((SOURCE.parent / name).read_text()) self.env = {**os.environ, "HOME": str(self.home)} self.git("init", "-q") def git(self, *args): return subprocess.check_output( ["git", *args], cwd=self.repo, env=self.env ) def shell(self, script, *args, cwd=None): return subprocess.run( ["zsh", "-f", "-c", script, "test", str(SOURCE), *args], cwd=cwd or self.repo, env=self.env, text=True, capture_output=True, ) def stage(self, count, suffix=".txt"): # A newline is part of one path, not an extra staged file. files = [f"file {i}{suffix}" for i in range(count)] if files: files[0] = "line\nbreak" + suffix for name in files: (self.repo / name).write_text("staged content\n") self.git("add", "--all") return files def test_thirty_paths_use_single_commit(self): self.stage(30) result = self.shell(ROUTE, "opencode") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), "ENGINE opencode commit") def test_thirty_one_paths_route_all_engines_and_forward_yes(self): self.stage(31) for engine in ("claude", "copilot", "opencode"): with self.subTest(engine=engine): result = self.shell(ROUTE, "--yes", engine) self.assertEqual(result.returncode, 7, result.stderr) self.assertEqual(result.stdout.strip(), f"MODULES {engine} 1") self.assertIn("exceed 30", result.stderr) def test_explicit_modules_below_threshold(self): self.stage(1) result = self.shell(ROUTE, "--modules", "copilot") self.assertEqual(result.returncode, 7, result.stderr) self.assertEqual(result.stdout.strip(), "MODULES copilot 0") def test_filtered_files_still_trigger_modules_from_subdirectory(self): self.stage(31, ".lock") nested = self.repo / "nested" nested.mkdir() result = self.shell(ROUTE, cwd=nested) self.assertEqual(result.returncode, 7, result.stderr) self.assertEqual(result.stdout.strip(), "MODULES claude 0") def test_empty_index_and_git_errors_never_call_engine(self): for cwd in (self.repo, self.root): with self.subTest(cwd=cwd): result = self.shell(ROUTE, cwd=cwd) self.assertNotEqual(result.returncode, 0) self.assertNotIn("ENGINE", result.stdout) self.assertNotIn("MODULES", result.stdout) def plan(self, count, groups): run = self.root / "plan" run.mkdir() files = [f"file-{i}" for i in range(count)] (run / "staged").write_bytes(b"".join(p.encode() + b"\0" for p in files)) (run / "plan.out").write_text(json.dumps({"commits": [ {"type": "feat", "scope": "test", "files": group(files)} for group in groups ]})) return self.shell('source "$1"; _aicommit_plan "$2"', str(run)), run def test_single_group_plan_allowed_at_thirty(self): result, run = self.plan(30, [lambda files: files]) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual((run / "count").read_text(), "1") def test_single_group_plan_rejected_above_thirty_after_deduplication(self): result, run = self.plan(31, [lambda files: files, lambda files: files]) self.assertNotEqual(result.returncode, 0) self.assertIn("require multiple module commits", result.stderr) self.assertFalse((run / "count").exists()) def test_empty_plan_cannot_fall_back_to_one_large_commit(self): result, _ = self.plan(31, []) self.assertNotEqual(result.returncode, 0) self.assertIn("require multiple module commits", result.stderr) def test_multiple_groups_allowed_even_with_large_module(self): result, run = self.plan(32, [lambda files: files[:-1], lambda files: files[-1:]]) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual((run / "count").read_text(), "2") def test_rejected_auto_plan_preserves_partial_index_and_worktree(self): files = self.stage(31) changed = self.repo / files[0] changed.write_text("unstaged content\n") before = self.git("write-tree") plan = self.root / "single-plan.json" plan.write_text(json.dumps({"commits": [{"files": files}]})) result = self.shell(''' source "$1" plan_file="$2" _aicommit_engine() { if [ "$2" = plan ]; then cat "$plan_file"; else printf 'UNEXPECTED COMMIT\n'; fi } aicommit --yes opencode ''', str(plan)) self.assertNotEqual(result.returncode, 0) self.assertIn("require multiple module commits", result.stderr) self.assertNotIn("UNEXPECTED COMMIT", result.stdout) self.assertEqual(self.git("write-tree"), before) self.assertEqual(changed.read_text(), "unstaged content\n") if __name__ == "__main__": unittest.main()