{node}{node|short}{p1node}{p1node|short}{p1node}{p1node|short}{author|person}{author|email}{date|rfc822date}{author|person}{author|email}{date|rfc822date}"';
// Mercurial does not support ISO 8601 properly
public const MERCURIAL_DATE_FORMAT = 'Y-m-d H:i:s';
protected ?string $path;
public function __construct(string $path = null)
{
if (!$path) {
$path = (new ExecutableFinder())->find('hg', '/usr/bin/hg');
}
$this->path = $path;
}
public function isValidRepository(Repository $repository): bool
{
$path = $repository->getPath();
return file_exists($path) && (file_exists($path . '/.hg'));
}
public function getDescription(Repository $repository): string
{
$path = $repository->getPath();
if (file_exists($path . '/.hg/hgrc')) {
$hgrc = parse_ini_file($path . '/.hg/hgrc');
return $hgrc['description'] ?? '';
}
return '';
}
public function getDefaultBranch(Repository $repository): string
{
return 'default';
}
public function getBranches(Repository $repository): array
{
$output = $this->run(['heads', '-T {bookmarks}||{node}\n'], $repository);
$branchData = explode("\n", $output);
$branches = [];
foreach ($branchData as $branchItem) {
if (empty($branchItem)) {
continue;
}
$branchInfo = explode('||', $branchItem);
$commit = $this->getCommit($repository, trim($branchInfo[1]));
$branches[] = new Branch($repository, trim($branchInfo[0]), $commit);
}
return $branches;
}
public function getTags(Repository $repository): array
{
$output = $this->run(['tags', '-T', '{tag}||{node}||{node|short}||{author|person}||{author|email}||{date|rfc822date}||{desc|firstline}\n'], $repository);
$tagData = explode("\n", $output);
$tags = [];
foreach ($tagData as $tagItem) {
if (empty($tagItem)) {
continue;
}
$tagInfo = explode('||', $tagItem);
if (!isset($tagInfo[0])) {
continue;
}
$author = new Person($tagInfo[3], $tagInfo[4]);
$authoredAt = new CarbonImmutable($tagInfo[5]);
$tag = new Tag($repository, $tagInfo[0], $author, $authoredAt);
if (isset($tagInfo[1])) {
$commit = new Commit($repository, $tagInfo[1], $tagInfo[2] ?? null);
$tag->setTarget($commit);
}
if (isset($tagInfo[6])) {
$tag->setSubject($tagInfo[6]);
}
$tags[] = $tag;
}
return $tags;
}
public function getTree(Repository $repository, ?string $hash = 'tip'): Tree
{
$output = $this->run(['manifest', '-v', '--debug', '-r', $hash], $repository);
return $this->buildTree($repository, $hash, $output);
}
public function getRecursiveTree(Repository $repository, ?string $hash = 'tip'): Tree
{
$output = $this->run(['manifest', '-v', '--debug', '-r', $hash], $repository);
return $this->buildTree($repository, $hash, $output);
}
public function getPathTree(Repository $repository, string $path, ?string $hash = 'tip'): Tree
{
// Mercurial manifest doesn't seem to support path specification, so we filter here
$tree = $this->getTree($repository, $hash);
foreach ($tree->getChildren() as $child) {
if (str_starts_with($child->getName(), $path)) {
continue;
}
$tree->removeChild($child);
}
return $tree;
}
public function getCommit(Repository $repository, ?string $hash = 'tip'): Commit
{
$commitOutput = $this->run(['log', self::DEFAULT_COMMIT_FORMAT, '-r', $hash], $repository);
$commits = $this->parseCommitDataXml($repository, $commitOutput);
$commit = reset($commits);
$diffOutput = $this->run(['diff', '--change', $hash], $repository);
$commit->setRawDiffs($diffOutput);
$fileDiffs = (new Parse())->fromRawBlock($diffOutput);
$commit->setDiffs($fileDiffs);
return $commit;
}
public function getCommits(Repository $repository, ?string $hash = 'tip', int $page = 1, int $perPage = 10): array
{
$range = sprintf('limit(branch("%s"), %d, %d)', $hash, $page * $perPage, ($page - 1) * $perPage);
$output = $this->run([
'log',
self::DEFAULT_COMMIT_FORMAT,
'-r',
$range,
], $repository);
return $this->parseCommitDataXml($repository, $output);
}
public function getCommitsFromPath(Repository $repository, string $path, ?string $hash = 'tip', int $page = 1, int $perPage = 10): array
{
$range = sprintf('limit(branch("%s"), %d, %d)', $hash, $page * $perPage, ($page - 1) * $perPage);
$output = $this->run([
'log',
self::DEFAULT_COMMIT_FORMAT,
'-r',
$range,
$path,
], $repository);
return $this->parseCommitDataXml($repository, $output);
}
public function getSpecificCommits(Repository $repository, array $hashes): array
{
$output = $this->run(['log', self::DEFAULT_COMMIT_FORMAT, '-r', implode(':', $hashes)], $repository);
return $this->parseCommitDataXml($repository, $output);
}
public function getBlame(Repository $repository, string $hash, string $path): Blame
{
$output = $this->run(['annotate', '-cv', '-r', $hash, $path], $repository);
$blameLines = explode(PHP_EOL, $output);
$annotatedLines = [];
$commits = [];
foreach ($blameLines as $blameLine) {
if (empty($blameLine)) {
continue;
}
$commit = substr($blameLine, 0, 12);
$line = substr($blameLine, 14);
$commits[] = $commit;
$annotatedLines[] = [
'commit' => $commit,
'line' => $line,
];
}
$blame = new Blame($hash, $path);
$commits = $this->getSpecificCommits($repository, array_unique($commits));
foreach ($annotatedLines as $annotatedLine) {
$commit = $commits[$annotatedLine['commit']];
$blame->addAnnotatedLine(new AnnotatedLine($commit, $annotatedLine['line']));
}
return $blame;
}
public function getBlob(Repository $repository, string $hash, string $path): Blob
{
$output = $this->run(['cat', '-r', $hash, $path], $repository);
$blob = new Blob($repository, $hash);
$blob->setName(basename($path));
$blob->setContents($output);
return $blob;
}
public function searchCommits(Repository $repository, Criteria $criteria, ?string $hash = 'tip'): array
{
$command = ['log', self::DEFAULT_COMMIT_FORMAT];
$commits = [];
if ($criteria->getFrom() && $criteria->getTo()) {
$command[] = '--date';
$command[] = sprintf(
'%s to %s',
$criteria->getFrom()->format(self::MERCURIAL_DATE_FORMAT),
$criteria->getTo()->format(self::MERCURIAL_DATE_FORMAT)
);
}
if ($criteria->getFrom() && !$criteria->getTo()) {
$command[] = '--date';
$command[] = '>' . $criteria->getFrom()->format(self::MERCURIAL_DATE_FORMAT);
}
if (!$criteria->getFrom() && $criteria->getTo()) {
$command[] = '--date';
$command[] = '<' . $criteria->getTo()->format(self::MERCURIAL_DATE_FORMAT);
}
if ($criteria->getAuthor()) {
$command[] = '--user';
$command[] = $criteria->getAuthor();
}
if ($criteria->getMessage()) {
$command[] = '--keyword';
$command[] = $criteria->getMessage();
}
$command[] = '-r';
$command[] = sprintf('sort(branch("%s"), -date)', $hash);
$output = $this->run($command, $repository);
$commits += $this->parseCommitDataXml($repository, $output);
return $commits;
}
public function archive(Repository $repository, string $format, string $hash, string $path = ''): string
{
$destination = sprintf('%s/%s.%s', sys_get_temp_dir(), $hash, $format);
$this->run(['archive', '-r', $hash, '-I', $path, $destination], $repository);
return $destination;
}
protected function run(array $command, Repository $repository = null): string
{
array_unshift($command, $this->path);
$process = new Process($command);
$process->setTimeout(self::DEFAULT_TIMEOUT);
if ($repository) {
$process->setWorkingDirectory($repository->getPath());
}
try {
$process->mustRun();
} catch (ProcessFailedException $exception) {
throw new CommandException($exception->getProcess()->getErrorOutput());
}
return $process->getOutput();
}
protected function parseCommitDataXml(Repository $repository, string $input): array
{
$items = new SimpleXMLElement('' . $input . '');
$commits = [];
foreach ($items as $item) {
$commit = new Commit($repository, (string) $item->hash, (string) $item->short_hash);
$commit->setTree(new Tree($repository, (string) $item->tree, (string) $item->short_tree));
$parents = explode(' ', (string) $item->parent);
$shortParents = explode(' ', (string) $item->short_parent);
foreach ($parents as $key => $parent) {
$commit->addParent(new Commit($repository, $parent, $shortParents[$key] ?? null));
}
$commit->setSubject((string) $item->subject);
$commit->setBody((string) $item->body);
$commit->setAuthor(new Person((string) $item->author, (string) $item->author_email));
$commit->setAuthoredAt(new CarbonImmutable((string) $item->author_date));
$commit->setCommiter(new Person((string) $item->commiter, (string) $item->commiter_email));
$commit->setCommitedAt(new CarbonImmutable((string) $item->commiter_date));
$commits[(string) $item->short_hash] = $commit;
}
return $commits;
}
protected function buildTree(Repository $repository, string $hash, string $output): Tree
{
$lines = explode("\n", $output);
$root = new Tree($repository, $hash);
foreach ($lines as $line) {
if (empty($line)) {
continue;
}
$file = preg_split('/[\s]+/', $line, 4);
if ($file[2] == '.hgtags') {
continue;
}
if ($file[2] == '@') {
$symlinkTarget = $this->run(['cat', '-r', $hash, $file[3]], $repository);
$symlink = new Symlink($repository, $file[0]);
$symlink->setMode($file[1]);
$symlink->setName($file[3]);
$symlink->setSize(0);
$symlink->setTarget($symlinkTarget);
$root->addChild($symlink);
continue;
}
$blob = new Blob($repository, $file[0]);
$blob->setMode($file[1]);
$blob->setName($file[2]);
$blob->setSize(0);
$root->addChild($blob);
}
return $root;
}
}