Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -99,15 +101,15 @@ 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.

You may check for individual flags or simply check if the status is greater or equal 8 if you don't want to downgrade any package.

# 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
Expand Down
22 changes: 14 additions & 8 deletions src/Command/DiffCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <info>%command.name%</info> command displays all dependency changes between two <comment>composer.lock</comment> files.
Expand All @@ -80,31 +81,31 @@ protected function doConfigure()
To compare with specific branch, pass its name as argument:

<info>%command.full_name% master</info>

You can specify any valid git refs to compare with:

<info>%command.full_name% HEAD~3 be4aabc</info>

You can also use more verbose syntax for <info>base</info> and <info>target</info> options:

<info>%command.full_name% --base master --target composer.lock</info>

To compare files in specific path, use following syntax:

<info>%command.full_name% master:subdirectory/composer.lock /path/to/another/composer.lock</info>

By default, <info>platform</info> dependencies are hidden. Add <info>--with-platform</info> option to include them in the report:

<info>%command.full_name% --with-platform</info>

By default, <info>transient</info> dependencies are displayed. Add <info>--direct</info> option to only show direct dependencies:

<info>%command.full_name% --direct</info>

Use <info>--with-links</info> to include release and compare URLs in the report:

<info>%command.full_name% --with-links</info>

You can customize output format by specifying it with <info>--format</info> option. Choose between <comment>mdtable</comment>, <comment>mdlist</comment> and <comment>json</comment>:

<info>%command.full_name% --format=json</info>
Expand All @@ -117,6 +118,10 @@ protected function doConfigure()

<info>%command.full_name% --strict</info>

Use <info>--skip-io-errors</info> to treat missing base <comment>composer.lock</comment> files as empty input:

<info>%command.full_name% /path/to/missing/composer.lock composer.lock --skip-io-errors</info>

Exit code
---------

Expand All @@ -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
Expand All @@ -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());
Expand Down
86 changes: 71 additions & 15 deletions src/PackageDiff.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class PackageDiff
/** @var UrlGenerator */
protected $urlGenerator;

/** @var bool */
protected $skipIoErrors = false;

public function __construct()
{
$this->urlGenerator = new GeneratorContainer();
Expand All @@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand Down
21 changes: 21 additions & 0 deletions tests/Command/DiffCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions tests/PackageDiffTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions tests/fixtures/empty-target/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.