diff --git a/.gitattributes b/.gitattributes index 8068826..243b015 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,6 @@ /.github export-ignore .gitattributes export-ignore .gitignore export-ignore -.php_cs.dist export-ignore +.php-cs-fixer.dist.php export-ignore phpunit.xml.dist export-ignore infection.json.dist export-ignore diff --git a/README.md b/README.md index da799b7..abd6bab 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,21 @@ composer diff -p # include platform dependencies composer diff -f json # Output as JSON instead of table ``` +### Strict mode + +To help you control your dependencies, you may pass `--strict` option when running in CI. If there are any changes detected, a non-zero exit code will be returned. + +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. +* `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. + # Similar packages While there are several existing packages offering similar functionality: diff --git a/src/Command/DiffCommand.php b/src/Command/DiffCommand.php index 98f2346..4b92702 100644 --- a/src/Command/DiffCommand.php +++ b/src/Command/DiffCommand.php @@ -3,6 +3,8 @@ namespace IonBazan\ComposerDiff\Command; use Composer\Command\BaseCommand; +use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\DependencyResolver\Operation\UpdateOperation; use IonBazan\ComposerDiff\Formatter\Formatter; use IonBazan\ComposerDiff\Formatter\JsonFormatter; use IonBazan\ComposerDiff\Formatter\MarkdownListFormatter; @@ -16,6 +18,10 @@ class DiffCommand extends BaseCommand { + const CHANGES_PROD = 2; + const CHANGES_DEV = 4; + const DOWNGRADES_PROD = 8; + const DOWNGRADES_DEV = 16; /** * @var PackageDiff */ @@ -54,43 +60,61 @@ protected function configure() ->addOption('with-links', 'l', InputOption::VALUE_NONE, 'Include compare/release URLs') ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (mdtable, mdlist, json)', 'mdtable') ->addOption('gitlab-domains', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Extra Gitlab domains (inherited from Composer config by default)', array()) + ->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. -By default, it will compare current filesystem changes with git HEAD: +The %command.name% command displays all dependency changes between two composer.lock files. - %command.full_name% +By default, it will compare current filesystem changes with git HEAD: + + %command.full_name% To compare with specific branch, pass its name as argument: - %command.full_name% master + %command.full_name% master You can specify any valid git refs to compare with: - %command.full_name% HEAD~3 be4aabc + %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 + %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 + %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: +By default, platform dependencies are hidden. Add --with-platform option to include them in the report: - %command.full_name% --with-platform + %command.full_name% --with-platform -Use --with-links to include release and compare URLs in the report: +Use --with-links to include release and compare URLs in the report: - %command.full_name% --with-links + %command.full_name% --with-links -You can customize output format by specifying it with --format option. Choose between mdtable, mdlist and json: +You can customize output format by specifying it with --format option. Choose between mdtable, mdlist and json: + + %command.full_name% --format=json + +Hide dev dependencies using --no-dev option: + + %command.full_name% --no-dev + +Passing --strict option may help you to disallow changes or downgrades by returning non-zero exit code: + + %command.full_name% --strict - %command.full_name% --format=json +Exit code +--------- -Hide dev dependencies using --no-dev option: +Exit code of the command is built using following bit flags: - %command.full_name% --no-dev +* 0 - OK. +* 1 - General error. +* 2 - There were changes in prod packages. +* 4 - There were changes is dev packages. +* 8 - There were downgrades in prod packages. +* 16 - There were downgrades in dev packages. EOF ) ; @@ -122,7 +146,50 @@ protected function execute(InputInterface $input, OutputInterface $output) $formatter->render($prodOperations, $devOperations, $withUrls); - return 0; + return $input->getOption('strict') ? $this->getExitCode($prodOperations, $devOperations) : 0; + } + + /** + * @param OperationInterface[] $prodOperations + * @param OperationInterface[] $devOperations + * + * @return int Exit code + */ + private function getExitCode(array $prodOperations, array $devOperations) + { + $exitCode = 0; + + if (!empty($prodOperations)) { + $exitCode = self::CHANGES_PROD; + + if ($this->hasDowngrades($prodOperations)) { + $exitCode |= self::DOWNGRADES_PROD; + } + } + + if (!empty($devOperations)) { + $exitCode |= self::CHANGES_DEV; + + if ($this->hasDowngrades($devOperations)) { + $exitCode |= self::DOWNGRADES_DEV; + } + } + + return $exitCode; + } + + /** + * @param OperationInterface[] $operations + * + * @return bool + */ + private function hasDowngrades(array $operations) + { + $downgrades = array_filter($operations, function (OperationInterface $operation) { + return $operation instanceof UpdateOperation && !PackageDiff::isUpgrade($operation); + }); + + return !empty($downgrades); } /** diff --git a/src/Formatter/AbstractFormatter.php b/src/Formatter/AbstractFormatter.php index c623bd2..71c8de5 100644 --- a/src/Formatter/AbstractFormatter.php +++ b/src/Formatter/AbstractFormatter.php @@ -7,11 +7,8 @@ use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\Package\PackageInterface; -use Composer\Semver\Semver; -use Composer\Semver\VersionParser; use IonBazan\ComposerDiff\Url\GeneratorContainer; use Symfony\Component\Console\Output\OutputInterface; -use UnexpectedValueException; abstract class AbstractFormatter implements Formatter { @@ -74,21 +71,4 @@ private function getReleaseUrl(PackageInterface $package) return $generator->getReleaseUrl($package); } - - /** - * @return bool - */ - protected static function isUpgrade(UpdateOperation $operation) - { - $versionParser = new VersionParser(); - try { - $normalizedFrom = $versionParser->normalize($operation->getInitialPackage()->getVersion()); - $normalizedTo = $versionParser->normalize($operation->getTargetPackage()->getVersion()); - } catch (UnexpectedValueException $e) { - return true; // Consider as upgrade if versions are not parsable - } - $sorted = Semver::sort(array($normalizedTo, $normalizedFrom)); - - return $sorted[0] === $normalizedFrom; - } } diff --git a/src/Formatter/JsonFormatter.php b/src/Formatter/JsonFormatter.php index f9923ad..d6638a9 100644 --- a/src/Formatter/JsonFormatter.php +++ b/src/Formatter/JsonFormatter.php @@ -6,6 +6,7 @@ use Composer\DependencyResolver\Operation\OperationInterface; use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\DependencyResolver\Operation\UpdateOperation; +use IonBazan\ComposerDiff\PackageDiff; class JsonFormatter extends AbstractFormatter { @@ -78,7 +79,7 @@ private function transformOperation(OperationInterface $operation) if ($operation instanceof UpdateOperation) { return array( 'name' => $operation->getInitialPackage()->getName(), - 'operation' => self::isUpgrade($operation) ? 'upgrade' : 'downgrade', + 'operation' => PackageDiff::isUpgrade($operation) ? 'upgrade' : 'downgrade', 'version_base' => $operation->getInitialPackage()->getFullPrettyVersion(), 'version_target' => $operation->getTargetPackage()->getFullPrettyVersion(), ); diff --git a/src/Formatter/MarkdownListFormatter.php b/src/Formatter/MarkdownListFormatter.php index 06edc36..e95e9a5 100644 --- a/src/Formatter/MarkdownListFormatter.php +++ b/src/Formatter/MarkdownListFormatter.php @@ -6,6 +6,7 @@ use Composer\DependencyResolver\Operation\OperationInterface; use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\DependencyResolver\Operation\UpdateOperation; +use IonBazan\ComposerDiff\PackageDiff; class MarkdownListFormatter extends MarkdownFormatter { @@ -58,7 +59,7 @@ private function getRow(OperationInterface $operation, $withUrls) } if ($operation instanceof UpdateOperation) { - $isUpgrade = self::isUpgrade($operation); + $isUpgrade = PackageDiff::isUpgrade($operation); return sprintf( ' - %s %s (%s => %s)%s', diff --git a/src/Formatter/MarkdownTableFormatter.php b/src/Formatter/MarkdownTableFormatter.php index 1727dbf..761f689 100644 --- a/src/Formatter/MarkdownTableFormatter.php +++ b/src/Formatter/MarkdownTableFormatter.php @@ -7,6 +7,7 @@ use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\DependencyResolver\Operation\UpdateOperation; use IonBazan\ComposerDiff\Formatter\Helper\Table; +use IonBazan\ComposerDiff\PackageDiff; class MarkdownTableFormatter extends MarkdownFormatter { @@ -68,7 +69,7 @@ private function getTableRow(OperationInterface $operation) if ($operation instanceof UpdateOperation) { return array( $operation->getInitialPackage()->getName(), - self::isUpgrade($operation) ? 'Upgraded' : 'Downgraded', + PackageDiff::isUpgrade($operation) ? 'Upgraded' : 'Downgraded', $operation->getInitialPackage()->getFullPrettyVersion(), $operation->getTargetPackage()->getFullPrettyVersion(), ); diff --git a/src/PackageDiff.php b/src/PackageDiff.php index 66f8413..8b76abb 100644 --- a/src/PackageDiff.php +++ b/src/PackageDiff.php @@ -9,6 +9,9 @@ use Composer\Package\CompletePackage; use Composer\Package\Loader\ArrayLoader; use Composer\Repository\ArrayRepository; +use Composer\Semver\Semver; +use Composer\Semver\VersionParser; +use UnexpectedValueException; class PackageDiff { @@ -50,6 +53,23 @@ public function getPackageDiff($from, $to, $dev, $withPlatform) return $operations; } + /** + * @return bool + */ + public static function isUpgrade(UpdateOperation $operation) + { + $versionParser = new VersionParser(); + try { + $normalizedFrom = $versionParser->normalize($operation->getInitialPackage()->getVersion()); + $normalizedTo = $versionParser->normalize($operation->getTargetPackage()->getVersion()); + } catch (UnexpectedValueException $e) { + return true; // Consider as upgrade if versions are not parsable + } + $sorted = Semver::sort(array($normalizedTo, $normalizedFrom)); + + return $sorted[0] === $normalizedFrom; + } + /** * @param string $path * @param bool $dev diff --git a/tests/Command/DiffCommandTest.php b/tests/Command/DiffCommandTest.php index b7579f3..acf24b4 100644 --- a/tests/Command/DiffCommandTest.php +++ b/tests/Command/DiffCommandTest.php @@ -3,6 +3,7 @@ namespace IonBazan\ComposerDiff\Tests\Command; use Composer\DependencyResolver\Operation\InstallOperation; +use Composer\DependencyResolver\Operation\OperationInterface; use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\DependencyResolver\Operation\UpdateOperation; use IonBazan\ComposerDiff\Command\DiffCommand; @@ -30,6 +31,7 @@ public function testItGeneratesReportInGivenFormat($expectedOutput, array $optio new UninstallOperation($this->getPackageWithSource('a/package-4', '0.1.1', 'gitlab.org')), new UninstallOperation($this->getPackageWithSource('a/package-5', '0.1.1', 'gitlab2.org')), new UninstallOperation($this->getPackageWithSource('a/package-6', '0.1.1', 'gitlab3.org')), + new UpdateOperation($this->getPackageWithSource('a/package-7', '1.2.0', 'github.com'), $this->getPackageWithSource('a/package-7', '1.0.0', 'github.com')), )) ; $result = $tester->execute($options); @@ -37,19 +39,90 @@ public function testItGeneratesReportInGivenFormat($expectedOutput, array $optio $this->assertSame($expectedOutput, $tester->getDisplay()); } + /** + * @param int $exitCode + * @param OperationInterface[] $prodOperations + * @param OperationInterface[] $devOperations + * + * @dataProvider strictDataProvider + */ + public function testStrictMode($exitCode, array $prodOperations, array $devOperations) + { + $diff = $this->getMockBuilder('IonBazan\ComposerDiff\PackageDiff')->getMock(); + $tester = new CommandTester(new DiffCommand($diff, array('gitlab2.org'))); + $diff->expects($this->exactly(2)) + ->method('getPackageDiff') + ->with($this->isType('string'), $this->isType('string'), $this->isType('boolean'), false) + ->willReturnOnConsecutiveCalls($prodOperations, $devOperations) + ; + $this->assertSame($exitCode, $tester->execute(array('--strict' => null))); + } + + public function strictDataProvider() + { + return array( + 'No changes' => array(0, array(), array()), + 'Changes in prod and dev' => array( + 6, + array( + new InstallOperation($this->getPackageWithSource('a/package-1', '1.0.0', 'github.com')), + new UpdateOperation($this->getPackageWithSource('a/package-2', '1.0.0', 'github.com'), $this->getPackageWithSource('a/package-2', '1.2.0', 'github.com')), + ), + array( + new UpdateOperation($this->getPackageWithSource('a/package-3', '1.0.0', 'github.com'), $this->getPackageWithSource('a/package-3', '1.2.0', 'github.com')), + new InstallOperation($this->getPackageWithSource('a/package-4', '1.0.0', 'github.com')), + ), + ), + 'Downgrades in prod and changes in dev' => array( + 14, + array( + new InstallOperation($this->getPackageWithSource('a/package-1', '1.0.0', 'github.com')), + new UpdateOperation($this->getPackageWithSource('a/package-2', '1.2.0', 'github.com'), $this->getPackageWithSource('a/package-2', '1.0.0', 'github.com')), + ), + array( + new UpdateOperation($this->getPackageWithSource('a/package-3', '1.0.0', 'github.com'), $this->getPackageWithSource('a/package-3', '1.2.0', 'github.com')), + new InstallOperation($this->getPackageWithSource('a/package-4', '1.0.0', 'github.com')), + ), + ), + 'Changes in prod and downgrades in dev' => array( + 22, + array( + new InstallOperation($this->getPackageWithSource('a/package-1', '1.0.0', 'github.com')), + new UpdateOperation($this->getPackageWithSource('a/package-2', '1.0.0', 'github.com'), $this->getPackageWithSource('a/package-2', '1.2.0', 'github.com')), + ), + array( + new UpdateOperation($this->getPackageWithSource('a/package-3', '1.2.0', 'github.com'), $this->getPackageWithSource('a/package-3', '1.0.0', 'github.com')), + new InstallOperation($this->getPackageWithSource('a/package-4', '1.0.0', 'github.com')), + ), + ), + 'Downgrades in both' => array( + 30, + array( + new InstallOperation($this->getPackageWithSource('a/package-1', '1.0.0', 'github.com')), + new UpdateOperation($this->getPackageWithSource('a/package-2', '1.2.0', 'github.com'), $this->getPackageWithSource('a/package-2', '1.0.0', 'github.com')), + ), + array( + new UpdateOperation($this->getPackageWithSource('a/package-3', '1.2.0', 'github.com'), $this->getPackageWithSource('a/package-3', '1.0.0', 'github.com')), + new InstallOperation($this->getPackageWithSource('a/package-4', '1.0.0', 'github.com')), + ), + ), + ); + } + public function outputDataProvider() { return array( 'Markdown table' => array( << array( << array( << 1.0.0) OUTPUT @@ -159,6 +235,12 @@ public function outputDataProvider() 'version_base' => '0.1.1', 'version_target' => null, ), + 'a/package-7' => array( + 'name' => 'a/package-7', + 'operation' => 'downgrade', + 'version_base' => '1.2.0', + 'version_target' => '1.0.0', + ), ), 'packages-dev' => array( ),