From 4cfe23acbff4fe184c32331ca7d8078a88bfefdc Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 20 Mar 2026 04:31:07 +0100 Subject: [PATCH] feat: add diff stats and patch content for local pull requests Populate additions, deletions, changes and patch content on LocalFile by parsing git numstat and unified diff output, bringing local PR structs to parity with GitHub and Gitlab implementations. --- src/Struct/Local/LocalPullRequest.php | 116 ++++++++++++++++++-- tests/Struct/Local/LocalPullRequestTest.php | 91 +++++++++++++++ 2 files changed, 197 insertions(+), 10 deletions(-) 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