<?php
/*
* igitigit - Web frontend for Git repositories
* Copyright (C) 2011 Klaus Reimer <hide@address.com>
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace igitigit;
/**
* A GIT repository.
*
* @author Klaus Reimer <hide@address.com>
*/
class GitRepository implements Repository, Directory, SystemObject
{
/** The parent directory. */
private $parent;
/** The repository name. */
private $name;
/** The path. */
private $path;
/** The absolute repository path. */
private $repoPath;
/** The executed GIT command. */
private $gitCmd;
/** The GIT error file. */
private $gitErrorFile;
/** The GIT pipes. */
private $gitPipes;
/** The GIT process. */
private $gitProc;
/** The selected branch. */
private $branch;
/**
* Constructs a new repository.
*
* @param string $name
* The directory name.
* @param Directory $parent
* The parent directory.
*/
public function __construct($name, Directory $parent = NULL)
{
$this->name = $name;
$this->parent = $parent;
// Determine the path.
if ($parent)
{
$parentPath = $this->parent->getPath();
$this->path = $parentPath;
if ($parentPath && $name) $this->path .= "/";
$this->path .= $name;
}
else
$this->path = "";
// Determine repository path.
$this->repoPath = $this->getAbsPath();
if (is_dir($this->repoPath . "/.git/refs/heads"))
$this->repoPath .= "/.git";
// Determine selected branch.
$key = $this->getSelectedBranchKey();
if (isset($_SESSION[$key]))
{
// Read selected branch from session.
$this->branch = $_SESSION[$key];
// Correct selection if branch no longer exists.
if (!$this->hasBranch($this->branch))
$this->setBranch($this->branch);
}
else $this->branch = $this->getCurrentBranch();
}
/**
* Checks if the specified absolute path is a repository.
*
* @param string $path
* The absolute path to the potential repository.
* @return boolean
* True if path is a repository, false if not.
*/
public static function isGitRepository($path)
{
return is_dir("$path/refs/heads") || is_dir("$path/.git/refs/heads");
}
/**
* @see Object#getName()
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @see Object#getPath()
*
* @return string
*/
public function getPath()
{
return $this->path;
}
/**
* @see Object#getMode()
*
* @return int
*/
public function getMode()
{
$stats = stat($this->getAbsPath());
return $stats["mode"];
}
/**
* @see Object#getLastModified()
*
* @return int
*/
public function getLastModified()
{
return filemtime($this->getAbsPath());
}
/**
* @see Object#getUrl()
*
* @return string
*/
public function getUrl()
{
$path = $this->getPath();
return CONTROLLER . ($path ? "/$path" : "");
}
/**
* @see Object#getBreadcrumbs()
*
* @return Breadcrumb[]
*/
public function getBreadcrumbs()
{
$breadcrumbs = array();
$current = $this;
while ($current)
{
array_unshift($breadcrumbs, new Breadcrumb($current->getName(),
$current->getUrl(), $current == $this));
$current = $current->getParent();
}
return $breadcrumbs;
}
/**
* @see SystemObject#getAbsPath()
*
* @return string
*/
public function getAbsPath()
{
$path = $this->getPath();
return REPOS . ($path ? ("/" . $this->getPath()) : "");
}
/**
* @see Directory#getParent()
*
* @return Directory
*/
public function getParent()
{
return $this->parent;
}
/**
* @see Directory#isRoot()
*
* @return boolean
*/
public function isRoot()
{
return $this->parent != NULL;
}
/**
* @see Directory#getObject()
*
* @param string $revision
* @param string $path
* @return GitObject
*/
public function getObject($revision = NULL, $path = "")
{
if (!$revision) $revision = $this->branch;
// If no revision is specified then we can't access anything.
if (!$revision)
throw new HttpException("No such file or directory", 404);
// GIT has no properties for the root directory so we have to use
// default values here.
if (!$path)
return new GitDirectory($this, $revision, "$path", 0100755);
$data = $this->gitString("ls-tree", "-l", $revision, $path);
if (!$data)
throw new HttpException("No such file or directory", 404);
$columns = preg_split('/\s+/', trim($data), 5);
$mode = octdec($columns[0]);
$type = $columns[1];
$object = $columns[2];
$size = $columns[3];
$size = $size == "-" ? 0 : intval($size);
if ($type == "blob")
return new GitFile($this, $revision, "$path", $mode, $size);
else
return new GitDirectory($this, $revision, "$path", $mode);
}
/**
* @see Directory#getObjects()
*
* @param string $revision
* @param string $path
* @return GitObject[]
*/
public function getObjects($revision = NULL, $path = "")
{
if (!$revision) $revision = $this->branch;
// If no revision is specified then there are no objects
if (!$revision) return array();
$objects = array();
$repo = $this;
if ($path) $path .= "/";
$this->gitForEachLine(function($line) use ($revision, $path,
&$objects, $repo)
{
$columns = preg_split('/\s+/', trim($line), 5);
$mode = octdec($columns[0]);
$type = $columns[1];
$object = $columns[2];
$size = $columns[3];
$size = $size == "-" ? 0 : intval($size);
$file = basename($columns[4]);
if ($type == "blob")
$object = new GitFile($repo, $revision, "$path$file", $mode,
$size);
else
$object = new GitDirectory($repo, $revision, "$path$file",
$mode);
$objects[] = $object;
}, "ls-tree", "-l", $revision, $path);
usort($objects, array($this, "compareObjects"));
return $objects;
}
/**
* Compare method to sort tree items by type and name.
*
* @param GitObject $a
* First item to compare.
* @param GitObject $b
* Second item to compare.
* @return int
* The comparison result.
*/
private function compareObjects($a, $b)
{
$r = strcmp($a->getType(), $b->getType());
if (!$r) $r = strcmp($a->getName(), $b->getName());
return $r;
}
/**
* @see Object#getType()
*
* @return string
*/
public function getType()
{
return Object::TYPE_REPO;
}
/**
* Opens GIT.
*
* @param mixed $args___
* Variable number of arguments to pass to GIT.
*/
private function openGit($args___)
{
$this->gitErrorFile = tempnam(sys_get_temp_dir(), "igitigit");
$descriptors = array(
0 => array("pipe", "r"),
1 => array("pipe", "w"),
2 => array("file", $this->gitErrorFile, "w")
);
$args = array(
GIT,
"--git-dir",
escapeshellarg($this->repoPath)
);
foreach (func_get_args() as $arg)
$args[] = escapeshellarg($arg);
$this->gitCmd = implode(" ", $args);
$this->gitProc = proc_open($this->gitCmd, $descriptors,
$this->gitPipes);
if (!is_resource($this->gitProc))
throw new GitException($this->gitCmd, -1, $this->gitErrorFile);
}
/**
* Closes GIT.
*/
private function closeGit()
{
$result = proc_close($this->gitProc);
if ($result)
throw new GitException($this->gitCmd, $result,
$this->gitErrorFile);
unlink($this->gitErrorFile);
}
/**
* Executes a GIT command and returns the result as rows.
*
* @param string $rowDelimiter
* The row delimiter.
* @param mixed $args___
* Variable number of GIT arguments.
*/
private function gitRows($rowDelimiter, $args___)
{
$args = func_get_args();
array_shift($args);
call_user_func_array(array($this, "openGit"), $args);
$data = stream_get_contents($this->gitPipes[1]);
$this->closeGit();
$rows = explode($rowDelimiter, $data);
return $rows;
}
/**
* Executes a GIT command and returns the result as a string.
*
* @param mixed $args___
* Variable number of GIT arguments.
*/
private function gitString($args___)
{
$args = func_get_args();
call_user_func_array(array($this, "openGit"), $args);
$data = stream_get_contents($this->gitPipes[1]);
$this->closeGit();
return $data;
}
/**
* Executes a GIT command and passes the returned lines one
* by one to the specified callback function.
*
* @param callback $callback
* The callback function.
* @param mixed $args___
* Variable number of GIT arguments.
*/
public function gitForEachLine($callback, $args___)
{
$args = func_get_args();
array_shift($args);
call_user_func_array(array($this, "openGit"), $args);
while ($line = fgets($this->gitPipes[1]))
{
call_user_func($callback, $line);
}
$this->closeGit();
}
/**
* @see Repository#getBranches()
*
* @return string[]
*/
public function getBranches()
{
$rows = $this->gitRows("\n", "branch");
$branches = array();
foreach ($rows as $row)
{
if (!$row) continue;
$branch = substr($row, 2);
if ($branch == "(no branch)") continue;
$branches[] = $branch;
}
return $branches;
}
/**
* @see Repository#getTags()
*
* @return string[]
*/
public function getTags()
{
$rows = $this->gitRows("\n", "for-each-ref", "--sort=taggerdate",
"--format=%(refname:short)", "refs/tags");
$tags = array();
foreach ($rows as $row)
{
if (!$row) continue;
array_unshift($tags, $row);
}
return $tags;
}
/**
* @see Repository#hasBranch()
*
* @param string $branch
* @return boolean
*/
public function hasBranch($branch)
{
$branches = $this->getBranches();
return in_array($branch, $branches);
}
/**
* @see Repository#getCurrentBranch()
*
* @return string
*/
public function getCurrentBranch()
{
$rows = $this->gitRows("\n", "branch");
$branches = array();
foreach ($rows as $row)
{
if (!$row) continue;
if ($row[0] == "*" && $row != "* (no branch)")
return substr($row, 2);
}
$branches = $this->getBranches();
if (!count($branches)) return NULL;
return $branches[0];
}
/**
* @see Repository#getBranch()
*
* @return string
*/
public function getBranch()
{
return $this->branch;
}
/**
* @see Repository#setBranch()
*
* @param string $branch
*/
public function setBranch($branch)
{
$this->branch = $branch;
$key = $this->getSelectedBranchKey();
$_SESSION[$key] = $branch;
}
/**
* @see RepoObject#getRevision()
*
* @return string
*/
public function getRevision()
{
return $this->branch;
}
/**
* @see RepoObject#getRep()
*
* @return GitRepository
*/
public function getRepo()
{
return $this;
}
/**
* @see RepoObject#getLastCommit()
*
* @return Commit
*/
public function getLastCommit()
{
$commits = $this->getCommits($this->branch, "", 1);
// Check if there is a commit at all.
if (!count($commits)) return NULL;
return $commits[0];
}
/**
* @see RepoObject#getCommit()
*
* @return Commit
*/
public function getCommit($hash)
{
$commits = $this->getCommits($hash, "", 1);
if (!$commits)
throw new HttpException("Commit not found", 404);
return $commits[0];
}
/**
* Returns the session key for the selected branch of this repository.
*
* @return string
* The session key.
*/
private function getSelectedBranchKey()
{
return "selectedBranch_" . md5($this->repoPath);
}
/**
* Checks if the specified path is a GIT repository or a file.
*
* @return boolean
* True if GIT repository, false if just a file.
*/
private function isGitDirectory($revision = NULL, $path = "")
{
// If path is empty then it must be a tree.
if (!$path) return true;
if (!$revision) $revision = $this->branch;
// If no revision is specified then this can't be a Git directory.
if (!$revision) return false;
$data = $this->gitString("ls-tree", "-l", $revision, $path);
$columns = preg_split('/\s+/', trim($data), 5);
return $columns[1] == "tree";
}
/**
* Returns the URL to view the full history of the currently selected
* branch.
*
* @return string
* The history URL.
*/
public function getHistoryUrl()
{
return $this->getUrl() . "/commits/" . $this->branch;
}
/**
* Returns the commits for the specified revision and path.
*
* @param string $revision
* Optional revision. Defaults to currently selected branch.
* @param string $path
* Optional path. Defaults to root directory.
* @param int number
* Optional number of commits to return. Defaults to 35.
* @return Commit[]
* The commits.
*/
public function getCommits($revision = NULL, $path = "", $number = 35)
{
if (!$revision) $revision = $this->branch;
// If no revision is specified then there are no commits.
if (!$revision) return array();
$commits = array();
$repo = $this;
$this->gitForEachLine(function($row) use (&$commits, $repo)
{
$cols = explode("\0", $row);
$commitHash = $cols[0];
$treeHash = $cols[1];
$parentHash = $cols[2];
$authorDate = $cols[3];
$author = new Contact($cols[4], $cols[5]);
$authorEMail = $cols[5];
$committerDate = $cols[6];
$committer = new Contact($cols[7], $cols[8]);
$subject = $cols[9];
$commits[] = new Commit($repo, $commitHash, $treeHash,
$parentHash, $authorDate, $author, $committerDate,
$committer, $subject);
}, "log", "-n", $number,
"--format=format:%H%x00%T%x00%P%x00%at%x00%an%x00%ae%x00%ct%x00%cn%x00%ce%x00%s",
$revision, "--", $path);
return $commits;
}
/**
* Dumps the content of a blob to the HTML output. Special
* characters are encoded but line-breaks are not touched so the caller
* must wrap the output with a pre tag.
*
* @param string $revision
* The revision. If NULL then currently selected branch is used.
* @param string $path
* The path.
* @return int
* The number of dumped lines. NULL if no line numbers are
* available.
*/
public function dumpBlob($revision = NULL, $path = "")
{
if (!$revision) $revision = $this->branch;
$lines = 0;
$this->gitForEachLine(function($row) use (&$lines)
{
$lines++;
printf("<span id=\"L%d\" class=\"line\">%s</span>", $lines,
htmlspecialchars($row));
}, "show", "$revision:$path");
return $lines;
}
/**
* Dumps the raw content of a blob to the HTML output.
*
* @param string $revision
* The revision.
* @param string $path
* The path.
*/
public function dumpRawBlob($revision = NULL, $path = "")
{
if (!$revision) $revision = $this->branch;
$this->gitForEachLine(function($row) use (&$lines)
{
echo $row;
}, "show", "$revision:$path");
}
/**
* Dumps the blame lines of a blob to the HTML output. The lines are
* grouped by commit in div statements. The blame information is
* returned.
*
* @param string $revision
* The revision. If NULL then currently selected branch is used.
* @param string $path
* The path.
* @return Blame
* The blame information.
*/
public function dumpBlame($revision = NULL, $path = "")
{
if (!$revision) $revision = $this->branch;
$blame = new Blame($this, $path);
$mode = 0;
$first = true;
$lineNo = 1;
$this->gitForEachLine(function($line) use (&$blame, &$mode, &$first,
&$lineNo)
{
switch ($mode)
{
case 0:
$parts = explode(" ", $line);
$hash = $parts[0];
$lines = count($parts) > 3 ? intval($parts[3]) : NULL;
if ($lines && $blame->setCommit($hash, $lines))
{
if ($first)
$first = false;
else
echo "</span>";
echo "<span class=\"commit\">";
}
$mode = 1;
break;
case 1:
if ($line[0] == "\t")
{
printf("<span id=\"L%d\" class=\"line\">%s</span>",
$lineNo, htmlspecialchars(substr($line, 1)));
$lineNo++;
$mode = 0;
}
else
{
$parts = explode(" ", trim($line), 2);
if (count($parts) >1 )
{
$key = $parts[0];
$value = $parts[1];
if ($key == "author")
$blame->setAuthorName($value);
else if ($key == "author-time")
$blame->setAuthorDate($value);
else if ($key == "author-mail")
$blame->setAuthorEMail(substr($value, 1, -1));
else if ($key == "summary")
$blame->setSummary($value);
}
}
}
}, "blame", "-p", $revision, "--", $path);
if (!$first) echo "</span>";
return $blame;
}
}