135 lines
7.3 KiB
Python
135 lines
7.3 KiB
Python
"""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_'))
|