diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..01f04051 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,7 @@ +parameters: + ignoreErrors: + - + message: '#^Direct usage of DateTimeImmutable is not allowed\. Use DateAndTimeService\:\:getDateTimeImmutable\(\) instead\.$#' + identifier: noDirectDateTimeUsage + count: 1 + path: src/WorkspaceMgmt/Domain/Service/WorkspaceGitService.php diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 5cde6493..57655d6f 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,6 @@ includes: - vendor/enterprise-tooling-for-symfony/shared-bundle/config/phpstan.app.dist.neon + - phpstan-baseline.neon parameters: # Use container cache directory for better performance in Docker diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index 59a1a139..65e8dd5e 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -101,6 +101,18 @@ public function __invoke(RunEditSessionMessage $message): void $project->remoteContentAssetsManifestUrls ); + // Set git context info for the agent + try { + $gitInfo = $this->workspaceMgmtFacade->getGitInfo($conversation->getWorkspaceId()); + $this->executionContext->setGitInfo($gitInfo); + } catch (Throwable $e) { + // Log but don't fail the session - git info is nice-to-have + $this->logger->debug('Failed to get git info for agent', [ + 'workspaceId' => $conversation->getWorkspaceId(), + 'error' => $e->getMessage(), + ]); + } + // Build agent configuration from project settings (#79). $agentConfig = new AgentConfigDto( $project->agentBackgroundInstructions, diff --git a/src/LlmContentEditor/Domain/Agent/ContentEditorAgent.php b/src/LlmContentEditor/Domain/Agent/ContentEditorAgent.php index 468552a4..4589c080 100644 --- a/src/LlmContentEditor/Domain/Agent/ContentEditorAgent.php +++ b/src/LlmContentEditor/Domain/Agent/ContentEditorAgent.php @@ -70,6 +70,11 @@ public function instructions(): string $base .= "\n\nWORKING FOLDER (use for all path-based tools): " . $this->agentConfig->workingFolderPath; } + $gitContextInfo = $this->sitebuilderFacade->getGitContextInfo(); + if ($gitContextInfo !== '') { + $base .= "\n\n" . $gitContextInfo; + } + $history = $this->resolveChatHistory(); if ($history instanceof TurnActivityProviderInterface) { $summary = $history->getTurnActivitySummary(); diff --git a/src/WorkspaceMgmt/Domain/Service/WorkspaceGitService.php b/src/WorkspaceMgmt/Domain/Service/WorkspaceGitService.php index 8dc23dbb..d881523b 100644 --- a/src/WorkspaceMgmt/Domain/Service/WorkspaceGitService.php +++ b/src/WorkspaceMgmt/Domain/Service/WorkspaceGitService.php @@ -6,9 +6,13 @@ use App\ProjectMgmt\Facade\ProjectMgmtFacadeInterface; use App\WorkspaceMgmt\Domain\Entity\Workspace; +use App\WorkspaceMgmt\Facade\Dto\WorkspaceCommitDto; +use App\WorkspaceMgmt\Facade\Dto\WorkspaceGitInfoDto; +use App\WorkspaceMgmt\Infrastructure\Adapter\Dto\RawCommitDto; use App\WorkspaceMgmt\Infrastructure\Adapter\GitAdapterInterface; use App\WorkspaceMgmt\Infrastructure\Adapter\GitHubAdapterInterface; use App\WorkspaceMgmt\Infrastructure\Service\GitHubUrlServiceInterface; +use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use RuntimeException; @@ -270,4 +274,32 @@ private function buildPrBody( return implode("\n", $lines); } + + /** + * Get git context information for a workspace. + */ + public function getGitInfo(Workspace $workspace, int $commitLimit = 10): WorkspaceGitInfoDto + { + $workspacePath = $this->getWorkspacePath($workspace); + + $currentBranch = $this->gitAdapter->getCurrentBranch($workspacePath); + $rawCommits = $this->gitAdapter->getRecentCommits($workspacePath, $commitLimit); + $branches = $this->gitAdapter->getBranches($workspacePath); + + $commits = array_map( + static function (RawCommitDto $raw): WorkspaceCommitDto { + $committedAt = new DateTimeImmutable($raw->timestamp); + + return new WorkspaceCommitDto( + $raw->hash, + $raw->subject, + $raw->body, + $committedAt + ); + }, + $rawCommits + ); + + return new WorkspaceGitInfoDto($currentBranch, $commits, $branches); + } } diff --git a/src/WorkspaceMgmt/Facade/Dto/WorkspaceCommitDto.php b/src/WorkspaceMgmt/Facade/Dto/WorkspaceCommitDto.php new file mode 100644 index 00000000..4bd3d012 --- /dev/null +++ b/src/WorkspaceMgmt/Facade/Dto/WorkspaceCommitDto.php @@ -0,0 +1,21 @@ + $recentCommits + * @param list $localBranches + */ + public function __construct( + public string $currentBranch, + public array $recentCommits, + public array $localBranches, + ) { + } +} diff --git a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php index 3f72a0e7..cfed7fed 100644 --- a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php +++ b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php @@ -9,6 +9,7 @@ use App\WorkspaceMgmt\Domain\Service\WorkspaceGitService; use App\WorkspaceMgmt\Domain\Service\WorkspaceService; use App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuard; +use App\WorkspaceMgmt\Facade\Dto\WorkspaceGitInfoDto; use App\WorkspaceMgmt\Facade\Dto\WorkspaceInfoDto; use App\WorkspaceMgmt\Facade\Enum\WorkspaceStatus; use App\WorkspaceMgmt\Infrastructure\Adapter\FilesystemAdapterInterface; @@ -257,6 +258,13 @@ public function runBuild(string $workspaceId): string return $process->getOutput(); } + public function getGitInfo(string $workspaceId, int $commitLimit = 10): WorkspaceGitInfoDto + { + $workspace = $this->getWorkspaceOrFail($workspaceId); + + return $this->gitService->getGitInfo($workspace, $commitLimit); + } + /** * Resolve a relative path to an absolute path and validate it's within the workspace. */ diff --git a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php index 961c6953..5fb16651 100644 --- a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php +++ b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php @@ -4,6 +4,7 @@ namespace App\WorkspaceMgmt\Facade; +use App\WorkspaceMgmt\Facade\Dto\WorkspaceGitInfoDto; use App\WorkspaceMgmt\Facade\Dto\WorkspaceInfoDto; /** @@ -137,4 +138,15 @@ public function writeWorkspaceFile(string $workspaceId, string $relativePath, st * @throws \Symfony\Component\Process\Exception\ProcessFailedException if the build fails */ public function runBuild(string $workspaceId): string; + + /** + * Get git context information for a workspace. + * + * Returns the current branch name, recent commits (with hash, message, body, timestamp), + * and all local branches. + * + * @param string $workspaceId the workspace ID + * @param int $commitLimit the maximum number of recent commits to return + */ + public function getGitInfo(string $workspaceId, int $commitLimit = 10): WorkspaceGitInfoDto; } diff --git a/src/WorkspaceMgmt/Infrastructure/Adapter/Dto/RawCommitDto.php b/src/WorkspaceMgmt/Infrastructure/Adapter/Dto/RawCommitDto.php new file mode 100644 index 00000000..0262d52c --- /dev/null +++ b/src/WorkspaceMgmt/Infrastructure/Adapter/Dto/RawCommitDto.php @@ -0,0 +1,20 @@ + + */ + public function getRecentCommits(string $workspacePath, int $limit = 10): array; + + /** + * Get all local branch names. + * + * @param string $workspacePath the workspace directory + * + * @return list + */ + public function getBranches(string $workspacePath): array; } diff --git a/src/WorkspaceMgmt/Infrastructure/Adapter/GitCliAdapter.php b/src/WorkspaceMgmt/Infrastructure/Adapter/GitCliAdapter.php index 5393420c..3bcc0d74 100644 --- a/src/WorkspaceMgmt/Infrastructure/Adapter/GitCliAdapter.php +++ b/src/WorkspaceMgmt/Infrastructure/Adapter/GitCliAdapter.php @@ -4,6 +4,7 @@ namespace App\WorkspaceMgmt\Infrastructure\Adapter; +use App\WorkspaceMgmt\Infrastructure\Adapter\Dto\RawCommitDto; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; @@ -218,6 +219,91 @@ private function ensureCleanRemoteUrl(string $workspacePath): void } } + public function getCurrentBranch(string $workspacePath): string + { + $process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD']); + $process->setWorkingDirectory($workspacePath); + $process->setTimeout(self::TIMEOUT_SECONDS); + + $this->runProcess($process, 'Failed to get current branch'); + + return trim($process->getOutput()); + } + + public function getRecentCommits(string $workspacePath, int $limit = 10): array + { + // Use NUL byte (\x00) as field separator to safely handle multiline bodies + // Format: hash\x00subject\x00body\x00timestamp\x00 + $process = new Process([ + 'git', + 'log', + '-n', (string) $limit, + '--pretty=format:%H%x00%s%x00%b%x00%aI%x00', + ]); + $process->setWorkingDirectory($workspacePath); + $process->setTimeout(self::TIMEOUT_SECONDS); + + $process->run(); + + // Return empty array if git log fails (e.g., no commits yet) + if (!$process->isSuccessful()) { + return []; + } + + $output = $process->getOutput(); + if (trim($output) === '') { + return []; + } + + // Split by NUL+newline (git log adds newlines between commits by default) + $records = explode("\x00\n", rtrim($output, "\x00\n")); + $commits = []; + + foreach ($records as $record) { + $fields = explode("\x00", $record); + if (count($fields) < 4) { + continue; + } + + [$hash, $subject, $body, $timestamp] = $fields; + + $commits[] = new RawCommitDto( + trim($hash), + trim($subject), + trim($body), + trim($timestamp) + ); + } + + return $commits; + } + + public function getBranches(string $workspacePath): array + { + $process = new Process(['git', 'branch', '--format=%(refname:short)']); + $process->setWorkingDirectory($workspacePath); + $process->setTimeout(self::TIMEOUT_SECONDS); + + $process->run(); + + // Return empty array if git branch fails (e.g., no commits yet) + if (!$process->isSuccessful()) { + return []; + } + + $output = trim($process->getOutput()); + if ($output === '') { + return []; + } + + $branches = array_filter( + explode("\n", $output), + static fn (string $branch): bool => trim($branch) !== '' + ); + + return array_values(array_map('trim', $branches)); + } + private function runProcess(Process $process, string $errorMessage): void { $process->run(); diff --git a/src/WorkspaceMgmt/TestHarness/SimulatedGitAdapter.php b/src/WorkspaceMgmt/TestHarness/SimulatedGitAdapter.php index ecabef35..e7182cca 100644 --- a/src/WorkspaceMgmt/TestHarness/SimulatedGitAdapter.php +++ b/src/WorkspaceMgmt/TestHarness/SimulatedGitAdapter.php @@ -74,6 +74,21 @@ public function hasBranchDifferences(string $workspacePath, string $branchName, return $this->realGitAdapter->hasBranchDifferences($workspacePath, $branchName, $baseBranch); } + public function getCurrentBranch(string $workspacePath): string + { + return $this->realGitAdapter->getCurrentBranch($workspacePath); + } + + public function getRecentCommits(string $workspacePath, int $limit = 10): array + { + return $this->realGitAdapter->getRecentCommits($workspacePath, $limit); + } + + public function getBranches(string $workspacePath): array + { + return $this->realGitAdapter->getBranches($workspacePath); + } + private function copyDirectory(string $source, string $target): void { if (!is_dir($source)) { diff --git a/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php b/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php index 8451f9e8..00ae42a8 100644 --- a/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php +++ b/src/WorkspaceTooling/Facade/AgentExecutionContextInterface.php @@ -4,6 +4,8 @@ namespace App\WorkspaceTooling\Facade; +use App\WorkspaceMgmt\Facade\Dto\WorkspaceGitInfoDto; + /** * Interface for setting agent execution context. * @@ -67,4 +69,14 @@ public function setSuggestedCommitMessage(string $message): void; * @return string|null The suggested message, or null if none was set */ public function getSuggestedCommitMessage(): ?string; + + /** + * Set git context information for the current agent run. + */ + public function setGitInfo(?WorkspaceGitInfoDto $gitInfo): void; + + /** + * Get git context information for the current agent run. + */ + public function getGitInfo(): ?WorkspaceGitInfoDto; } diff --git a/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php b/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php index 0cbde63a..7cab6e62 100644 --- a/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php +++ b/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php @@ -241,4 +241,42 @@ public function runBuildInWorkspace(string $workspacePath, string $agentImage): true ); } + + public function getGitContextInfo(): string + { + $gitInfo = $this->executionContext->getGitInfo(); + if ($gitInfo === null) { + return ''; + } + + $lines = []; + $lines[] = '---'; + $lines[] = 'GIT CONTEXT (active branch: ' . $gitInfo->currentBranch . ')'; + $lines[] = ''; + + if ($gitInfo->recentCommits !== []) { + $lines[] = 'Recent commits:'; + foreach ($gitInfo->recentCommits as $commit) { + $shortHash = substr($commit->hash, 0, 8); + $timestamp = $commit->committedAt->format('Y-m-d H:i:s T'); + $lines[] = '- ' . $shortHash . ' | ' . $commit->message . ' | ' . $timestamp; + if (trim($commit->body) !== '') { + $bodyLines = explode("\n", trim($commit->body)); + foreach ($bodyLines as $bodyLine) { + $lines[] = ' ' . $bodyLine; + } + } + } + $lines[] = ''; + } + + if ($gitInfo->localBranches !== []) { + $lines[] = 'Available branches:'; + foreach ($gitInfo->localBranches as $branch) { + $lines[] = '- ' . $branch; + } + } + + return implode("\n", $lines); + } } diff --git a/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php b/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php index f2da7bd4..ed3afc0d 100644 --- a/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php +++ b/src/WorkspaceTooling/Facade/WorkspaceToolingServiceInterface.php @@ -87,4 +87,14 @@ public function getWorkspaceRules(): string; * @return string the build output */ public function runBuildInWorkspace(string $workspacePath, string $agentImage): string; + + /** + * Get git context information formatted for the agent's system prompt. + * + * Returns a formatted string with current branch, recent commits (with hash, subject, body, timestamp), + * and all local branches. Returns empty string if no git info is available. + * + * @return string formatted git context, or empty string if not available + */ + public function getGitContextInfo(): string; } diff --git a/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php b/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php index 559de1b4..ace2ab0d 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/AgentExecutionContext.php @@ -4,6 +4,7 @@ namespace App\WorkspaceTooling\Infrastructure\Execution; +use App\WorkspaceMgmt\Facade\Dto\WorkspaceGitInfoDto; use App\WorkspaceTooling\Facade\AgentExecutionContextInterface; /** @@ -33,6 +34,7 @@ final class AgentExecutionContext implements AgentExecutionContextInterface private ?string $projectName = null; private ?string $agentImage = null; private ?string $suggestedCommitMessage = null; + private ?WorkspaceGitInfoDto $gitInfo = null; /** * @var list|null @@ -71,6 +73,7 @@ public function clearContext(): void $this->projectName = null; $this->agentImage = null; $this->suggestedCommitMessage = null; + $this->gitInfo = null; $this->remoteContentAssetsManifestUrls = null; } @@ -131,6 +134,16 @@ public function getSuggestedCommitMessage(): ?string return $this->suggestedCommitMessage; } + public function setGitInfo(?WorkspaceGitInfoDto $gitInfo): void + { + $this->gitInfo = $gitInfo; + } + + public function getGitInfo(): ?WorkspaceGitInfoDto + { + return $this->gitInfo; + } + /** * Build a Docker container name from the current context. * diff --git a/tests/Integration/WorkspaceMgmt/WorkspaceGitServiceTest.php b/tests/Integration/WorkspaceMgmt/WorkspaceGitServiceTest.php new file mode 100644 index 00000000..0b1750af --- /dev/null +++ b/tests/Integration/WorkspaceMgmt/WorkspaceGitServiceTest.php @@ -0,0 +1,244 @@ +get(WorkspaceGitService::class); + $this->gitService = $gitService; + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $container->get(EntityManagerInterface::class); + $this->entityManager = $entityManager; + + $workspaceRoot = $container->getParameter('workspace_mgmt.workspace_root'); + assert(is_string($workspaceRoot)); + $this->workspaceRoot = $workspaceRoot; + + // Create a test git repository + $this->testRepoPath = sys_get_temp_dir() . '/workspace-git-test-' . uniqid(); + mkdir($this->testRepoPath, 0777, true); + + $this->runGitCommand('init -b main'); + $this->runGitCommand('config user.name "Test User"'); + $this->runGitCommand('config user.email "test@example.com"'); + + // Create some commits with bodies + file_put_contents($this->testRepoPath . '/file1.txt', 'Content 1'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Initial commit" -m "Set up the project structure"'); + + file_put_contents($this->testRepoPath . '/file2.txt', 'Content 2'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Add feature" -m "Implemented the main feature" -m "" -m "Related to issue #123"'); + + // Create a feature branch + $this->runGitCommand('checkout -b feature/new-feature'); + file_put_contents($this->testRepoPath . '/file3.txt', 'Content 3'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Feature work"'); + + // Go back to main + $this->runGitCommand('checkout main'); + } + + protected function tearDown(): void + { + if (is_dir($this->testRepoPath)) { + $this->removeDirectory($this->testRepoPath); + } + + parent::tearDown(); + } + + public function testGetGitInfoReturnsCompleteInformation(): void + { + // Arrange: Create a workspace pointing to our test repo + $workspace = $this->createTestWorkspace(); + + // Act: Get git info + $gitInfo = $this->gitService->getGitInfo($workspace, 5); + + // Assert: Current branch + self::assertSame('main', $gitInfo->currentBranch); + + // Assert: Commits + self::assertCount(2, $gitInfo->recentCommits); + + $firstCommit = $gitInfo->recentCommits[0]; + self::assertSame('Add feature', $firstCommit->message); + self::assertStringContainsString('Implemented the main feature', $firstCommit->body); + self::assertStringContainsString('Related to issue #123', $firstCommit->body); + self::assertNotEmpty($firstCommit->hash); + + $secondCommit = $gitInfo->recentCommits[1]; + self::assertSame('Initial commit', $secondCommit->message); + self::assertStringContainsString('Set up the project structure', $secondCommit->body); + + // Assert: Branches + self::assertCount(2, $gitInfo->localBranches); + self::assertContains('main', $gitInfo->localBranches); + self::assertContains('feature/new-feature', $gitInfo->localBranches); + } + + public function testGetGitInfoOnFeatureBranch(): void + { + // Arrange: Switch to feature branch + $this->runGitCommand('checkout feature/new-feature'); + $workspace = $this->createTestWorkspace(); + + // Act + $gitInfo = $this->gitService->getGitInfo($workspace, 10); + + // Assert + self::assertSame('feature/new-feature', $gitInfo->currentBranch); + self::assertCount(3, $gitInfo->recentCommits); + self::assertSame('Feature work', $gitInfo->recentCommits[0]->message); + } + + public function testGetGitInfoWithLimitedCommits(): void + { + // Arrange + $workspace = $this->createTestWorkspace(); + + // Act: Request only 1 commit + $gitInfo = $this->gitService->getGitInfo($workspace, 1); + + // Assert + self::assertCount(1, $gitInfo->recentCommits); + self::assertSame('Add feature', $gitInfo->recentCommits[0]->message); + } + + private function createTestWorkspace(): Workspace + { + $workspace = new Workspace('test-project-' . uniqid()); + $workspace->setStatus(WorkspaceStatus::AVAILABLE_FOR_CONVERSATION); + $workspace->setBranchName('main'); + + $this->entityManager->persist($workspace); + $this->entityManager->flush(); + + $workspaceId = $workspace->getId(); + self::assertNotNull($workspaceId); + + // Move test repo to the workspace root location + if (!is_dir($this->workspaceRoot)) { + $result = mkdir($this->workspaceRoot, 0777, true); + if (!$result) { + throw new \RuntimeException('Failed to create workspace root directory: ' . $this->workspaceRoot); + } + } + + $targetPath = $this->workspaceRoot . '/' . $workspaceId; + if (is_dir($targetPath)) { + $this->removeDirectory($targetPath); + } + + // Copy instead of rename (rename may fail across filesystems) + $this->copyDirectory($this->testRepoPath, $targetPath); + $this->removeDirectory($this->testRepoPath); + $this->testRepoPath = $targetPath; + + return $workspace; + } + + private function runGitCommand(string $command): void + { + $process = Process::fromShellCommandline('git ' . $command); + $process->setWorkingDirectory($this->testRepoPath); + $process->setTimeout(10); + $process->mustRun(); + } + + private function copyDirectory(string $source, string $target): void + { + if (!is_dir($source)) { + throw new \RuntimeException('Source directory does not exist: ' . $source); + } + + if (!is_dir($target)) { + mkdir($target, 0777, true); + } + + $items = scandir($source); + if ($items === false) { + throw new \RuntimeException('Failed to scan source directory: ' . $source); + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $sourcePath = $source . '/' . $item; + $targetPath = $target . '/' . $item; + + if (is_dir($sourcePath)) { + $this->copyDirectory($sourcePath, $targetPath); + } else { + copy($sourcePath, $targetPath); + } + } + } + + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $items = scandir($path); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $itemPath = $path . '/' . $item; + if (is_dir($itemPath)) { + $this->removeDirectory($itemPath); + } else { + unlink($itemPath); + } + } + + rmdir($path); + } +} diff --git a/tests/Integration/WorkspaceMgmt/WorkspaceMgmtFacadeGitInfoTest.php b/tests/Integration/WorkspaceMgmt/WorkspaceMgmtFacadeGitInfoTest.php new file mode 100644 index 00000000..a1554557 --- /dev/null +++ b/tests/Integration/WorkspaceMgmt/WorkspaceMgmtFacadeGitInfoTest.php @@ -0,0 +1,301 @@ +get(WorkspaceMgmtFacadeInterface::class); + $this->facade = $facade; + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $container->get(EntityManagerInterface::class); + $this->entityManager = $entityManager; + + $workspaceRoot = $container->getParameter('workspace_mgmt.workspace_root'); + assert(is_string($workspaceRoot)); + $this->workspaceRoot = $workspaceRoot; + + // Create a test git repository + $this->testRepoPath = sys_get_temp_dir() . '/facade-git-test-' . uniqid(); + mkdir($this->testRepoPath, 0777, true); + + $this->runGitCommand('init -b main'); + $this->runGitCommand('config user.name "Test User"'); + $this->runGitCommand('config user.email "test@example.com"'); + + // Create commits with various body formats + file_put_contents($this->testRepoPath . '/readme.md', '# Project'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Initial commit" -m "Project setup and initialization"'); + + file_put_contents($this->testRepoPath . '/feature.txt', 'Feature content'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Add main feature" -m "Implemented core functionality" -m "" -m "Fixes #42"'); + + file_put_contents($this->testRepoPath . '/docs.txt', 'Documentation'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Add documentation"'); + + // Create development and staging branches + $this->runGitCommand('checkout -b development'); + file_put_contents($this->testRepoPath . '/dev.txt', 'Dev work'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Development work"'); + + $this->runGitCommand('checkout main'); + $this->runGitCommand('checkout -b staging'); + $this->runGitCommand('checkout main'); + } + + protected function tearDown(): void + { + if (is_dir($this->testRepoPath)) { + $this->removeDirectory($this->testRepoPath); + } + + parent::tearDown(); + } + + public function testGetGitInfoReturnsCompleteWorkspaceGitInfo(): void + { + // Arrange: Create a workspace + $workspace = $this->createTestWorkspace(); + $workspaceId = $workspace->getId(); + self::assertNotNull($workspaceId); + + // Act: Get git info via facade + $gitInfo = $this->facade->getGitInfo($workspaceId); + + // Assert: Current branch + self::assertSame('main', $gitInfo->currentBranch); + + // Assert: Recent commits (should have 3 commits on main) + self::assertCount(3, $gitInfo->recentCommits); + + // Check most recent commit (Add documentation) + $latestCommit = $gitInfo->recentCommits[0]; + self::assertSame('Add documentation', $latestCommit->message); + self::assertSame('', $latestCommit->body); + self::assertNotEmpty($latestCommit->hash); + + // Check second commit (Add main feature with body) + $secondCommit = $gitInfo->recentCommits[1]; + self::assertSame('Add main feature', $secondCommit->message); + self::assertStringContainsString('Implemented core functionality', $secondCommit->body); + self::assertStringContainsString('Fixes #42', $secondCommit->body); + + // Check third commit (Initial commit) + $thirdCommit = $gitInfo->recentCommits[2]; + self::assertSame('Initial commit', $thirdCommit->message); + self::assertStringContainsString('Project setup and initialization', $thirdCommit->body); + + // Assert: All local branches + self::assertCount(3, $gitInfo->localBranches); + self::assertContains('main', $gitInfo->localBranches); + self::assertContains('development', $gitInfo->localBranches); + self::assertContains('staging', $gitInfo->localBranches); + } + + public function testGetGitInfoRespectsCommitLimit(): void + { + // Arrange + $workspace = $this->createTestWorkspace(); + $workspaceId = $workspace->getId(); + self::assertNotNull($workspaceId); + + // Act: Request only 2 commits + $gitInfo = $this->facade->getGitInfo($workspaceId, 2); + + // Assert: Only 2 commits returned + self::assertCount(2, $gitInfo->recentCommits); + self::assertSame('Add documentation', $gitInfo->recentCommits[0]->message); + self::assertSame('Add main feature', $gitInfo->recentCommits[1]->message); + } + + public function testGetGitInfoReturnsCorrectBranchWhenOnDevelopment(): void + { + // Arrange: Switch to development branch + $this->runGitCommand('checkout development'); + $workspace = $this->createTestWorkspace(); + $workspaceId = $workspace->getId(); + self::assertNotNull($workspaceId); + + // Act + $gitInfo = $this->facade->getGitInfo($workspaceId); + + // Assert: Current branch is development + self::assertSame('development', $gitInfo->currentBranch); + + // Assert: 4 commits on development branch (3 from main + 1 dev commit) + self::assertCount(4, $gitInfo->recentCommits); + self::assertSame('Development work', $gitInfo->recentCommits[0]->message); + } + + public function testGetGitInfoCommitHashesAreValid(): void + { + // Arrange + $workspace = $this->createTestWorkspace(); + $workspaceId = $workspace->getId(); + self::assertNotNull($workspaceId); + + // Act + $gitInfo = $this->facade->getGitInfo($workspaceId); + + // Assert: All commit hashes are valid (40 character hex strings) + foreach ($gitInfo->recentCommits as $commit) { + self::assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $commit->hash); + } + } + + public function testGetGitInfoCommitTimestampsAreValid(): void + { + // Arrange + $workspace = $this->createTestWorkspace(); + $workspaceId = $workspace->getId(); + self::assertNotNull($workspaceId); + + // Act + $gitInfo = $this->facade->getGitInfo($workspaceId); + + // Assert: All timestamps are recent (within last hour - tests should run fast) + foreach ($gitInfo->recentCommits as $commit) { + $now = DateAndTimeService::getDateTimeImmutable(); + $diff = $now->getTimestamp() - $commit->committedAt->getTimestamp(); + self::assertLessThan(3600, $diff, 'Commit timestamp should be recent'); + } + } + + private function createTestWorkspace(): Workspace + { + $workspace = new Workspace('test-project-' . uniqid()); + $workspace->setStatus(WorkspaceStatus::AVAILABLE_FOR_CONVERSATION); + $workspace->setBranchName('main'); + + $this->entityManager->persist($workspace); + $this->entityManager->flush(); + + $workspaceId = $workspace->getId(); + self::assertNotNull($workspaceId); + + // Move test repo to the workspace root location + if (!is_dir($this->workspaceRoot)) { + $result = mkdir($this->workspaceRoot, 0777, true); + if (!$result) { + throw new \RuntimeException('Failed to create workspace root directory: ' . $this->workspaceRoot); + } + } + + $targetPath = $this->workspaceRoot . '/' . $workspaceId; + if (is_dir($targetPath)) { + $this->removeDirectory($targetPath); + } + + // Copy instead of rename (rename may fail across filesystems) + $this->copyDirectory($this->testRepoPath, $targetPath); + $this->removeDirectory($this->testRepoPath); + $this->testRepoPath = $targetPath; + + return $workspace; + } + + private function runGitCommand(string $command): void + { + $process = Process::fromShellCommandline('git ' . $command); + $process->setWorkingDirectory($this->testRepoPath); + $process->setTimeout(10); + $process->mustRun(); + } + + private function copyDirectory(string $source, string $target): void + { + if (!is_dir($source)) { + throw new \RuntimeException('Source directory does not exist: ' . $source); + } + + if (!is_dir($target)) { + mkdir($target, 0777, true); + } + + $items = scandir($source); + if ($items === false) { + throw new \RuntimeException('Failed to scan source directory: ' . $source); + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $sourcePath = $source . '/' . $item; + $targetPath = $target . '/' . $item; + + if (is_dir($sourcePath)) { + $this->copyDirectory($sourcePath, $targetPath); + } else { + copy($sourcePath, $targetPath); + } + } + } + + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $items = scandir($path); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $itemPath = $path . '/' . $item; + if (is_dir($itemPath)) { + $this->removeDirectory($itemPath); + } else { + unlink($itemPath); + } + } + + rmdir($path); + } +} diff --git a/tests/Unit/WorkspaceMgmt/GitCliAdapterTest.php b/tests/Unit/WorkspaceMgmt/GitCliAdapterTest.php new file mode 100644 index 00000000..8d0d7fe5 --- /dev/null +++ b/tests/Unit/WorkspaceMgmt/GitCliAdapterTest.php @@ -0,0 +1,215 @@ +adapter = new GitCliAdapter(); + + // Create a temporary git repository for testing + $this->testRepoPath = sys_get_temp_dir() . '/git-test-' . uniqid(); + mkdir($this->testRepoPath, 0777, true); + + $this->runGitCommand('init -b main'); + $this->runGitCommand('config user.name "Test User"'); + $this->runGitCommand('config user.email "test@example.com"'); + + // Create some initial commits + file_put_contents($this->testRepoPath . '/file1.txt', 'Content 1'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "First commit"'); + + file_put_contents($this->testRepoPath . '/file2.txt', 'Content 2'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Second commit" -m "This is the body of the second commit"'); + + file_put_contents($this->testRepoPath . '/file3.txt', 'Content 3'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Third commit" -m "Body line 1" -m "Body line 2"'); + + // Create a feature branch + $this->runGitCommand('checkout -b feature-branch'); + file_put_contents($this->testRepoPath . '/file4.txt', 'Content 4'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Feature commit"'); + + // Go back to main + $this->runGitCommand('checkout main'); + } + + protected function tearDown(): void + { + // Clean up test repository + if (is_dir($this->testRepoPath)) { + $this->removeDirectory($this->testRepoPath); + } + + parent::tearDown(); + } + + public function testGetCurrentBranch(): void + { + $currentBranch = $this->adapter->getCurrentBranch($this->testRepoPath); + + self::assertSame('main', $currentBranch); + } + + public function testGetCurrentBranchOnFeatureBranch(): void + { + $this->runGitCommand('checkout feature-branch'); + + $currentBranch = $this->adapter->getCurrentBranch($this->testRepoPath); + + self::assertSame('feature-branch', $currentBranch); + } + + public function testGetRecentCommits(): void + { + $commits = $this->adapter->getRecentCommits($this->testRepoPath, 10); + + self::assertCount(3, $commits); + + // Check first commit (most recent) + self::assertNotEmpty($commits[0]->hash); + self::assertSame('Third commit', $commits[0]->subject); + self::assertStringContainsString('Body line 1', $commits[0]->body); + self::assertStringContainsString('Body line 2', $commits[0]->body); + + // Check second commit + self::assertSame('Second commit', $commits[1]->subject); + self::assertStringContainsString('This is the body of the second commit', $commits[1]->body); + + // Check third commit (oldest) + self::assertSame('First commit', $commits[2]->subject); + self::assertSame('', $commits[2]->body); + } + + public function testGetRecentCommitsWithLimit(): void + { + $commits = $this->adapter->getRecentCommits($this->testRepoPath, 2); + + self::assertCount(2, $commits); + self::assertSame('Third commit', $commits[0]->subject); + self::assertSame('Second commit', $commits[1]->subject); + } + + public function testGetRecentCommitsReturnsEmptyArrayForNewRepo(): void + { + $emptyRepoPath = sys_get_temp_dir() . '/git-empty-' . uniqid(); + mkdir($emptyRepoPath, 0777, true); + + $process = new Process(['git', 'init']); + $process->setWorkingDirectory($emptyRepoPath); + $process->run(); + + $commits = $this->adapter->getRecentCommits($emptyRepoPath, 10); + + self::assertSame([], $commits); + + $this->removeDirectory($emptyRepoPath); + } + + public function testGetBranches(): void + { + $branches = $this->adapter->getBranches($this->testRepoPath); + + self::assertCount(2, $branches); + self::assertContains('main', $branches); + self::assertContains('feature-branch', $branches); + } + + public function testGetBranchesReturnsEmptyForNewRepo(): void + { + $emptyRepoPath = sys_get_temp_dir() . '/git-empty-' . uniqid(); + mkdir($emptyRepoPath, 0777, true); + + $process = new Process(['git', 'init']); + $process->setWorkingDirectory($emptyRepoPath); + $process->run(); + + $branches = $this->adapter->getBranches($emptyRepoPath); + + self::assertSame([], $branches); + + $this->removeDirectory($emptyRepoPath); + } + + public function testGetRecentCommitsHandlesMultilineBody(): void + { + file_put_contents($this->testRepoPath . '/file5.txt', 'Content 5'); + $this->runGitCommand('add .'); + $this->runGitCommand('commit -m "Multiline commit" -m "Line 1" -m "" -m "Line 3"'); + + $commits = $this->adapter->getRecentCommits($this->testRepoPath, 1); + + self::assertCount(1, $commits); + self::assertSame('Multiline commit', $commits[0]->subject); + + $body = $commits[0]->body; + self::assertStringContainsString('Line 1', $body); + self::assertStringContainsString('Line 3', $body); + } + + public function testGetRecentCommitsTimestampIsIso8601(): void + { + $commits = $this->adapter->getRecentCommits($this->testRepoPath, 1); + + self::assertCount(1, $commits); + + // Verify timestamp is in ISO 8601 format (e.g., 2024-01-01T12:00:00+00:00 or 2024-01-01T12:00:00Z) + $timestamp = $commits[0]->timestamp; + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/', $timestamp); + } + + private function runGitCommand(string $command): void + { + $process = Process::fromShellCommandline('git ' . $command); + $process->setWorkingDirectory($this->testRepoPath); + $process->setTimeout(10); + $process->mustRun(); + } + + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $items = scandir($path); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $itemPath = $path . '/' . $item; + if (is_dir($itemPath)) { + $this->removeDirectory($itemPath); + } else { + unlink($itemPath); + } + } + + rmdir($path); + } +}