diff --git a/PhpcsChanged/BatchScanResult.php b/PhpcsChanged/BatchScanResult.php new file mode 100644 index 0000000..1e8c8cc --- /dev/null +++ b/PhpcsChanged/BatchScanResult.php @@ -0,0 +1,48 @@ + phpcs output for the modified version, keyed by file + */ + private $modifiedOutputs; + + /** + * @var array phpcs output for the unmodified version, keyed by file + */ + private $unmodifiedOutputs; + + /** + * @var float Wall-clock time attributed to each scanned file + */ + private $timePerFile; + + /** + * @param array $modifiedOutputs + * @param array $unmodifiedOutputs + */ + public function __construct(array $modifiedOutputs, array $unmodifiedOutputs, float $timePerFile) { + $this->modifiedOutputs = $modifiedOutputs; + $this->unmodifiedOutputs = $unmodifiedOutputs; + $this->timePerFile = $timePerFile; + } + + public function getModifiedOutput(string $file): string { + return $this->modifiedOutputs[$file] ?? ''; + } + + public function getUnmodifiedOutput(string $file): string { + return $this->unmodifiedOutputs[$file] ?? ''; + } + + public function getTimePerFile(): float { + return $this->timePerFile; + } +} diff --git a/PhpcsChanged/Cli.php b/PhpcsChanged/Cli.php index c2a837f..93e9a24 100644 --- a/PhpcsChanged/Cli.php +++ b/PhpcsChanged/Cli.php @@ -11,6 +11,8 @@ use PhpcsChanged\JunitReporter; use PhpcsChanged\CheckstyleReporter; use PhpcsChanged\PhpcsMessages; +use PhpcsChanged\ScanPlan; +use PhpcsChanged\BatchScanResult; use PhpcsChanged\ShellException; use PhpcsChanged\ShellOperator; use PhpcsChanged\XmlReporter; @@ -229,95 +231,179 @@ function runSvnWorkflow(array $svnFiles, CliOptions $options, ShellOperator $she loadCache($cache, $shell, $options->toArray()); - $phpcsMessages = array_map(function(string $svnFile) use ($options, $shell, $cache, $debug): PhpcsMessages { - return runSvnWorkflowForFile($svnFile, $options, $shell, $cache, $debug); - }, $svnFiles); + $plan = prepareSvnScanPlan($svnFiles, $options, $shell, $cache, $debug); + $outputs = runSvnBatchScan($plan, $options, $shell, $cache); + $phpcsMessages = getNewSvnMessagesForFiles($svnFiles, $plan, $outputs, $shell, $debug); saveCache($cache, $shell, $options->toArray()); - $shell->clearCaches(); return PhpcsMessages::merge($phpcsMessages); } -function runSvnWorkflowForFile(string $svnFile, CliOptions $options, ShellOperator $shell, CacheManager $cache, callable $debug): PhpcsMessages { +/** + * Determine which files need fresh phpcs scans and which can be served from the cache. + * + * @param string[] $svnFiles + */ +function prepareSvnScanPlan(array $svnFiles, CliOptions $options, ShellOperator $shell, CacheManager $cache, callable $debug): ScanPlan { $phpcsStandard = $options->phpcsStandard; - $warningSeverity = $options->warningSeverity; $errorSeverity = $options->errorSeverity; - $fileName = $shell->getFileNameFromPath($svnFile); - try { - if (! $shell->isReadable($svnFile)) { - throw new ShellException("Cannot read file '{$svnFile}'"); - } + $needsModifiedPhpcs = []; + $needsUnmodifiedPhpcs = []; + $modifiedOutputs = []; + $unmodifiedOutputs = []; + $isNewFileMap = []; + $modifiedHashMap = []; + $revisionIdMap = []; - $modifiedFileHash = ''; - $modifiedFilePhpcsOutput = null; - if (isCachingEnabled($options->toArray())) { - $modifiedFileHash = $shell->getFileHash($svnFile); - $modifiedFilePhpcsOutput = $cache->getCacheForFile($svnFile, 'new', $modifiedFileHash, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? ''); - $debug(($modifiedFilePhpcsOutput ? 'Using' : 'Not using') . " cache for modified file '{$svnFile}' at hash '{$modifiedFileHash}', and standard '{$phpcsStandard}'"); - } - $modifiedFileTiming = 0.0; - if (! $modifiedFilePhpcsOutput) { - $modifiedFileStartTime = microtime(true); - $modifiedFilePhpcsOutput = $shell->getPhpcsOutputOfModifiedSvnFile($svnFile); - $modifiedFileTiming = microtime(true) - $modifiedFileStartTime; + foreach ($svnFiles as $svnFile) { + try { + if (! $shell->isReadable($svnFile)) { + throw new ShellException("Cannot read file '{$svnFile}'"); + } + + $modifiedFileHash = ''; + $modifiedCached = null; if (isCachingEnabled($options->toArray())) { - $cache->setCacheForFile($svnFile, 'new', $modifiedFileHash, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? '', $modifiedFilePhpcsOutput); + $modifiedFileHash = $shell->getFileHash($svnFile); + $modifiedCached = $cache->getCacheForFile($svnFile, 'new', $modifiedFileHash, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? ''); + $debug(($modifiedCached !== null ? 'Using' : 'Not using') . " cache for modified file '{$svnFile}' at hash '{$modifiedFileHash}', and standard '{$phpcsStandard}'"); } - } + $modifiedHashMap[$svnFile] = $modifiedFileHash; - $modifiedFilePhpcsMessages = PhpcsMessages::fromPhpcsJson($modifiedFilePhpcsOutput, $fileName); - $modifiedFilePhpcsMessages->setTiming($fileName, $modifiedFileTiming); - $hasNewPhpcsMessages = count($modifiedFilePhpcsMessages->getMessages()) > 0; + if ($modifiedCached !== null) { + $modifiedOutputs[$svnFile] = $modifiedCached; + } else { + $needsModifiedPhpcs[] = $svnFile; + } - if (! $hasNewPhpcsMessages) { - throw new NoChangesException("Modified file '{$svnFile}' has no PHPCS messages; skipping"); + $revisionId = $shell->getSvnRevisionId($svnFile); + $isNewFile = $shell->doesUnmodifiedFileExistInSvn($svnFile); + $isNewFileMap[$svnFile] = $isNewFile; + $revisionIdMap[$svnFile] = $revisionId; + if ($isNewFile) { + $debug("File '{$svnFile}' is new; unmodified version will not be scanned."); + } + + if (! $isNewFile) { + $unmodifiedCached = null; + if (isCachingEnabled($options->toArray())) { + $unmodifiedCached = $cache->getCacheForFile($svnFile, 'old', $revisionId, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? ''); + $debug(($unmodifiedCached !== null ? 'Using' : 'Not using') . " cache for unmodified file '{$svnFile}' at revision '{$revisionId}', and standard '{$phpcsStandard}'"); + } + + if ($unmodifiedCached !== null) { + $unmodifiedOutputs[$svnFile] = $unmodifiedCached; + } else { + $needsUnmodifiedPhpcs[] = $svnFile; + } + } + } catch( ShellException $err ) { + $shell->printError($err->getMessage()); + $shell->exitWithCode(1); + throw $err; // Just in case we do not actually exit, like in tests } + } + + return new ScanPlan($needsModifiedPhpcs, $needsUnmodifiedPhpcs, $modifiedOutputs, $unmodifiedOutputs, $isNewFileMap, $modifiedHashMap, $revisionIdMap); +} + +/** + * Run a single phpcs invocation for all uncached files in the plan and merge the + * results with the outputs already served from the cache. + */ +function runSvnBatchScan(ScanPlan $plan, CliOptions $options, ShellOperator $shell, CacheManager $cache): BatchScanResult { + $phpcsStandard = $options->phpcsStandard; + $warningSeverity = $options->warningSeverity; + $errorSeverity = $options->errorSeverity; - $unifiedDiff = $shell->getSvnUnifiedDiff($svnFile); + $needsModifiedPhpcs = $plan->getNeedsModifiedPhpcs(); + $needsUnmodifiedPhpcs = $plan->getNeedsUnmodifiedPhpcs(); + $modifiedOutputs = $plan->getModifiedOutputs(); + $unmodifiedOutputs = $plan->getUnmodifiedOutputs(); - $revisionId = $shell->getSvnRevisionId($svnFile); - $isNewFile = $shell->doesUnmodifiedFileExistInSvn($svnFile); - if ($isNewFile) { - $debug('Skipping the linting of the unmodified file as it is a new file.'); + $batchTime = 0.0; + $batchSize = count($needsModifiedPhpcs) + count($needsUnmodifiedPhpcs); + if ($batchSize > 0) { + try { + $batchStartTime = microtime(true); + $batchResults = $shell->getPhpcsOutputForSvnBatch($needsModifiedPhpcs, $needsUnmodifiedPhpcs); + $batchTime = microtime(true) - $batchStartTime; + } catch( \Exception $err ) { + $shell->printError($err->getMessage()); + $shell->exitWithCode(1); + throw $err; // Just in case we do not actually exit, like in tests } - $unmodifiedFilePhpcsOutput = ''; - if (! $isNewFile) { + + foreach ($needsModifiedPhpcs as $svnFile) { + $modifiedOutputs[$svnFile] = $batchResults['new'][$svnFile] ?? ''; if (isCachingEnabled($options->toArray())) { - $unmodifiedFilePhpcsOutput = $cache->getCacheForFile($svnFile, 'old', $revisionId, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? ''); - $debug(($unmodifiedFilePhpcsOutput ? 'Using' : 'Not using') . " cache for unmodified file '{$svnFile}' at revision '{$revisionId}', and standard '{$phpcsStandard}'"); + $cache->setCacheForFile($svnFile, 'new', $plan->getModifiedCacheKey($svnFile), $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? '', $modifiedOutputs[$svnFile]); } - $unmodifiedFileTiming = 0.0; - if (! $unmodifiedFilePhpcsOutput) { - $unmodifiedFileStartTime = microtime(true); - $unmodifiedFilePhpcsOutput = $shell->getPhpcsOutputOfUnmodifiedSvnFile($svnFile); - $unmodifiedFileTiming = microtime(true) - $unmodifiedFileStartTime; - if (isCachingEnabled($options->toArray())) { - $cache->setCacheForFile($svnFile, 'old', $revisionId, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? '', $unmodifiedFilePhpcsOutput); - } + } + + foreach ($needsUnmodifiedPhpcs as $svnFile) { + $unmodifiedOutputs[$svnFile] = $batchResults['old'][$svnFile] ?? ''; + if (isCachingEnabled($options->toArray())) { + $cache->setCacheForFile($svnFile, 'old', $plan->getUnmodifiedCacheKey($svnFile), $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? '', $unmodifiedOutputs[$svnFile]); } - // Add timing for the unmodified scan (accumulated with modified scan time) - $modifiedFileTiming += $unmodifiedFileTiming; } - } catch( NoChangesException $err ) { - $debug($err->getMessage()); - $unifiedDiff = ''; - $unmodifiedFilePhpcsOutput = ''; - $modifiedFilePhpcsMessages = PhpcsMessages::fromPhpcsJson(''); - } catch( \Exception $err ) { - $shell->printError($err->getMessage()); - $shell->exitWithCode(1); - throw $err; // Just in case we do not actually exit, like in tests } - $debug('processing data...'); - return getNewPhpcsMessages( - $unifiedDiff, - PhpcsMessages::fromPhpcsJson($unmodifiedFilePhpcsOutput, $fileName), - $modifiedFilePhpcsMessages - ); + return new BatchScanResult($modifiedOutputs, $unmodifiedOutputs, $batchSize > 0 ? $batchTime / $batchSize : 0.0); +} + +/** + * Compute the new phpcs messages for each file from its modified/unmodified phpcs output. + * + * @param string[] $svnFiles + * + * @return PhpcsMessages[] + */ +function getNewSvnMessagesForFiles(array $svnFiles, ScanPlan $plan, BatchScanResult $outputs, ShellOperator $shell, callable $debug): array { + $phpcsMessages = []; + foreach ($svnFiles as $svnFile) { + $fileName = $shell->getFileNameFromPath($svnFile); + try { + $modifiedOutput = $outputs->getModifiedOutput($svnFile); + $modifiedFilePhpcsMessages = PhpcsMessages::fromPhpcsJson($modifiedOutput, $fileName); + $modifiedFilePhpcsMessages->setTiming($fileName, $outputs->getTimePerFile()); + $hasNewPhpcsMessages = count($modifiedFilePhpcsMessages->getMessages()) > 0; + + if (! $hasNewPhpcsMessages) { + throw new NoChangesException("Modified file '{$svnFile}' has no PHPCS messages; skipping"); + } + + $unifiedDiff = $shell->getSvnUnifiedDiff($svnFile); + $isNewFile = $plan->isNewFile($svnFile); + + if ($isNewFile) { + $debug('Skipping the linting of the unmodified file as it is a new file.'); + $phpcsMessages[] = getNewPhpcsMessages($unifiedDiff, PhpcsMessages::fromPhpcsJson('', $fileName), $modifiedFilePhpcsMessages); + continue; + } + + $unmodifiedOutput = $outputs->getUnmodifiedOutput($svnFile); + $phpcsMessages[] = getNewPhpcsMessages($unifiedDiff, PhpcsMessages::fromPhpcsJson($unmodifiedOutput, $fileName), $modifiedFilePhpcsMessages); + } catch( NoChangesException $err ) { + $debug($err->getMessage()); + $unifiedDiff = ''; + $unmodifiedFilePhpcsOutput = ''; + $modifiedFilePhpcsMessages = PhpcsMessages::fromPhpcsJson(''); + $phpcsMessages[] = getNewPhpcsMessages( + $unifiedDiff, + PhpcsMessages::fromPhpcsJson($unmodifiedFilePhpcsOutput, $fileName), + $modifiedFilePhpcsMessages + ); + } catch( \Exception $err ) { + $shell->printError($err->getMessage()); + $shell->exitWithCode(1); + throw $err; // Just in case we do not actually exit, like in tests + } + } + return $phpcsMessages; } function runGitWorkflow(CliOptions $options, ShellOperator $shell, CacheManager $cache, callable $debug): PhpcsMessages { @@ -336,92 +422,170 @@ function runGitWorkflow(CliOptions $options, ShellOperator $shell, CacheManager loadCache($cache, $shell, $options->toArray()); - $phpcsMessages = array_map(function(string $gitFile) use ($options, $shell, $cache, $debug): PhpcsMessages { - return runGitWorkflowForFile($gitFile, $options, $shell, $cache, $debug); - }, $options->files); + $plan = prepareGitScanPlan($options, $shell, $cache, $debug); + $outputs = runGitBatchScan($plan, $options, $shell, $cache); + $phpcsMessages = getNewGitMessagesForFiles($options->files, $plan, $outputs, $shell, $debug); saveCache($cache, $shell, $options->toArray()); - $shell->clearCaches(); return PhpcsMessages::merge($phpcsMessages); } -function runGitWorkflowForFile(string $gitFile, CliOptions $options, ShellOperator $shell, CacheManager $cache, callable $debug): PhpcsMessages { +/** + * Determine which files need fresh phpcs scans and which can be served from the cache. + */ +function prepareGitScanPlan(CliOptions $options, ShellOperator $shell, CacheManager $cache, callable $debug): ScanPlan { $phpcsStandard = $options->phpcsStandard; $warningSeverity = $options->warningSeverity; $errorSeverity = $options->errorSeverity; - try { - if (! $shell->isReadable($gitFile)) { - throw new ShellException("Cannot read file '{$gitFile}'"); - } + $needsModifiedPhpcs = []; + $needsUnmodifiedPhpcs = []; + $modifiedOutputs = []; + $unmodifiedOutputs = []; + $isNewFileMap = []; + $modifiedHashMap = []; + $unmodifiedHashMap = []; - $modifiedFilePhpcsOutput = null; - $modifiedFileHash = ''; - if (isCachingEnabled($options->toArray())) { - $modifiedFileHash = $shell->getGitHashOfModifiedFile($gitFile); - $modifiedFilePhpcsOutput = $cache->getCacheForFile($gitFile, 'new', $modifiedFileHash, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? ''); - $debug(($modifiedFilePhpcsOutput ? 'Using' : 'Not using') . " cache for modified file '{$gitFile}' at hash '{$modifiedFileHash}', and standard '{$phpcsStandard}'"); - } - $modifiedFileTiming = 0.0; - if (! $modifiedFilePhpcsOutput) { - $modifiedFileStartTime = microtime(true); - $modifiedFilePhpcsOutput = $shell->getPhpcsOutputOfModifiedGitFile($gitFile); - $modifiedFileTiming = microtime(true) - $modifiedFileStartTime; + foreach ($options->files as $gitFile) { + try { + if (! $shell->isReadable($gitFile)) { + throw new ShellException("Cannot read file '{$gitFile}'"); + } + + $modifiedHash = ''; + $modifiedCached = null; if (isCachingEnabled($options->toArray())) { - $cache->setCacheForFile($gitFile, 'new', $modifiedFileHash, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? '', $modifiedFilePhpcsOutput); + $modifiedHash = $shell->getGitHashOfModifiedFile($gitFile); + $modifiedCached = $cache->getCacheForFile($gitFile, 'new', $modifiedHash, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? ''); + $debug(($modifiedCached !== null ? 'Using' : 'Not using') . " cache for modified file '{$gitFile}' at hash '{$modifiedHash}', and standard '{$phpcsStandard}'"); } - } + $modifiedHashMap[$gitFile] = $modifiedHash; - $modifiedFilePhpcsMessages = PhpcsMessages::fromPhpcsJson($modifiedFilePhpcsOutput, $gitFile); - $modifiedFilePhpcsMessages->setTiming($gitFile, $modifiedFileTiming); - $hasNewPhpcsMessages = count($modifiedFilePhpcsMessages->getMessages()) > 0; + if ($modifiedCached !== null) { + $modifiedOutputs[$gitFile] = $modifiedCached; + } else { + $needsModifiedPhpcs[] = $gitFile; + } - $unifiedDiff = ''; - $unmodifiedFilePhpcsOutput = ''; - if (! $hasNewPhpcsMessages) { - throw new NoChangesException("Modified file '{$gitFile}' has no PHPCS messages; skipping"); + $isNewFile = $shell->doesUnmodifiedFileExistInGit($gitFile); + $isNewFileMap[$gitFile] = $isNewFile; + if ($isNewFile) { + $debug("File '{$gitFile}' is new; unmodified version will not be scanned."); + } + + if (! $isNewFile) { + $unmodifiedHash = ''; + $unmodifiedCached = null; + if (isCachingEnabled($options->toArray())) { + $unmodifiedHash = $shell->getGitHashOfUnmodifiedFile($gitFile); + $unmodifiedCached = $cache->getCacheForFile($gitFile, 'old', $unmodifiedHash, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? ''); + $debug(($unmodifiedCached !== null ? 'Using' : 'Not using') . " cache for unmodified file '{$gitFile}' at hash '{$unmodifiedHash}', and standard '{$phpcsStandard}'"); + } + $unmodifiedHashMap[$gitFile] = $unmodifiedHash; + + if ($unmodifiedCached !== null) { + $unmodifiedOutputs[$gitFile] = $unmodifiedCached; + } else { + $needsUnmodifiedPhpcs[] = $gitFile; + } + } + } catch(ShellException $err) { + $shell->printError($err->getMessage()); + $shell->exitWithCode(1); + throw $err; // Just in case we do not actually exit } + } + + return new ScanPlan($needsModifiedPhpcs, $needsUnmodifiedPhpcs, $modifiedOutputs, $unmodifiedOutputs, $isNewFileMap, $modifiedHashMap, $unmodifiedHashMap); +} + +/** + * Run a single phpcs invocation for all uncached files in the plan and merge the + * results with the outputs already served from the cache. + */ +function runGitBatchScan(ScanPlan $plan, CliOptions $options, ShellOperator $shell, CacheManager $cache): BatchScanResult { + $phpcsStandard = $options->phpcsStandard; + $warningSeverity = $options->warningSeverity; + $errorSeverity = $options->errorSeverity; - $isNewFile = $shell->doesUnmodifiedFileExistInGit($gitFile); - if ($isNewFile) { - $debug('Skipping the linting of the unmodified file as it is a new file.'); + $needsModifiedPhpcs = $plan->getNeedsModifiedPhpcs(); + $needsUnmodifiedPhpcs = $plan->getNeedsUnmodifiedPhpcs(); + $modifiedOutputs = $plan->getModifiedOutputs(); + $unmodifiedOutputs = $plan->getUnmodifiedOutputs(); + + $batchTime = 0.0; + $batchSize = count($needsModifiedPhpcs) + count($needsUnmodifiedPhpcs); + if ($batchSize > 0) { + try { + $batchStartTime = microtime(true); + $batchResults = $shell->getPhpcsOutputForGitBatch($needsModifiedPhpcs, $needsUnmodifiedPhpcs); + $batchTime = microtime(true) - $batchStartTime; + } catch(\Exception $err) { + $shell->printError($err->getMessage()); + $shell->exitWithCode(1); + throw $err; // Just in case we do not actually exit } - if (! $isNewFile) { - $debug('Checking the unmodified file with PHPCS since the file is not new and contains some messages.'); - $unifiedDiff = $shell->getGitUnifiedDiff($gitFile); - $unmodifiedFilePhpcsOutput = null; - $unmodifiedFileHash = ''; + + foreach ($needsModifiedPhpcs as $gitFile) { + $modifiedOutputs[$gitFile] = $batchResults['new'][$gitFile] ?? ''; if (isCachingEnabled($options->toArray())) { - $unmodifiedFileHash = $shell->getGitHashOfUnmodifiedFile($gitFile); - $unmodifiedFilePhpcsOutput = $cache->getCacheForFile($gitFile, 'old', $unmodifiedFileHash, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? ''); - $debug(($unmodifiedFilePhpcsOutput ? 'Using' : 'Not using') . " cache for unmodified file '{$gitFile}' at hash '{$unmodifiedFileHash}', and standard '{$phpcsStandard}'"); + $cache->setCacheForFile($gitFile, 'new', $plan->getModifiedCacheKey($gitFile), $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? '', $modifiedOutputs[$gitFile]); } - $unmodifiedFileTiming = 0.0; - if (! $unmodifiedFilePhpcsOutput) { - $unmodifiedFileStartTime = microtime(true); - $unmodifiedFilePhpcsOutput = $shell->getPhpcsOutputOfUnmodifiedGitFile($gitFile); - $unmodifiedFileTiming = microtime(true) - $unmodifiedFileStartTime; - if (isCachingEnabled($options->toArray())) { - $cache->setCacheForFile($gitFile, 'old', $unmodifiedFileHash, $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? '', $unmodifiedFilePhpcsOutput); - } + } + + foreach ($needsUnmodifiedPhpcs as $gitFile) { + $unmodifiedOutputs[$gitFile] = $batchResults['old'][$gitFile] ?? ''; + if (isCachingEnabled($options->toArray())) { + $cache->setCacheForFile($gitFile, 'old', $plan->getUnmodifiedCacheKey($gitFile), $phpcsStandard ?? '', $warningSeverity ?? '', $errorSeverity ?? '', $unmodifiedOutputs[$gitFile]); } - // Add timing for the unmodified scan (accumulated with modified scan time) - $modifiedFileTiming += $unmodifiedFileTiming; } - } catch( NoChangesException $err ) { - $debug($err->getMessage()); - $unifiedDiff = ''; - $unmodifiedFilePhpcsOutput = ''; - $modifiedFilePhpcsMessages = PhpcsMessages::fromPhpcsJson(''); - } catch(\Exception $err) { - $shell->printError($err->getMessage()); - $shell->exitWithCode(1); - throw $err; // Just in case we do not actually exit } - $debug('processing data...'); - return getNewPhpcsMessages($unifiedDiff, PhpcsMessages::fromPhpcsJson($unmodifiedFilePhpcsOutput, $gitFile), $modifiedFilePhpcsMessages); + return new BatchScanResult($modifiedOutputs, $unmodifiedOutputs, $batchSize > 0 ? $batchTime / $batchSize : 0.0); +} + +/** + * Compute the new phpcs messages for each file from its modified/unmodified phpcs output. + * + * @param string[] $gitFiles + * + * @return PhpcsMessages[] + */ +function getNewGitMessagesForFiles(array $gitFiles, ScanPlan $plan, BatchScanResult $outputs, ShellOperator $shell, callable $debug): array { + $phpcsMessages = []; + foreach ($gitFiles as $gitFile) { + try { + $modifiedOutput = $outputs->getModifiedOutput($gitFile); + $modifiedFilePhpcsMessages = PhpcsMessages::fromPhpcsJson($modifiedOutput, $gitFile); + $modifiedFilePhpcsMessages->setTiming($gitFile, $outputs->getTimePerFile()); + + $unifiedDiff = ''; + $unmodifiedFilePhpcsOutput = ''; + if (count($modifiedFilePhpcsMessages->getMessages()) === 0) { + throw new NoChangesException("Modified file '{$gitFile}' has no PHPCS messages; skipping"); + } + + $isNewFile = $plan->isNewFile($gitFile); + if (! $isNewFile) { + $debug('Checking the unmodified file with PHPCS since the file is not new and contains some messages.'); + $unifiedDiff = $shell->getGitUnifiedDiff($gitFile); + $unmodifiedFilePhpcsOutput = $outputs->getUnmodifiedOutput($gitFile); + } else { + $debug('Skipping the linting of the unmodified file as it is a new file.'); + } + + $phpcsMessages[] = getNewPhpcsMessages($unifiedDiff, PhpcsMessages::fromPhpcsJson($unmodifiedFilePhpcsOutput, $gitFile), $modifiedFilePhpcsMessages); + } catch( NoChangesException $err ) { + $debug($err->getMessage()); + $phpcsMessages[] = PhpcsMessages::fromPhpcsJson(''); + } catch(\Exception $err) { + $shell->printError($err->getMessage()); + $shell->exitWithCode(1); + throw $err; // Just in case we do not actually exit + } + } + return $phpcsMessages; } function reportMessagesAndExit(PhpcsMessages $messages, CliOptions $options, ShellOperator $shell): void { diff --git a/PhpcsChanged/ScanPlan.php b/PhpcsChanged/ScanPlan.php new file mode 100644 index 0000000..0edb10b --- /dev/null +++ b/PhpcsChanged/ScanPlan.php @@ -0,0 +1,105 @@ + Cached phpcs output for the modified version, keyed by file + */ + private $modifiedOutputs; + + /** + * @var array Cached phpcs output for the unmodified version, keyed by file + */ + private $unmodifiedOutputs; + + /** + * @var array Whether each file is new (has no unmodified version), keyed by file + */ + private $isNewFileMap; + + /** + * @var array Cache key (file hash) for the modified version, keyed by file + */ + private $modifiedHashMap; + + /** + * @var array Cache key for the unmodified version (svn revision id or git hash), keyed by file + */ + private $unmodifiedCacheKeyMap; + + /** + * @param string[] $needsModifiedPhpcs + * @param string[] $needsUnmodifiedPhpcs + * @param array $modifiedOutputs + * @param array $unmodifiedOutputs + * @param array $isNewFileMap + * @param array $modifiedHashMap + * @param array $unmodifiedCacheKeyMap + */ + public function __construct(array $needsModifiedPhpcs, array $needsUnmodifiedPhpcs, array $modifiedOutputs, array $unmodifiedOutputs, array $isNewFileMap, array $modifiedHashMap, array $unmodifiedCacheKeyMap) { + $this->needsModifiedPhpcs = $needsModifiedPhpcs; + $this->needsUnmodifiedPhpcs = $needsUnmodifiedPhpcs; + $this->modifiedOutputs = $modifiedOutputs; + $this->unmodifiedOutputs = $unmodifiedOutputs; + $this->isNewFileMap = $isNewFileMap; + $this->modifiedHashMap = $modifiedHashMap; + $this->unmodifiedCacheKeyMap = $unmodifiedCacheKeyMap; + } + + /** + * @return string[] + */ + public function getNeedsModifiedPhpcs(): array { + return $this->needsModifiedPhpcs; + } + + /** + * @return string[] + */ + public function getNeedsUnmodifiedPhpcs(): array { + return $this->needsUnmodifiedPhpcs; + } + + /** + * @return array + */ + public function getModifiedOutputs(): array { + return $this->modifiedOutputs; + } + + /** + * @return array + */ + public function getUnmodifiedOutputs(): array { + return $this->unmodifiedOutputs; + } + + public function isNewFile(string $file): bool { + return $this->isNewFileMap[$file] ?? false; + } + + public function getModifiedCacheKey(string $file): string { + return $this->modifiedHashMap[$file] ?? ''; + } + + public function getUnmodifiedCacheKey(string $file): string { + return $this->unmodifiedCacheKeyMap[$file] ?? ''; + } +} diff --git a/PhpcsChanged/ShellOperator.php b/PhpcsChanged/ShellOperator.php index 13106b9..7367ea4 100644 --- a/PhpcsChanged/ShellOperator.php +++ b/PhpcsChanged/ShellOperator.php @@ -35,14 +35,6 @@ public function getGitHashOfModifiedFile(string $fileName): string; public function getGitHashOfUnmodifiedFile(string $fileName): string; - public function getPhpcsOutputOfModifiedGitFile(string $fileName): string; - - public function getPhpcsOutputOfUnmodifiedGitFile(string $fileName): string; - - public function getPhpcsOutputOfModifiedSvnFile(string $fileName): string; - - public function getPhpcsOutputOfUnmodifiedSvnFile(string $fileName): string; - public function getGitUnifiedDiff(string $fileName): string; public function getGitMergeBase(): string; @@ -50,4 +42,26 @@ public function getGitMergeBase(): string; public function getSvnRevisionId(string $fileName): string; public function getSvnUnifiedDiff(string $fileName): string; + + /** + * Run phpcs once on all modified and unmodified versions of the given git files. + * New files should not appear in $unmodifiedFileNames. + * + * @param string[] $modifiedFileNames files needing modified (new) phpcs output + * @param string[] $unmodifiedFileNames files needing unmodified (old) phpcs output + * @return array{new: array, old: array} + * Each sub-array maps originalPath => single-file phpcs JSON string + */ + public function getPhpcsOutputForGitBatch(array $modifiedFileNames, array $unmodifiedFileNames): array; + + /** + * Run phpcs once on all modified and unmodified versions of the given svn files. + * New files should not appear in $unmodifiedFileNames. + * + * @param string[] $modifiedFileNames files needing modified (new) phpcs output + * @param string[] $unmodifiedFileNames files needing unmodified (old) phpcs output + * @return array{new: array, old: array} + * Each sub-array maps originalPath => single-file phpcs JSON string + */ + public function getPhpcsOutputForSvnBatch(array $modifiedFileNames, array $unmodifiedFileNames): array; } diff --git a/PhpcsChanged/ShellPlatform.php b/PhpcsChanged/ShellPlatform.php index 665d43b..7ef1d44 100644 --- a/PhpcsChanged/ShellPlatform.php +++ b/PhpcsChanged/ShellPlatform.php @@ -14,6 +14,17 @@ interface ShellPlatform { */ public function executeCommand(string $command, ?int &$return_val = null): string; + /** + * Run $command and write its stdout to $filePath, preserving the exact bytes. + * + * Unlike executeCommand(), which is line-oriented and normalizes trailing + * newlines, this redirects the command's stdout straight to the file so that + * file content (e.g. from `git show` or `cat`) reaches phpcs byte-for-byte. + * + * @return int The command's exit code. + */ + public function writeCommandOutputToFile(string $command, string $filePath): int; + /** * Validate that an executable exists and is runnable. * diff --git a/PhpcsChanged/ShellRunner.php b/PhpcsChanged/ShellRunner.php index 8b0865c..828e98c 100644 --- a/PhpcsChanged/ShellRunner.php +++ b/PhpcsChanged/ShellRunner.php @@ -241,24 +241,6 @@ private function getPhpcsExtensionsOption(): string { return strlen($phpcsExtensions) > 0 ? ' --extensions=' . escapeshellarg($phpcsExtensions) : ''; } - public function getPhpcsOutputOfModifiedGitFile(string $fileName): string { - $debug = getDebug($this->options->debug); - $fileContentsCommand = $this->getModifiedFileContentsCommand($fileName); - $command = "{$fileContentsCommand} | " . $this->getPhpcsCommand($fileName); - $debug('running modified file phpcs command:', $command); - $modifiedFilePhpcsOutput = $this->platform->executeCommand($command); - return $this->processPhpcsOutput($fileName, 'modified', $modifiedFilePhpcsOutput); - } - - public function getPhpcsOutputOfUnmodifiedGitFile(string $fileName): string { - $debug = getDebug($this->options->debug); - $unmodifiedFileContentsCommand = $this->getUnmodifiedFileContentsCommand($fileName); - $command = "{$unmodifiedFileContentsCommand} | " . $this->getPhpcsCommand($fileName); - $debug('running unmodified file phpcs command:', $command); - $unmodifiedFilePhpcsOutput = $this->platform->executeCommand($command); - return $this->processPhpcsOutput($fileName, 'unmodified', $unmodifiedFilePhpcsOutput); - } - public function getGitUnifiedDiff(string $fileName): string { $debug = getDebug($this->options->debug); $git = $this->options->getExecutablePath('git'); @@ -291,41 +273,6 @@ public function getGitMergeBase(): string { return trim($mergeBase); } - public function getPhpcsOutputOfModifiedSvnFile(string $fileName): string { - $debug = getDebug($this->options->debug); - $command = $this->platform->getLocalFileContentsCommand($fileName) . ' | ' . $this->getPhpcsCommand($fileName); - $debug('running modified file phpcs command:', $command); - $modifiedFilePhpcsOutput = $this->platform->executeCommand($command); - return $this->processPhpcsOutput($fileName, 'modified', $modifiedFilePhpcsOutput); - } - - public function getPhpcsOutputOfUnmodifiedSvnFile(string $fileName): string { - $debug = getDebug($this->options->debug); - $svn = $this->options->getExecutablePath('svn'); - $command = "{$svn} cat " . escapeshellarg($fileName) . " | " . $this->getPhpcsCommand($fileName); - $debug('running unmodified file phpcs command:', $command); - $unmodifiedFilePhpcsOutput = $this->platform->executeCommand($command); - return $this->processPhpcsOutput($fileName, 'unmodified', $unmodifiedFilePhpcsOutput); - } - - private function getPhpcsCommand(string $fileName): string { - $phpcs = $this->getPhpcsExecutable(); - return "{$phpcs} --report=json -q" . $this->getPhpcsStandardOption() . $this->getPhpcsExtensionsOption() . ' --stdin-path=' . escapeshellarg($fileName) . ' -'; - } - - private function processPhpcsOutput(string $fileName, string $modifiedOrUnmodified, string $phpcsOutput): string { - $debug = getDebug($this->options->debug); - if (! $phpcsOutput) { - throw new ShellException("Cannot get {$modifiedOrUnmodified} file phpcs output for file '{$fileName}'"); - } - $debug("{$modifiedOrUnmodified} file phpcs command output:", $phpcsOutput); - if (false !== strpos($phpcsOutput, 'You must supply at least one file or directory to process')) { - $debug("phpcs output implies {$modifiedOrUnmodified} file is empty"); - return ''; - } - return $phpcsOutput; - } - public function doesUnmodifiedFileExistInSvn(string $fileName): bool { $svnFileInfo = $this->getSvnFileInfo($fileName); return (false !== strpos($svnFileInfo, 'Schedule: add')); @@ -388,4 +335,188 @@ public function getPhpcsVersion(): string { return $matches[1]; } + + private function writeTempFile(string $contentCommand, string $tempPath): void { + $dir = dirname($tempPath); + if (! is_dir($dir)) { + mkdir($dir, 0777, true); + } + // Redirect the content command's stdout straight to the temp file rather than + // round-tripping through the line-oriented executeCommand(), which would force a + // trailing newline and collapse trailing blank lines. phpcs must scan the file's + // exact bytes so that eg: PSR2.Files.EndFileNewline violations are detected. + $returnVal = $this->platform->writeCommandOutputToFile($contentCommand, $tempPath); + if ($returnVal !== 0) { + throw new ShellException("Cannot get file contents for temp file '{$tempPath}'; command failed with code {$returnVal}: {$contentCommand}"); + } + } + + /** + * @param array $tempToOriginal Maps temp file path => original file path + * @param string $tempDir The batch temp directory; the --file-list file is written here so it is cleaned up with the rest of the batch + * @return array Maps temp file path => single-file phpcs JSON string + */ + private function runBatchPhpcs(array $tempToOriginal, string $tempDir): array { + if (empty($tempToOriginal)) { + return []; + } + $debug = getDebug($this->options->debug); + $phpcs = $this->getPhpcsExecutable(); + // Pass the files to scan via a phpcs --file-list file rather than as command-line + // arguments. Inlining one argument per file overflows the OS ARG_MAX limit (and fails + // with a cryptic "Argument list too long") once a batch reaches thousands of files; a + // file list keeps the command line a constant size regardless of how many files we scan. + $listFile = $tempDir . '/phpcs-file-list.txt'; + if (file_put_contents($listFile, implode("\n", array_keys($tempToOriginal))) === false) { + throw new ShellException("Cannot write phpcs file list to '{$listFile}'"); + } + $command = "{$phpcs} --report=json -q" . $this->getPhpcsStandardOption() . $this->getPhpcsExtensionsOption() . ' --file-list=' . escapeshellarg($listFile); + $debug('running batch phpcs command:', $command); + $phpcsOutput = $this->platform->executeCommand($command); + $debug('batch phpcs command output:', $phpcsOutput); + + // When phpcs cannot run (eg: a missing coding standard) it writes a plain-text + // error to stdout rather than valid JSON. We do not key off the exit code because + // phpcs exits non-zero (1/2) as its normal "found errors/warnings" result, while a + // non-decodable JSON response reliably means phpcs failed to produce a report. Treat + // any output that is not JSON with a 'files' key as a failure so we surface the phpcs + // error instead of silently reporting success. + $decoded = json_decode($phpcsOutput, true); + if (! is_array($decoded) || ! isset($decoded['files'])) { + throw new ShellException("Failed to run phpcs on batch of files; phpcs output: " . var_export($phpcsOutput, true)); + } + + $results = []; + foreach ($tempToOriginal as $tempPath => $originalPath) { + $realTempPath = ($resolved = realpath($tempPath)) !== false ? $resolved : $tempPath; + $fileData = $decoded['files'][$realTempPath] ?? $decoded['files'][$tempPath] ?? null; + if ($fileData === null) { + $results[$tempPath] = ''; + continue; + } + $singleFileJson = json_encode([ + 'totals' => [ + 'errors' => $fileData['errors'] ?? 0, + 'warnings' => $fileData['warnings'] ?? 0, + 'fixable' => $fileData['fixable'] ?? 0, + ], + 'files' => [ + $originalPath => $fileData, + ], + ]); + $results[$tempPath] = $singleFileJson !== false ? $singleFileJson : ''; + } + + return $results; + } + + private function cleanupTempDir(string $dir): void { + if (! is_dir($dir)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + rmdir($dir); + } + + /** + * @param string[] $modifiedFileNames + * @param string[] $unmodifiedFileNames + * @return array{new: array, old: array} + */ + public function getPhpcsOutputForGitBatch(array $modifiedFileNames, array $unmodifiedFileNames): array { + if (empty($modifiedFileNames) && empty($unmodifiedFileNames)) { + return ['new' => [], 'old' => []]; + } + + $tempDir = sys_get_temp_dir() . '/phpcs-changed-' . uniqid(); + mkdir($tempDir); + $modifiedTempToOriginal = []; + $unmodifiedTempToOriginal = []; + + try { + foreach ($modifiedFileNames as $fileName) { + $tempPath = $tempDir . '/new/' . ltrim($fileName, '/'); + $this->writeTempFile($this->getModifiedFileContentsCommand($fileName), $tempPath); + $modifiedTempToOriginal[$tempPath] = $fileName; + } + foreach ($unmodifiedFileNames as $fileName) { + $tempPath = $tempDir . '/old/' . ltrim($fileName, '/'); + $this->writeTempFile($this->getUnmodifiedFileContentsCommand($fileName), $tempPath); + $unmodifiedTempToOriginal[$tempPath] = $fileName; + } + $allTempToOriginal = $modifiedTempToOriginal + $unmodifiedTempToOriginal; + $allResults = $this->runBatchPhpcs($allTempToOriginal, $tempDir); + return [ + 'new' => $this->mapBatchResultsToOriginalFiles($allResults, $modifiedTempToOriginal), + 'old' => $this->mapBatchResultsToOriginalFiles($allResults, $unmodifiedTempToOriginal), + ]; + } finally { + $this->cleanupTempDir($tempDir); + } + } + + /** + * @param string[] $modifiedFileNames + * @param string[] $unmodifiedFileNames + * @return array{new: array, old: array} + */ + public function getPhpcsOutputForSvnBatch(array $modifiedFileNames, array $unmodifiedFileNames): array { + if (empty($modifiedFileNames) && empty($unmodifiedFileNames)) { + return ['new' => [], 'old' => []]; + } + + $tempDir = sys_get_temp_dir() . '/phpcs-changed-' . uniqid(); + mkdir($tempDir); + $modifiedTempToOriginal = []; + $unmodifiedTempToOriginal = []; + + try { + $svn = $this->options->getExecutablePath('svn'); + foreach ($modifiedFileNames as $fileName) { + $tempPath = $tempDir . '/new/' . ltrim($fileName, '/'); + $this->writeTempFile($this->platform->getLocalFileContentsCommand($fileName), $tempPath); + $modifiedTempToOriginal[$tempPath] = $fileName; + } + foreach ($unmodifiedFileNames as $fileName) { + $tempPath = $tempDir . '/old/' . ltrim($fileName, '/'); + $this->writeTempFile("{$svn} cat " . escapeshellarg($fileName), $tempPath); + $unmodifiedTempToOriginal[$tempPath] = $fileName; + } + $allTempToOriginal = $modifiedTempToOriginal + $unmodifiedTempToOriginal; + $allResults = $this->runBatchPhpcs($allTempToOriginal, $tempDir); + return [ + 'new' => $this->mapBatchResultsToOriginalFiles($allResults, $modifiedTempToOriginal), + 'old' => $this->mapBatchResultsToOriginalFiles($allResults, $unmodifiedTempToOriginal), + ]; + } finally { + $this->cleanupTempDir($tempDir); + } + } + + /** + * Re-key batch phpcs results (keyed by temp path) to original file paths for one side + * (modified or unmodified). Keying by temp path keeps the two sides separate even when + * the same original file appears in both. + * + * @param array $resultsByTempPath Maps temp file path => single-file phpcs JSON string + * @param array $tempToOriginal Maps temp file path => original file path + * @return array Maps original file path => single-file phpcs JSON string + */ + private function mapBatchResultsToOriginalFiles(array $resultsByTempPath, array $tempToOriginal): array { + $results = []; + foreach ($tempToOriginal as $tempPath => $originalPath) { + $results[$originalPath] = $resultsByTempPath[$tempPath] ?? ''; + } + return $results; + } } diff --git a/PhpcsChanged/UnixShell.php b/PhpcsChanged/UnixShell.php index d4efa5d..ca34fe4 100644 --- a/PhpcsChanged/UnixShell.php +++ b/PhpcsChanged/UnixShell.php @@ -41,6 +41,14 @@ public function executeCommand(string $command, ?int &$return_val = null): strin return implode(PHP_EOL, $output) . PHP_EOL; } + #[\Override] + public function writeCommandOutputToFile(string $command, string $filePath): int { + $output = []; + $return_val = 0; + exec($command . ' > ' . escapeshellarg($filePath), $output, $return_val); + return $return_val; + } + #[\Override] public function validateExecutableExists(string $name, string $command): void { exec(sprintf("type %s > /dev/null 2>&1", escapeshellarg($command)), $ignore, $returnVal); @@ -81,6 +89,16 @@ public function getFileNameFromPath(string $path): string { return end($parts); } + #[\Override] + public function getPhpcsOutputForGitBatch(array $modifiedFileNames, array $unmodifiedFileNames): array { + return $this->runner->getPhpcsOutputForGitBatch($modifiedFileNames, $unmodifiedFileNames); + } + + #[\Override] + public function getPhpcsOutputForSvnBatch(array $modifiedFileNames, array $unmodifiedFileNames): array { + return $this->runner->getPhpcsOutputForSvnBatch($modifiedFileNames, $unmodifiedFileNames); + } + #[\Override] public function isReadable(string $fileName): bool { return is_readable($fileName); @@ -144,26 +162,6 @@ public function getGitHashOfUnmodifiedFile(string $fileName): string { return $this->runner->getGitHashOfUnmodifiedFile($fileName); } - #[\Override] - public function getPhpcsOutputOfModifiedGitFile(string $fileName): string { - return $this->runner->getPhpcsOutputOfModifiedGitFile($fileName); - } - - #[\Override] - public function getPhpcsOutputOfUnmodifiedGitFile(string $fileName): string { - return $this->runner->getPhpcsOutputOfUnmodifiedGitFile($fileName); - } - - #[\Override] - public function getPhpcsOutputOfModifiedSvnFile(string $fileName): string { - return $this->runner->getPhpcsOutputOfModifiedSvnFile($fileName); - } - - #[\Override] - public function getPhpcsOutputOfUnmodifiedSvnFile(string $fileName): string { - return $this->runner->getPhpcsOutputOfUnmodifiedSvnFile($fileName); - } - #[\Override] public function getGitUnifiedDiff(string $fileName): string { return $this->runner->getGitUnifiedDiff($fileName); diff --git a/PhpcsChanged/WindowsShell.php b/PhpcsChanged/WindowsShell.php index 6008282..d33a671 100644 --- a/PhpcsChanged/WindowsShell.php +++ b/PhpcsChanged/WindowsShell.php @@ -45,6 +45,14 @@ public function executeCommand(string $command, ?int &$return_val = null): strin return implode(PHP_EOL, $output) . PHP_EOL; } + #[\Override] + public function writeCommandOutputToFile(string $command, string $filePath): int { + $output = []; + $return_val = 0; + exec($command . ' > ' . escapeshellarg($filePath), $output, $return_val); + return $return_val; + } + #[\Override] public function validateExecutableExists(string $name, string $command): void { // Full or relative path — check that the file exists on disk @@ -168,26 +176,6 @@ public function getGitHashOfUnmodifiedFile(string $fileName): string { return $this->runner->getGitHashOfUnmodifiedFile($fileName); } - #[\Override] - public function getPhpcsOutputOfModifiedGitFile(string $fileName): string { - return $this->runner->getPhpcsOutputOfModifiedGitFile($fileName); - } - - #[\Override] - public function getPhpcsOutputOfUnmodifiedGitFile(string $fileName): string { - return $this->runner->getPhpcsOutputOfUnmodifiedGitFile($fileName); - } - - #[\Override] - public function getPhpcsOutputOfModifiedSvnFile(string $fileName): string { - return $this->runner->getPhpcsOutputOfModifiedSvnFile($fileName); - } - - #[\Override] - public function getPhpcsOutputOfUnmodifiedSvnFile(string $fileName): string { - return $this->runner->getPhpcsOutputOfUnmodifiedSvnFile($fileName); - } - #[\Override] public function getGitUnifiedDiff(string $fileName): string { return $this->runner->getGitUnifiedDiff($fileName); @@ -212,4 +200,14 @@ public function getSvnUnifiedDiff(string $fileName): string { public function getPhpcsVersion(): string { return $this->runner->getPhpcsVersion(); } + + #[\Override] + public function getPhpcsOutputForGitBatch(array $modifiedFileNames, array $unmodifiedFileNames): array { + return $this->runner->getPhpcsOutputForGitBatch($modifiedFileNames, $unmodifiedFileNames); + } + + #[\Override] + public function getPhpcsOutputForSvnBatch(array $modifiedFileNames, array $unmodifiedFileNames): array { + return $this->runner->getPhpcsOutputForSvnBatch($modifiedFileNames, $unmodifiedFileNames); + } } diff --git a/index.php b/index.php index b314991..c997425 100644 --- a/index.php +++ b/index.php @@ -12,6 +12,8 @@ require_once __DIR__ . '/PhpcsChanged/DiffLineMap.php'; require_once __DIR__ . '/PhpcsChanged/LintMessage.php'; require_once __DIR__ . '/PhpcsChanged/LintMessages.php'; +require_once __DIR__ . '/PhpcsChanged/ScanPlan.php'; +require_once __DIR__ . '/PhpcsChanged/BatchScanResult.php'; require_once __DIR__ . '/PhpcsChanged/PhpcsMessages.php'; require_once __DIR__ . '/PhpcsChanged/PhpcsMessagesHelpers.php'; require_once __DIR__ . '/PhpcsChanged/Reporter.php'; diff --git a/tests/GitWorkflowTest.php b/tests/GitWorkflowTest.php index 509a0ad..9526ac7 100644 --- a/tests/GitWorkflowTest.php +++ b/tests/GitWorkflowTest.php @@ -35,8 +35,8 @@ public function testFullGitWorkflowForOneFileStaged() { $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show HEAD:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); - $shell->registerCommand("git show :0:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show HEAD:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); $cache = new CacheManager( new TestCache() ); $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); @@ -44,6 +44,54 @@ public function testFullGitWorkflowForOneFileStaged() { $this->assertEquals($expected->getMessages(), $messages->getMessages()); } + public function testFullGitWorkflowBatchesPhpcsViaFileListNotCommandLineArgs() { + // Files to scan must be passed to phpcs through a --file-list file, never inlined as + // command-line arguments. Inlining one argument per file overflows the OS ARG_MAX limit + // once a batch reaches thousands of files; the file list keeps the command line a + // constant size regardless of how many files are scanned. + $gitFile = 'foobar.php'; + $options = CliOptions::fromArray(['no-cache-git-root' => false, 'git-staged' => false, 'files' => [$gitFile]]); + $shell = new TestShell($options, [$gitFile]); + $shell->registerExecutable('git'); + $shell->registerExecutable('phpcs'); + $fixture = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); + $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); + $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); + $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); + $shell->registerCommand("git show HEAD:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); + $cache = new CacheManager( new TestCache() ); + runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); + $this->assertTrue($shell->wasCommandCalledContaining('--report=json'), 'expected a batch phpcs invocation'); + $this->assertTrue($shell->wasCommandCalledContaining('--file-list='), 'batch phpcs must pass files via --file-list'); + // The temp file paths live under the batch temp dir; none of them should appear inline as + // command-line arguments alongside --report=json. + $this->assertFalse($shell->wasCommandCalledContaining("/new/foobar.php"), 'temp file paths must not be inlined as phpcs arguments'); + } + + public function testFullGitWorkflowThrowsWhenBatchPhpcsErrors() { + // When phpcs cannot run (eg: an uninstalled standard) it writes a non-JSON error to + // stdout and exits non-zero. The batch path must surface this as a failure rather than + // silently reporting a bogus success with zero messages. + $gitFile = 'foobar.php'; + $options = CliOptions::fromArray(['no-cache-git-root' => false, 'git-staged' => false, 'files' => [$gitFile]]); + $shell = new TestShell($options, [$gitFile]); + $shell->registerExecutable('git'); + $shell->registerExecutable('phpcs'); + $fixture = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); + $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); + $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); + $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); + $phpcsError = 'BATCH_PHPCS_RAW:ERROR: the "WordPress-Core" coding standard is not installed.'; + $shell->registerCommand("git show HEAD:'files/foobar.php'", $phpcsError); + $shell->registerCommand("git show :0:'files/foobar.php'", $phpcsError); + $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); + $cache = new CacheManager( new TestCache() ); + $this->expectException(ShellException::class); + runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); + } + public function testFullGitWorkflowForOneFileStagedWithReplacedGit() { $gitFile = 'foobar.php'; $gitPath = 'bin/foo/git'; @@ -60,8 +108,8 @@ public function testFullGitWorkflowForOneFileStagedWithReplacedGit() { $shell->registerCommand("{$gitPath} diff --staged --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("{$gitPath} status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("{$gitPath} ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("{$gitPath} show HEAD:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); - $shell->registerCommand("{$gitPath} show :0:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("{$gitPath} show HEAD:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $shell->registerCommand("{$gitPath} show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("{$gitPath} rev-parse --show-toplevel", 'run-from-git-root'); $cache = new CacheManager( new TestCache() ); $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); @@ -85,8 +133,8 @@ public function testFullGitWorkflowForOneFileStagedWithReplacedPhpcs() { $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show HEAD:'files/foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); - $shell->registerCommand("git show :0:'files/foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show HEAD:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); $cache = new CacheManager( new TestCache() ); $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); @@ -109,8 +157,8 @@ public function testFullGitWorkflowForOneFileStagedWithVendorDefaultPhpcs() { $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show HEAD:'files/foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); - $shell->registerCommand("git show :0:'files/foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show HEAD:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); $cache = new CacheManager( new TestCache() ); $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); @@ -135,8 +183,8 @@ public function testFullGitWorkflowForOneFileStagedWithNoVendorDefaultPhpcs() { $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show HEAD:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); - $shell->registerCommand("git show :0:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show HEAD:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); $cache = new CacheManager( new TestCache() ); $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); @@ -175,7 +223,9 @@ public function testFullGitWorkflowForOneChangedFileWithoutPhpcsMessagesLintsOnl $shell->registerExecutable('phpcs'); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getEmptyResults()->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getEmptyResults()->toPhpcsJson()); + // With batch approach, unmodified is always scanned for non-new files even when modified has no messages + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getEmptyResults()->toPhpcsJson()); $cache = new CacheManager( new TestCache(), '\PhpcsChangedTests\Debug' ); $expected = $this->phpcs->getEmptyResults(); @@ -184,7 +234,6 @@ public function testFullGitWorkflowForOneChangedFileWithoutPhpcsMessagesLintsOnl $this->assertEquals($expected->getMessages(), $messages->getMessages()); $this->assertFalse($shell->wasCommandCalled("git diff --no-prefix 'foobar.php'")); - $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php' | phpcs")); } public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCache() { @@ -202,8 +251,8 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCache() { $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show :0:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show :0:'files/foobar.php' | git hash-object --stdin", 'previous-file-hash'); $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); @@ -215,8 +264,8 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCache() { $shell->resetCommandsCalled(); $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); - $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php' | phpcs")); - $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php' | phpcs")); + $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php'")); + $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); } public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCacheWithSeveritySet() { @@ -237,8 +286,8 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCacheWith $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show :0:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show :0:'files/foobar.php' | git hash-object --stdin", 'previous-file-hash'); $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); @@ -247,12 +296,14 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCacheWith runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); + $this->assertTrue($shell->wasCommandCalledContaining("--standard='standard'")); + $shell->resetCommandsCalled(); $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); - $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php' | phpcs")); - $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php' | phpcs")); + $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php'")); + $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); foreach( $cache->getEntries() as $entry ) { $this->assertEquals( 'standard:w1e2', $entry->phpcsStandard ); @@ -277,8 +328,8 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCacheWith $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show :0:'files/foobar.php' | phpcs --report=json -q --standard='standard' --warning-severity='0' --error-severity='0'", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show :0:'files/foobar.php' | git hash-object --stdin", 'previous-file-hash'); $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); @@ -287,12 +338,14 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCacheWith runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); + $this->assertTrue($shell->wasCommandCalledContaining("--standard='standard'")); + $shell->resetCommandsCalled(); $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); - $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php' | phpcs")); - $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php' | phpcs")); + $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php'")); + $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); $cacheEntries = $cache->getEntries(); $this->assertNotEmpty($cacheEntries); @@ -317,8 +370,8 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCacheWith $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show :0:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show :0:'files/foobar.php' | git hash-object --stdin", 'previous-file-hash'); $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); @@ -327,12 +380,14 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCacheWith runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); + $this->assertTrue($shell->wasCommandCalledContaining("--standard='standard'")); + $shell->resetCommandsCalled(); $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); - $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php' | phpcs")); - $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php' | phpcs")); + $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php'")); + $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); $cacheEntries = $cache->getEntries(); $this->assertNotEmpty($cacheEntries); @@ -356,8 +411,8 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenClearsOldCach $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); - $shell->registerCommand("git show :0:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show :0:'files/foobar.php' | git hash-object --stdin", 'previous-file-hash'); $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); @@ -371,8 +426,8 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenClearsOldCach $shell->resetCommandsCalled(); $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); - $this->assertTrue($shell->wasCommandCalled("git show :0:'files/foobar.php' | phpcs")); - $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php' | phpcs")); + $this->assertTrue($shell->wasCommandCalled("git show :0:'files/foobar.php'")); + $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); } public function testFullGitWorkflowForOneFileUnstagedCachesDataThenClearsNewCacheWhenFileChanges() { @@ -390,8 +445,8 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenClearsNewCach $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show :0:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show :0:'files/foobar.php' | git hash-object --stdin", 'previous-file-hash'); $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); @@ -405,8 +460,8 @@ public function testFullGitWorkflowForOneFileUnstagedCachesDataThenClearsNewCach $shell->resetCommandsCalled(); $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); - $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php' | phpcs")); - $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php' | phpcs")); + $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php'")); + $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); } public function testFullGitWorkflowForMultipleFilesStaged() { @@ -545,15 +600,23 @@ public function testFullGitWorkflowForEmptyNewFile() { $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getNewFileInfo('foobar.php')); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); - $fixture ='ERROR: You must supply at least one file or directory to process. - -Run "phpcs --help" for usage information -'; $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show :0:'files/foobar.php", $fixture, 1); + // An empty staged new file: `git show :0:` succeeds (exit 0) with empty content, so the + // batch path writes an empty temp file. phpcs then reports an "Internal.NoCodeFound" + // warning for the empty file, and since the file is new that warning is a new message. + $noCodeFound = [[ + 'type' => 'WARNING', + 'severity' => 5, + 'fixable' => false, + 'column' => 1, + 'source' => 'Internal.NoCodeFound', + 'line' => 1, + 'message' => 'No PHP code was found in this file and short open tags are not allowed by this install of PHP. This file may be using short open tags but PHP does not allow them.', + ]]; + $shell->registerCommand("git show :0:'files/foobar.php'", PhpcsMessages::fromArrays($noCodeFound, 'STDIN')->toPhpcsJson(), 0); $cache = new CacheManager( new TestCache() ); - $expected = PhpcsMessages::fromArrays([], '/dev/null'); + $expected = PhpcsMessages::fromArrays($noCodeFound, 'foobar.php'); $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); } @@ -570,9 +633,9 @@ public function testFullGitWorkflowForInterBranchDiff() { $shell->registerCommand("git diff '0123456789abcdef0123456789abcdef01234567'... --no-prefix 'bin/foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'bin/foobar.php'", $this->fixture->getModifiedFileInfo('bin/foobar.php')); $shell->registerCommand("git cat-file -e '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php'", ''); - $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php' | phpcs --report=json -q --stdin-path='bin/foobar.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php'", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php' | git hash-object --stdin", 'previous-file-hash'); - $shell->registerCommand("git show HEAD:'files/bin/foobar.php' | phpcs --report=json -q --stdin-path='bin/foobar.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6, 7], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show HEAD:'files/bin/foobar.php'", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6, 7], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show HEAD:'files/bin/foobar.php' | git hash-object --stdin", 'new-file-hash'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); $cache = new CacheManager( new TestCache() ); @@ -592,9 +655,9 @@ public function testFullGitWorkflowForUnchangedFileForInterBranchDiff() { $shell->registerCommand("git diff '0123456789abcdef0123456789abcdef01234567'... --no-prefix 'bin/foobar.php'", ''); $shell->registerCommand("git status --porcelain 'bin/foobar.php'", ''); $shell->registerCommand("git cat-file -e '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php'", ''); - $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php' | phpcs --report=json -q --stdin-path='bin/foobar.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php'", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php' | git hash-object --stdin", 'previous-file-hash'); - $shell->registerCommand("git show HEAD:'files/bin/foobar.php' | phpcs --report=json -q --stdin-path='bin/foobar.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show HEAD:'files/bin/foobar.php'", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git show HEAD:'files/bin/foobar.php' | git hash-object --stdin", 'new-file-hash'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); $cache = new CacheManager( new TestCache() ); @@ -608,23 +671,88 @@ public function testFullGitWorkflowWithUntrackedFileForInterBranchDiff() { $shell = new TestShell($options, [$gitFile]); $shell->registerExecutable('git'); $shell->registerExecutable('phpcs'); - $fixture = $this->fixture->getAltAddedLineDiff('foobar.php', 'use Foobar;'); $shell->registerCommand("git ls-files --full-name 'bin/foobar.php'", ""); $shell->registerCommand("git merge-base 'master' HEAD", "0123456789abcdef0123456789abcdef01234567\n"); - $shell->registerCommand("git diff '0123456789abcdef0123456789abcdef01234567'... --no-prefix 'bin/foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'bin/foobar.php'", ""); - $shell->registerCommand("git cat-file -e '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php'", ''); - $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php' | phpcs --report=json -q --stdin-path='bin/foobar.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6], 'Found unused symbol Foobar.')->toPhpcsJson()); - $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':'files/bin/foobar.php' | git hash-object --stdin", 'previous-file-hash'); - $shell->registerCommand("git show HEAD:'files/bin/foobar.php' | phpcs --report=json -q --stdin-path='bin/foobar.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6, 7], 'Found unused symbol Foobar.')->toPhpcsJson()); - $shell->registerCommand("git show HEAD:'files/bin/foobar.php' | git hash-object --stdin", 'new-file-hash'); + // With empty full path (untracked file), cat-file uses '' as the path; non-zero return means file not in base (treated as new) + $shell->registerCommand("git cat-file -e '0123456789abcdef0123456789abcdef01234567':''", '', 1); + $shell->registerCommand("git show HEAD:''", '{"totals":{"errors":0,"warnings":0,"fixable":0},"files":{"bin\/foobar.php":{"errors":0,"warnings":0,"messages":[]}}}'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); - $shell->registerCommand("git show HEAD:'' | phpcs --report=json -q --stdin-path='bin/foobar.php' -", '{"totals":{"errors":0,"warnings":0,"fixable":0},"files":{"bin\/foobar.php":{"errors":0,"warnings":0,"messages":[]}}}'); $cache = new CacheManager( new TestCache() ); $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); $this->assertEquals([], $messages->getMessages()); } + public function testFullGitWorkflowBatchTwoFilesOneNewOneExisting() { + $gitFiles = ['foobar.php', 'newfile.php']; + $options = CliOptions::fromArray(['no-cache-git-root' => false, 'git-staged' => false, 'files' => $gitFiles]); + $shell = new TestShell($options, $gitFiles); + $shell->registerExecutable('git'); + $shell->registerExecutable('phpcs'); + // Existing file + $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); + $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); + $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); + $shell->registerCommand("git show HEAD:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + // New file (staged for adding) — unmodified should NOT be scanned + $shell->registerCommand("git status --porcelain 'newfile.php'", $this->fixture->getNewFileInfo('newfile.php')); + $shell->registerCommand("git ls-files --full-name 'newfile.php'", "files/newfile.php"); + $shell->registerCommand("git diff --staged --no-prefix 'newfile.php'", $this->fixture->getNewFileDiff('newfile.php')); + $shell->registerCommand("git show :0:'files/newfile.php'", $this->phpcs->getResults('STDIN', [5], 'Found unused symbol New.')->toPhpcsJson()); + $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); + $cache = new CacheManager( new TestCache() ); + $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); + // Existing file: line 21 is new; new file: line 5 is new + $this->assertNotEmpty($messages->getMessages()); + // Unmodified scan should not be called for the new file + $this->assertFalse($shell->wasCommandCalled("git show HEAD:'files/newfile.php'")); + // Unmodified scan should be called for the existing file + $this->assertTrue($shell->wasCommandCalled("git show HEAD:'files/foobar.php'")); + } + + public function testFullGitWorkflowBatchTwoFilesWithCacheHitsSkipsPhpcs() { + $gitFiles = ['foobar.php', 'baz.php']; + $options = CliOptions::fromArray([ + 'no-cache-git-root' => false, + 'git-staged' => false, + 'cache' => false, // getopt is weird and sets options to false + 'files' => $gitFiles, + ]); + $shell = new TestShell($options, $gitFiles); + $shell->registerExecutable('git'); + $shell->registerExecutable('phpcs'); + $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); + $shell->registerCommand("git status --porcelain 'baz.php'", $this->fixture->getModifiedFileInfo('baz.php')); + $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); + $shell->registerCommand("git ls-files --full-name 'baz.php'", "files/baz.php"); + $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); + $shell->registerCommand("git diff --staged --no-prefix 'baz.php'", $this->fixture->getAddedLineDiff('baz.php', 'use Baz;')); + $shell->registerCommand("git show HEAD:'files/foobar.php' | git hash-object --stdin", 'old-hash-foobar'); + $shell->registerCommand("git show HEAD:'files/baz.php' | git hash-object --stdin", 'old-hash-baz'); + $shell->registerCommand("git show :0:'files/foobar.php' | git hash-object --stdin", 'new-hash-foobar'); + $shell->registerCommand("git show :0:'files/baz.php' | git hash-object --stdin", 'new-hash-baz'); + $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); + + // Pre-populate cache for all four versions + $testCache = new TestCache(); + $testCache->setEntry('foobar.php', 'new', 'new-hash-foobar', '', $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + $testCache->setEntry('foobar.php', 'old', 'old-hash-foobar', '', $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $testCache->setEntry('baz.php', 'new', 'new-hash-baz', '', $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Baz.')->toPhpcsJson()); + $testCache->setEntry('baz.php', 'old', 'old-hash-baz', '', $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $cache = new CacheManager($testCache, '\PhpcsChangedTests\Debug'); + + $messages = runGitWorkflow($options, $shell, $cache, '\PhpcsChangedTests\Debug'); + + // Both files processed correctly from cache + $this->assertNotEmpty($messages->getMessages()); + // No phpcs invocations needed since all were cached + $this->assertFalse($shell->wasCommandCalled("git show HEAD:'files/foobar.php'")); + $this->assertFalse($shell->wasCommandCalled("git show HEAD:'files/baz.php'")); + $this->assertFalse($shell->wasCommandCalled("git show :0:'files/foobar.php'")); + $this->assertFalse($shell->wasCommandCalled("git show :0:'files/baz.php'")); + } + public function testNameDetectionInFullGitWorkflowForInterBranchDiff() { $gitFile = 'test.php'; $options = CliOptions::fromArray(['no-cache-git-root' => false, 'git-base' => 'master', 'files' => [$gitFile]]); @@ -638,7 +766,7 @@ public function testNameDetectionInFullGitWorkflowForInterBranchDiff() { $shell->registerCommand("git merge-base 'master' HEAD", "0123456789abcdef0123456789abcdef01234567\n"); $shell->registerCommand("git diff '0123456789abcdef0123456789abcdef01234567'... --no-prefix 'test.php'", $fixture); $shell->registerCommand("git cat-file -e '0123456789abcdef0123456789abcdef01234567':'files/test.php'", '', 128); - $shell->registerCommand("git show HEAD:'files/test.php' | phpcs --report=json -q --stdin-path='test.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/test.php', [6, 7, 8], "Found unused symbol 'Foobar'.")->toPhpcsJson()); + $shell->registerCommand("git show HEAD:'files/test.php'", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/test.php', [6, 7, 8], "Found unused symbol 'Foobar'.")->toPhpcsJson()); $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':'files/test.php' | git hash-object --stdin", 'previous-file-hash'); $shell->registerCommand("git show HEAD:'files/test.php | git hash-object --stdin", 'new-file-hash'); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); diff --git a/tests/GitWorkflowWindowsTest.php b/tests/GitWorkflowWindowsTest.php index a9b97c7..38ff32d 100644 --- a/tests/GitWorkflowWindowsTest.php +++ b/tests/GitWorkflowWindowsTest.php @@ -34,8 +34,8 @@ public function testFullGitWorkflowForOneFileStagedOnWindows() { $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show HEAD:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); - $shell->registerCommand("git show :0:'files/foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show HEAD:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); $cache = new CacheManager(new TestCache()); $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); @@ -59,8 +59,8 @@ public function testFullGitWorkflowForOneFileStagedWithVendorDefaultPhpcsOnWindo $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); $shell->registerCommand("git status --porcelain 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); $shell->registerCommand("git ls-files --full-name 'foobar.php'", "files/foobar.php"); - $shell->registerCommand("git show HEAD:'files/foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); - $shell->registerCommand("git show :0:'files/foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); + $shell->registerCommand("git show HEAD:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); + $shell->registerCommand("git show :0:'files/foobar.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); $shell->registerCommand("git rev-parse --show-toplevel", 'run-from-git-root'); $cache = new CacheManager(new TestCache()); $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); diff --git a/tests/SvnWorkflowTest.php b/tests/SvnWorkflowTest.php index 82c0f73..ebeecd6 100644 --- a/tests/SvnWorkflowTest.php +++ b/tests/SvnWorkflowTest.php @@ -37,8 +37,8 @@ public function testFullSvnWorkflowForOneFile() { $shell->registerExecutable('cat'); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("svn cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $expected = $this->phpcs->getResults('bin/foobar.php', [20]); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); @@ -58,8 +58,8 @@ public function testFullSvnWorkflowForOneFileWithReplacedSvn() { $shell->registerExecutable('cat'); $shell->registerCommand("{$svnPath} diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("{$svnPath} info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("{$svnPath} cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("{$svnPath} cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $expected = $this->phpcs->getResults('bin/foobar.php', [20]); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); @@ -79,8 +79,8 @@ public function testFullSvnWorkflowForOneFileWithReplacedCat() { $shell->registerExecutable($catPath); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("svn cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); - $shell->registerCommand("{$catPath} 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("{$catPath} 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $expected = $this->phpcs->getResults('bin/foobar.php', [20]); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); @@ -100,8 +100,8 @@ public function testFullSvnWorkflowForOneFileWithReplacedPhpcs() { $shell->registerExecutable('cat'); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("svn cat 'foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $expected = $this->phpcs->getResults('bin/foobar.php', [20]); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); @@ -120,8 +120,8 @@ public function testFullSvnWorkflowForOneFileWithVendorDefaultPhpcs() { $shell->registerExecutable('cat'); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("svn cat 'foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $expected = $this->phpcs->getResults('bin/foobar.php', [20]); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); @@ -142,8 +142,8 @@ public function testFullSvnWorkflowForOneFileWithNoVendorDefaultPhpcs() { $shell->registerExecutable('cat'); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("svn cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $expected = $this->phpcs->getResults('bin/foobar.php', [20]); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); @@ -161,11 +161,11 @@ public function testFullSvnWorkflowForOneFileWithNoMessages() { $shell->registerExecutable('cat'); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getEmptyResults()->toPhpcsJson()); $expected = $this->phpcs->getEmptyResults(); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); - $this->assertFalse($shell->wasCommandCalled("svn cat 'foobar.php'")); } public function testFullSvnWorkflowForOneFileWithCachingEnabledButNoCache() { @@ -617,8 +617,6 @@ public function testFullSvnWorkflowForChangedFileWithoutPhpCsMessagesLintsOnlyNe $shell->registerCommand("cat 'foobar.php'", '{"totals":{"errors":0,"warnings":0,"fixable":0},"files":{"STDIN":{"errors":0,"warnings":0,"messages":[]}}}'); runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertFalse($shell->wasCommandCalled("svn diff 'foobar.php'")); - $this->assertFalse($shell->wasCommandCalled("svn cat 'foobar.php'")); - $this->assertFalse($shell->wasCommandCalled("svn info 'foobar.php'")); } public function testFullSvnWorkflowForNonSvnFile() { @@ -669,16 +667,80 @@ public function testFullSvnWorkflowForEmptyNewFile() { $shell->registerExecutable('cat'); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getNewFileDiff('foobar.php')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfoNewFile('foobar.php')); - $fixture = 'ERROR: You must supply at least one file or directory to process. - -Run "phpcs --help" for usage information -'; - $shell->registerCommand( "cat 'foobar.php'", $fixture); - $expected = PhpcsMessages::fromArrays([], 'STDIN'); + // An empty new file: `cat` succeeds (exit 0) with empty content, so the batch path writes + // an empty temp file. phpcs then reports an "Internal.NoCodeFound" warning for the empty + // file, and since the file is new that warning is a new message. + $noCodeFound = [[ + 'type' => 'WARNING', + 'severity' => 5, + 'fixable' => false, + 'column' => 1, + 'source' => 'Internal.NoCodeFound', + 'line' => 1, + 'message' => 'No PHP code was found in this file and short open tags are not allowed by this install of PHP. This file may be using short open tags but PHP does not allow them.', + ]]; + $shell->registerCommand("cat 'foobar.php'", PhpcsMessages::fromArrays($noCodeFound, 'STDIN')->toPhpcsJson()); + $expected = PhpcsMessages::fromArrays($noCodeFound, 'foobar.php'); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); } + public function testFullSvnWorkflowBatchTwoFilesOneNewOneExisting() { + $svnFiles = ['foobar.php', 'newfile.php']; + $options = CliOptions::fromArray([ + 'svn' => false, + 'files' => $svnFiles, + ]); + $shell = new TestShell($options, $svnFiles); + $shell->registerExecutable('svn'); + $shell->registerExecutable('phpcs'); + $shell->registerExecutable('cat'); + // Existing file + $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); + $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + // New file (scheduled for adding) + $shell->registerCommand("svn diff 'newfile.php'", $this->fixture->getNewFileDiff('newfile.php')); + $shell->registerCommand("svn info 'newfile.php'", $this->fixture->getSvnInfoNewFile('newfile.php')); + $shell->registerCommand("cat 'newfile.php'", $this->phpcs->getResults('STDIN', [5, 6])->toPhpcsJson()); + $messages = runSvnWorkflow($svnFiles, $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); + $this->assertNotEmpty($messages->getMessages()); + // Existing file: unmodified scan should be called + $this->assertTrue($shell->wasCommandCalled("svn cat 'foobar.php'")); + // New file: unmodified scan should NOT be called + $this->assertFalse($shell->wasCommandCalled("svn cat 'newfile.php'")); + } + + public function testFullSvnWorkflowBatchTwoFilesWithCacheHitsSkipsPhpcs() { + $svnFiles = ['foobar.php', 'baz.php']; + $options = CliOptions::fromArray([ + 'svn' => false, + 'cache' => false, // getopt is weird and sets options to false + 'files' => $svnFiles, + ]); + $shell = new TestShell($options, $svnFiles); + $shell->registerExecutable('svn'); + $shell->registerExecutable('phpcs'); + $shell->registerExecutable('cat'); + $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); + $shell->registerCommand("svn diff 'baz.php'", $this->fixture->getAddedLineDiff('baz.php', 'use Baz;')); + $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280')); + $shell->registerCommand("svn info 'baz.php'", $this->fixture->getSvnInfo('baz.php', '188280')); + $testCache = new TestCache(); + $testCache->setEntry('foobar.php', 'new', 'foobar.php', '', $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $testCache->setEntry('foobar.php', 'old', '188280', '', $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $testCache->setEntry('baz.php', 'new', 'baz.php', '', $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Baz.')->toPhpcsJson()); + $testCache->setEntry('baz.php', 'old', '188280', '', $this->phpcs->getResults('STDIN', [20, 99], 'Found unused symbol Baz.')->toPhpcsJson()); + $messages = runSvnWorkflow($svnFiles, $options, $shell, new CacheManager($testCache), '\PhpcsChangedTests\debug'); + $this->assertNotEmpty($messages->getMessages()); + // No phpcs invocations needed since all were cached + $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); + $this->assertFalse($shell->wasCommandCalled("svn cat 'foobar.php'")); + $this->assertFalse($shell->wasCommandCalled("cat 'baz.php'")); + $this->assertFalse($shell->wasCommandCalled("svn cat 'baz.php'")); + } + public function testFullSvnWorkflowForOneFileWithSeveritySetToZero() { $svnFile = 'foobar.php'; $options = CliOptions::fromArray([ @@ -695,11 +757,13 @@ public function testFullSvnWorkflowForOneFileWithSeveritySetToZero() { $shell->registerExecutable('cat'); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("svn cat 'foobar.php' | phpcs --report=json -q --standard='standard' --warning-severity='0' --error-severity='0'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); - $shell->registerCommand("cat 'foobar.php' | phpcs --report=json -q --standard='standard' --warning-severity='0' --error-severity='0'" , $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("cat 'foobar.php'" , $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $cache = new CacheManager(new TestCache()); runSvnWorkflow([$svnFile], $options, $shell, $cache, '\PhpcsChangedTests\debug' ); + $this->assertTrue($shell->wasCommandCalledContaining("--standard='standard'")); + $cacheEntries = $cache->getEntries(); $this->assertNotEmpty($cacheEntries); foreach( $cacheEntries as $entry ) { diff --git a/tests/SvnWorkflowWindowsTest.php b/tests/SvnWorkflowWindowsTest.php index 06b315c..78c2ccc 100644 --- a/tests/SvnWorkflowWindowsTest.php +++ b/tests/SvnWorkflowWindowsTest.php @@ -35,9 +35,9 @@ public function testFullSvnWorkflowForOneFileOnWindows() { // Note: no 'cat' registration needed on Windows — 'type' is a built-in $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("svn cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); // Windows uses 'type' instead of 'cat' for reading the modified local file - $shell->registerCommand("type 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("type 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $expected = $this->phpcs->getResults('bin/foobar.php', [20]); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); @@ -57,9 +57,9 @@ public function testFullSvnWorkflowForOneFileWithReplacedCatOnWindows() { $shell->registerExecutable($catPath); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("svn cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); // Custom cat path is used instead of 'type' - $shell->registerCommand("{$catPath} 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("{$catPath} 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $expected = $this->phpcs->getResults('bin/foobar.php', [20]); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); @@ -78,8 +78,8 @@ public function testFullSvnWorkflowForOneFileWithVendorPhpcsBatOnWindows() { $shell->registerExecutable($phpcsPath); $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); - $shell->registerCommand("svn cat 'foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); - $shell->registerCommand("type 'foobar.php' | {$phpcsPath}", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); + $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); + $shell->registerCommand("type 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); $expected = $this->phpcs->getResults('bin/foobar.php', [20]); $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\PhpcsChangedTests\debug'); $this->assertEquals($expected->getMessages(), $messages->getMessages()); diff --git a/tests/UnixShellTest.php b/tests/UnixShellTest.php new file mode 100644 index 0000000..0933858 --- /dev/null +++ b/tests/UnixShellTest.php @@ -0,0 +1,80 @@ +tmpFiles as $file) { + if (is_file($file)) { + unlink($file); + } + } + $this->tmpFiles = []; + parent::tearDown(); + } + + private function makeTempFile(string $contents): string { + $path = tempnam(sys_get_temp_dir(), 'phpcs-changed-test-'); + $this->tmpFiles[] = $path; + file_put_contents($path, $contents); + return $path; + } + + private function shell(): UnixShell { + return new UnixShell(CliOptions::fromArray(['git-staged' => true, 'files' => ['foo.php']])); + } + + /** + * @dataProvider provideExactByteContents + */ + public function testWriteCommandOutputToFilePreservesExactBytes(string $contents) { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('UnixShell test does not run on Windows'); + } + $source = $this->makeTempFile($contents); + $dest = tempnam(sys_get_temp_dir(), 'phpcs-changed-test-out-'); + $this->tmpFiles[] = $dest; + + $shell = $this->shell(); + $returnVal = $shell->writeCommandOutputToFile('cat ' . escapeshellarg($source), $dest); + + $this->assertSame(0, $returnVal); + $this->assertSame($contents, file_get_contents($dest), 'temp file should be byte-identical to the source'); + } + + public function provideExactByteContents(): array { + return [ + 'no trailing newline' => [" [" [" [''], + ]; + } + + public function testWriteCommandOutputToFileReturnsNonZeroWhenCommandFails() { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('UnixShell test does not run on Windows'); + } + $dest = tempnam(sys_get_temp_dir(), 'phpcs-changed-test-out-'); + $this->tmpFiles[] = $dest; + + $shell = $this->shell(); + $missing = sys_get_temp_dir() . '/phpcs-changed-does-not-exist-' . getmypid(); + $returnVal = $shell->writeCommandOutputToFile('cat ' . escapeshellarg($missing) . ' 2>/dev/null', $dest); + + $this->assertNotSame(0, $returnVal); + } +} diff --git a/tests/helpers/Functions.php b/tests/helpers/Functions.php index 6fadc87..6668f4e 100644 --- a/tests/helpers/Functions.php +++ b/tests/helpers/Functions.php @@ -10,3 +10,61 @@ function debugWithOutput(...$messages) { var_dump($message); } } + +/** + * Synthesize the output of a batched phpcs invocation for the test shells. + * + * The real ShellRunner batch path writes each file's content to a temp file (here, the + * mocked per-file phpcs JSON registered for that file's content command) and runs a single + * phpcs over all of them, keyed by temp path. We read those temp files back and re-key each + * file's data under its temp path exactly as phpcs would, so the production batch and + * JSON-splitting logic is exercised for real rather than mocked away. + */ +function buildBatchPhpcsOutput(string $command): string { + $tempPaths = readBatchPhpcsFileList($command); + $files = []; + foreach ($tempPaths as $tempPath) { + if (! is_file($tempPath)) { + continue; + } + $perFileJson = file_get_contents($tempPath); + // A file's content beginning with this sentinel simulates phpcs failing on the whole + // batch (eg: an uninstalled standard), where phpcs writes a non-JSON error to stdout + // instead of valid JSON. Emit the raw error as the entire batch output. + if (is_string($perFileJson) && strpos($perFileJson, 'BATCH_PHPCS_RAW:') === 0) { + return substr($perFileJson, strlen('BATCH_PHPCS_RAW:')); + } + $decoded = $perFileJson ? json_decode($perFileJson, true) : null; + if (! is_array($decoded) || empty($decoded['files'])) { + continue; + } + $key = realpath($tempPath) ?: $tempPath; + $files[$key] = reset($decoded['files']); + } + $output = json_encode(['totals' => ['errors' => 0, 'warnings' => 0, 'fixable' => 0], 'files' => $files]); + return $output !== false ? $output : ''; +} + +/** + * Extract the temp file paths a batched phpcs invocation will scan. + * + * The real ShellRunner passes the files to scan via a phpcs --file-list file (rather than as + * command-line arguments) so the batch never overflows ARG_MAX. Read that list back so the test + * shells see exactly the files phpcs would, regardless of how many there are. + * + * @return string[] + */ +function readBatchPhpcsFileList(string $command): array { + if (! preg_match('#--file-list=([\'"]?)(.+?)\1(?:\s|$)#', $command, $match)) { + return []; + } + $listFile = $match[2]; + if (! is_file($listFile)) { + return []; + } + $contents = file_get_contents($listFile); + if ($contents === false || $contents === '') { + return []; + } + return array_values(array_filter(explode("\n", $contents), 'strlen')); +} diff --git a/tests/helpers/TestShell.php b/tests/helpers/TestShell.php index fb879ec..5bf40d4 100644 --- a/tests/helpers/TestShell.php +++ b/tests/helpers/TestShell.php @@ -4,10 +4,6 @@ namespace PhpcsChangedTests; use PhpcsChanged\CliOptions; -use PhpcsChanged\Modes; -use PhpcsChanged\ShellOperator; -use PhpcsChanged\ShellException; -use PhpcsChanged\NoChangesException; use PhpcsChanged\UnixShell; class TestShell extends UnixShell { @@ -83,10 +79,35 @@ public function getFileHash(string $fileName): string { return $this->fileHashes[$fileName] ?? $fileName; } + public function writeCommandOutputToFile(string $command, string $filePath): int { + // The real shell redirects the content command's stdout to the file to preserve exact + // bytes. Here we capture the registered output and write it verbatim (file_put_contents + // does not alter bytes), keeping the batch test harness working. + $return_val = 0; + $content = $this->executeCommand($command, $return_val); + file_put_contents($filePath, $content); + return $return_val; + } + public function executeCommand(string $command, ?int &$return_val = null): string { + // The real ShellRunner batch path writes each file's content to a temp file and runs a + // single phpcs over all of them. Intercept that combined invocation and synthesize its + // output from the temp files so the production batch + JSON-splitting logic runs for real. + if (strpos($command, 'phpcs-changed-') !== false && strpos($command, '--report=json') !== false) { + $return_val = 0; + $this->commandsCalled[$command] = $command; + return buildBatchPhpcsOutput($command); + } // Normalize double quotes to single quotes so commands registered with Unix-style // quoting (single quotes) also match on Windows where escapeshellarg() uses double quotes. $normalizedCommand = str_replace('"', "'", $command); + // Prefer an exact match so a short command (e.g. a file-contents command) does not shadow + // a longer command that has it as a prefix (e.g. that same command piped to git hash-object). + if (isset($this->commands[$normalizedCommand])) { + $return_val = $this->commands[$normalizedCommand]['return_val']; + $this->commandsCalled[$normalizedCommand] = $command; + return $this->commands[$normalizedCommand]['output']; + } foreach ($this->commands as $registeredCommand => $return) { if ($registeredCommand === substr($normalizedCommand, 0, strlen($registeredCommand)) ) { $return_val = $return['return_val']; @@ -105,4 +126,15 @@ public function resetCommandsCalled(): void { public function wasCommandCalled(string $registeredCommand): bool { return isset($this->commandsCalled[$registeredCommand]); } + + public function wasCommandCalledContaining(string $needle): bool { + foreach ($this->commandsCalled as $calledCommand) { + // Normalize double quotes to single quotes so a needle written with Unix-style + // quoting also matches on Windows where escapeshellarg() uses double quotes. + if (strpos(str_replace('"', "'", $calledCommand), $needle) !== false) { + return true; + } + } + return false; + } } diff --git a/tests/helpers/WindowsTestShell.php b/tests/helpers/WindowsTestShell.php index f8f2a67..9f82501 100644 --- a/tests/helpers/WindowsTestShell.php +++ b/tests/helpers/WindowsTestShell.php @@ -79,10 +79,35 @@ public function getFileHash(string $fileName): string { return $this->fileHashes[$fileName] ?? $fileName; } + public function writeCommandOutputToFile(string $command, string $filePath): int { + // The real shell redirects the content command's stdout to the file to preserve exact + // bytes. Here we capture the registered output and write it verbatim (file_put_contents + // does not alter bytes), keeping the batch test harness working. + $return_val = 0; + $content = $this->executeCommand($command, $return_val); + file_put_contents($filePath, $content); + return $return_val; + } + public function executeCommand(string $command, ?int &$return_val = null): string { + // The real ShellRunner batch path writes each file's content to a temp file and runs a + // single phpcs over all of them. Intercept that combined invocation and synthesize its + // output from the temp files so the production batch + JSON-splitting logic runs for real. + if (strpos($command, 'phpcs-changed-') !== false && strpos($command, '--report=json') !== false) { + $return_val = 0; + $this->commandsCalled[$command] = $command; + return buildBatchPhpcsOutput($command); + } // Normalize double quotes to single quotes so commands registered with Unix-style // quoting (single quotes) also match on Windows where escapeshellarg() uses double quotes. $normalizedCommand = str_replace('"', "'", $command); + // Prefer an exact match so a short command (e.g. a file-contents command) does not shadow + // a longer command that has it as a prefix (e.g. that same command piped to git hash-object). + if (isset($this->commands[$normalizedCommand])) { + $return_val = $this->commands[$normalizedCommand]['return_val']; + $this->commandsCalled[$normalizedCommand] = $command; + return $this->commands[$normalizedCommand]['output']; + } foreach ($this->commands as $registeredCommand => $return) { if ($registeredCommand === substr($normalizedCommand, 0, strlen($registeredCommand))) { $return_val = $return['return_val']; @@ -101,4 +126,15 @@ public function resetCommandsCalled(): void { public function wasCommandCalled(string $registeredCommand): bool { return isset($this->commandsCalled[$registeredCommand]); } + + public function wasCommandCalledContaining(string $needle): bool { + foreach ($this->commandsCalled as $calledCommand) { + // Normalize double quotes to single quotes so a needle written with Unix-style + // quoting also matches on Windows where escapeshellarg() uses double quotes. + if (strpos(str_replace('"', "'", $calledCommand), $needle) !== false) { + return true; + } + } + return false; + } }