Skip to content

Commit c8e7b3e

Browse files
committed
Add --skip-io-errors option to handle missing composer.lock files as empty input
1 parent 6f365b0 commit c8e7b3e

5 files changed

Lines changed: 120 additions & 52 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,14 @@ composer diff --help # Display detailed usage instructions
7777
- `--with-licenses` (`-c`) - include license information
7878
- `--format` (`-f`) - output format (mdtable, mdlist, json, github) - default: `mdtable`
7979
- `--gitlab-domains` - custom gitlab domains for compare/release URLs - default: use composer config
80+
- `--skip-io-errors` - treat missing `composer.lock` files as empty input
8081

8182
## Advanced usage
8283

8384
```shell script
8485
composer diff master # Compare current composer.lock with the one on master branch
8586
composer diff master:composer.lock develop:composer.lock -p # Compare master and develop branches, including platform dependencies
86-
composer diff /path/to/missing/composer.lock composer.lock # Compare a new lock file against a non-existing base lock file
87+
composer diff /path/to/missing/composer.lock composer.lock --skip-io-errors # Compare a new lock file against a non-existing base lock file
8788
composer diff --no-dev # ignore dev dependencies
8889
composer diff -p # include platform dependencies
8990
composer diff -f json # Output as JSON instead of table

src/Command/DiffCommand.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ protected function doConfigure()
6969
->addOption('with-licenses', 'c', InputOption::VALUE_NONE, 'Include licenses')
7070
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (mdtable, mdlist, json, github)', 'mdtable')
7171
->addOption('gitlab-domains', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Extra Gitlab domains (inherited from Composer config by default)', array())
72+
->addOption('skip-io-errors', null, InputOption::VALUE_NONE, 'Treat missing composer.lock files as empty')
7273
->addOption('strict', 's', InputOption::VALUE_NONE, 'Return non-zero exit code if there are any changes')
7374
->setHelp(<<<'EOF'
7475
The <info>%command.name%</info> command displays all dependency changes between two <comment>composer.lock</comment> files.
@@ -117,6 +118,10 @@ protected function doConfigure()
117118
118119
<info>%command.full_name% --strict</info>
119120
121+
Use <info>--skip-io-errors</info> to treat missing base <comment>composer.lock</comment> files as empty input:
122+
123+
<info>%command.full_name% /path/to/missing/composer.lock composer.lock --skip-io-errors</info>
124+
120125
Exit code
121126
---------
122127
@@ -151,6 +156,7 @@ protected function handle(InputInterface $input, OutputInterface $output)
151156
$formatter = $formatters->getFormatter($input->getOption('format'));
152157

153158
$this->packageDiff->setUrlGenerator($urlGenerators);
159+
$this->packageDiff->setSkipIoErrors($input->getOption('skip-io-errors'));
154160

155161
$prodOperations = new DiffEntries(array());
156162
$devOperations = new DiffEntries(array());

src/PackageDiff.php

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class PackageDiff
2525
/** @var UrlGenerator */
2626
protected $urlGenerator;
2727

28+
/** @var bool */
29+
protected $skipIoErrors = false;
30+
2831
public function __construct()
2932
{
3033
$this->urlGenerator = new GeneratorContainer();
@@ -38,6 +41,16 @@ public function setUrlGenerator(UrlGenerator $urlGenerator)
3841
$this->urlGenerator = $urlGenerator;
3942
}
4043

44+
/**
45+
* @param bool $skipIoErrors
46+
*
47+
* @return void
48+
*/
49+
public function setSkipIoErrors($skipIoErrors)
50+
{
51+
$this->skipIoErrors = (bool) $skipIoErrors;
52+
}
53+
4154
/**
4255
* @param string[] $directPackages
4356
* @param bool $onlyDirect
@@ -213,79 +226,71 @@ private function getFileContents($path, $lockFile = true)
213226
return file_get_contents($localPath);
214227
}
215228

216-
if ($lockFile && !$this->containsGitSeparator($originalPath) && $this->looksLikeComposerLockFile($localPath)) {
217-
return '{}';
218-
}
229+
$attemptedSpecs = array();
230+
$lastError = '';
219231

220-
if (!$this->containsGitSeparator($originalPath)) {
221-
$path .= self::GIT_SEPARATOR.self::COMPOSER.($lockFile ? self::EXTENSION_LOCK : self::EXTENSION_JSON);
222-
}
223-
224-
if (!$lockFile) {
225-
$path = $this->getJsonPath($path);
226-
}
232+
foreach ($this->getGitCandidates($path, $lockFile) as $gitPath) {
233+
$attemptedSpecs[] = $gitPath;
234+
$output = array();
235+
@exec(sprintf('git show %s 2>&1', escapeshellarg($gitPath)), $output, $exit);
236+
$outputString = implode("\n", $output);
227237

228-
$output = array();
229-
@exec(sprintf('git show %s 2>&1', escapeshellarg($path)), $output, $exit);
230-
$outputString = implode("\n", $output);
238+
if (0 !== $exit) {
239+
$lastError = $outputString;
231240

232-
if (0 !== $exit) {
233-
if ($lockFile && $this->isMissingFileError($outputString)) {
234-
return '{}';
241+
continue;
235242
}
236243

237-
if ($lockFile) {
238-
throw new \RuntimeException(sprintf('Could not open file %s or find it in git as %s: %s', $originalPath, $path, $outputString));
244+
if (!$this->looksLikeJsonDocument($outputString)) {
245+
$lastError = sprintf('Unexpected non-JSON output for git spec %s', $gitPath);
246+
247+
continue;
239248
}
240249

241-
/* @infection-ignore-all False-positive */
242-
return '{}'; // Do not throw exception for composer.json as it might not exist and that's fine
250+
return $outputString;
251+
}
252+
253+
if ($lockFile && !$this->skipIoErrors) {
254+
throw new \RuntimeException(sprintf('Could not open file %s or find it in git as %s: %s', $originalPath, implode(', ', $attemptedSpecs), $lastError));
243255
}
244256

245-
return $outputString;
257+
/* @infection-ignore-all False-positive */
258+
// Do not throw exception for composer.json as it might not exist and that's fine.
259+
// For composer.lock this fallback is optional and controlled by setSkipIoErrors().
260+
return '{}';
246261
}
247262

248263
/**
249264
* @param string $path
265+
* @param bool $lockFile
250266
*
251-
* @return bool
267+
* @return string[]
252268
*/
253-
private function looksLikeComposerLockFile($path)
269+
private function getGitCandidates($path, $lockFile = true)
254270
{
255-
return self::EXTENSION_LOCK === substr($path, -strlen(self::EXTENSION_LOCK));
256-
}
271+
$candidates = array();
272+
273+
if ($lockFile) {
274+
$candidates[] = $path;
275+
$candidates[] = $path.self::GIT_SEPARATOR.self::COMPOSER.self::EXTENSION_LOCK;
276+
} else {
277+
$candidates[] = $this->getJsonPath($path);
278+
$candidates[] = $this->getJsonPath($path.self::GIT_SEPARATOR.self::COMPOSER.self::EXTENSION_LOCK);
279+
}
257280

258-
/**
259-
* @param string $gitOutput
260-
*
261-
* @return bool
262-
*/
263-
private function isMissingFileError($gitOutput)
264-
{
265-
return false !== stripos($gitOutput, 'does not exist in') || false !== stripos($gitOutput, 'exists on disk, but not in');
281+
return array_values(array_unique($candidates));
266282
}
267283

268284
/**
269-
* @param string $path
285+
* @param string $contents
270286
*
271287
* @return bool
272288
*/
273-
private function containsGitSeparator($path)
289+
private function looksLikeJsonDocument($contents)
274290
{
275-
$pos = strpos($path, self::GIT_SEPARATOR);
276-
277-
if (false === $pos) {
278-
return false;
279-
}
280-
281-
// Ignore Windows absolute drive paths (e.g. "C:\path" or "C:/path").
282-
if (1 === $pos && ctype_alpha($path[0])) {
283-
if (isset($path[2]) && ('\\' === $path[2] || '/' === $path[2])) {
284-
return false;
285-
}
286-
}
291+
$data = json_decode($contents, true);
287292

288-
return true;
293+
return JSON_ERROR_NONE === json_last_error() && is_array($data);
289294
}
290295

291296
/**

tests/Command/DiffCommandTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,27 @@ public function testExtraGitlabDomains()
8080
$this->assertSame(0, $result);
8181
}
8282

83+
public function testSkipIoErrorsOption()
84+
{
85+
$diff = $this->getMockBuilder('IonBazan\ComposerDiff\PackageDiff')->getMock();
86+
$application = $this->getComposerApplication();
87+
$command = new DiffCommand($diff, array('gitlab2.org'));
88+
$command->setApplication($application);
89+
$tester = new CommandTester($command);
90+
91+
$diff->expects($this->once())
92+
->method('setSkipIoErrors')
93+
->with(true);
94+
95+
$diff->expects($this->atLeast(1))
96+
->method('getPackageDiff')
97+
->willReturn($this->getEntries(array(), $this->getGenerators()));
98+
99+
$result = $tester->execute(array('--skip-io-errors' => null));
100+
101+
$this->assertSame(0, $result);
102+
}
103+
83104
/**
84105
* @param int $exitCode
85106
* @param OperationInterface[] $prodOperations

tests/PackageDiffTest.php

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,22 @@ public function testLoadFromEmptyArray()
152152
$this->assertInstanceOf('Composer\Repository\ArrayRepository', $diff->loadPackagesFromArray(array(), true, true));
153153
}
154154

155-
public function testDiffAgainstMissingBaseLockFile()
155+
public function testDiffAgainstMissingBaseLockFileThrowsByDefault()
156156
{
157157
$diff = new PackageDiff();
158+
$this->setExpectedException('RuntimeException');
159+
$diff->getPackageDiff(
160+
__DIR__.'/fixtures/missing/composer.lock',
161+
__DIR__.'/fixtures/empty-target/composer.lock',
162+
false,
163+
false
164+
);
165+
}
166+
167+
public function testDiffAgainstMissingBaseLockFileWithSkipIoErrors()
168+
{
169+
$diff = new PackageDiff();
170+
$diff->setSkipIoErrors(true);
158171

159172
$prodOperations = $diff->getPackageDiff(
160173
__DIR__.'/fixtures/missing/composer.lock',
@@ -177,9 +190,18 @@ public function testDiffAgainstMissingBaseLockFile()
177190
), array_map(array($this, 'entryToString'), $devOperations->getArrayCopy()));
178191
}
179192

180-
public function testDiffAgainstMissingGitLockFilePath()
193+
public function testDiffAgainstMissingGitLockFilePathThrowsByDefault()
194+
{
195+
$diff = new PackageDiff();
196+
$this->prepareGit();
197+
$this->setExpectedException('RuntimeException');
198+
$diff->getPackageDiff('HEAD:missing/composer.lock', '', false, false);
199+
}
200+
201+
public function testDiffAgainstMissingGitLockFilePathWithSkipIoErrors()
181202
{
182203
$diff = new PackageDiff();
204+
$diff->setSkipIoErrors(true);
183205
$this->prepareGit();
184206
$operations = $diff->getPackageDiff('HEAD:missing/composer.lock', '', false, false);
185207

@@ -189,9 +211,22 @@ public function testDiffAgainstMissingGitLockFilePath()
189211
}
190212
}
191213

192-
public function testDiffAgainstMissingWindowsAbsoluteBaseLockFile()
214+
public function testDiffAgainstMissingWindowsAbsoluteBaseLockFileThrowsByDefault()
215+
{
216+
$diff = new PackageDiff();
217+
$this->setExpectedException('RuntimeException');
218+
$diff->getPackageDiff(
219+
'C:\\tmp\\missing\\composer.lock',
220+
__DIR__.'/fixtures/empty-target/composer.lock',
221+
false,
222+
false
223+
);
224+
}
225+
226+
public function testDiffAgainstMissingWindowsAbsoluteBaseLockFileWithSkipIoErrors()
193227
{
194228
$diff = new PackageDiff();
229+
$diff->setSkipIoErrors(true);
195230

196231
$prodOperations = $diff->getPackageDiff(
197232
'C:\\tmp\\missing\\composer.lock',

0 commit comments

Comments
 (0)