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"
+ }
+ ]
+}