From cc904c01ed1850e954b1c0f7891e7a209e434517 Mon Sep 17 00:00:00 2001 From: Len Woodward Date: Mon, 6 Jan 2025 07:20:12 -0800 Subject: [PATCH] [1.x] Adds `--diff` option (#327) * add [diff] option to default command * add path resolver for git diff * add [diff] method to PathsRepository contract. (Breaking?) * extract method for future re-use * implement new [diff] method * lint * skip duplicates * include untracked files in diff list * add tests * fixes * make stan happy --- app/Commands/DefaultCommand.php | 1 + app/Contracts/PathsRepository.php | 8 +++ app/Project.php | 21 ++++++ app/Repositories/GitPathsRepository.php | 47 +++++++++++++- tests/Feature/DiffTest.php | 86 +++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/DiffTest.php diff --git a/app/Commands/DefaultCommand.php b/app/Commands/DefaultCommand.php index 4d487f12..e4736001 100644 --- a/app/Commands/DefaultCommand.php +++ b/app/Commands/DefaultCommand.php @@ -41,6 +41,7 @@ protected function configure() new InputOption('test', '', InputOption::VALUE_NONE, 'Test for code style errors without fixing them'), new InputOption('bail', '', InputOption::VALUE_NONE, 'Test for code style errors without fixing them and stop on first error'), new InputOption('repair', '', InputOption::VALUE_NONE, 'Fix code style errors but exit with status 1 if there were any changes made'), + new InputOption('diff', '', InputOption::VALUE_REQUIRED, 'Only fix files that have changed since branching off from the given branch', null, ['main', 'master', 'origin/main', 'origin/master']), new InputOption('dirty', '', InputOption::VALUE_NONE, 'Only fix files that have uncommitted changes'), new InputOption('format', '', InputOption::VALUE_REQUIRED, 'The output format that should be used'), new InputOption('cache-file', '', InputArgument::OPTIONAL, 'The path to the cache file'), diff --git a/app/Contracts/PathsRepository.php b/app/Contracts/PathsRepository.php index b1548694..3a291585 100644 --- a/app/Contracts/PathsRepository.php +++ b/app/Contracts/PathsRepository.php @@ -10,4 +10,12 @@ interface PathsRepository * @return array */ public function dirty(); + + /** + * Determine the files that have changed since branching off from the given branch. + * + * @param string $branch + * @return array + */ + public function diff($branch); } diff --git a/app/Project.php b/app/Project.php index 50461c75..f5a5da5c 100644 --- a/app/Project.php +++ b/app/Project.php @@ -18,6 +18,10 @@ public static function paths($input) return static::resolveDirtyPaths(); } + if ($diff = $input->getOption('diff')) { + return static::resolveDiffPaths($diff); + } + return $input->getArgument('path'); } @@ -46,4 +50,21 @@ public static function resolveDirtyPaths() return $files; } + + /** + * Resolves the paths that have changed since branching off from the given branch, if any. + * + * @param string $branch + * @return array + */ + public static function resolveDiffPaths($branch) + { + $files = app(PathsRepository::class)->diff($branch); + + if (empty($files)) { + abort(0, "No files have changed since branching off of {$branch}."); + } + + return $files; + } } diff --git a/app/Repositories/GitPathsRepository.php b/app/Repositories/GitPathsRepository.php index 37bdc058..d1763b41 100644 --- a/app/Repositories/GitPathsRepository.php +++ b/app/Repositories/GitPathsRepository.php @@ -4,6 +4,7 @@ use App\Contracts\PathsRepository; use App\Factories\ConfigurationFactory; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Symfony\Component\Process\Process; @@ -41,6 +42,49 @@ public function dirty() ->mapWithKeys(fn ($file) => [substr($file, 3) => trim(substr($file, 0, 3))]) ->reject(fn ($status) => $status === 'D') ->map(fn ($status, $file) => $status === 'R' ? Str::after($file, ' -> ') : $file) + ->values(); + + return $this->processFileNames($dirtyFiles); + } + + /** + * {@inheritDoc} + */ + public function diff($branch) + { + $files = [ + 'committed' => tap(new Process(['git', 'diff', '--name-only', '--diff-filter=AM', "{$branch}...HEAD", '--', '**.php']))->run(), + 'staged' => tap(new Process(['git', 'diff', '--name-only', '--diff-filter=AM', '--cached', '--', '**.php']))->run(), + 'unstaged' => tap(new Process(['git', 'diff', '--name-only', '--diff-filter=AM', '--', '**.php']))->run(), + 'untracked' => tap(new Process(['git', 'ls-files', '--others', '--exclude-standard', '--', '**.php']))->run(), + ]; + + $files = collect($files) + ->each(fn ($process) => abort_if( + boolean: ! $process->isSuccessful(), + code: 1, + message: 'The [--diff] option is only available when using Git.', + )) + ->map(fn ($process) => $process->getOutput()) + ->map(fn ($output) => explode(PHP_EOL, $output)) + ->flatten() + ->filter() + ->unique() + ->values() + ->map(fn ($s) => (string) $s); + + return $this->processFileNames($files); + } + + /** + * Process the files. + * + * @param \Illuminate\Support\Collection $fileNames + * @return array + */ + protected function processFileNames(Collection $fileNames) + { + $processedFileNames = $fileNames ->map(function ($file) { if (PHP_OS_FAMILY === 'Windows') { $file = str_replace('/', DIRECTORY_SEPARATOR, $file); @@ -48,7 +92,6 @@ public function dirty() return $this->path.DIRECTORY_SEPARATOR.$file; }) - ->values() ->all(); $files = array_values(array_map(function ($splFile) { @@ -58,6 +101,6 @@ public function dirty() ->files() ))); - return array_values(array_intersect($files, $dirtyFiles)); + return array_values(array_intersect($files, $processedFileNames)); } } diff --git a/tests/Feature/DiffTest.php b/tests/Feature/DiffTest.php new file mode 100644 index 00000000..76b1fcfc --- /dev/null +++ b/tests/Feature/DiffTest.php @@ -0,0 +1,86 @@ +shouldReceive('diff') + ->with('main') + ->once() + ->andReturn([ + base_path('tests/Fixtures/without-issues-laravel/file.php'), + ]); + + $this->swap(PathsRepository::class, $paths); + + [$statusCode, $output] = run('default', ['--diff' => 'main']); + + expect($statusCode)->toBe(0) + ->and($output) + ->toContain('── Laravel', ' 1 file'); +}); + +it('ignores the path argument', function () { + $paths = Mockery::mock(PathsRepository::class); + + $paths + ->shouldReceive('diff') + ->once() + ->andReturn([ + base_path('tests/Fixtures/without-issues-laravel/file.php'), + ]); + + $this->swap(PathsRepository::class, $paths); + + [$statusCode, $output] = run('default', [ + '--diff' => 'main', + 'path' => base_path(), + ]); + + expect($statusCode)->toBe(0) + ->and($output) + ->toContain('── Laravel', ' 1 file'); +}); + +it('does not abort when there are no diff files', function () { + $paths = Mockery::mock(PathsRepository::class); + + $paths + ->shouldReceive('diff') + ->once() + ->andReturn([]); + + $this->swap(PathsRepository::class, $paths); + + [$statusCode, $output] = run('default', [ + '--diff' => 'main', + ]); + + expect($statusCode)->toBe(0) + ->and($output) + ->toContain('── Laravel', ' 0 files'); +}); + +it('parses nested branch names', function () { + $paths = Mockery::mock(PathsRepository::class); + + $paths + ->shouldReceive('diff') + ->with('origin/main') + ->once() + ->andReturn([ + base_path('tests/Fixtures/without-issues-laravel/file.php'), + ]); + + $this->swap(PathsRepository::class, $paths); + + [$statusCode, $output] = run('default', [ + '--diff' => 'origin/main', + ]); + + expect($statusCode)->toBe(0) + ->and($output) + ->toContain('── Laravel', ' 1 file'); +});