From 4c1e61cc88c0421a7f4027750c3456250822c265 Mon Sep 17 00:00:00 2001 From: Clemens Krack Date: Wed, 18 Mar 2026 20:18:37 +0100 Subject: [PATCH 1/4] Support diffing against a non-existing composer.lock file --- README.md | 1 + src/PackageDiff.php | 28 +++++++++++++++++ tests/PackageDiffTest.php | 37 +++++++++++++++++++++++ tests/fixtures/empty-target/composer.lock | 14 +++++++++ 4 files changed, 80 insertions(+) create mode 100644 tests/fixtures/empty-target/composer.lock diff --git a/README.md b/README.md index 259f510..71f80d6 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ composer diff --help # Display detailed usage instructions ```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 # 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 diff --git a/src/PackageDiff.php b/src/PackageDiff.php index 3fc92eb..7a986cc 100644 --- a/src/PackageDiff.php +++ b/src/PackageDiff.php @@ -213,6 +213,10 @@ private function getFileContents($path, $lockFile = true) return file_get_contents($localPath); } + if ($lockFile && false === strpos($originalPath, self::GIT_SEPARATOR) && $this->looksLikeComposerLockFile($localPath)) { + return '{}'; + } + if (false === strpos($originalPath, self::GIT_SEPARATOR)) { $path .= self::GIT_SEPARATOR.self::COMPOSER.($lockFile ? self::EXTENSION_LOCK : self::EXTENSION_JSON); } @@ -226,6 +230,10 @@ private function getFileContents($path, $lockFile = true) $outputString = implode("\n", $output); if (0 !== $exit) { + if ($lockFile && $this->isMissingFileError($outputString)) { + return '{}'; + } + if ($lockFile) { throw new \RuntimeException(sprintf('Could not open file %s or find it in git as %s: %s', $originalPath, $path, $outputString)); } @@ -237,6 +245,26 @@ private function getFileContents($path, $lockFile = true) return $outputString; } + /** + * @param string $path + * + * @return bool + */ + private function looksLikeComposerLockFile($path) + { + return self::EXTENSION_LOCK === substr($path, -strlen(self::EXTENSION_LOCK)); + } + + /** + * @param string $gitOutput + * + * @return bool + */ + private function isMissingFileError($gitOutput) + { + return false !== stripos($gitOutput, 'does not exist in') || false !== stripos($gitOutput, 'exists on disk, but not in'); + } + /** * @param string $path * diff --git a/tests/PackageDiffTest.php b/tests/PackageDiffTest.php index bf1ee88..0003c1d 100644 --- a/tests/PackageDiffTest.php +++ b/tests/PackageDiffTest.php @@ -152,6 +152,43 @@ public function testLoadFromEmptyArray() $this->assertInstanceOf('Composer\Repository\ArrayRepository', $diff->loadPackagesFromArray(array(), true, true)); } + public function testDiffAgainstMissingBaseLockFile() + { + $diff = new PackageDiff(); + + $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 testDiffAgainstMissingGitLockFilePath() + { + $diff = new PackageDiff(); + $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 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" + } + ] +} From 3311fab51eedce3cdedafd598c3f696fa56d2a49 Mon Sep 17 00:00:00 2001 From: Clemens Krack Date: Fri, 20 Mar 2026 08:50:58 +0100 Subject: [PATCH 2/4] Fix typo in docs/command output --- README.md | 6 +++--- src/Command/DiffCommand.php | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 71f80d6..e656a32 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ 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 - + ## Advanced usage ```shell script @@ -100,7 +100,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. @@ -108,7 +108,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..a76b81b 100644 --- a/src/Command/DiffCommand.php +++ b/src/Command/DiffCommand.php @@ -80,23 +80,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 +104,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 @@ -125,7 +125,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 From 6f365b0862de3e94ae79c3661cbc837654d1b21d Mon Sep 17 00:00:00 2001 From: Clemens Krack Date: Fri, 20 Mar 2026 09:17:03 +0100 Subject: [PATCH 3/4] Fix handling one-letter git refs in lockfile paths Add regression tests for Windows absolute base lock paths and one-letter ref:path syntax. --- src/PackageDiff.php | 27 +++++++++++++++++++++++++-- tests/PackageDiffTest.php | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/PackageDiff.php b/src/PackageDiff.php index 7a986cc..589b191 100644 --- a/src/PackageDiff.php +++ b/src/PackageDiff.php @@ -213,11 +213,11 @@ private function getFileContents($path, $lockFile = true) return file_get_contents($localPath); } - if ($lockFile && false === strpos($originalPath, self::GIT_SEPARATOR) && $this->looksLikeComposerLockFile($localPath)) { + if ($lockFile && !$this->containsGitSeparator($originalPath) && $this->looksLikeComposerLockFile($localPath)) { return '{}'; } - if (false === strpos($originalPath, self::GIT_SEPARATOR)) { + if (!$this->containsGitSeparator($originalPath)) { $path .= self::GIT_SEPARATOR.self::COMPOSER.($lockFile ? self::EXTENSION_LOCK : self::EXTENSION_JSON); } @@ -265,6 +265,29 @@ private function isMissingFileError($gitOutput) return false !== stripos($gitOutput, 'does not exist in') || false !== stripos($gitOutput, 'exists on disk, but not in'); } + /** + * @param string $path + * + * @return bool + */ + private function containsGitSeparator($path) + { + $pos = strpos($path, self::GIT_SEPARATOR); + + if (false === $pos) { + return false; + } + + // Ignore Windows absolute drive paths (e.g. "C:\path" or "C:/path"). + if (1 === $pos && ctype_alpha($path[0])) { + if (isset($path[2]) && ('\\' === $path[2] || '/' === $path[2])) { + return false; + } + } + + return true; + } + /** * @param string $path * diff --git a/tests/PackageDiffTest.php b/tests/PackageDiffTest.php index 0003c1d..3920a17 100644 --- a/tests/PackageDiffTest.php +++ b/tests/PackageDiffTest.php @@ -189,6 +189,41 @@ public function testDiffAgainstMissingGitLockFilePath() } } + public function testDiffAgainstMissingWindowsAbsoluteBaseLockFile() + { + $diff = new PackageDiff(); + + $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( From c8e7b3e58f4747011732dc856c7d668f403062c0 Mon Sep 17 00:00:00 2001 From: Clemens Krack Date: Thu, 23 Apr 2026 20:26:10 +0200 Subject: [PATCH 4/4] Add --skip-io-errors option to handle missing composer.lock files as empty input --- README.md | 3 +- src/Command/DiffCommand.php | 6 ++ src/PackageDiff.php | 101 ++++++++++++++++-------------- tests/Command/DiffCommandTest.php | 21 +++++++ tests/PackageDiffTest.php | 41 +++++++++++- 5 files changed, 120 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index e656a32..975773c 100644 --- a/README.md +++ b/README.md @@ -77,13 +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 # Compare a new lock file against a non-existing base lock file +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 diff --git a/src/Command/DiffCommand.php b/src/Command/DiffCommand.php index a76b81b..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. @@ -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 --------- @@ -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 589b191..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,79 +226,71 @@ private function getFileContents($path, $lockFile = true) return file_get_contents($localPath); } - if ($lockFile && !$this->containsGitSeparator($originalPath) && $this->looksLikeComposerLockFile($localPath)) { - return '{}'; - } + $attemptedSpecs = array(); + $lastError = ''; - if (!$this->containsGitSeparator($originalPath)) { - $path .= self::GIT_SEPARATOR.self::COMPOSER.($lockFile ? self::EXTENSION_LOCK : self::EXTENSION_JSON); - } - - 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 && $this->isMissingFileError($outputString)) { - return '{}'; + continue; } - if ($lockFile) { - throw new \RuntimeException(sprintf('Could not open file %s or find it in git as %s: %s', $originalPath, $path, $outputString)); + if (!$this->looksLikeJsonDocument($outputString)) { + $lastError = sprintf('Unexpected non-JSON output for git spec %s', $gitPath); + + continue; } - /* @infection-ignore-all False-positive */ - return '{}'; // Do not throw exception for composer.json as it might not exist and that's fine + 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 bool + * @return string[] */ - private function looksLikeComposerLockFile($path) + private function getGitCandidates($path, $lockFile = true) { - return self::EXTENSION_LOCK === substr($path, -strlen(self::EXTENSION_LOCK)); - } + $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); + } - /** - * @param string $gitOutput - * - * @return bool - */ - private function isMissingFileError($gitOutput) - { - return false !== stripos($gitOutput, 'does not exist in') || false !== stripos($gitOutput, 'exists on disk, but not in'); + return array_values(array_unique($candidates)); } /** - * @param string $path + * @param string $contents * * @return bool */ - private function containsGitSeparator($path) + private function looksLikeJsonDocument($contents) { - $pos = strpos($path, self::GIT_SEPARATOR); - - if (false === $pos) { - return false; - } - - // Ignore Windows absolute drive paths (e.g. "C:\path" or "C:/path"). - if (1 === $pos && ctype_alpha($path[0])) { - if (isset($path[2]) && ('\\' === $path[2] || '/' === $path[2])) { - return false; - } - } + $data = json_decode($contents, true); - return 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 3920a17..c2545b3 100644 --- a/tests/PackageDiffTest.php +++ b/tests/PackageDiffTest.php @@ -152,9 +152,22 @@ public function testLoadFromEmptyArray() $this->assertInstanceOf('Composer\Repository\ArrayRepository', $diff->loadPackagesFromArray(array(), true, true)); } - public function testDiffAgainstMissingBaseLockFile() + 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', @@ -177,9 +190,18 @@ public function testDiffAgainstMissingBaseLockFile() ), array_map(array($this, 'entryToString'), $devOperations->getArrayCopy())); } - public function testDiffAgainstMissingGitLockFilePath() + 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); @@ -189,9 +211,22 @@ public function testDiffAgainstMissingGitLockFilePath() } } - public function testDiffAgainstMissingWindowsAbsoluteBaseLockFile() + 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',