diff --git a/src/Configuration/ConfigurationFactory.php b/src/Configuration/ConfigurationFactory.php index 7a7ed3d2c4f..eac4cb4ec80 100644 --- a/src/Configuration/ConfigurationFactory.php +++ b/src/Configuration/ConfigurationFactory.php @@ -5,6 +5,7 @@ use Rector\ChangesReporting\Output\ConsoleOutputFormatter; use Rector\Configuration\Parameter\SimpleParameterProvider; +use Rector\FileSystem\GitDirtyFileFetcher; use Rector\ValueObject\Configuration; use RectorPrefix202511\Symfony\Component\Console\Input\InputInterface; use RectorPrefix202511\Symfony\Component\Console\Style\SymfonyStyle; @@ -97,6 +98,12 @@ private function resolvePaths(InputInterface $input): array $this->setFilesWithoutExtensionParameter($commandLinePaths); return $commandLinePaths; } + + $optionDirty = (bool) $input->getOption(\Rector\Configuration\Option::DIRTY); + if ($optionDirty) { + return (new GitDirtyFileFetcher())->fetchDirtyFiles(); + } + // fallback to parameter $configPaths = SimpleParameterProvider::provideArrayParameter(\Rector\Configuration\Option::PATHS); $this->setFilesWithoutExtensionParameter($configPaths); diff --git a/src/Configuration/Option.php b/src/Configuration/Option.php index dad5f4cd56a..e5451e7fb5c 100644 --- a/src/Configuration/Option.php +++ b/src/Configuration/Option.php @@ -143,6 +143,10 @@ final class Option * @var string */ public const NO_DIFFS = 'no-diffs'; + /** + * @var string + */ + public const DIRTY = 'dirty'; /** * @var string */ diff --git a/src/Console/ProcessConfigureDecorator.php b/src/Console/ProcessConfigureDecorator.php index 724ce613cb9..12ddb811666 100644 --- a/src/Console/ProcessConfigureDecorator.php +++ b/src/Console/ProcessConfigureDecorator.php @@ -27,5 +27,6 @@ public static function decorate(Command $command): void $command->addOption(Option::PARALLEL_PORT, null, InputOption::VALUE_REQUIRED); $command->addOption(Option::PARALLEL_IDENTIFIER, null, InputOption::VALUE_REQUIRED); $command->addOption(Option::XDEBUG, null, InputOption::VALUE_NONE, 'Display xdebug output.'); + $command->addOption(Option::DIRTY, null, InputOption::VALUE_NONE, 'Only process files with uncommitted changes according to Git (git status --porcelain)'); } } diff --git a/src/FileSystem/GitDirtyFileFetcher.php b/src/FileSystem/GitDirtyFileFetcher.php new file mode 100644 index 00000000000..1054f5ee348 --- /dev/null +++ b/src/FileSystem/GitDirtyFileFetcher.php @@ -0,0 +1,96 @@ + uses exec(). + * + * @var callable|null + */ + private $commandRunner; + + /** + * @param callable|null $commandRunner signature: fn(string $cmd): array|string + */ + public function __construct(?callable $commandRunner = null) + { + $this->commandRunner = $commandRunner; + } + + /** + * Return relative file paths reported by `git status --porcelain`. + * + * - includes modified (M), added (A), untracked (??) files + * - strips the two-character status prefix and whitespace + * + * @return string[] relative paths + */ + public function fetchDirtyFiles(): array + { + $lines = $this->runCommand('git status --porcelain'); + + $files = []; + + foreach ($lines as $line) { + $line = (string)$line; + if (trim($line) === '') { + continue; + } + + // porcelain format: two-char status space path (path can include spaces) + // e.g. " M src/Service/Foo.php", "?? newfile.php" + if (strlen($line) <= 3) { + continue; + } + + $path = trim(substr($line, 3)); + + if ($path === '') { + continue; + } + + // prefer files on disk to avoid passing directories + if (is_file($path)) { + $files[] = $path; + } + } + + // remove duplicates and empty entries + $files = array_filter($files); + + return array_values($files); + } + + /** + * @return string[] lines + */ + private function runCommand(string $command): array + { + if ($this->commandRunner !== null) { + $result = ($this->commandRunner)($command); + if (is_array($result)) { + return $result; + } + if (is_string($result)) { + return explode("\n", $result); + } + return []; + } + + $output = []; + @exec($command, $output); + return $output; + } +} \ No newline at end of file