diff --git a/doc/03-cli.md b/doc/03-cli.md index 166fed193..8d4d88466 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -556,6 +556,7 @@ php composer.phar show monolog/monolog 1.0.2 * **--major-only (-M):** Use with --latest or --outdated. Only shows packages that have major SemVer-compatible updates. * **--minor-only (-m):** Use with --latest or --outdated. Only shows packages that have minor SemVer-compatible updates. * **--patch-only:** Use with --latest or --outdated. Only shows packages that have patch-level SemVer-compatible updates. +* **--sort-by-age (-A):** Displays the installed version's age, and sorts packages oldest first. Use with the --latest or --outdated option. * **--direct (-D):** Restricts the list of packages to your direct dependencies. * **--strict:** Return a non-zero exit code when there are outdated packages. * **--format (-f):** Lets you pick between text (default) or json output format. @@ -589,6 +590,7 @@ The color coding is as such: * **--major-only (-M):** Only shows packages that have major SemVer-compatible updates. * **--minor-only (-m):** Only shows packages that have minor SemVer-compatible updates. * **--patch-only (-p):** Only shows packages that have patch-level SemVer-compatible updates. +* **--sort-by-age (-A):** Displays the installed version's age, and sorts packages oldest first. * **--format (-f):** Lets you pick between text (default) or json output format. * **--no-dev:** Do not show outdated dev dependencies. * **--locked:** Shows updates for packages from the lock file, regardless of what is currently in vendor dir. diff --git a/src/Composer/Command/OutdatedCommand.php b/src/Composer/Command/OutdatedCommand.php index 05f22ebbb..b6c71c175 100644 --- a/src/Composer/Command/OutdatedCommand.php +++ b/src/Composer/Command/OutdatedCommand.php @@ -40,6 +40,7 @@ class OutdatedCommand extends BaseCommand new InputOption('major-only', 'M', InputOption::VALUE_NONE, 'Show only packages that have major SemVer-compatible updates.'), new InputOption('minor-only', 'm', InputOption::VALUE_NONE, 'Show only packages that have minor SemVer-compatible updates.'), new InputOption('patch-only', 'p', InputOption::VALUE_NONE, 'Show only packages that have patch SemVer-compatible updates.'), + new InputOption('sort-by-age', 'A', InputOption::VALUE_NONE, 'Displays the installed version\'s age, and sorts packages oldest first.'), new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text', ['json', 'text']), new InputOption('ignore', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore specified package(s). Use it if you don\'t want to be informed about new versions of some packages.', null, $this->suggestInstalledPackage(false)), new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables search in require-dev packages.'), @@ -97,6 +98,9 @@ EOT if ($input->getOption('no-dev')) { $args['--no-dev'] = true; } + if ($input->getOption('sort-by-age')) { + $args['--sort-by-age'] = true; + } $args['--ignore-platform-req'] = $input->getOption('ignore-platform-req'); if ($input->getOption('ignore-platform-reqs')) { $args['--ignore-platform-reqs'] = true; diff --git a/src/Composer/Command/ShowCommand.php b/src/Composer/Command/ShowCommand.php index 3e010fc31..c7d1ae9c1 100644 --- a/src/Composer/Command/ShowCommand.php +++ b/src/Composer/Command/ShowCommand.php @@ -98,6 +98,7 @@ class ShowCommand extends BaseCommand new InputOption('major-only', 'M', InputOption::VALUE_NONE, 'Show only packages that have major SemVer-compatible updates. Use with the --latest or --outdated option.'), new InputOption('minor-only', 'm', InputOption::VALUE_NONE, 'Show only packages that have minor SemVer-compatible updates. Use with the --latest or --outdated option.'), new InputOption('patch-only', null, InputOption::VALUE_NONE, 'Show only packages that have patch SemVer-compatible updates. Use with the --latest or --outdated option.'), + new InputOption('sort-by-age', 'A', InputOption::VALUE_NONE, 'Displays the installed version\'s age, and sorts packages oldest first. Use with the --latest or --outdated option.'), new InputOption('direct', 'D', InputOption::VALUE_NONE, 'Shows only packages that are directly required by the root package'), new InputOption('strict', null, InputOption::VALUE_NONE, 'Return a non-zero exit code when there are outdated packages'), new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text', ['json', 'text']), @@ -450,7 +451,7 @@ EOT if (isset($packages[$type])) { ksort($packages[$type]); - $nameLength = $versionLength = $latestLength = 0; + $nameLength = $versionLength = $latestLength = $releaseDateLength = 0; if ($showLatest && $showVersion) { foreach ($packages[$type] as $package) { @@ -469,9 +470,20 @@ EOT $writeVersion = !$input->getOption('name-only') && !$input->getOption('path') && $showVersion; $writeLatest = $writeVersion && $showLatest; $writeDescription = !$input->getOption('name-only') && !$input->getOption('path'); + $writeReleaseDate = $writeLatest && $input->getOption('sort-by-age'); $hasOutdatedPackages = false; + if ($input->getOption('sort-by-age')) { + usort($packages[$type], function ($a, $b) { + if (is_object($a) && is_object($b)) { + return $a->getReleaseDate() <=> $b->getReleaseDate(); + } + + return 0; + }); + } + $viewData[$type] = []; foreach ($packages[$type] as $package) { $packageViewData = []; @@ -505,6 +517,17 @@ EOT $packageViewData['version'] = $package->getFullPrettyVersion(); $versionLength = max($versionLength, strlen($package->getFullPrettyVersion())); } + if ($writeReleaseDate) { + if ($package->getReleaseDate() !== null) { + $packageViewData['release-age'] = str_replace(' ago', ' old', $this->getRelativeTime($package->getReleaseDate())); + if (!str_contains($packageViewData['release-age'], ' old')) { + $packageViewData['release-age'] = 'from '.$packageViewData['release-age']; + } + $releaseDateLength = max($releaseDateLength, strlen($packageViewData['release-age'])); + } else { + $packageViewData['release-age'] = ''; + } + } if ($writeLatest && $latestPackage) { $packageViewData['latest'] = $latestPackage->getFullPrettyVersion(); $packageViewData['latest-status'] = $this->getUpdateStatus($latestPackage, $package); @@ -552,7 +575,9 @@ EOT 'nameLength' => $nameLength, 'versionLength' => $versionLength, 'latestLength' => $latestLength, + 'releaseDateLength' => $releaseDateLength, 'writeLatest' => $writeLatest, + 'writeReleaseDate' => $writeReleaseDate, ]; if ($input->getOption('strict') && $hasOutdatedPackages) { $exitCode = 1; @@ -588,11 +613,14 @@ EOT $nameLength = $viewMetaData[$type]['nameLength']; $versionLength = $viewMetaData[$type]['versionLength']; $latestLength = $viewMetaData[$type]['latestLength']; + $releaseDateLength = $viewMetaData[$type]['releaseDateLength']; $writeLatest = $viewMetaData[$type]['writeLatest']; + $writeReleaseDate = $viewMetaData[$type]['writeReleaseDate']; $versionFits = $nameLength + $versionLength + 3 <= $width; $latestFits = $nameLength + $versionLength + $latestLength + 3 <= $width; - $descriptionFits = $nameLength + $versionLength + $latestLength + 24 <= $width; + $releaseDateFits = $nameLength + $versionLength + $latestLength + $releaseDateLength + 3 <= $width; + $descriptionFits = $nameLength + $versionLength + $latestLength + $releaseDateLength + 24 <= $width; if ($latestFits && !$io->isDecorated()) { $latestLength += 2; @@ -620,14 +648,14 @@ EOT $io->writeError(''); $io->writeError('Direct dependencies required in composer.json:'); if (\count($directDeps) > 0) { - $this->printPackages($io, $directDeps, $indent, $writeVersion && $versionFits, $latestFits, $writeDescription && $descriptionFits, $width, $versionLength, $nameLength, $latestLength); + $this->printPackages($io, $directDeps, $indent, $writeVersion && $versionFits, $latestFits, $writeDescription && $descriptionFits, $width, $versionLength, $nameLength, $latestLength, $writeReleaseDate && $releaseDateFits, $releaseDateLength); } else { $io->writeError('Everything up to date'); } $io->writeError(''); $io->writeError('Transitive dependencies not required in composer.json:'); if (\count($transitiveDeps) > 0) { - $this->printPackages($io, $transitiveDeps, $indent, $writeVersion && $versionFits, $latestFits, $writeDescription && $descriptionFits, $width, $versionLength, $nameLength, $latestLength); + $this->printPackages($io, $transitiveDeps, $indent, $writeVersion && $versionFits, $latestFits, $writeDescription && $descriptionFits, $width, $versionLength, $nameLength, $latestLength, $writeReleaseDate && $releaseDateFits, $releaseDateLength); } else { $io->writeError('Everything up to date'); } @@ -635,7 +663,7 @@ EOT if ($writeLatest && \count($packages) === 0) { $io->writeError('All your direct dependencies are up to date'); } else { - $this->printPackages($io, $packages, $indent, $writeVersion && $versionFits, $writeLatest && $latestFits, $writeDescription && $descriptionFits, $width, $versionLength, $nameLength, $latestLength); + $this->printPackages($io, $packages, $indent, $writeVersion && $versionFits, $writeLatest && $latestFits, $writeDescription && $descriptionFits, $width, $versionLength, $nameLength, $latestLength, $writeReleaseDate && $releaseDateFits, $releaseDateLength); } } @@ -651,11 +679,12 @@ EOT /** * @param array $packages */ - private function printPackages(IOInterface $io, array $packages, string $indent, bool $writeVersion, bool $writeLatest, bool $writeDescription, int $width, int $versionLength, int $nameLength, int $latestLength): void + private function printPackages(IOInterface $io, array $packages, string $indent, bool $writeVersion, bool $writeLatest, bool $writeDescription, int $width, int $versionLength, int $nameLength, int $latestLength, bool $writeReleaseDate, int $releaseDateLength): void { - $padName = $writeVersion || $writeLatest || $writeDescription; - $padVersion = $writeLatest || $writeDescription; - $padLatest = $writeDescription; + $padName = $writeVersion || $writeLatest || $writeReleaseDate || $writeDescription; + $padVersion = $writeLatest || $writeReleaseDate || $writeDescription; + $padLatest = $writeDescription || $writeReleaseDate; + $padReleaseDate = $writeDescription; foreach ($packages as $package) { $link = $package['source'] ?? $package['homepage'] ?? ''; if ($link !== '') { @@ -674,10 +703,13 @@ EOT $latestVersion = str_replace(['up-to-date', 'semver-safe-update', 'update-possible'], ['=', '!', '~'], $updateStatus) . ' ' . $latestVersion; } $io->write(' <' . $style . '>' . str_pad($latestVersion, ($padLatest ? $latestLength : 0), ' ') . '', false); + if ($writeReleaseDate && isset($package['release-age'])) { + $io->write(' '.str_pad($package['release-age'], ($padReleaseDate ? $releaseDateLength : 0), ' '), false); + } } if (isset($package['description']) && $writeDescription) { $description = strtok($package['description'], "\r\n"); - $remaining = $width - $nameLength - $versionLength - 4; + $remaining = $width - $nameLength - $versionLength - $releaseDateLength - 4; if ($writeLatest) { $remaining -= $latestLength; } @@ -806,14 +838,20 @@ EOT */ protected function printMeta(CompletePackageInterface $package, array $versions, InstalledRepository $installedRepo, ?PackageInterface $latestPackage = null): void { + $isInstalledPackage = !PlatformRepository::isPlatformPackage($package->getName()) && $installedRepo->hasPackage($package); + $io = $this->getIO(); $io->write('name : ' . $package->getPrettyName()); $io->write('descrip. : ' . $package->getDescription()); $io->write('keywords : ' . implode(', ', $package->getKeywords() ?: [])); $this->printVersions($package, $versions, $installedRepo); + if ($isInstalledPackage && $package->getReleaseDate() !== null) { + $io->write('released : ' . $package->getReleaseDate()->format('Y-m-d') . ', ' . $this->getRelativeTime($package->getReleaseDate())); + } if ($latestPackage) { $style = $this->getVersionStyle($latestPackage, $package); - $io->write('latest : <'.$style.'>' . $latestPackage->getPrettyVersion() . ''); + $releasedTime = $latestPackage->getReleaseDate() === null ? '' : ' released ' . $latestPackage->getReleaseDate()->format('Y-m-d') . ', ' . $this->getRelativeTime($latestPackage->getReleaseDate()); + $io->write('latest : <'.$style.'>' . $latestPackage->getPrettyVersion() . '' . $releasedTime); } else { $latestPackage = $package; } @@ -822,7 +860,7 @@ EOT $io->write('homepage : ' . $package->getHomepage()); $io->write('source : ' . sprintf('[%s] %s %s', $package->getSourceType(), $package->getSourceUrl(), $package->getSourceReference())); $io->write('dist : ' . sprintf('[%s] %s %s', $package->getDistType(), $package->getDistUrl(), $package->getDistReference())); - if (!PlatformRepository::isPlatformPackage($package->getName()) && $installedRepo->hasPackage($package)) { + if ($isInstalledPackage) { $path = $this->requireComposer()->getInstallationManager()->getInstallPath($package); if (is_string($path)) { $io->write('path : ' . realpath($path)); @@ -993,6 +1031,10 @@ EOT } else { $json['path'] = null; } + + if ($package->getReleaseDate() !== null) { + $json['released'] = $package->getReleaseDate()->format(DATE_ATOM); + } } if ($latestPackage instanceof CompletePackageInterface && $latestPackage->isAbandoned()) { @@ -1447,4 +1489,30 @@ EOT return $this->repositorySet; } + + private function getRelativeTime(\DateTimeInterface $releaseDate): string + { + if ($releaseDate->format('Ymd') === date('Ymd')) { + return 'today'; + } + + $diff = $releaseDate->diff(new \DateTimeImmutable()); + if ($diff->days < 7) { + return 'this week'; + } + + if ($diff->days < 14) { + return 'last week'; + } + + if ($diff->m < 1 && $diff->days < 31) { + return floor($diff->days / 7) . ' weeks ago'; + } + + if ($diff->y < 1) { + return $diff->m . ' month' . ($diff->m > 1 ? 's' : '') . ' ago'; + } + + return $diff->y . ' year' . ($diff->y > 1 ? 's' : '') . ' ago'; + } } diff --git a/tests/Composer/Test/Command/ShowCommandTest.php b/tests/Composer/Test/Command/ShowCommandTest.php index 9601205d9..b2b561bd3 100644 --- a/tests/Composer/Test/Command/ShowCommandTest.php +++ b/tests/Composer/Test/Command/ShowCommandTest.php @@ -17,6 +17,7 @@ use Composer\Pcre\Preg; use Composer\Pcre\Regex; use Composer\Repository\PlatformRepository; use Composer\Test\TestCase; +use DateTimeImmutable; class ShowCommandTest extends TestCase { @@ -55,13 +56,14 @@ class ShowCommandTest extends TestCase $pkg = self::getPackage('vendor/package', '1.0.0'); $pkg->setDescription('description of installed package'); + $major = self::getPackage('outdated/major', '1.0.0'); + $major->setReleaseDate(new DateTimeImmutable()); + $minor = self::getPackage('outdated/minor', '1.0.0'); + $minor->setReleaseDate(new DateTimeImmutable('-2 years')); + $patch = self::getPackage('outdated/patch', '1.0.0'); + $patch->setReleaseDate(new DateTimeImmutable('-2 weeks')); - $this->createInstalledJson([ - $pkg, - self::getPackage('outdated/major', '1.0.0'), - self::getPackage('outdated/minor', '1.0.0'), - self::getPackage('outdated/patch', '1.0.0'), - ]); + $this->createInstalledJson([$pkg, $major, $minor, $patch]); $appTester = $this->getApplicationTester(); $appTester->run(array_merge(['command' => 'show'], $command)); @@ -112,6 +114,21 @@ outdated/minor 1.0.0 ! 1.1.1 outdated/patch 1.0.0 ! 1.0.1', ]; + yield 'outdated deps sorting by age' => [ + ['command' => 'outdated', '--sort-by-age' => true], +'Legend: +! patch or minor release available - update recommended +~ major release available - update possible + +Direct dependencies required in composer.json: +Everything up to date + +Transitive dependencies not required in composer.json: +outdated/minor 1.0.0 ! 1.1.1 2 years old +outdated/patch 1.0.0 ! 1.0.1 2 weeks old +outdated/major 1.0.0 ~ 2.0.0 from today', + ]; + yield 'outdated deps with --direct only show direct deps with updated' => [ ['command' => 'outdated', '--direct' => true], 'Legend: @@ -533,7 +550,7 @@ OUTPUT; public function testSelf(): void { - $this->initTempComposer(['name' => 'vendor/package']); + $this->initTempComposer(['name' => 'vendor/package', 'time' => date('Y-m-d')]); $appTester = $this->getApplicationTester(); $appTester->run(['command' => 'show', '--self' => true]); @@ -542,6 +559,7 @@ OUTPUT; 'descrip.' => '', 'keywords' => '', 'versions' => '* 1.0.0+no-version-set', + 'released' => date('Y-m-d'). ', today', 'type' => 'library', 'homepage' => '', 'source' => '[] ',