diff --git a/src/Struct/Local/LocalPullRequest.php b/src/Struct/Local/LocalPullRequest.php index e0bbc92..be61c28 100644 --- a/src/Struct/Local/LocalPullRequest.php +++ b/src/Struct/Local/LocalPullRequest.php @@ -90,18 +90,30 @@ public function getFiles(): FileCollection return $this->files; } - $process = new Process([ - 'git', - 'diff', - $this->target . '..' . $this->local, - '--name-status', + $diffRange = $this->target . '..' . $this->local; + + $nameStatusProcess = new Process([ + 'git', 'diff', $diffRange, '--name-status', ], $this->repo); - $process->mustRun(); + $numstatProcess = new Process([ + 'git', 'diff', $diffRange, '--numstat', + ], $this->repo); + + $patchProcess = new Process([ + 'git', 'diff', $diffRange, + ], $this->repo); + + $nameStatusProcess->mustRun(); + $numstatProcess->mustRun(); + $patchProcess->mustRun(); + + $numstats = $this->parseNumstat($numstatProcess->getOutput()); + $patches = $this->parsePatch($patchProcess->getOutput()); $files = new FileCollection(); - foreach (explode(\PHP_EOL, $process->getOutput()) as $line) { + foreach (explode(\PHP_EOL, $nameStatusProcess->getOutput()) as $line) { if ($line === '') { continue; } @@ -111,9 +123,10 @@ public function getFiles(): FileCollection $element = new LocalFile($this->repo . '/' . $file); $element->name = $file; - $element->additions = 0; - $element->changes = 0; - $element->deletions = 0; + $element->additions = $numstats[$file]['additions'] ?? 0; + $element->deletions = $numstats[$file]['deletions'] ?? 0; + $element->changes = $element->additions + $element->deletions; + $element->patch = $patches[$file] ?? ''; if ($status === 'A') { $element->status = File::STATUS_ADDED; @@ -129,6 +142,89 @@ public function getFiles(): FileCollection return $this->files = $files; } + /** + * @return array + */ + private function parseNumstat(string $output): array + { + $result = []; + + foreach (explode(\PHP_EOL, $output) as $line) { + if ($line === '') { + continue; + } + + $parts = preg_split('/\t/', $line, 3); + if ($parts === false || \count($parts) < 3) { + continue; + } + + $result[$parts[2]] = [ + 'additions' => $parts[0] === '-' ? 0 : (int) $parts[0], + 'deletions' => $parts[1] === '-' ? 0 : (int) $parts[1], + ]; + } + + return $result; + } + + /** + * @return array + */ + private function parsePatch(string $output): array + { + $result = []; + $currentFile = null; + $currentPatch = ''; + $fallbackFile = null; + + foreach (explode(\PHP_EOL, $output) as $line) { + if (str_starts_with($line, 'diff --git ')) { + if ($currentFile !== null) { + $result[$currentFile] = $currentPatch; + } + + $currentFile = null; + $currentPatch = ''; + $fallbackFile = null; + + continue; + } + + if ($currentFile === null && str_starts_with($line, '--- a/')) { + $fallbackFile = mb_substr($line, 6); + + continue; + } + + if ($currentFile === null && str_starts_with($line, '+++ b/')) { + $currentFile = mb_substr($line, 6); + + continue; + } + + if ($currentFile === null && $line === '+++ /dev/null') { + $currentFile = $fallbackFile; + + continue; + } + + if ($currentFile === null && str_starts_with($line, '--- ')) { + continue; + } + + if ($currentFile !== null) { + $currentPatch .= ($currentPatch !== '' ? \PHP_EOL : '') . $line; + } + } + + if ($currentFile !== null) { + $result[$currentFile] = $currentPatch; + } + + return $result; + } + public function getComments(): CommentCollection { return new CommentCollection(); diff --git a/tests/Struct/Local/LocalPullRequestTest.php b/tests/Struct/Local/LocalPullRequestTest.php index 1cbe43a..abc9a32 100644 --- a/tests/Struct/Local/LocalPullRequestTest.php +++ b/tests/Struct/Local/LocalPullRequestTest.php @@ -98,6 +98,9 @@ public function testGetFiles(): void static::assertSame('a.txt', $fileA->name); static::assertSame(File::STATUS_REMOVED, $fileA->status); + static::assertSame(0, $fileA->additions); + static::assertSame(1, $fileA->deletions); + static::assertSame(1, $fileA->changes); $fileB = $files->get('b.txt'); @@ -105,6 +108,10 @@ public function testGetFiles(): void static::assertSame('b2', $fileB->getContent()); static::assertSame(File::STATUS_ADDED, $fileB->status); + static::assertSame(1, $fileB->additions); + static::assertSame(0, $fileB->deletions); + static::assertSame(1, $fileB->changes); + static::assertNotEmpty($fileB->patch); $fileC = $files->get('c.txt'); @@ -112,10 +119,94 @@ public function testGetFiles(): void static::assertSame('c', $fileC->getContent()); static::assertSame(File::STATUS_ADDED, $fileC->status); + static::assertSame(1, $fileC->additions); + static::assertSame(0, $fileC->deletions); + static::assertSame(1, $fileC->changes); $fileModified = $files->get('modified.txt'); static::assertNotNull($fileModified); static::assertSame(File::STATUS_MODIFIED, $fileModified->status); + static::assertSame(1, $fileModified->additions); + static::assertSame(1, $fileModified->deletions); + static::assertSame(2, $fileModified->changes); + static::assertNotEmpty($fileModified->patch); + } + + public function testDiffStatsForSingleAddedFile(): void + { + $pr = new LocalPullRequest($this->tmpDir, 'feature', 'main'); + + $files = $pr->getFiles(); + + static::assertCount(1, $files); + + $fileB = $files->get('b.txt'); + static::assertNotNull($fileB); + + static::assertSame(File::STATUS_ADDED, $fileB->status); + static::assertSame(1, $fileB->additions); + static::assertSame(0, $fileB->deletions); + static::assertSame(1, $fileB->changes); + static::assertNotEmpty($fileB->patch); + } + + public function testDiffStatsForMultilineChanges(): void + { + (new Process(['git', 'checkout', '-b', 'multiline'], $this->tmpDir))->mustRun(); + + file_put_contents($this->tmpDir . '/multi.txt', "line1\nline2\nline3\nline4\nline5\n"); + (new Process(['git', 'add', 'multi.txt'], $this->tmpDir))->mustRun(); + (new Process(['git', 'commit', '-m', 'add multiline file'], $this->tmpDir))->mustRun(); + + (new Process(['git', 'checkout', '-b', 'multiline-edit'], $this->tmpDir))->mustRun(); + + file_put_contents($this->tmpDir . '/multi.txt', "line1\nchanged2\nline3\nchanged4\nline5\nnewline6\n"); + (new Process(['git', 'add', 'multi.txt'], $this->tmpDir))->mustRun(); + (new Process(['git', 'commit', '-m', 'edit multiline file'], $this->tmpDir))->mustRun(); + + $pr = new LocalPullRequest($this->tmpDir, 'multiline-edit', 'multiline'); + + $files = $pr->getFiles(); + + static::assertCount(1, $files); + + $file = $files->get('multi.txt'); + static::assertNotNull($file); + + static::assertSame(File::STATUS_MODIFIED, $file->status); + static::assertSame(3, $file->additions); + static::assertSame(2, $file->deletions); + static::assertSame(5, $file->changes); + + static::assertStringContainsString('+changed2', $file->patch); + static::assertStringContainsString('-line2', $file->patch); + static::assertStringContainsString('+newline6', $file->patch); + } + + public function testPatchContentForDeletedFile(): void + { + $pr = new LocalPullRequest($this->tmpDir, 'feature2', 'main'); + + $files = $pr->getFiles(); + + $fileA = $files->get('a.txt'); + static::assertNotNull($fileA); + static::assertSame(File::STATUS_REMOVED, $fileA->status); + static::assertStringContainsString('-a', $fileA->patch); + } + + public function testPatchContentForModifiedFile(): void + { + $pr = new LocalPullRequest($this->tmpDir, 'feature2', 'main'); + + $files = $pr->getFiles(); + + $fileModified = $files->get('modified.txt'); + static::assertNotNull($fileModified); + + static::assertStringContainsString('-a', $fileModified->patch); + static::assertStringContainsString('+b', $fileModified->patch); + static::assertStringContainsString('@@', $fileModified->patch); } public function testGetSingleFile(): void