src/Libs/VersionControl/Git.php
<?php
/**
* @copyright (c) Copyright by authors of the Tiki Manager Project. All Rights Reserved.
* See copyright.txt for details and a complete list of authors.
* @licence Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See LICENSE for details.
*/
namespace TikiManager\Libs\VersionControl;
use Psr\Log\LoggerInterface;
use Symfony\Component\Process\Process;
use TikiManager\Application\Exception\VcsException;
use TikiManager\Application\Instance;
use TikiManager\Application\Version;
use TikiManager\Libs\Host\Command;
class Git extends VersionControlSystem
{
protected const DEFAULT_GIT_REPOSITORY = 'https://gitlab.com/tikiwiki/tiki.git';
protected $isDefultRepository = false;
protected $command = 'git';
protected $quiet = true;
/**
* GIT constructor.
* @inheritDoc
*/
public function __construct(Instance $instance, array $vcsOptions = [], LoggerInterface $logger = null)
{
parent::__construct($instance, $vcsOptions, $logger);
$this->setRepositoryUrl($_ENV['GIT_TIKIWIKI_URI'] ?? self::DEFAULT_GIT_REPOSITORY);
$this->setSafeDirectory($instance);
}
public function setRepositoryUrl($url): void
{
$this->repositoryUrl = $url;
$this->isDefultRepository = ($url === self::DEFAULT_GIT_REPOSITORY);
}
/**
* Get available branches within the repository
* @return array
*/
public function getAvailableBranches()
{
$versions = [];
foreach (explode("\n", `git ls-remote --heads --tags --refs $this->repositoryUrl`) as $line) {
$parsed = explode("\t", $line);
if (!isset($parsed[1])) {
continue;
}
$line = trim($parsed[1]);
if (empty($line)) {
continue;
}
if (strpos($line, 'refs/heads/') !== false) {
if ($this->isDefultRepository) {
// For the main tiki repository only list master, versions (20.x, 19.x, 18.3) and experimental
// branches (example: experimental/acme).
if (!preg_match('/^refs\/heads\/(\d+\.(\d+|x)|master|experimental\/.+)$/', $line)) {
continue;
}
}
$versions[] = str_replace('refs/heads/', '', $line); // Example: branch/master
}
if (strpos($line, 'refs/tags/tags') !== false) {
$versions[] = str_replace('refs/tags/', '', $line); // Example: tags/tags/21.0
continue;
}
if (strpos($line, 'refs/tags/') !== false) {
$versions[] = str_replace('refs/', '', $line); // example: tags/19.x
}
}
sort($versions, SORT_NATURAL);
$sortedVersions = [];
foreach ($versions as $version) {
$sortedVersions[] = Version::buildFake('git', $version);
}
return $sortedVersions;
}
/**
* @param $targetFolder
* @return mixed|string
* @throws VcsException
*/
public function getRepositoryBranch($targetFolder)
{
return $this->info($targetFolder);
}
/**
* @param $targetFolder
* @param $fileName
* @return bool
*/
public function isFileVersioned($targetFolder, $fileName)
{
try {
$this->exec($targetFolder, "ls-files --error-unmatch $fileName");
} catch (VcsException $ex) {
return false;
}
return true;
}
/**
* @param $targetFolder
* @param $toAppend
* @return mixed|string
* @throws VcsException
*/
public function exec($targetFolder, $toAppend)
{
$command = sprintf('%s %s', $this->command, $toAppend);
if (!empty($targetFolder)) {
$command = sprintf('cd %s && ', $targetFolder) . $command;
}
if ($this->runLocally) {
$cmd = Process::fromShellCommandline($command, null, null, null, 1800); // 30min tops
$cmd->run();
$output = $cmd->getOutput();
$error = $cmd->getErrorOutput();
$exitCode = $cmd->getExitCode();
} else {
$commandInstance = new Command($command);
$result = $this->access->runCommand($commandInstance);
$output = $result->getStdoutContent();
$error = $result->getStderrContent();
$exitCode = $result->getReturn();
}
if ($exitCode !== 0) {
throw new VcsException($error);
}
if (empty($output) && ! empty($error)) {
$output = $error;
}
return rtrim($output, "\n");
}
public function clone($branchName, $targetFolder)
{
$branch = escapeshellarg($branchName);
$repoUrl = escapeshellarg($this->repositoryUrl);
$folder = escapeshellarg($targetFolder);
return $this->exec(null, sprintf('clone --depth 1 -b %s %s %s', $branch, $repoUrl, $folder));
}
/**
* @param $targetFolder
* @return mixed|string
* @throws VcsException
*/
public function revert($targetFolder)
{
$gitCmd = 'reset --hard' . ($this->quiet ? ' --quiet' : '');
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param $targetFolder
* @return mixed|string
* @throws VcsException
*/
public function pull($targetFolder)
{
$gitCmd = 'pull' . ($this->quiet ? ' --quiet' : '');
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param $targetFolder
* @return mixed|string
* @throws VcsException
*/
public function cleanup($targetFolder)
{
$gitCmd = 'gc' . ($this->quiet ? ' --quiet' : '');
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param $targetFolder
* @param $branch
* @param null $commitSHA
* @return mixed|string
* @throws VcsException
*/
public function merge($targetFolder, $branch, $commitSHA = null)
{
$gitCmd = "merge $branch";
$gitCmd .= $commitSHA ? " $commitSHA" : '';
$gitCmd .= $this->quiet ? ' --quiet' : '';
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param $targetFolder
* @param false $raw
* @return mixed|string
* @throws VcsException
*/
public function info($targetFolder, $raw = false)
{
$gitCmd = 'rev-parse --abbrev-ref HEAD' . ($this->quiet ? ' --quiet' : '');
$output = $this->exec($targetFolder, $gitCmd);
// When using tags, the HEAD is detached (this will calculate if it belongs to a tag)
if ($output == 'HEAD') {
$gitCmd = 'name-rev --name-only HEAD';
$output = $this->exec($targetFolder, $gitCmd);
if (preg_match('/^tags\\/[^\^\~]*/', $output, $matches)) {
$output = str_replace('tags/tags', 'tags', $matches[0]);
} else {
$output = 'HEAD';
}
}
return $output;
}
/**
* @param $targetFolder
* @param $url
* @param string $remote
* @return mixed|string
* @throws VcsException
*/
public function remoteSetUrl($targetFolder, $url, $remote = 'origin')
{
$gitCmd = sprintf('remote set-url %s %s', $remote, $url);
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param $targetFolder
* @param $branch
* @param string $remote
* @return mixed|string
* @throws VcsException
*/
public function remoteSetBranch($targetFolder, $branch, $remote = 'origin')
{
$gitCmd = sprintf('remote set-branches %s %s', $remote, $branch);
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param $targetFolder
* @return mixed|string
* @throws VcsException
*/
public function getRevision($targetFolder)
{
$gitCmd = 'rev-parse --short=8 --verify HEAD' . ($this->quiet ? ' --quiet' : '');
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param $targetFolder
* @param $commitId
* @return mixed|string
*
* @throws VcsException
*/
public function getDateRevision($targetFolder, $commitId)
{
$gitCmd = 'show -s --format=%cd --date=format:\'%Y-%m-%d\' '. $commitId;
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param $targetFolder
* @param $branch
* @param null $commitSHA
* @return mixed|string
* @throws VcsException
*/
public function checkoutBranch($targetFolder, $branch, $commitSHA = null)
{
$gitCmd = $commitSHA ? "checkout -B $branch $commitSHA" : "checkout $branch";
$gitCmd .= $this->quiet ? ' --quiet' : '';
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param $targetFolder
* @param $branch
* @param null $commitSHA
* @throws VcsException
*/
public function upgrade($targetFolder, $branch, $commitSHA = null): void
{
$stash = $this->canStash() &&
(count($this->getChangedFiles($targetFolder)) || count($this->getDeletedFiles($targetFolder)));
if ($stash) {
$this->stash($targetFolder);
}
$this->checkoutBranch($targetFolder, $branch, $commitSHA);
if ($stash) {
$this->stashPop($targetFolder);
}
$this->cleanup($targetFolder);
}
/**
* Update current instance's branch
* @param string $targetFolder
* @param string $branch
* @param int $lag The number of days
* @void
* @throws VcsException
*/
public function update(string $targetFolder, string $branch, int $lag = 0)
{
$commitSHA = null;
$fetchOptions = [];
$time = time() - $lag * 60 * 60 * 24;
$messageUpdate = "Updating '{$branch}' branch";
$branchInfo = $this->info($targetFolder);
$isUpgrade = $this->isUpgrade($branchInfo, $branch);
$isShallow = $this->isShallow($targetFolder);
if ($isUpgrade && $isShallow) {
$fetchOptions['--depth'] = 1;
}
$fetch = function () use ($targetFolder, $branch, $fetchOptions) {
$this->fetch($targetFolder, $branch, 'origin', $fetchOptions);
};
if ($isUpgrade) {
$messageUpdate = "Upgrading to '{$branch}' branch";
$this->remoteSetBranch($targetFolder, $branch);
}
if ($lag && $isShallow) {
$fetch = function () use ($targetFolder, $branch, $time) {
$gitVersion = $this->getVersion($targetFolder);
if (version_compare($gitVersion, '2.11.0', '<')) {
// LARGE NUMBER OF COMMITS as --shallow-since/--deepen is not supported
$this->fetch($targetFolder, $branch, 'origin', ['--depth' => 200]);
return;
}
$this->fetch($targetFolder, $branch, 'origin', ['--shallow-since' => date('Y-m-d H:i', $time)]);
$this->fetch($targetFolder, $branch, 'origin', ['--deepen' => 1]);
};
}
$fetch();
if ($lag) {
list('commit' => $commitSHA, 'date' => $date) = $this->getLastCommit($targetFolder, $branch, $time);
$messageUpdate .= " ({$commitSHA}) at {$date}";
}
$this->io->writeln($messageUpdate);
if ($isUpgrade) {
$this->upgrade($targetFolder, $branch, $commitSHA);
return;
}
$stash = $this->canStash() &&
(count($this->getChangedFiles($targetFolder)) || count($this->getDeletedFiles($targetFolder)));
if ($stash) {
// Cannot use --include-untracked (due to maintenance.php and .htaccess.bck)
$this->stash($targetFolder);
}
$commitSHA
? $this->checkoutBranch($targetFolder, $branch, $commitSHA)
: $this->pull($targetFolder);
if ($stash) {
$this->stashPop($targetFolder);
}
$this->cleanup($targetFolder);
}
/**
* @inheritDoc
*/
public function isUpgrade($current, $branch)
{
return $current !== $branch;
}
/**
* @param bool $quiet
*/
public function setQuiet(bool $quiet): void
{
$this->quiet = $quiet;
}
public function hasRemote($targetFolder, $branch)
{
$output = $this->exec(
$targetFolder,
'ls-remote --heads --exit-code origin ' . $branch
);
return !empty($output);
}
public function getChangedFiles($folder, $exclude = [])
{
$command = $this->command . ' ls-files --modified';
$command = sprintf('cd %s && %s', $folder, $command);
$commandInstance = new Command($command);
$result = $this->access->runCommand($commandInstance);
if ($result->getReturn() !== 0) {
throw new VcsException($result->getStderrContent());
}
$output = $result->getStdoutContent();
$output = trim($output);
return empty($output) ? [] : array_values(explode(PHP_EOL, $output));
}
public function getDeletedFiles($folder)
{
$command = $this->command . ' ls-files -d';
$command = sprintf('cd %s && %s', $folder, $command);
$commandInstance = new Command($command);
$result = $this->access->runCommand($commandInstance);
if ($result->getReturn() !== 0) {
throw new VcsException($result->getStderrContent());
}
$output = $result->getStdoutContent();
$output = trim($output);
return empty($output) ? [] : array_values(explode(PHP_EOL, $output));
}
public function getUntrackedFiles($folder, $includeIgnore = false)
{
$command = $this->command . ' ls-files --others';
if (!$includeIgnore) {
$command .= ' --exclude-standard';
}
$command = sprintf('cd %s && %s', $folder, $command);
$commandInstance = new Command($command);
$result = $this->access->runCommand($commandInstance);
if ($result->getReturn() !== 0) {
throw new VcsException($result->getStderrContent());
}
$output = $result->getStdoutContent();
$output = trim($output);
return empty($output) ? [] : array_values(explode(PHP_EOL, $output));
}
/**
* @param string $targetFolder
* @param string $branch
* @param array $options
* @return mixed|string
* @throws VcsException
*/
public function log(string $targetFolder, string $branch, array $options = [])
{
$logOptions = implode(' ', $options);
$gitCmd = "log $logOptions $branch";
return $this->exec($targetFolder, $gitCmd);
}
/**
* @param string $targetFolder
* @param string $branch
* @param int|null $timestamp
* @return array
* @throws VcsException
*/
public function getLastCommit(string $targetFolder, string $branch, $timestamp = null): array
{
$lag = date('Y-m-d H:i', $timestamp ?? time());
$options = [
'-1',
sprintf('--before=%s', escapeshellarg($lag))
];
$gitLog = $this->log($targetFolder, 'origin/' . $branch, $options);
if (!$gitLog) {
throw new VcsException('Git log returned with empty output');
}
if (!preg_match('/commit (\w+).*Date:\s+([^\\n]*)/s', $gitLog, $matches)) {
throw new VcsException('Unable to parse Git log output');
}
return [
'commit' => $matches[1],
'date' => $matches[2],
];
}
public function fetch($targetFolder, $branch = null, $remote = 'origin', $options = [])
{
$cmdOptions = [];
foreach ($options as $option => $value) {
$cmdOptions[] = $option . ($value ? '=' . escapeshellarg($value) : '');
}
$cmd = sprintf('fetch %s %s', $remote, $branch);
$cmd .= ' ' . implode(' ', $cmdOptions);
$cmd .= ($this->quiet ? ' --quiet' : '');
return $this->exec($targetFolder, $cmd);
}
public function isShallow($targetFolder): bool
{
$file = '.git/shallow';
if ($this->runLocally) {
return file_exists($targetFolder . '/' . $file);
}
return $this->access->fileExists($targetFolder . '/' . $file);
}
public function unshallow($targetFolder)
{
if (! $this->isShallow($targetFolder)) {
return;
}
return $this->exec($targetFolder, "fetch --unshallow");
}
public function getVersion($targetFolder): string
{
$version = $this->exec($targetFolder, '--version');
preg_match('/[\d\.]+/', $version, $matches);
return $matches ? $matches[0] : '';
}
public function stash(string $targetFolder, bool $includeNonTracked = false)
{
$cmd = 'stash';
$cmd .= ($includeNonTracked ? ' --include-untracked' : '');
$cmd .= ($this->quiet ? ' --quiet' : '');
return $this->exec($targetFolder, $cmd);
}
/**
* @throws VcsException
*/
public function stashPop(string $targetFolder, bool $revertOnFailure = true)
{
$cmd = 'stash pop';
$cmd .= ($this->quiet ? ' --quiet' : '');
try {
return $this->exec($targetFolder, $cmd);
} catch (\Exception $e) {
if (!$revertOnFailure) {
throw $e;
}
// Because Tiki Manager stashes with --include-untracked
// in case of failure, reset --hard can be used.
$this->logger->error('Failed to apply stash@{0}.', [
'path' => $targetFolder,
'exception' => $e,
]);
$this->logger->notice('Reverting stashed changes...');
$this->revert($targetFolder);
return false;
}
}
/**
* @return bool
*/
protected function canStash(): bool
{
return $this->vcsOptions['allow_stash'] ?? false;
}
/**
* Set safe.directory in git global config
*
* @param Instance $instance
* @return null
*/
private function setSafeDirectory($instance)
{
$skipSafeDir = isset($_ENV['GIT_DONT_ADD_SAFEDIR']) ? (bool) $_ENV['GIT_DONT_ADD_SAFEDIR'] : false;
if ($skipSafeDir) {
return; // return early if we should not process safedir
}
$cacheTimestamp = 0;
$cacheFile = $_ENV['CACHE_FOLDER'] . '/' . $instance->name . '.txt';
if (file_exists($cacheFile)) {
$cacheTimestamp = (int) file_get_contents($cacheFile);
}
$gitConfigFileTimestamp = 0;
// From https://git-scm.com/docs/git-config#FILES
$envHome = $_SERVER['HOME'] ?? (getenv('HOME') ?: '');
$envXdgConfigHome = $_SERVER['XDG_CONFIG_HOME'] ?? (getenv('XDG_CONFIG_HOME') ?: '');
if (empty($envXdgConfigHome)) {
$envXdgConfigHome = $envHome . '/.config' ;
}
$possibleGlobalConfigFiles = [
$envXdgConfigHome . '/git/config',
$envHome . '/.gitconfig',
];
foreach ($possibleGlobalConfigFiles as $gitConfigFile) {
if (file_exists($gitConfigFile)) {
$gitConfigFileTimestamp = max($gitConfigFileTimestamp, filemtime($gitConfigFile));
}
}
if ($gitConfigFileTimestamp == 0) { // no file found, force refresh
$gitConfigFileTimestamp = time();
}
if (! empty($instance->webroot) && $gitConfigFileTimestamp > $cacheTimestamp) {
$command = 'config --global --add safe.directory \'' . $instance->webroot . '\'';
try {
$safeDirectories = $this->exec(null, 'config --list');
if (strpos($safeDirectories, 'safe.directory=' . $instance->webroot) === false) {
$this->exec(null, $command);
file_put_contents($cacheFile, time());
}
} catch (\Exception $e) {
$this->exec(null, $command);
}
}
}
}