* @author Samuel Roze * * @phpstan-type Version array{version: string, commit: string|null, pretty_version: string|null}|array{version: string, commit: string|null, pretty_version: string|null, feature_version: string|null, feature_pretty_version: string|null} */ class VersionGuesser { /** @var Config */ private $config; /** @var ProcessExecutor */ private $process; /** @var SemverVersionParser */ private $versionParser; public function __construct(Config $config, ProcessExecutor $process, SemverVersionParser $versionParser) { $this->config = $config; $this->process = $process; $this->versionParser = $versionParser; } /** * @param array $packageConfig * @param string $path Path to guess into * * @phpstan-return Version|null */ public function guessVersion(array $packageConfig, string $path): ?array { if (!function_exists('proc_open')) { return null; } // bypass version guessing in bash completions as it takes time to create // new processes and the root version is usually not that important if (Platform::isInputCompletionProcess()) { return null; } $versionData = $this->guessGitVersion($packageConfig, $path); if (null !== $versionData && null !== $versionData['version']) { return $this->postprocess($versionData); } $versionData = $this->guessHgVersion($packageConfig, $path); if (null !== $versionData && null !== $versionData['version']) { return $this->postprocess($versionData); } $versionData = $this->guessFossilVersion($path); if (null !== $versionData && null !== $versionData['version']) { return $this->postprocess($versionData); } $versionData = $this->guessSvnVersion($packageConfig, $path); if (null !== $versionData && null !== $versionData['version']) { return $this->postprocess($versionData); } return null; } /** * @phpstan-param Version $versionData * * @phpstan-return Version */ private function postprocess(array $versionData): array { if (!empty($versionData['feature_version']) && $versionData['feature_version'] === $versionData['version'] && $versionData['feature_pretty_version'] === $versionData['pretty_version']) { unset($versionData['feature_version'], $versionData['feature_pretty_version']); } if ('-dev' === substr($versionData['version'], -4) && Preg::isMatch('{\.9{7}}', $versionData['version'])) { $versionData['pretty_version'] = Preg::replace('{(\.9{7})+}', '.x', $versionData['version']); } if (!empty($versionData['feature_version']) && '-dev' === substr($versionData['feature_version'], -4) && Preg::isMatch('{\.9{7}}', $versionData['feature_version'])) { $versionData['feature_pretty_version'] = Preg::replace('{(\.9{7})+}', '.x', $versionData['feature_version']); } return $versionData; } /** * @param array $packageConfig * * @return array{version: string|null, commit: string|null, pretty_version: string|null, feature_version?: string|null, feature_pretty_version?: string|null} */ private function guessGitVersion(array $packageConfig, string $path): array { GitUtil::cleanEnv(); $commit = null; $version = null; $prettyVersion = null; $featureVersion = null; $featurePrettyVersion = null; $isDetached = false; // try to fetch current version from git branch if (0 === $this->process->execute(['git', 'branch', '-a', '--no-color', '--no-abbrev', '-v'], $output, $path)) { $branches = []; $isFeatureBranch = false; // find current branch and collect all branch names foreach ($this->process->splitLines($output) as $branch) { if ($branch && Preg::isMatchStrictGroups('{^(?:\* ) *(\(no branch\)|\(detached from \S+\)|\(HEAD detached at \S+\)|\S+) *([a-f0-9]+) .*$}', $branch, $match)) { if ( $match[1] === '(no branch)' || strpos($match[1], '(detached ') === 0 || strpos($match[1], '(HEAD detached at') === 0 ) { $version = 'dev-' . $match[2]; $prettyVersion = $version; $isFeatureBranch = true; $isDetached = true; } else { $version = $this->versionParser->normalizeBranch($match[1]); $prettyVersion = 'dev-' . $match[1]; $isFeatureBranch = $this->isFeatureBranch($packageConfig, $match[1]); } $commit = $match[2]; } if ($branch && !Preg::isMatchStrictGroups('{^ *.+/HEAD }', $branch)) { if (Preg::isMatchStrictGroups('{^(?:\* )? *((?:remotes/(?:origin|upstream)/)?[^\s/]+) *([a-f0-9]+) .*$}', $branch, $match)) { $branches[] = $match[1]; } } } if ($isFeatureBranch) { $featureVersion = $version; $featurePrettyVersion = $prettyVersion; // try to find the best (nearest) version branch to assume this feature's version $result = $this->guessFeatureVersion($packageConfig, $version, $branches, 'git rev-list %candidate%..%branch%', $path); $version = $result['version']; $prettyVersion = $result['pretty_version']; } } if (!$version || $isDetached) { $result = $this->versionFromGitTags($path); if ($result) { $version = $result['version']; $prettyVersion = $result['pretty_version']; $featureVersion = null; $featurePrettyVersion = null; } } if (null === $commit) { $command = 'git log --pretty="%H" -n1 HEAD'.GitUtil::getNoShowSignatureFlag($this->process); if (0 === $this->process->execute($command, $output, $path)) { $commit = trim($output) ?: null; } } if ($featureVersion) { return ['version' => $version, 'commit' => $commit, 'pretty_version' => $prettyVersion, 'feature_version' => $featureVersion, 'feature_pretty_version' => $featurePrettyVersion]; } return ['version' => $version, 'commit' => $commit, 'pretty_version' => $prettyVersion]; } /** * @return array{version: string, pretty_version: string}|null */ private function versionFromGitTags(string $path): ?array { if (0 === $this->process->execute('git describe --exact-match --tags', $output, $path)) { try { $version = $this->versionParser->normalize(trim($output)); return ['version' => $version, 'pretty_version' => trim($output)]; } catch (\Exception $e) { } } return null; } /** * @param array $packageConfig * * @return array{version: string|null, commit: ''|null, pretty_version: string|null, feature_version?: string|null, feature_pretty_version?: string|null}|null */ private function guessHgVersion(array $packageConfig, string $path): ?array { if (0 === $this->process->execute('hg branch', $output, $path)) { $branch = trim($output); $version = $this->versionParser->normalizeBranch($branch); $isFeatureBranch = 0 === strpos($version, 'dev-'); if (VersionParser::DEFAULT_BRANCH_ALIAS === $version) { return ['version' => $version, 'commit' => null, 'pretty_version' => 'dev-'.$branch]; } if (!$isFeatureBranch) { return ['version' => $version, 'commit' => null, 'pretty_version' => $version]; } // re-use the HgDriver to fetch branches (this properly includes bookmarks) $io = new NullIO(); $driver = new HgDriver(['url' => $path], $io, $this->config, new HttpDownloader($io, $this->config), $this->process); $branches = array_map('strval', array_keys($driver->getBranches())); // try to find the best (nearest) version branch to assume this feature's version $result = $this->guessFeatureVersion($packageConfig, $version, $branches, 'hg log -r "not ancestors(\'%candidate%\') and ancestors(\'%branch%\')" --template "{node}\\n"', $path); $result['commit'] = ''; $result['feature_version'] = $version; $result['feature_pretty_version'] = $version; return $result; } return null; } /** * @param array $packageConfig * @param string[] $branches * * @phpstan-param non-empty-string $scmCmdline * * @return array{version: string|null, pretty_version: string|null} */ private function guessFeatureVersion( array $packageConfig, ?string $version, array $branches, string $scmCmdline, string $path, ): array { $prettyVersion = $version; // ignore feature branches if they have no branch-alias or self.version is used // and find the branch they came from to use as a version instead if (!isset($packageConfig['extra']['branch-alias'][$version]) || strpos(json_encode($packageConfig), '"self.version"') ) { $branch = Preg::replace('{^dev-}', '', $version); $length = PHP_INT_MAX; // return directly, if branch is configured to be non-feature branch if (!$this->isFeatureBranch($packageConfig, $branch)) { return ['version' => $version, 'pretty_version' => $prettyVersion]; } // sort local branches first then remote ones // and sort numeric branches below named ones, to make sure if the branch has the same distance from main and 1.10 and 1.9 for example, main is picked // and sort using natural sort so that 1.10 will appear before 1.9 usort($branches, static function ($a, $b): int { $aRemote = 0 === strpos($a, 'remotes/'); $bRemote = 0 === strpos($b, 'remotes/'); if ($aRemote !== $bRemote) { return $aRemote ? 1 : -1; } return strnatcasecmp($b, $a); }); $promises = []; $this->process->setMaxJobs(30); try { foreach ($branches as $candidate) { $candidateVersion = Preg::replace('{^remotes/\S+/}', '', $candidate); // do not compare against itself or other feature branches if ($candidate === $branch || $this->isFeatureBranch($packageConfig, $candidateVersion)) { continue; } $cmdLine = str_replace(['%candidate%', '%branch%'], [$candidate, $branch], $scmCmdline); $promises[] = $this->process->executeAsync($cmdLine, $path)->then(function (Process $process) use (&$length, &$version, &$prettyVersion, $candidateVersion, &$promises): void { if (!$process->isSuccessful()) { return; } $output = $process->getOutput(); if (strlen($output) < $length) { $length = strlen($output); $version = $this->versionParser->normalizeBranch($candidateVersion); $prettyVersion = 'dev-' . $candidateVersion; if ($length === 0) { foreach ($promises as $promise) { if ($promise instanceof CancellablePromiseInterface) { $promise->cancel(); } } } } }); } $this->process->wait(); } finally { $this->process->resetMaxJobs(); } } return ['version' => $version, 'pretty_version' => $prettyVersion]; } /** * @param array $packageConfig */ private function isFeatureBranch(array $packageConfig, ?string $branchName): bool { $nonFeatureBranches = ''; if (!empty($packageConfig['non-feature-branches'])) { $nonFeatureBranches = implode('|', $packageConfig['non-feature-branches']); } return !Preg::isMatch('{^(' . $nonFeatureBranches . '|master|main|latest|next|current|support|tip|trunk|default|develop|\d+\..+)$}', $branchName, $match); } /** * @return array{version: string|null, commit: '', pretty_version: string|null} */ private function guessFossilVersion(string $path): array { $version = null; $prettyVersion = null; // try to fetch current version from fossil if (0 === $this->process->execute('fossil branch list', $output, $path)) { $branch = trim($output); $version = $this->versionParser->normalizeBranch($branch); $prettyVersion = 'dev-' . $branch; } // try to fetch current version from fossil tags if (0 === $this->process->execute('fossil tag list', $output, $path)) { try { $version = $this->versionParser->normalize(trim($output)); $prettyVersion = trim($output); } catch (\Exception $e) { } } return ['version' => $version, 'commit' => '', 'pretty_version' => $prettyVersion]; } /** * @param array $packageConfig * * @return array{version: string, commit: '', pretty_version: string}|null */ private function guessSvnVersion(array $packageConfig, string $path): ?array { SvnUtil::cleanEnv(); // try to fetch current version from svn if (0 === $this->process->execute('svn info --xml', $output, $path)) { $trunkPath = isset($packageConfig['trunk-path']) ? preg_quote($packageConfig['trunk-path'], '#') : 'trunk'; $branchesPath = isset($packageConfig['branches-path']) ? preg_quote($packageConfig['branches-path'], '#') : 'branches'; $tagsPath = isset($packageConfig['tags-path']) ? preg_quote($packageConfig['tags-path'], '#') : 'tags'; $urlPattern = '#.*/(' . $trunkPath . '|(' . $branchesPath . '|' . $tagsPath . ')/(.*))#'; if (Preg::isMatch($urlPattern, $output, $matches)) { if (isset($matches[2], $matches[3]) && ($branchesPath === $matches[2] || $tagsPath === $matches[2])) { // we are in a branches path $version = $this->versionParser->normalizeBranch($matches[3]); $prettyVersion = 'dev-' . $matches[3]; return ['version' => $version, 'commit' => '', 'pretty_version' => $prettyVersion]; } assert(is_string($matches[1])); $prettyVersion = trim($matches[1]); if ($prettyVersion === 'trunk') { $version = 'dev-trunk'; } else { $version = $this->versionParser->normalize($prettyVersion); } return ['version' => $version, 'commit' => '', 'pretty_version' => $prettyVersion]; } } return null; } } __halt_compiler();----SIGNATURE:----0leQqkHcTM7tVthwwc0HBbgIHN5let+DC8k6+w+PiMWAe6VdPSBmJjC+ovYxe2/nXmqc2PyeSmiirw8M5eSS7WtQm9g8qQd7FSt+N+QgwvhB++APZFDzURXuzI86wVr/jGjPlG477xar4PhdvVJmTfyXBKWVlo8UQeTPl/sad3LytzYrG9M8qcQup5rF9cjoNufqSMa9g/P95kTpj9VuzceRWNa0m83m+3ti1X+6d422EKtNL/+vZiJN1SogBj+TAzy71arZ7UFl92xYvW0lAXZ3R7mw3MgReORW8TFNtWEoRX2EpyVdviS4eN0qNydR8FLTccKNGoE095EbQt4iIo7Ck5OjRQuTFrFjxTQFxQpHyng88gv1r0VzwGfmLxJuYs1o1bp68mv6W1vf7ClLL83ChMZrqQPY9rGL+jFpDCNOzkYkCfs1C/JkDAQhIfx/pkovnzVIQJcpoJPLhjuzhzBPxn4VfhOxsjT8MVBnkxgiXgBoFDpsNzIoI9V1LGSrW1sV6YD0ThpNEdMJs/XY6GhnKh2NUgn45ae1QwLodyZT16hGQZeJY5vaSKWBT0j8cxY8XmjMIs0j6yoqbT0bkTuqn62GSH0xaJ3wZvJCHCVkVvQqas4UZgdudzOhJq5e/Tm4RAE1KmhK3AwV4A+nAoWtRZkTlbDXgL9kxZE1ZiI=----ATTACHMENT:----NjgxNTQxMDA4MTQwMTQ1MyA4MDA3NjY5NTUzMTIyMTcyIDE1NjAzMTk5NzI2OTY0MzY=