<?php
/*
* This file is part of the Gitter library.
*
* (c) Klaus Silveira <klaussilveira@php.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Gitter;
use Gitter\Model\Commit\Commit;
use Gitter\Model\Tree;
use Gitter\Model\Blob;
use Gitter\Model\Commit\Diff;
use Gitter\Statistics\StatisticsInterface;
use Gitter\PrettyFormat;
use Symfony\Component\Filesystem\Filesystem;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class Repository
{
protected $path;
protected $client;
protected $commitsHaveBeenParsed = false;
protected $statistics = array();
public function __construct($path, Client $client)
{
$this->setPath($path);
$this->setClient($client);
}
/**
* @param bool $value
* @return void
*/
public function setCommitsHaveBeenParsed($value)
{
$this->commitsHaveBeenParsed = $value;
}
/**
* @return boolean
*/
public function getCommitsHaveBeenParsed()
{
return $this->commitsHaveBeenParsed;
}
/**
* Create a new git repository
*/
public function create($bare = null)
{
mkdir($this->getPath());
$command = 'init';
if ($bare) {
$command .= ' --bare';
}
$this->getClient()->run($this, $command);
return $this;
}
/**
* Get a git configuration variable
*
* @param string $key Configuration key
*/
public function getConfig($key)
{
$key = $this->getClient()->run($this, 'config ' . $key);
return trim($key);
}
/**
* Set a git configuration variable
*
* @param string $key Configuration key
* @param string $value Configuration value
*/
public function setConfig($key, $value)
{
$this->getClient()->run($this, "config $key \"$value\"");
return $this;
}
/**
* Add statistic aggregator
*
* @param StatisticsInterface|array $statistics
*/
public function addStatistics ($statistics)
{
if (!is_array($statistics)) {
$statistics = array($statistics);
}
foreach ($statistics as $statistic) {
$reflect = new \ReflectionClass($statistic);
$this->statistics[strtolower($reflect->getShortName())] = $statistic;
}
}
/**
* Get statistic aggregators
*
* @return array
*/
public function getStatistics($branch = null)
{
// Calculate amount of files, extensions and file size
$logs = $this->getClient()->run($this, 'ls-tree -r -l ' . $branch);
$lines = explode("\n", $logs);
$files = [];
$data['extensions'] = [];
$data['size'] = 0;
$data['files'] = 0;
foreach ($lines as $key => $line) {
if (empty($line)) {
unset($lines[$key]);
continue;
}
$files[] = preg_split("/[\s]+/", $line);
}
foreach ($files as $file) {
if ($file[1] == 'blob') {
$data['files']++;
}
if (is_numeric($file[3])) {
$data['size'] += $file[3];
}
}
$logs = $this->getClient()->run($this, 'ls-tree -l -r --name-only ' . $branch);
$files = explode("\n", $logs);
foreach ($files as $file) {
if (($pos = strrpos($file, '.')) !== false) {
$extension = substr($file, $pos);
if (($pos = strrpos($extension, '/')) === false) {
$data['extensions'][] = $extension;
}
}
}
$data['extensions'] = array_count_values($data['extensions']);
arsort($data['extensions']);
return $data;
}
/**
* Add untracked files
*
* @param mixed $files Files to be added to the repository
*/
public function add($files = '.')
{
if (is_array($files)) {
$files = implode(' ', array_map('escapeshellarg', $files));
} else {
$files = escapeshellarg($files);
}
$this->getClient()->run($this, "add $files");
return $this;
}
/**
* Add all untracked files
*/
public function addAll()
{
$this->getClient()->run($this, "add -A");
return $this;
}
/**
* Commit changes to the repository
*
* @param string $message Description of the changes made
*/
public function commit($message)
{
$this->getClient()->run($this, "commit -m \"$message\"");
return $this;
}
/**
* Checkout a branch
*
* @param string $branch Branch to be checked out
*/
public function checkout($branch)
{
$this->getClient()->run($this, "checkout $branch");
return $this;
}
/**
* Pull repository changes
*/
public function pull()
{
$this->getClient()->run($this, "pull");
return $this;
}
/**
* Update remote references
*
* @param string $repository Repository to be pushed
* @param string $refspec Refspec for the push
*/
public function push($repository = null, $refspec = null)
{
$command = "push";
if ($repository) {
$command .= " $repository";
}
if ($refspec) {
$command .= " $refspec";
}
$this->getClient()->run($this, $command);
return $this;
}
/**
* Get name of repository (top level directory)
*
* @return string
*/
public function getName ()
{
$name = rtrim($this->path, '/');
if (strstr($name, DIRECTORY_SEPARATOR)) {
$name = substr($name, strrpos($name, DIRECTORY_SEPARATOR) + 1);
}
return trim($name);
}
/**
* Show a list of the repository branches
*
* @return array List of branches
*/
public function getBranches()
{
static $cache = array();
if (array_key_exists($this->path, $cache)) {
return $cache[$this->path];
}
$branches = $this->getClient()->run($this, "branch");
$branches = explode("\n", $branches);
$branches = array_filter(preg_replace('/[\*\s]/', '', $branches));
if (empty($branches)) {
return $cache[$this->path] = $branches;
}
// Since we've stripped whitespace, the result "* (detached from "
// and "* (no branch)" that is displayed in detached HEAD state
// becomes "(detachedfrom" and "(nobranch)" respectively.
if ((strpos($branches[0], '(detachedfrom') === 0) || ($branches[0] === '(nobranch)')) {
$branches = array_slice($branches, 1);
}
return $cache[$this->path] = $branches;
}
/**
* Return the current repository branch
*
* @return mixed Current repository branch as a string, or NULL if in
* detached HEAD state.
*/
public function getCurrentBranch()
{
$branches = $this->getClient()->run($this, "branch");
$branches = explode("\n", $branches);
foreach ($branches as $branch) {
if ($branch[0] === '*') {
if ((strpos($branch, '* (detached from ') === 0) || ($branch === '* (no branch)')) {
return NULL;
}
return substr($branch, 2);
}
}
}
/**
* Check if a specified branch exists
*
* @param string $branch Branch to be checked
* @return boolean True if the branch exists
*/
public function hasBranch($branch)
{
$branches = $this->getBranches();
$status = in_array($branch, $branches);
return $status;
}
/**
* Create a new repository branch
*
* @param string $branch Branch name
*/
public function createBranch($branch)
{
$this->getClient()->run($this, "branch $branch");
}
/**
* Create a new repository tag
*
* @param string $tag Tag name
*/
public function createTag($tag, $message = null)
{
$command = "tag";
if ($message) {
$command .= " -a -m '$message'";
}
$command .= " $tag";
$this->getClient()->run($this, $command);
}
/**
* Show a list of the repository tags
*
* @return array List of tags
*/
public function getTags()
{
static $cache = array();
if (array_key_exists($this->path, $cache)) {
return $cache[$this->path];
}
$tags = $this->getClient()->run($this, "tag");
$tags = explode("\n", $tags);
array_pop($tags);
if (empty($tags[0])) {
return $cache[$this->path] = NULL;
}
return $cache[$this->path] = $tags;
}
/**
* Show the amount of commits on the repository
*
* @return integer Total number of commits
*/
public function getTotalCommits($file = null)
{
if (defined('PHP_WINDOWS_VERSION_BUILD')) {
$command = "rev-list --count --all $file";
} else {
$command = "rev-list --all $file | wc -l";
}
$commits = $this->getClient()->run($this, $command);
return trim($commits);
}
/**
* Show the repository commit log
*
* @return array Commit log
*/
public function getCommits($file = null)
{
$command = "log --pretty=format:\"<item><hash>%H</hash><short_hash>%h</short_hash><tree>%T</tree><parents>%P</parents><author>%an</author><author_email>%ae</author_email><date>%at</date><commiter>%cn</commiter><commiter_email>%ce</commiter_email><commiter_date>%ct</commiter_date><message><![CDATA[%s]]></message></item>\"";
if ($file) {
$command .= " $file";
}
$logs = $this->getPrettyFormat($command);
foreach ($logs as $log) {
$commit = new Commit;
$commit->importData($log);
$commits[] = $commit;
foreach ($this->statistics as $statistic) {
$statistic->addCommit($commit);
}
}
$this->setCommitsHaveBeenParsed(true);
return $commits;
}
/**
* Show the data from a specific commit.
*
* @param string $commitHash Hash of the specific commit to read data
*
* @return array Commit data
*/
public function getCommit($commitHash)
{
$logs = $this->getClient()->run(
$this,
'show --pretty=format:"<item><hash>%H</hash>'
. '<short_hash>%h</short_hash><tree>%T</tree><parents>%P</parents>'
. '<author>%aN</author><author_email>%aE</author_email>'
. '<date>%at</date><commiter>%cN</commiter><commiter_email>%cE</commiter_email>'
. '<commiter_date>%ct</commiter_date>'
. '<message><![CDATA[%s]]></message>'
. '<body><![CDATA[%b]]></body>'
. "</item>\" $commitHash"
);
$xmlEnd = strpos($logs, '</item>') + 7;
$commitInfo = substr($logs, 0, $xmlEnd);
$commitData = substr($logs, $xmlEnd);
$logs = explode("\n", $commitData);
// Read commit metadata
$format = new PrettyFormat();
$data = $format->parse($commitInfo);
$commit = new Commit();
$commit->importData($data[0]);
if ($commit->getParentsHash()) {
$command = 'diff ' . $commitHash . '~1..' . $commitHash;
$logs = explode("\n", $this->getClient()->run($this, $command));
}
$commit->setDiffs($this->readDiffLogs($logs));
return $commit;
}
/**
* Read diff logs and generate a collection of diffs.
*
* @param array $logs Array of log rows
*
* @return array Array of diffs
*/
public function readDiffLogs(array $logs)
{
$diffs = [];
$lineNumOld = 0;
$lineNumNew = 0;
foreach ($logs as $log) {
// Skip empty lines
if ($log == '') {
continue;
}
if ('diff' === substr($log, 0, 4)) {
if (isset($diff)) {
$diffs[] = $diff;
}
$diff = new Diff();
if (preg_match('/^diff --[\S]+ a\/?(.+) b\/?/', $log, $name)) {
$diff->setFile($name[1]);
}
continue;
}
if ('index' === substr($log, 0, 5)) {
$diff->setIndex($log);
continue;
}
if ('---' === substr($log, 0, 3)) {
$diff->setOld($log);
continue;
}
if ('+++' === substr($log, 0, 3)) {
$diff->setNew($log);
continue;
}
// Handle binary files properly.
if ('Binary' === substr($log, 0, 6)) {
$m = [];
if (preg_match('/Binary files (.+) and (.+) differ/', $log, $m)) {
$diff->setOld($m[1]);
$diff->setNew(" {$m[2]}");
}
}
if (!empty($log)) {
switch ($log[0]) {
case '@':
// Set the line numbers
preg_match('/@@ -([0-9]+)(?:,[0-9]+)? \+([0-9]+)/', $log, $matches);
$lineNumOld = $matches[1] - 1;
$lineNumNew = $matches[2] - 1;
break;
case '-':
$lineNumOld++;
break;
case '+':
$lineNumNew++;
break;
default:
$lineNumOld++;
$lineNumNew++;
}
} else {
$lineNumOld++;
$lineNumNew++;
}
if (isset($diff)) {
$diff->addLine($log, $lineNumOld, $lineNumNew);
}
}
if (isset($diff)) {
$diffs[] = $diff;
}
return $diffs;
}
/**
* Get the current HEAD.
*
* @param $default Optional branch to default to if in detached HEAD state.
* If not passed, just grabs the first branch listed.
* @return string the name of the HEAD branch, or a backup option if
* in detached HEAD state.
*/
public function getHead($default = null)
{
if ($default === null) {
$client = $this->getClient();
$default = $client->getDefaultBranch();
}
$file = '';
if (file_exists($this->getPath() . '/.git/HEAD')) {
$file = file_get_contents($this->getPath() . '/.git/HEAD');
} elseif (file_exists($this->getPath() . '/HEAD')) {
$file = file_get_contents($this->getPath() . '/HEAD');
}
// Find first existing branch
foreach (explode("\n", $file) as $line) {
$m = array();
if (preg_match('#ref:\srefs/heads/(.+)#', $line, $m)) {
if ($this->hasBranch($m[1])) {
return $m[1];
}
}
}
// If we were given a default branch and it exists, return that.
if ($default !== null && $this->hasBranch($default)) {
return $default;
}
// Otherwise, return the first existing branch.
$branches = $this->getBranches();
if (!empty($branches)) {
return current($branches);
}
// No branches exist - null is the best we can do in this case.
return null;
}
/**
* Extract the tree hash for a given branch or tree reference
*
* @param string $branch
* @return string
*/
public function getBranchTree($branch)
{
$hash = $this->getClient()->run($this, "log --pretty=\"%T\" --max-count=1 $branch");
$hash = trim($hash, "\r\n ");
return $hash ? : false;
}
/**
* Get the Tree for the provided folder
*
* @param string $tree Folder that will be parsed
* @return Tree Instance of Tree for the provided folder
*/
public function getTree($tree)
{
$tree = new Tree($tree, $this);
$tree->parse();
return $tree;
}
/**
* Get the Blob for the provided file
*
* @param string $blob File that will be parsed
* @return Blob Instance of Blob for the provided file
*/
public function getBlob($blob)
{
return new Blob($blob, $this);
}
/**
* Blames the provided file and parses the output.
*
* @param string $file File that will be blamed
*
* @return array Commits hashes containing the lines
*/
public function getBlame($file)
{
$blame = [];
$logs = $this->getClient()->run($this, "blame --root -sl $file");
$logs = explode("\n", $logs);
$i = 0;
$previousCommit = '';
foreach ($logs as $log) {
if ($log == '') {
continue;
}
preg_match_all("/([a-zA-Z0-9]{40})\s+.*?([0-9]+)\)(.+)/", $log, $match);
$currentCommit = $match[1][0];
if ($currentCommit != $previousCommit) {
++$i;
$blame[$i] = [
'line' => '',
'commit' => $currentCommit,
'commitShort' => substr($currentCommit, 0, 8),
];
}
$blame[$i]['line'] .= $match[3][0] . PHP_EOL;
$previousCommit = $currentCommit;
}
return $blame;
}
/**
* Get the current Repository path
*
* @return string Path where the repository is located
*/
public function getPath()
{
return $this->path;
}
/**
* Set the current Repository path
*
* @param string $path Path where the repository is located
*/
public function setPath($path)
{
$this->path = $path;
}
/**
* Get the current Client instance
*
* @return Client Client instance
*/
public function getClient()
{
return $this->client;
}
/**
* Set the Client
*
* @param Client $path Client instance
*/
public function setClient(Client $client)
{
$this->client = $client;
return $this;
}
/**
* Get and parse the output of a git command with a XML-based pretty format
*
* @param string $command Command to be run by git
* @return array Parsed command output
*/
public function getPrettyFormat($command)
{
$output = $this->getClient()->run($this, $command);
$format = new PrettyFormat;
return $format->parse($output);
}
public function getShortHash($commit) {
$shortHash = $this->getClient()->run($this, 'rev-parse --short ' . $commit);
return $shortHash;
}
public function changeFile($cachePath, $repo, $branch, $repoFilename, $value, $name, $email, $comment) {
$temporaryDirectory = '';
$tempRepo = '';
$hadError = false;
$command = '';
$output = '';
$outputs = [];
try {
$temporaryDirectory = (new TemporaryDirectory($cachePath))->create();
$client = $this->getClient();
$repoPath = realpath($this->getPath());
$tempRepo = $temporaryDirectory->path();
$output = $client->run($this, 'clone '. $repoPath . ' ' . $tempRepo);
$this->setPath($tempRepo);
$command = "checkout $branch";
$output = $client->run($this, $command);
array_push($outputs, $output);
$filename = realpath($tempRepo . DIRECTORY_SEPARATOR . $repoFilename);
//$originalFileContent = file_get_contents($filename);
file_put_contents($filename, $value);
$command = " -c \"user.name=$name\" -c \"user.email=$email\" commit -am \"$comment\" ";
$output = $client->run($this, $command);
array_push($outputs, $output);
// $command = "commit -am \"$comment\"";
// $output = $client->run($repository, $command);
$command = "push";
$output = $client->run($this, $command);
array_push($outputs, $output);
$result = (object) [
'status' => 'ok',
'output' => $output,
'outputs' => $outputs
];
return $result;
} catch(\Throwable $e) {
$hadError = $e;
} finally {
if ($temporaryDirectory !== '') {
$temporaryDirectory->delete();
}
if ($hadError !== false) {
$message = $hadError->getMessage();
return ((object) [
'status' => $message === '' ? 'ok' : 'error',
'error' => $message === '' ? false : true,
//'temporaryDirectory' => $tempRepo,
'message' => $message,
//'currentdir' => getcwd(),
//'command' => $command,
'output' => $output,
'outputs' => $outputs,
//'$filename' => $filename,
//'$value' => $value,
]);
}
}
}
/**
* Return true if the repo contains this commit.
*
* @param $commitHash Hash of commit whose existence we want to check
*
* @return bool Whether or not the commit exists in this repo
*/
public function hasCommit($commitHash)
{
$logs = $this->getClient()->run($this, "show $commitHash");
$logs = explode("\n", $logs);
return strpos($logs[0], 'commit') === 0;
}
/**
* Show Patches that where apllied to the selected file.
*
* @param string $file File path for which we will retrieve a list of patch logs
*
* @return array Collection of Commits data
*/
public function getCommitsLogPatch($file)
{
$record_delimiter = chr(hexdec('0x1e'));
$file_patches = $this->getClient()->run(
$this,
'log -p --pretty=format:"' . $record_delimiter . '<item><hash>%H</hash>'
. '<short_hash>%h</short_hash><tree>%T</tree><parents>%P</parents>'
. '<author>%aN</author><author_email>%aE</author_email>'
. '<date>%at</date><commiter>%cN</commiter><commiter_email>%cE</commiter_email>'
. '<commiter_date>%ct</commiter_date>'
. '<message><![CDATA[%s]]></message>'
. '<body><![CDATA[%b]]></body>'
. "</item>\" $file"
);
$patch_collection = [];
foreach (preg_split('/(' . $record_delimiter . '\<item\>)/', $file_patches, null, PREG_SPLIT_NO_EMPTY) as $patches) {
$patches = '<item>' . $patches;
$xmlEnd = strpos($patches, '</item>') + 7;
$commitInfo = substr($patches, 0, $xmlEnd);
$commitData = substr($patches, $xmlEnd);
$logs = explode("\n", $commitData);
// Read commit metadata
$format = new PrettyFormat();
$data = $format->parse($commitInfo);
$commit = new Commit();
$commit->importData($data[0]);
$commit->setDiffs($this->readDiffLogs($logs));
$patch_collection[] = $commit;
}
return $patch_collection;
}
/**
* Show the repository commit log with pagination.
*
* @param string $file
* @param int page
*
* @return array Commit log
*/
public function getPaginatedCommits($file = null, $page = 0)
{
$page = 15 * $page;
$pager = "--skip=$page --max-count=15";
$command =
"log $pager --pretty=format:\"<item><hash>%H</hash>"
. '<short_hash>%h</short_hash><tree>%T</tree><parents>%P</parents>'
. '<author>%aN</author><author_email>%aE</author_email>'
. '<date>%at</date><commiter>%cN</commiter>'
. '<commiter_email>%cE</commiter_email>'
. '<commiter_date>%ct</commiter_date>'
. '<message><![CDATA[%s]]></message></item>"';
if ($file) {
$command .= " $file";
}
try {
$logs = $this->getPrettyFormat($command);
} catch (\RuntimeException $e) {
return [];
}
foreach ($logs as $log) {
$commit = new Commit();
$commit->importData($log);
$commits[] = $commit;
}
return $commits;
}
public function searchCommitLog($query, $branch)
{
$query = escapeshellarg($query);
$query = strtr($query, ['[' => '\\[', ']' => '\\]']);
$command =
"log --grep={$query} -i --pretty=format:\"<item><hash>%H</hash>"
. '<short_hash>%h</short_hash><tree>%T</tree><parents>%P</parents>'
. '<author>%aN</author><author_email>%aE</author_email>'
. '<date>%at</date><commiter>%cN</commiter>'
. '<commiter_email>%cE</commiter_email>'
. '<commiter_date>%ct</commiter_date>'
. '<message><![CDATA[%s]]></message></item>"'
. " $branch";
try {
$logs = $this->getPrettyFormat($command);
} catch (\RuntimeException $e) {
return [];
}
foreach ($logs as $log) {
$commit = new Commit();
$commit->importData($log);
$commits[] = $commit;
}
return $commits;
}
public function searchTree($query, $branch)
{
if (empty($query)) {
return null;
}
$query = preg_replace('/(--?[A-Za-z0-9\-]+)/', '', $query);
$query = escapeshellarg($query);
try {
$results = $this->getClient()->run($this, "grep -i --line-number -- {$query} $branch");
} catch (\RuntimeException $e) {
return false;
}
$results = explode("\n", $results);
$searchResults = [];
foreach ($results as $result) {
if ($result == '') {
continue;
}
preg_match_all('/([\w-._]+):([^:]+):([0-9]+):(.+)/', $result, $matches, PREG_SET_ORDER);
if (isset($matches[0])) {
$data['branch'] = $matches[0][1];
$data['file'] = $matches[0][2];
$data['line'] = $matches[0][3];
$data['match'] = $matches[0][4];
$searchResults[] = $data;
}
}
return $searchResults;
}
public function getAuthorStatistics($branch)
{
$logs = $this->getClient()->run($this, 'log --pretty=format:"%aN||%aE" ' . $branch);
if (empty($logs)) {
throw new \RuntimeException('No statistics available');
}
$logs = explode("\n", $logs);
$logs = array_count_values($logs);
arsort($logs);
foreach ($logs as $user => $count) {
$user = explode('||', $user);
$data[] = ['name' => $user[0], 'email' => $user[1], 'commits' => $count];
}
return $data;
}
/**
* Create a TAR or ZIP archive of a git tree.
*
* @param string $tree Tree-ish reference
* @param string $output Output File name
* @param string $format Archive format
*/
public function createArchive($tree, $output, $format = 'zip')
{
$fs = new Filesystem();
$fs->mkdir(dirname($output));
$this->getClient()->run($this, "archive --format=$format --output='$output' $tree");
}
/**
* Return true if $path exists in $branch; return false otherwise.
*
* @param string $commitish commitish reference; branch, tag, SHA1, etc
* @param string $path path whose existence we want to verify
*
* @return bool
*
* GRIPE Arguably belongs in Gitter, as it's generally useful functionality.
* Also, this really may not be the best way to do this.
*/
public function pathExists($commitish, $path)
{
$output = $this->getClient()->run($this, "ls-tree $commitish '$path'");
if (strlen($output) > 0) {
return true;
}
return false;
}
}