diff --git a/README.md b/README.md index 259f510..975773c 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,14 @@ composer diff --help # Display detailed usage instructions - `--with-licenses` (`-c`) - include license information - `--format` (`-f`) - output format (mdtable, mdlist, json, github) - default: `mdtable` - `--gitlab-domains` - custom gitlab domains for compare/release URLs - default: use composer config - + - `--skip-io-errors` - treat missing `composer.lock` files as empty input + ## Advanced usage ```shell script composer diff master # Compare current composer.lock with the one on master branch composer diff master:composer.lock develop:composer.lock -p # Compare master and develop branches, including platform dependencies +composer diff /path/to/missing/composer.lock composer.lock --skip-io-errors # Compare a new lock file against a non-existing base lock file composer diff --no-dev # ignore dev dependencies composer diff -p # include platform dependencies composer diff -f json # Output as JSON instead of table @@ -99,7 +101,7 @@ Exit code of the command is built using following bit flags: * `0` - OK. * `1` - General error. * `2` - There were changes in prod packages. -* `4` - There were changes is dev packages. +* `4` - There were changes in dev packages. * `8` - There were downgrades in prod packages. * `16` - There were downgrades in dev packages. @@ -107,7 +109,7 @@ You may check for individual flags or simply check if the status is greater or e # Contributing -Composer Diff is an open source project that welcomes pull requests and issues from anyone. +Composer Diff is an open source project that welcomes pull requests and issues from anyone. Before opening pull requests, please consider reading our short [Contribution Guidelines](docs/CONTRIBUTING.md). # Similar packages diff --git a/src/Command/DiffCommand.php b/src/Command/DiffCommand.php index 0b795d8..428773b 100644 --- a/src/Command/DiffCommand.php +++ b/src/Command/DiffCommand.php @@ -69,6 +69,7 @@ protected function doConfigure() ->addOption('with-licenses', 'c', InputOption::VALUE_NONE, 'Include licenses') ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (mdtable, mdlist, json, github)', 'mdtable') ->addOption('gitlab-domains', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Extra Gitlab domains (inherited from Composer config by default)', array()) + ->addOption('skip-io-errors', null, InputOption::VALUE_NONE, 'Treat missing composer.lock files as empty') ->addOption('strict', 's', InputOption::VALUE_NONE, 'Return non-zero exit code if there are any changes') ->setHelp(<<<'EOF' The %command.name% command displays all dependency changes between two composer.lock files. @@ -80,23 +81,23 @@ protected function doConfigure() To compare with specific branch, pass its name as argument: %command.full_name% master - + You can specify any valid git refs to compare with: %command.full_name% HEAD~3 be4aabc - + You can also use more verbose syntax for base and target options: %command.full_name% --base master --target composer.lock - + To compare files in specific path, use following syntax: %command.full_name% master:subdirectory/composer.lock /path/to/another/composer.lock - + By default, platform dependencies are hidden. Add --with-platform option to include them in the report: - + %command.full_name% --with-platform - + By default, transient dependencies are displayed. Add --direct option to only show direct dependencies: %command.full_name% --direct @@ -104,7 +105,7 @@ protected function doConfigure() Use --with-links to include release and compare URLs in the report: %command.full_name% --with-links - + You can customize output format by specifying it with --format option. Choose between mdtable, mdlist and json: %command.full_name% --format=json @@ -117,6 +118,10 @@ protected function doConfigure() %command.full_name% --strict +Use --skip-io-errors to treat missing base composer.lock files as empty input: + + %command.full_name% /path/to/missing/composer.lock composer.lock --skip-io-errors + Exit code --------- @@ -125,7 +130,7 @@ protected function doConfigure() * 0 - OK. * 1 - General error. * 2 - There were changes in prod packages. -* 4 - There were changes is dev packages. +* 4 - There were changes in dev packages. * 8 - There were downgrades in prod packages. * 16 - There were downgrades in dev packages. EOF @@ -151,6 +156,7 @@ protected function handle(InputInterface $input, OutputInterface $output) $formatter = $formatters->getFormatter($input->getOption('format')); $this->packageDiff->setUrlGenerator($urlGenerators); + $this->packageDiff->setSkipIoErrors($input->getOption('skip-io-errors')); $prodOperations = new DiffEntries(array()); $devOperations = new DiffEntries(array()); diff --git a/src/PackageDiff.php b/src/PackageDiff.php index 3fc92eb..eeffa02 100644 --- a/src/PackageDiff.php +++ b/src/PackageDiff.php @@ -25,6 +25,9 @@ class PackageDiff /** @var UrlGenerator */ protected $urlGenerator; + /** @var bool */ + protected $skipIoErrors = false; + public function __construct() { $this->urlGenerator = new GeneratorContainer(); @@ -38,6 +41,16 @@ public function setUrlGenerator(UrlGenerator $urlGenerator) $this->urlGenerator = $urlGenerator; } + /** + * @param bool $skipIoErrors + * + * @return void + */ + public function setSkipIoErrors($skipIoErrors) + { + $this->skipIoErrors = (bool) $skipIoErrors; + } + /** * @param string[] $directPackages * @param bool $onlyDirect @@ -213,28 +226,71 @@ private function getFileContents($path, $lockFile = true) return file_get_contents($localPath); } - if (false === strpos($originalPath, self::GIT_SEPARATOR)) { - $path .= self::GIT_SEPARATOR.self::COMPOSER.($lockFile ? self::EXTENSION_LOCK : self::EXTENSION_JSON); - } + $attemptedSpecs = array(); + $lastError = ''; - if (!$lockFile) { - $path = $this->getJsonPath($path); - } + foreach ($this->getGitCandidates($path, $lockFile) as $gitPath) { + $attemptedSpecs[] = $gitPath; + $output = array(); + @exec(sprintf('git show %s 2>&1', escapeshellarg($gitPath)), $output, $exit); + $outputString = implode("\n", $output); - $output = array(); - @exec(sprintf('git show %s 2>&1', escapeshellarg($path)), $output, $exit); - $outputString = implode("\n", $output); + if (0 !== $exit) { + $lastError = $outputString; - if (0 !== $exit) { - if ($lockFile) { - throw new \RuntimeException(sprintf('Could not open file %s or find it in git as %s: %s', $originalPath, $path, $outputString)); + continue; } - /* @infection-ignore-all False-positive */ - return '{}'; // Do not throw exception for composer.json as it might not exist and that's fine + if (!$this->looksLikeJsonDocument($outputString)) { + $lastError = sprintf('Unexpected non-JSON output for git spec %s', $gitPath); + + continue; + } + + return $outputString; + } + + if ($lockFile && !$this->skipIoErrors) { + throw new \RuntimeException(sprintf('Could not open file %s or find it in git as %s: %s', $originalPath, implode(', ', $attemptedSpecs), $lastError)); } - return $outputString; + /* @infection-ignore-all False-positive */ + // Do not throw exception for composer.json as it might not exist and that's fine. + // For composer.lock this fallback is optional and controlled by setSkipIoErrors(). + return '{}'; + } + + /** + * @param string $path + * @param bool $lockFile + * + * @return string[] + */ + private function getGitCandidates($path, $lockFile = true) + { + $candidates = array(); + + if ($lockFile) { + $candidates[] = $path; + $candidates[] = $path.self::GIT_SEPARATOR.self::COMPOSER.self::EXTENSION_LOCK; + } else { + $candidates[] = $this->getJsonPath($path); + $candidates[] = $this->getJsonPath($path.self::GIT_SEPARATOR.self::COMPOSER.self::EXTENSION_LOCK); + } + + return array_values(array_unique($candidates)); + } + + /** + * @param string $contents + * + * @return bool + */ + private function looksLikeJsonDocument($contents) + { + $data = json_decode($contents, true); + + return JSON_ERROR_NONE === json_last_error() && is_array($data); } /** diff --git a/tests/Command/DiffCommandTest.php b/tests/Command/DiffCommandTest.php index 645da95..fe3448d 100644 --- a/tests/Command/DiffCommandTest.php +++ b/tests/Command/DiffCommandTest.php @@ -80,6 +80,27 @@ public function testExtraGitlabDomains() $this->assertSame(0, $result); } + public function testSkipIoErrorsOption() + { + $diff = $this->getMockBuilder('IonBazan\ComposerDiff\PackageDiff')->getMock(); + $application = $this->getComposerApplication(); + $command = new DiffCommand($diff, array('gitlab2.org')); + $command->setApplication($application); + $tester = new CommandTester($command); + + $diff->expects($this->once()) + ->method('setSkipIoErrors') + ->with(true); + + $diff->expects($this->atLeast(1)) + ->method('getPackageDiff') + ->willReturn($this->getEntries(array(), $this->getGenerators())); + + $result = $tester->execute(array('--skip-io-errors' => null)); + + $this->assertSame(0, $result); + } + /** * @param int $exitCode * @param OperationInterface[] $prodOperations diff --git a/tests/PackageDiffTest.php b/tests/PackageDiffTest.php index bf1ee88..c2545b3 100644 --- a/tests/PackageDiffTest.php +++ b/tests/PackageDiffTest.php @@ -152,6 +152,113 @@ public function testLoadFromEmptyArray() $this->assertInstanceOf('Composer\Repository\ArrayRepository', $diff->loadPackagesFromArray(array(), true, true)); } + public function testDiffAgainstMissingBaseLockFileThrowsByDefault() + { + $diff = new PackageDiff(); + $this->setExpectedException('RuntimeException'); + $diff->getPackageDiff( + __DIR__.'/fixtures/missing/composer.lock', + __DIR__.'/fixtures/empty-target/composer.lock', + false, + false + ); + } + + public function testDiffAgainstMissingBaseLockFileWithSkipIoErrors() + { + $diff = new PackageDiff(); + $diff->setSkipIoErrors(true); + + $prodOperations = $diff->getPackageDiff( + __DIR__.'/fixtures/missing/composer.lock', + __DIR__.'/fixtures/empty-target/composer.lock', + false, + false + ); + $devOperations = $diff->getPackageDiff( + __DIR__.'/fixtures/missing/composer.lock', + __DIR__.'/fixtures/empty-target/composer.lock', + true, + false + ); + + $this->assertSame(array( + 'install example/package 1.2.3', + ), array_map(array($this, 'entryToString'), $prodOperations->getArrayCopy())); + $this->assertSame(array( + 'install example/dev-package 2.3.4', + ), array_map(array($this, 'entryToString'), $devOperations->getArrayCopy())); + } + + public function testDiffAgainstMissingGitLockFilePathThrowsByDefault() + { + $diff = new PackageDiff(); + $this->prepareGit(); + $this->setExpectedException('RuntimeException'); + $diff->getPackageDiff('HEAD:missing/composer.lock', '', false, false); + } + + public function testDiffAgainstMissingGitLockFilePathWithSkipIoErrors() + { + $diff = new PackageDiff(); + $diff->setSkipIoErrors(true); + $this->prepareGit(); + $operations = $diff->getPackageDiff('HEAD:missing/composer.lock', '', false, false); + + $this->assertNotEmpty($operations); + foreach ($operations as $entry) { + $this->assertInstanceOf('Composer\DependencyResolver\Operation\InstallOperation', $entry->getOperation()); + } + } + + public function testDiffAgainstMissingWindowsAbsoluteBaseLockFileThrowsByDefault() + { + $diff = new PackageDiff(); + $this->setExpectedException('RuntimeException'); + $diff->getPackageDiff( + 'C:\\tmp\\missing\\composer.lock', + __DIR__.'/fixtures/empty-target/composer.lock', + false, + false + ); + } + + public function testDiffAgainstMissingWindowsAbsoluteBaseLockFileWithSkipIoErrors() + { + $diff = new PackageDiff(); + $diff->setSkipIoErrors(true); + + $prodOperations = $diff->getPackageDiff( + 'C:\\tmp\\missing\\composer.lock', + __DIR__.'/fixtures/empty-target/composer.lock', + false, + false + ); + $devOperations = $diff->getPackageDiff( + 'C:\\tmp\\missing\\composer.lock', + __DIR__.'/fixtures/empty-target/composer.lock', + true, + false + ); + + $this->assertSame(array( + 'install example/package 1.2.3', + ), array_map(array($this, 'entryToString'), $prodOperations->getArrayCopy())); + $this->assertSame(array( + 'install example/dev-package 2.3.4', + ), array_map(array($this, 'entryToString'), $devOperations->getArrayCopy())); + } + + public function testOneLetterGitRefPath() + { + $diff = new PackageDiff(); + $this->prepareGit(); + exec('git branch -f z'); + $operations = $diff->getPackageDiff('z:composer.lock', '', false, false); + + $this->assertNotEmpty($operations); + } + public function diffOperationsProvider() { return array( diff --git a/tests/fixtures/empty-target/composer.lock b/tests/fixtures/empty-target/composer.lock new file mode 100644 index 0000000..73f867e --- /dev/null +++ b/tests/fixtures/empty-target/composer.lock @@ -0,0 +1,14 @@ +{ + "packages": [ + { + "name": "example/package", + "version": "1.2.3" + } + ], + "packages-dev": [ + { + "name": "example/dev-package", + "version": "2.3.4" + } + ] +}