"""Real isolated Git repositories, fake agents; no user checkout commits.""" import json from pathlib import Path import subprocess import unittest from test_automation import RunnerTests, RUN_MODULE class GitCommitTests(RunnerTests): def git(self, *args): return subprocess.check_output(['git', '-C', str(self.root), *args], text=True).strip() def setUp(self): super().setUp() (self.root / '.gitignore').write_text('state/runs/\n.runner.lock/\n.state.lock/\n__pycache__/\n') fake = self.root / 'fake_agent.py' text = fake.read_text() text = text.replace("if action=='review':t.mutate", """messages=r/'state/commit_messages';messages.mkdir(exist_ok=True) (messages/(agent+'.txt')).write_text(('Review restored input and record acceptance' if action=='review' else 'Restore required input within assigned scope')+'\\n\\nChanges: Record the bounded fixture result and handoff.\\nValidation: Isolated positive and negative fixture checks passed.\\n') if action=='claim': source=r/('src/fix.txt' if id.startswith('B') else 'src/'+str(int(id.split('-')[-1]))+'.txt');source.parent.mkdir(exist_ok=True);source.write_text('verified task '+id) if action=='review':t.mutate""") # Remediation IDs use the actual parent-derived naming convention. text = text.replace("id.startswith('B')", "id not in {'S00-001','S00-002','S00-003'}") fake.write_text(text) self.tracker.render() self.git('init', '-b', 'main') self.git('config', 'user.name', 'Fixture Agent') self.git('config', 'user.email', 'fixture@example.invalid') self.git('config', 'commit.gpgsign', 'false') self.git('add', '.') self.git('commit', '-m', 'Initialize isolated fixture') self.config['git_commits'] = True def test_chain_commits_each_transition_with_task_and_actual_message(self): result = RUN_MODULE.Runner(self.root, self.config).run() self.assertEqual(result['stop'], 'complete', result) self.assertEqual(self.git('rev-list', '--count', 'HEAD'), '10') self.assertEqual(self.git('status', '--porcelain'), '') log = self.git('log', '--format=%B') self.assertIn('S00-001: Restore required input within assigned scope', log) self.assertIn('S00-001: Review restored input and record acceptance', log) self.assertIn('Record blocked task and remediation handoff', log) self.assertIn('Validation: Isolated positive and negative fixture checks passed.', log) self.assertEqual(result['last_commit'], self.git('rev-parse', 'HEAD')) commits = self.git('log', '--format=%H', '--grep=^S00-002: Restore').splitlines() files = self.git('show', '--format=', '--name-only', commits[0]) self.assertIn('src/2.txt', files) self.assertIn('state/tasks.json', files) self.assertNotIn('output.log', files) def test_dirty_existing_file_is_not_claimed_or_committed(self): before = self.tracker.state() (self.root / 'unrelated.txt').write_text('user work') with self.assertRaisesRegex(ValueError, 'must be clean'): RUN_MODULE.Runner(self.root, self.config).run() self.assertEqual(before, self.tracker.state()) self.assertEqual(self.git('rev-list', '--count', 'HEAD'), '1') def test_missing_message_stops_before_next_agent_and_preserves_work(self): fake = self.root / 'fake_agent.py' fake.write_text(fake.read_text().replace("(messages/(agent+'.txt')).write_text", "(messages/'wrong-name.txt').write_text")) self.git('add', 'fake_agent.py') self.git('commit', '-m', 'Simulate missing task message') result = RUN_MODULE.Runner(self.root, self.config).run() self.assertEqual(result['stop'], 'git_error') self.assertEqual(result['launched'], 2) # Blocker checkpoint, then failed resolver submit. self.assertIn('Missing task commit message', result['commit_error']) self.assertTrue(self.git('status', '--porcelain')) def test_rejected_git_hook_stops_without_starting_another_agent(self): hook = self.root / '.git/hooks/pre-commit' hook.write_text('#!/bin/sh\nexit 1\n') hook.chmod(0o755) result = RUN_MODULE.Runner(self.root, self.config).run() self.assertEqual(result['stop'], 'git_error') self.assertEqual(result['launched'], 1) self.assertIn('Git commit failed', result['commit_error']) self.assertEqual(self.git('rev-list', '--count', 'HEAD'), '1') self.assertTrue(self.git('diff', '--cached', '--name-only')) def test_changed_head_is_not_accepted_as_coordinator_commit(self): committer = RUN_MODULE.Runner(self.root, self.config).commits head = committer.preflight() self.git('commit', '--allow-empty', '-m', 'Unexpected agent commit') with self.assertRaisesRegex(ValueError, 'changed Git HEAD'): committer.commit({'id': 'S00-001'}, 'agent', {}, {}, head, 'Fixture') def test_nested_project_rejects_edits_to_parent_files(self): project = self.root / 'nested' project.mkdir() committer = RUN_MODULE.TaskCommits(project) head = committer.preflight() (self.root / 'parent.txt').write_text('unrelated') with self.assertRaisesRegex(ValueError, 'outside project root'): committer.commit({'id': 'S00-001'}, 'agent', {}, {}, head, 'Fixture') self.assertEqual(self.git('diff', '--cached', '--name-only'), '') def test_nested_project_commits_literal_paths_and_deletions(self): project = self.root / 'nested' project.mkdir() (project / '.gitignore').write_text('state/runs/\n') removed = project / 'old.txt' removed.write_text('old') self.git('add', 'nested') self.git('commit', '-m', 'Initialize nested fixture') committer = RUN_MODULE.TaskCommits(project) head = committer.preflight() removed.unlink() (project / 'file [1].txt').write_text('bounded output') draft = project / 'state/commit_messages/agent.txt' draft.parent.mkdir(parents=True) draft.write_text('Replace obsolete fixture with literal named output\n\nChanges: Replace the obsolete fixture file.\nValidation: Checked deletion and literal path staging.\n') # A nested foundation ships its own project-relative ignore rules. commit = committer.commit({'id': 'S00-001', 'role': 'front', 'action': 'claim'}, 'agent', {'status': 'review'}, {}, head, 'Fixture') files = self.git('show', '--format=', '--name-only', commit) self.assertIn('nested/file [1].txt', files) self.assertIn('nested/old.txt', files) self.assertFalse(removed.exists()) self.assertEqual(self.git('status', '--porcelain'), '') def test_commit_message_rejects_empty_or_unstructured_completion(self): committer = RUN_MODULE.TaskCommits(self.root) message = self.root / 'state/commit_messages/agent.txt' message.parent.mkdir(parents=True) message.write_text('Task done\n') with self.assertRaisesRegex(ValueError, 'concrete English subject'): committer.message({'id': 'S00-001'}, 'agent', {'status': 'review'}, {}) def load_tests(loader, tests, pattern): return unittest.TestSuite(GitCommitTests(name) for name in GitCommitTests.__dict__ if name.startswith('test_'))