From b0894b314845a52e979539cbf1e6bd94444dca54 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Mon, 9 Apr 2012 22:10:29 +0200 Subject: [PATCH] Add Craigs granite library to 3rdparty and files_versioning. Still not working and lots ot stuff to do. --- 3rdparty/granite/git/blob.php | 162 +++++ 3rdparty/granite/git/commit.php | 232 +++++++ 3rdparty/granite/git/object/index.php | 210 ++++++ 3rdparty/granite/git/object/loose.php | 81 +++ 3rdparty/granite/git/object/packed.php | 304 ++++++++ 3rdparty/granite/git/object/raw.php | 153 +++++ 3rdparty/granite/git/repository.php | 293 ++++++++ 3rdparty/granite/git/tag.php | 38 + 3rdparty/granite/git/tree.php | 198 ++++++ 3rdparty/granite/git/tree/node.php | 126 ++++ apps/files_versioning/ajax/gethead.php | 12 + apps/files_versioning/ajax/sethead.php | 14 + apps/files_versioning/appinfo/app.php | 20 + apps/files_versioning/appinfo/info.xml | 14 + apps/files_versioning/css/settings.css | 3 + apps/files_versioning/js/settings.js | 25 + apps/files_versioning/lib_granite.php | 12 + apps/files_versioning/settings.php | 34 + apps/files_versioning/templates/settings.php | 12 + apps/files_versioning/versionstorage.php | 386 +++++++++++ apps/files_versioning/versionwrapper.php | 686 +++++++++++++++++++ 21 files changed, 3015 insertions(+) create mode 100644 3rdparty/granite/git/blob.php create mode 100644 3rdparty/granite/git/commit.php create mode 100644 3rdparty/granite/git/object/index.php create mode 100644 3rdparty/granite/git/object/loose.php create mode 100644 3rdparty/granite/git/object/packed.php create mode 100644 3rdparty/granite/git/object/raw.php create mode 100644 3rdparty/granite/git/repository.php create mode 100644 3rdparty/granite/git/tag.php create mode 100644 3rdparty/granite/git/tree.php create mode 100644 3rdparty/granite/git/tree/node.php create mode 100644 apps/files_versioning/ajax/gethead.php create mode 100644 apps/files_versioning/ajax/sethead.php create mode 100644 apps/files_versioning/appinfo/app.php create mode 100644 apps/files_versioning/appinfo/info.xml create mode 100644 apps/files_versioning/css/settings.css create mode 100644 apps/files_versioning/js/settings.js create mode 100644 apps/files_versioning/lib_granite.php create mode 100644 apps/files_versioning/settings.php create mode 100644 apps/files_versioning/templates/settings.php create mode 100644 apps/files_versioning/versionstorage.php create mode 100644 apps/files_versioning/versionwrapper.php diff --git a/3rdparty/granite/git/blob.php b/3rdparty/granite/git/blob.php new file mode 100644 index 0000000000..781a697d56 --- /dev/null +++ b/3rdparty/granite/git/blob.php @@ -0,0 +1,162 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git; +use \Granite\Git\Object\Raw as Raw; +use \InvalidArgumentException as InvalidArgumentException; +use \finfo as finfo; +/** + * **Granite\Git\Blob** represents the raw content of an object in a Git repository, + * typically a **file**. This class provides methods related to the handling of + * blob content, mimetypes, sizes and write support. + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ +class Blob +{ + + /** + * Stores the SHA-1 id of the object requested; accessed through the `sha()` + * method where it is recalculated based on the blob content. + */ + private $sha = null; + /** + * The raw binary string of the file contents. + */ + private $content = ""; + /** + * The path to the repository location. + */ + private $path; + + /** + * Fetches a raw Git object and parses the result. Throws an + * InvalidArgumentException if the object is not of the correct type, + * or cannot be found. + * + * @param string $path The path to the repository root. + * @param string $sha The SHA-1 id of the requested object, or `null` if + * creating a new blob object. + * + * @throws InvalidArgumentException If the SHA-1 id provided is not a blob. + */ + public function __construct($path, $sha = NULL) + { + $this->path = $path; + if ($sha !== NULL) { + $this->sha = $sha; + $object = Raw::factory($path, $sha); + + if ($object->type() !== Raw::OBJ_BLOB) { + throw new InvalidArgumentException( + "The object $sha is not a blob, type is {$object->type()}" + ); + } + + $this->content = $object->content(); + unset($object); + } + } + + /** + * Sets or returns the raw file content, depending whether the parameter is + * provided. + * + * @param string $content The object content to set, or `null` if requesting the + * current content. + * + * @return string The raw binary string of the file contents. + */ + public function content($content = NULL) + { + if ($content == NULL) { + return $this->content; + } + $this->content = $content; + } + + /** + * Returns the size of the file content in bytes, equivalent to + * `strlen($blob->content())`. + * + * @return int The size of the object in bytes. + */ + public function size() + { + return strlen($this->content); + } + + /** + * Updates and returns the SHA-1 id of the object, based on it's contents. + * + * @return int The SHA-1 id of the object. + */ + public function sha() + { + $sha = hash_init('sha1'); + $header = 'blob ' . strlen($this->content) . "\0"; + hash_update($sha, $header); + hash_update($sha, $this->content); + $this->sha = hash_final($sha); + return $this->sha; + } + + /** + * Returns the mimetype of the object, using `finfo()` to determine the mimetype + * of the string. + * + * @return string The object mimetype. + * @see http://php.net/manual/en/function.finfo-open.php + */ + public function mimetype() + { + $finfo = new finfo(FILEINFO_MIME); + return $finfo->buffer($this->content); + } + + /** + * Encode and compress the object content, saving it to a 'loose' file. + * + * @return boolean True on success, false on failure. + */ + public function write() + { + $sha = $this->sha(TRUE); + $path = $this->path + . 'objects' + . DIRECTORY_SEPARATOR + . substr($sha, 0, 2) + . DIRECTORY_SEPARATOR + . substr($sha, 2); + // FIXME: currently writes loose objects only + if (file_exists($path)) { + return FALSE; + } + + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, TRUE); + } + + $loose = fopen($path, 'wb'); + $data = 'blob ' . strlen($this->content) . "\0" . $this->content; + $write = fwrite($loose, gzcompress($data)); + fclose($loose); + + return ($write !== FALSE); + } + +} diff --git a/3rdparty/granite/git/commit.php b/3rdparty/granite/git/commit.php new file mode 100644 index 0000000000..51077e89f3 --- /dev/null +++ b/3rdparty/granite/git/commit.php @@ -0,0 +1,232 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git; +use \Granite\Git\Object\Raw as Raw; +use \InvalidArgumentException as InvalidArgumentException; + +/** + * Commit represents a full commit object + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ +class Commit +{ + + /** + * The path to the repository root + */ + private $path; + /** + * The SHA-1 id of the requested commit + */ + private $sha; + /** + * The size of the commit in bytes + */ + private $size; + /** + * The commit message + */ + private $message; + /** + * The full committer string + */ + private $committer; + /** + * The full author string + */ + private $author; + /** + * The SHA-1 ids of the parent commits + */ + private $parents = array(); + + /** + * Fetches a raw Git object and parses the result. Throws an + * InvalidArgumentException if the object is not of the correct type, + * or cannot be found. + * + * @param string $path The path to the repository root + * @param string $sha The SHA-1 id of the requested object + * + * @throws InvalidArgumentException + */ + public function __construct($path, $sha = NULL) + { + $this->path = $path; + if ($sha !== NULL) { + $this->sha = $sha; + $object = Raw::factory($path, $sha); + $this->size = $object->size(); + + if ($object->type() !== Raw::OBJ_COMMIT) { + throw new InvalidArgumentException( + "The object $sha is not a commit, type is " . $object->type() + ); + } + + // Parse headers and commit message (delimited with "\n\n") + list($headers, $this->message) = explode("\n\n", $object->content(), 2); + $headers = explode("\n", $headers); + + foreach ($headers as $header) { + list($header, $value) = explode(' ', $header, 2); + if ($header == 'parent') { + $this->parents[] = $value; + } else { + $this->$header = $value; + } + } + + $this->tree = new Tree($this->path, $this->tree); + } + } + + /** + * Returns the message stored in the commit + * + * @return string The commit message + */ + public function message($message = NULL) + { + if ($message !== NULL) { + $this->message = $message; + return $this; + } + return $this->message; + } + + /** + * Returns the commiter string + * + * @return string The committer string + */ + public function committer($committer = NULL) + { + if ($committer !== NULL) { + $this->committer = $committer; + return $this; + } + return $this->committer; + } + + /** + * Returns the author string + * + * @return string The author string + */ + public function author($author = NULL) + { + if ($author !== NULL) { + $this->author = $author; + return $this; + } + return $this->author; + } + + /** + * Returns the parents of the commit, or an empty array if none + * + * @return array The parents of the commit + */ + public function parents($parents = NULL) + { + if ($parents !== NULL) { + $this->parents = $parents; + return $this; + } + return $this->parents; + } + + /** + * Returns a tree object associated with the commit + * + * @return Tree + */ + public function tree(Tree $tree = NULL) + { + if ($tree !== NULL) { + $this->tree = $tree; + return $this; + } + return $this->tree; + } + + /** + * Returns the size of the commit in bytes (Git header + data) + * + * @return int + */ + public function size() + { + return $this->size; + } + + /** + * Returns the size of the commit in bytes (Git header + data) + * + * @return int + */ + public function sha() + { + $this->sha = hash('sha1', $this->_raw()); + return $this->sha; + } + + public function write() + { + $sha = $this->sha(); + $path = $this->path + . 'objects' + . DIRECTORY_SEPARATOR + . substr($sha, 0, 2) + . DIRECTORY_SEPARATOR + . substr($sha, 2); + // FIXME: currently writes loose objects only + if (file_exists($path)) { + return FALSE; + } + + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, TRUE); + } + + $loose = fopen($path, 'wb'); + $data = $this->_raw(); + $write = fwrite($loose, gzcompress($data)); + fclose($loose); + + return ($write !== FALSE); + } + + public function _raw() + { + $data = 'tree ' . $this->tree->sha() . "\n"; + foreach ($this->parents as $parent) + { + $data .= "parent $parent\n"; + } + $data .= 'author ' . $this->author . "\n"; + $data .= 'committer ' . $this->committer . "\n\n"; + $data .= $this->message; + + $data = 'commit ' . strlen($data) . "\0" . $data; + return $data; + } + +} diff --git a/3rdparty/granite/git/object/index.php b/3rdparty/granite/git/object/index.php new file mode 100644 index 0000000000..239706d4ef --- /dev/null +++ b/3rdparty/granite/git/object/index.php @@ -0,0 +1,210 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git\Object; +use \UnexpectedValueException as UnexpectedValueException; + +/** + * Index represents a packfile index + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT License + * @link http://craig0990.github.com/Granite/ + */ +class Index +{ + const INDEX_MAGIC = "\377tOc"; + + /** + * The full path to the packfile index + */ + private $path; + /** + * The offset at which the fanout begins, version 2+ indexes have a 2-byte header + */ + private $offset = 8; + /** + * The size of the SHA-1 entries, version 1 stores 4-byte offsets alongside to + * total 24 bytes, version 2+ stores offsets separately + */ + private $size = 20; + /** + * The version of the index file format, versions 1 and 2 are in use and + * currently supported + */ + private $version; + + /** + * Fetches a raw Git object and parses the result + * + * @param string $path The path to the repository root + * @param string $packname The name of the packfile index to read + */ + public function __construct($path, $packname) + { + $this->path = $path + . 'objects' + . DIRECTORY_SEPARATOR + . 'pack' + . DIRECTORY_SEPARATOR + . 'pack-' . $packname . '.idx'; + + $this->version = $this->_readVersion(); + if ($this->version !== 1 && $this->version !== 2) { + throw new UnexpectedValueException( + "Unsupported index version (version $version)" + ); + } + + if ($this->version == 1) { + $this->offset = 0; // Version 1 index has no header/version + $this->size = 24; // Offsets + SHA-1 ids are stored together + } + } + + /** + * Returns the offset of the object stored in the index + * + * @param string $sha The SHA-1 id of the object being requested + * + * @return int The offset of the object in the packfile + */ + public function find($sha) + { + $index = fopen($this->path, 'rb'); + $offset = false; // Offset for object in packfile not found by default + + // Read the fanout to skip to the start char in the sorted SHA-1 list + list($start, $after) = $this->_readFanout($index, $sha); + + if ($start == $after) { + fclose($index); + return false; // Object is apparently located in a 0-length section + } + + // Seek $offset + 255 4-byte fanout entries and read 256th entry + fseek($index, $this->offset + 4 * 255); + $totalObjects = $this->_uint32($index); + + // Look up the SHA-1 id of the object + // TODO: Binary search + fseek($index, $this->offset + 1024 + $this->size * $start); + for ($i = $start; $i < $after; $i++) { + if ($this->version == 1) { + $offset = $this->_uint32($index); + } + + $name = fread($index, 20); + if ($name == pack('H40', $sha)) { + break; // Found it + } + } + + if ($i == $after) { + fclose($index); + return false; // Scanned entire section, couldn't find it + } + + if ($this->version == 2) { + // Jump to the offset location and read it + fseek($index, 1032 + 24 * $totalObjects + 4 * $i); + $offset = $this->_uint32($index); + if ($offset & 0x80000000) { + // Offset is a 64-bit integer; packfile is larger than 2GB + fclose($index); + throw new UnexpectedValueException( + "Packfile larger than 2GB, currently unsupported" + ); + } + } + + fclose($index); + return $offset; + } + + /** + * Converts a binary string into a 32-bit unsigned integer + * + * @param handle $file Binary string to convert + * + * @return int Integer value + */ + private function _uint32($file) + { + $val = unpack('Nx', fread($file, 4)); + return $val['x']; + } + + /** + * Reads the fanout for a particular SHA-1 id + * + * Largely modified from Glip, with some reference to Grit - largely because I + * can't see how to re-implement this in PHP + * + * @param handle $file File handle to the index file + * @param string $sha The SHA-1 id to search for + * @param int $offset The offset at which the fanout begins + * + * @return array Array containing integer 'start' and + * 'past-the-end' locations + */ + private function _readFanout($file, $sha) + { + $sha = pack('H40', $sha); + fseek($file, $this->offset); + if ($sha{0} == "\00") { + /** + * First character is 0, read first fanout entry to provide + * 'past-the-end' location (since first fanout entry provides start + * point for '1'-prefixed SHA-1 ids) + */ + $start = 0; + fseek($file, $this->offset); // Jump to start of fanout, $offset bytes in + $after = $this->_uint32($file); + } else { + /** + * Take ASCII value of first character, minus one to get the fanout + * position of the offset (minus one because the fanout does not + * contain an entry for "\00"), multiplied by four bytes per entry + */ + fseek($file, $this->offset + (ord($sha{0}) - 1) * 4); + $start = $this->_uint32($file); + $after = $this->_uint32($file); + } + + return array($start, $after); + } + + /** + * Returns the version number of the index file, or 1 if there is no version + * information + * + * @return int + */ + private function _readVersion() + { + $file = fopen($this->path, 'rb'); + $magic = fread($file, 4); + $version = $this->_uint32($file); + + if ($magic !== self::INDEX_MAGIC) { + $version = 1; + } + + fclose($file); + return $version; + } + +} diff --git a/3rdparty/granite/git/object/loose.php b/3rdparty/granite/git/object/loose.php new file mode 100644 index 0000000000..32f894845b --- /dev/null +++ b/3rdparty/granite/git/object/loose.php @@ -0,0 +1,81 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git\Object; +use \UnexpectedValueException as UnexpectedValueException; + +/** + * Loose represents a loose object in the Git repository + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ +class Loose extends Raw +{ + + /** + * Reads an object from a loose object file based on the SHA-1 id + * + * @param string $path The path to the repository root + * @param string $sha The SHA-1 id of the requested object + * + * @throws UnexpectedValueException If the type is not 'commit', 'tree', + * 'tag' or 'blob' + */ + public function __construct($path, $sha) + { + $this->sha = $sha; + + $loose_path = $path + . 'objects/' + . substr($sha, 0, 2) + . '/' + . substr($sha, 2); + + if (!file_exists($loose_path)) { + throw new InvalidArgumentException("Cannot open loose object file for $sha"); + } + + $raw = gzuncompress(file_get_contents($loose_path)); + $data = explode("\0", $raw, 2); + + $header = $data[0]; + $this->content = $data[1]; + + list($this->type, $this->size) = explode(' ', $header); + + switch ($this->type) { + case 'commit': + $this->type = Raw::OBJ_COMMIT; + break; + case 'tree': + $this->type = Raw::OBJ_TREE; + break; + case 'blob': + $this->type = Raw::OBJ_BLOB; + break; + case 'tag': + $this->type = Raw::OBJ_TAG; + break; + default: + throw new UnexpectedValueException( + "Unexpected type '{$this->type}'" + ); + break; + } + } + +} diff --git a/3rdparty/granite/git/object/packed.php b/3rdparty/granite/git/object/packed.php new file mode 100644 index 0000000000..7e8d663b32 --- /dev/null +++ b/3rdparty/granite/git/object/packed.php @@ -0,0 +1,304 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git\Object; +use \UnexpectedValueException as UnexpectedValueException; + +/** + * Packed represents a packed object in the Git repository + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ +class Packed extends Raw +{ + + /** + * The name of the packfile being read + */ + private $_packfile; + + /** + * Added to the object size to make a 'best-guess' effort at how much compressed + * data to read - should be reimplemented, ideally with streams. + */ + const OBJ_PADDING = 512; + + /** + * Reads the object data from the compressed data at $offset in $packfile + * + * @param string $packfile The path to the packfile + * @param int $offset The offset of the object data + */ + public function __construct($packfile, $offset) + { + $this->_packfile = $packfile; + + list($this->type, $this->size, $this->content) + = $this->_readPackedObject($offset); + } + + /** + * Reads the object data at $this->_offset + * + * @param int $offset Offset of the object header + * + * @return array Containing the type, size and object data + */ + private function _readPackedObject($offset) + { + $file = fopen($this->_packfile, 'rb'); + fseek($file, $offset); + // Read the type and uncompressed size from the object header + list($type, $size) = $this->_readHeader($file, $offset); + $object_offset = ftell($file); + + if ($type == self::OBJ_OFS_DELTA || $type == self::OBJ_REF_DELTA) { + return $this->_unpackDeltified( + $file, $offset, $object_offset, $type, $size + ); + } + + $content = gzuncompress(fread($file, $size + self::OBJ_PADDING), $size); + + return array($type, $size, $content); + } + + /** + * Reads a packed object header, returning the type and the size. For more + * detailed information, refer to the @see tag. + * + * From the @see tag: "Each byte is really 7 bits of data, with the first bit + * being used to say if that hunk is the last one or not before the data starts. + * If the first bit is a 1, you will read another byte, otherwise the data starts + * next. The first 3 bits in the first byte specifies the type of data..." + * + * @param handle $file File handle to read + * @param int $offset Offset of the object header + * + * @return array Containing the type and the size + * @see http://book.git-scm.com/7_the_packfile.html + */ + private function _readHeader($file, $offset) + { + // Read the object header byte-by-byte + fseek($file, $offset); + $byte = ord(fgetc($file)); + /** + * Bit-shift right by four, then ignore the first bit with a bitwise AND + * This gives us the object type in binary: + * 001 commit self::OBJ_COMMIT + * 010 tree self::OBJ_TREE + * 011 blob self::OBJ_BLOB + * 100 tag self::OBJ_TAG + * 110 offset delta self::OBJ_OFS_DELTA + * 111 ref delta self::OBJ_REF_DELTA + * + * (000 is undefined, 101 is not currently in use) + * See http://book.git-scm.com/7_the_packfile.html for details + */ + $type = ($byte >> 4) & 0x07; + + // Read the last four bits of the first byte, used to find the size + $size = $byte & 0x0F; + + /** + * $shift initially set to four, since we use the last four bits of the first + * byte + * + * $byte & 0x80 checks the initial bit is set to 1 (i.e. keep reading data) + * + * Finally, $shift is incremented by seven for each consecutive byte (because + * we ignore the initial bit) + */ + for ($shift = 4; $byte & 0x80; $shift += 7) { + $byte = ord(fgetc($file)); + /** + * The size is ANDed against 0x7F to strip the initial bit, then + * bitshifted by left $shift (4 or 7, depending on whether it's the + * initial byte) and ORed against the existing binary $size. This + * continuously increments the $size variable. + */ + $size |= (($byte & 0x7F) << $shift); + } + + return array($type, $size); + } + + /** + * Unpacks a deltified object located at $offset in $file + * + * @param handle $file File handle to read + * @param int $offset Offset of the object data + * @param int $object_offset Offset of the object data, past the header + * @param int $type The object type, either OBJ_REF_DELTA + or OBJ_OFS_DELTA + * @param int $size The expected size of the uncompressed data + * + * @return array Containing the type, size and object data + */ + private function _unpackDeltified($file, $offset, $object_offset, $type, $size) + { + fseek($file, $object_offset); + + if ($type == self::OBJ_REF_DELTA) { + + $base_sha = bin2hex(fread($file, 20)); + + $path = substr($this->_packfile, 0, strpos($this->_packfile, '.git')+5); + $base = Raw::factory($path, $base_sha); + $type = $base->type(); + $base = $base->content(); + + $delta = gzuncompress( + fread($file, $size + self::OBJ_PADDING), $size + ); + + $content = $this->_applyDelta($base, $delta); + + } elseif ($type == self::OBJ_OFS_DELTA) { + + // 20 = maximum varint size according to Glip + $data = fread($file, $size + self::OBJ_PADDING + 20); + + list($base_offset, $length) = $this->_bigEndianNumber($data); + + $delta = gzuncompress(substr($data, $length), $size); + unset($data); + + $base_offset = $offset - $base_offset; + list($type, $size, $base) = $this->_readPackedObject($base_offset); + + $content = $this->_applyDelta($base, $delta); + + } else { + throw new UnexpectedValueException( + "Unknown type $type for deltified object" + ); + } + + return array($type, strlen($content), $content); + } + + /** + * Applies the $delta byte-sequence to $base and returns the + * resultant binary string. + * + * This code is modified from Grit (see below), the Ruby + * implementation used for GitHub under an MIT license. + * + * @param string $base The base string for the delta to be applied to + * @param string $delta The delta string to apply + * + * @return string The patched binary string + * @see + * https://github.com/mojombo/grit/blob/master/lib/grit/git-ruby/internal/pack.rb + */ + private function _applyDelta($base, $delta) + { + $pos = 0; + $src_size = $this->_varint($delta, $pos); + $dst_size = $this->_varint($delta, $pos); + + if ($src_size !== strlen($base)) { + throw new UnexpectedValueException( + 'Expected base delta size ' . strlen($base) . ' does not match the expected ' + . "value $src_size" + ); + } + + $dest = ""; + while ($pos < strlen($delta)) { + $byte = ord($delta{$pos++}); + + if ($byte & 0x80) { + /* copy a part of $base */ + $offset = 0; + if ($byte & 0x01) $offset = ord($delta{$pos++}); + if ($byte & 0x02) $offset |= ord($delta{$pos++}) << 8; + if ($byte & 0x04) $offset |= ord($delta{$pos++}) << 16; + if ($byte & 0x08) $offset |= ord($delta{$pos++}) << 24; + $length = 0; + if ($byte & 0x10) $length = ord($delta{$pos++}); + if ($byte & 0x20) $length |= ord($delta{$pos++}) << 8; + if ($byte & 0x40) $length |= ord($delta{$pos++}) << 16; + if ($length == 0) $length = 0x10000; + $dest .= substr($base, $offset, $length); + } else { + /* take the next $byte bytes as they are */ + $dest .= substr($delta, $pos, $byte); + $pos += $byte; + } + } + + if (strlen($dest) !== $dst_size) { + throw new UnexpectedValueException( + "Deltified string expected to be $dst_size bytes, but actually " + . strlen($dest) . ' bytes' + ); + } + + return $dest; + } + + /** + * Parse a Git varint (variable-length integer). Used in the `_applyDelta()` + * method to read the delta header. + * + * @param string $string The string to parse + * @param int &$pos The position in the string to read from + * + * @return int The integer value + */ + private function _varint($string, &$pos = 0) + { + $varint = 0; + $bitmask = 0x80; + for ($i = 0; $bitmask & 0x80; $i += 7) { + $bitmask = ord($string{$pos++}); + $varint |= (($bitmask & 0x7F) << $i); + } + return $varint; + } + + /** + * Decodes a big endian modified base 128 number (refer to @see tag); this only + * appears to be used in one place, the offset delta in packfiles. The offset + * is the number of bytes to seek back from the start of the delta object to find + * the base object. + * + * This code has been implemented using the C code given in the @see tag below. + * + * @param string &$data The data to read from and decode the number + * + * @return Array Containing the base offset (number of bytes to seek back) and + * the length to use when reading the delta + * @see http://git.rsbx.net/Documents/Git_Data_Formats.txt + */ + private function _bigEndianNumber(&$data) + { + $i = 0; + $byte = ord($data{$i++}); + $number = $byte & 0x7F; + while ($byte & 0x80) { + $byte = ord($data{$i++}); + $number = (($number + 1) << 7) | ($byte & 0x7F); + } + + return array($number, $i); + } + +} diff --git a/3rdparty/granite/git/object/raw.php b/3rdparty/granite/git/object/raw.php new file mode 100644 index 0000000000..56f363c37b --- /dev/null +++ b/3rdparty/granite/git/object/raw.php @@ -0,0 +1,153 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git\Object; +use \InvalidArgumentException as InvalidArgumentException; + +/** + * Raw represents a raw Git object, using Index to locate + * packed objects. + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ +class Raw +{ + /** + * Integer values for Git objects + * @see http://book.git-scm.com/7_the_packfile.html + */ + const OBJ_COMMIT = 1; + const OBJ_TREE = 2; + const OBJ_BLOB = 3; + const OBJ_TAG = 4; + const OBJ_OFS_DELTA = 6; + const OBJ_REF_DELTA = 7; + + /** + * The SHA-1 id of the requested object + */ + protected $sha; + /** + * The type of the requested object (see class constants) + */ + protected $type; + /** + * The binary string content of the requested object + */ + protected $content; + + /** + * Returns an instance of a raw Git object + * + * @param string $path The path to the repository root + * @param string $sha The SHA-1 id of the requested object + * + * @return Packed|Loose + */ + public static function factory($path, $sha) + { + $loose_path = $path + . 'objects/' + . substr($sha, 0, 2) + . '/' + . substr($sha, 2); + if (file_exists($loose_path)) { + return new Loose($path, $sha); + } else { + return self::_findPackedObject($path, $sha); + } + } + + /** + * Returns the raw content of the Git object requested + * + * @return string Raw object content + */ + public function content() + { + return $this->content; + } + + /** + * Returns the size of the Git object + * + * @return int The size of the object in bytes + */ + public function size() + { + return strlen($this->content); + } + + /** + * Returns the type of the object as either commit, tag, blob or tree + * + * @return string The object type + */ + public function type() + { + return $this->type; + } + + /** + * Searches a packfile for the SHA id and reads the object from the packfile + * + * @param string $path The path to the repository + * @param string $sha The SHA-1 id of the object being requested + * + * @throws \InvalidArgumentException + * @return array An array containing the type, size and object data + */ + private static function _findPackedObject($path, $sha) + { + $packfiles = glob( + $path + . 'objects' + . DIRECTORY_SEPARATOR + . 'pack' + . DIRECTORY_SEPARATOR + . 'pack-*.pack' + ); + + $offset = false; + foreach ($packfiles as $packfile) { + $packname = substr(basename($packfile, '.pack'), 5); + $idx = new Index($path, $packname); + $offset = $idx->find($sha); + + if ($offset !== false) { + break; // Found it + } + } + + if ($offset == false) { + throw new InvalidArgumentException("Could not find packed object $sha"); + } + + $packname = $path + . 'objects' + . DIRECTORY_SEPARATOR + . 'pack' + . DIRECTORY_SEPARATOR + . 'pack-' . $packname . '.pack'; + $object = new Packed($packname, $offset); + + return $object; + } + +} + +?> diff --git a/3rdparty/granite/git/repository.php b/3rdparty/granite/git/repository.php new file mode 100644 index 0000000000..30b58a39f5 --- /dev/null +++ b/3rdparty/granite/git/repository.php @@ -0,0 +1,293 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git; +use \InvalidArgumentException as InvalidArgumentException; +use \UnexpectedValueException as UnexpectedValueException; + +/** + * Repository represents a Git repository, providing a variety of methods for + * fetching objects from SHA-1 ids or the tip of a branch with `head()` + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ +class Repository +{ + + /** + * The path to the repository root + */ + private $_path; + /** + * The indexed version of a commit, ready to write with `commit()` + */ + private $idx_commit; + /** + * The indexed version of a tree, modified to with `add()` and `remove()` + */ + private $idx_tree; + + /** + * Sets the repository path + * + * @param string $path The path to the repository root (i.e. /repo/.git/) + */ + public function __construct($path) + { + if (!is_dir($path)) { + throw new InvalidArgumentException("Unable to find directory $path"); + } elseif (!is_readable($path)) { + throw new InvalidArgumentException("Unable to read directory $path"); + } elseif (!is_dir($path . DIRECTORY_SEPARATOR . 'objects') + || !is_dir($path . DIRECTORY_SEPARATOR . 'refs') + ) { + throw new UnexpectedValueException( + "Invalid directory, could not find 'objects' or 'refs' in $path" + ); + } + + $this->_path = $path; + $this->idx_commit = $this->factory('commit'); + $this->idx_tree = $this->factory('tree'); + } + + /** + * Returns an object from the Repository of the given type, with the given + * SHA-1 id, or false if it cannot be found + * + * @param string $type The type (blob, commit, tag or tree) of object being + * requested + * @param string $sha The SHA-1 id of the object (or the name of a tag) + * + * @return Blob|Commit|Tag|Tree + */ + public function factory($type, $sha = null) + { + if (!in_array($type, array('blob', 'commit', 'tag', 'tree'))) { + throw new InvalidArgumentException("Invalid type: $type"); + } + + if ($type == 'tag') { + $sha = $this->_ref('tags' . DIRECTORY_SEPARATOR . $sha); + } + $type = 'Granite\\Git\\' . ucwords($type); + + return new $type($this->_path, $sha); + } + + /** + * Returns a Commit object representing the HEAD commit + * + * @param string $branch The branch name to lookup, defaults to 'master' + * + * @return Commit An object representing the HEAD commit + */ + public function head($branch = 'master', $value = NULL) + { + if ($value == NULL) + return $this->factory( + 'commit', $this->_ref('heads' . DIRECTORY_SEPARATOR . $branch) + ); + + file_put_contents( + $this->_path . DIRECTORY_SEPARATOR + . 'refs' . DIRECTORY_SEPARATOR + . 'heads' . DIRECTORY_SEPARATOR . 'master', + $value + ); + } + + /** + * Returns a string representing the repository's location, which may or may + * not be initialised + * + * @return string A string representing the repository's location + */ + public function path() + { + return $this->_path; + } + + /** + * Returns an array of the local branches under `refs/heads` + * + * @return array + */ + public function tags() + { + return $this->_refs('tags'); + } + + /** + * Returns an array of the local tags under `refs/tags` + * + * @return array + */ + public function branches() + { + return $this->_refs('heads'); + } + + private function _refs($type) + { + $dir = $this->_path . 'refs' . DIRECTORY_SEPARATOR . $type; + $refs = glob($dir . DIRECTORY_SEPARATOR . '*'); + foreach ($refs as &$ref) { + $ref = basename($ref); + } + return $refs; + } + + /** + * Initialises a Git repository + * + * @return boolean Returns true on success, false on error + */ + public static function init($path) + { + $path .= '/'; + if (!is_dir($path)) { + mkdir($path); + } elseif (is_dir($path . 'objects')) { + return false; + } + + mkdir($path . 'objects'); + mkdir($path . 'objects/info'); + mkdir($path . 'objects/pack'); + mkdir($path . 'refs'); + mkdir($path . 'refs/heads'); + mkdir($path . 'refs/tags'); + + file_put_contents($path . 'HEAD', 'ref: refs/heads/master'); + + return true; + } + + /** + * Writes the indexed commit to disk, with blobs added/removed via `add()` and + * `rm()` + * + * @param string $message The commit message + * @param string $author The author name + * + * @return boolean True on success, or false on failure + */ + public function commit($message, $author) + { + $user_string = $username . ' ' . time() . ' +0000'; + + try { + $parents = array($this->repo->head()->sha()); + } catch (InvalidArgumentException $e) { + $parents = array(); + } + + $this->idx_commit->message($message); + $this->idx_commit->author($user_string); + $this->idx_commit->committer($user_string); + $this->idx_commit->tree($this->idx_tree); + $commit->parents($parents); + + $this->idx_tree->write(); + $this->idx_commit->write(); + + $this->repo->head('master', $this->idx_commit->sha()); + + $this->idx_commit = $this->factory('commit'); + $this->idx_tree = $this->factory('tree'); + } + + /** + * Adds a file to the indexed commit, to be written to disk with `commit()` + * + * @param string $filename The filename to save it under + * @param Granite\Git\Blob $blob The raw blob object to add to the tree + */ + public function add($filename, Granite\Git\Blob $blob) + { + $blob->write(); + $nodes = $this->idx_tree->nodes(); + $nodes[$filename] = new Granite\Git\Tree\Node($filename, '100644', $blob->sha()); + $this->idx_tree->nodes($nodes); + } + + /** + * Removes a file from the indexed commit + */ + public function rm($filename) + { + $nodes = $this->idx_tree->nodes(); + unset($nodes[$filename]); + $this->idx_tree->nodes($nodes); + } + + /** + * Returns an SHA-1 id of the ref resource + * + * @param string $ref The ref name to lookup + * + * @return string An SHA-1 id of the ref resource + */ + private function _ref($ref) + { + // All refs are stored in `.git/refs` + $file = $this->_path . 'refs' . DIRECTORY_SEPARATOR . $ref; + + if (file_exists($file)) { + return trim(file_get_contents($file)); + } + + $sha = $this->_packedRef($ref); + + if ($sha == false) { + throw new InvalidArgumentException("The ref $ref could not be found"); + } + + return $sha; + } + + /** + * Returns an SHA-1 id of the ref resource, or false if it cannot be found + * + * @param string $ref The ref name to lookup + * + * @return string An SHA-1 id of the ref resource + */ + private function _packedRef($ref) + { + $sha = false; + if (file_exists($this->_path . 'packed-refs')) { + $file = fopen($this->_path . 'packed-refs', 'r'); + + while (($line = fgets($file)) !== false) { + $info = explode(' ', $line); + if (count($info) == 2 + && trim($info[1]) == 'refs' . DIRECTORY_SEPARATOR . $ref + ) { + $sha = trim($info[0]); + break; + } + } + + fclose($file); + } + + return $sha; + } + +} diff --git a/3rdparty/granite/git/tag.php b/3rdparty/granite/git/tag.php new file mode 100644 index 0000000000..e26ddaffa6 --- /dev/null +++ b/3rdparty/granite/git/tag.php @@ -0,0 +1,38 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git; + +/** + * Tag represents a full tag object + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ +class Tag +{ + + public function __construct($path, $sha) + { + $this->sha = $sha; + } + + public function sha() + { + return $this->sha; + } + +} diff --git a/3rdparty/granite/git/tree.php b/3rdparty/granite/git/tree.php new file mode 100644 index 0000000000..2de7227453 --- /dev/null +++ b/3rdparty/granite/git/tree.php @@ -0,0 +1,198 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git; +use \Granite\Git\Tree\Node as Node; + +/** + * Tree represents a full tree object, with nodes pointing to other tree objects + * and file blobs + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ +class Tree +{ + + /** + * The SHA-1 id of the requested tree + */ + private $sha; + /** + * The nodes/entries for the requested tree + */ + private $nodes = array(); + /** + * The path to the repository + */ + private $path; + + /** + * Reads a tree object by fetching the raw object + * + * @param string $path The path to the repository root + * @param string $sha The SHA-1 id of the requested object + */ + public function __construct($path, $sha = NULL, $dbg = FALSE) + { + $this->path = $path; + if ($sha !== NULL) { + $object = Object\Raw::factory($path, $sha); + $this->sha = $sha; + + if ($object->type() !== Object\Raw::OBJ_TREE) { + throw new \InvalidArgumentException( + "The object $sha is not a tree, type is " . $object->type() + ); + } + + $content = $object->content(); + file_put_contents('/tmp/tree_from_real_repo'.time(), $content); + $nodes = array(); + + for ($i = 0; $i < strlen($content); $i = $data_start + 21) { + $data_start = strpos($content, "\0", $i); + $info = substr($content, $i, $data_start-$i); + list($mode, $name) = explode(' ', $info, 2); + // Read the object SHA-1 id + $sha = bin2hex(substr($content, $data_start + 1, 20)); + + $this->nodes[$name] = new Node($name, $mode, $sha); + } + } + } + + /** + * Returns an array of Tree and Granite\Git\Blob objects, + * representing subdirectories and files + * + * @return array Array of Tree and Granite\Git\Blob objects + */ + public function nodes($nodes = null) + { + if ($nodes == null) { + return $this->nodes; + } + $this->nodes = $nodes; + } + + /** + * Adds a blob or a tree to the list of nodes + * + * @param string $name The basename (filename) of the blob or tree + * @param string $mode The mode of the blob or tree (see above) + * @param string $sha The SHA-1 id of the blob or tree to add + */ + public function add($name, $mode, $sha) + { + $this->nodes[$name] = new Node($name, $mode, $sha); + uasort($this->nodes, array($this, '_sort')); + } + + public function write() + { + $sha = $this->sha(); + $path = $this->path + . 'objects' + . DIRECTORY_SEPARATOR + . substr($sha, 0, 2) + . DIRECTORY_SEPARATOR + . substr($sha, 2); + // FIXME: currently writes loose objects only + if (file_exists($path)) { + return FALSE; + } + + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, TRUE); + } + + $loose = fopen($path, 'wb'); + $data = $this->_raw(); + $data = 'tree ' . strlen($data) . "\0" . $data; + $write = fwrite($loose, gzcompress($data)); + fclose($loose); + + return ($write !== FALSE); + } + + /** + * Returns the SHA-1 id of the Tree + * + * @return string SHA-1 id of the Tree + */ + public function sha() + { + $data = $this->_raw(); + $raw = 'tree ' . strlen($data) . "\0" . $data; + $this->sha = hash('sha1', $raw); + return $this->sha; + } + + /** + * Generates the raw object content to be saved to disk + */ + public function _raw() + { + uasort($this->nodes, array($this, '_sort')); + $data = ''; + foreach ($this->nodes as $node) + { + $data .= base_convert($node->mode(), 10, 8) . ' ' . $node->name() . "\0"; + $data .= pack('H40', $node->sha()); + } + file_put_contents('/tmp/tree_made'.time(), $data); + return $data; + } + + /** + * Sorts the node entries in a tree, general sort method adapted from original + * Git C code (see @see tag below). + * + * @return 1, 0 or -1 if the first entry is greater than, the same as, or less + * than the second, respectively. + * @see https://github.com/gitster/git/blob/master/read-cache.c Around line 352, + * the `base_name_compare` function + */ + public function _sort(&$a, &$b) + { + $length = strlen($a->name()) < strlen($b->name()) ? strlen($a->name()) : strlen($b->name()); + + $cmp = strncmp($a->name(), $b->name(), $length); + if ($cmp) { + return $cmp; + } + + $suffix1 = $a->name(); + $suffix1 = (strlen($suffix1) > $length) ? $suffix1{$length} : FALSE; + $suffix2 = $b->name(); + $suffix2 = (strlen($suffix2) > $length) ? $suffix2{$length} : FALSE; + if (!$suffix1 && $a->isDirectory()) { + $suffix1 = '/'; + } + if (!$suffix2 && $b->isDirectory()) { + $suffix2 = '/'; + } + if ($suffix1 < $suffix2) { + return -1; + } elseif ($suffix1 > $suffix2) { + return 1; + } + + return 0; + } + +} diff --git a/3rdparty/granite/git/tree/node.php b/3rdparty/granite/git/tree/node.php new file mode 100644 index 0000000000..f99eb1ae28 --- /dev/null +++ b/3rdparty/granite/git/tree/node.php @@ -0,0 +1,126 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ + +namespace Granite\Git\Tree; + +/** + * Node represents an entry in a Tree + * + * @category Git + * @package Granite + * @author Craig Roberts + * @license http://www.opensource.org/licenses/mit-license.php MIT Expat License + * @link http://craig0990.github.com/Granite/ + */ +class Node +{ + + /** + * Name of the file, directory or submodule + */ + private $_name; + /** + * Mode of the object, in octal + */ + private $_mode; + /** + * SHA-1 id of the tree + */ + private $_sha; + /** + * Boolean value for whether the entry represents a directory + */ + private $_is_dir; + /** + * Boolean value for whether the entry represents a submodule + */ + private $_is_submodule; + + /** + * Sets up a Node class with properties corresponding to the $mode parameter + * + * @param string $name The name of the object (file, directory or submodule name) + * @param int $mode The mode of the object, retrieved from the repository + * @param string $sha The SHA-1 id of the object + */ + public function __construct($name, $mode, $sha) + { + $this->_name = $name; + $this->_mode = intval($mode, 8); + $this->_sha = $sha; + + $this->_is_dir = (bool) ($this->_mode & 0x4000); + $this->_is_submodule = ($this->_mode == 0xE000); + } + + /** + * Returns a boolean value indicating whether the node is a directory + * + * @return boolean + */ + public function isDirectory() + { + return $this->_is_dir; + } + + /** + * Returns a boolean value indicating whether the node is a submodule + * + * @return boolean + */ + public function isSubmodule() + { + return $this->_is_submodule; + } + + /** + * Returns the object name + * + * @return string + */ + public function name() + { + return $this->_name; + } + + /** + * Returns the object's SHA-1 id + * + * @return string + */ + public function sha() + { + return $this->_sha; + } + + /** + * Returns the octal value of the file mode + * + * @return int + */ + public function mode() + { + return $this->_mode; + } + + public function type() + { + if ($this->isDirectory()) { + return 'tree'; + } elseif ($this->isSubmodule()) { + return 'commit'; + } else { + return 'blob'; + } + } +} diff --git a/apps/files_versioning/ajax/gethead.php b/apps/files_versioning/ajax/gethead.php new file mode 100644 index 0000000000..cc93b7a1d1 --- /dev/null +++ b/apps/files_versioning/ajax/gethead.php @@ -0,0 +1,12 @@ + $head)); diff --git a/apps/files_versioning/ajax/sethead.php b/apps/files_versioning/ajax/sethead.php new file mode 100644 index 0000000000..d1b2df9b00 --- /dev/null +++ b/apps/files_versioning/ajax/sethead.php @@ -0,0 +1,14 @@ + 10, + 'id' => 'files_versioning', + 'name' => 'Versioning and Backup' )); + +// Include stylesheets for the settings page +OC_Util::addStyle( 'files_versioning', 'settings' ); +OC_Util::addScript('files_versioning','settings'); + +// Register a settings section in the Admin > Personal page +OC_APP::registerPersonal('files_versioning','settings'); diff --git a/apps/files_versioning/appinfo/info.xml b/apps/files_versioning/appinfo/info.xml new file mode 100644 index 0000000000..d5546be54a --- /dev/null +++ b/apps/files_versioning/appinfo/info.xml @@ -0,0 +1,14 @@ + + + files_versioning + Versioning and Backup + 1.0.0 + GPLv2 + Craig Roberts + 3 + Versions files using Git repositories, providing a simple backup facility. Currently in *beta* and explicitly without warranty of any kind. + + + + + diff --git a/apps/files_versioning/css/settings.css b/apps/files_versioning/css/settings.css new file mode 100644 index 0000000000..afe2cd5508 --- /dev/null +++ b/apps/files_versioning/css/settings.css @@ -0,0 +1,3 @@ +#file_versioning_commit_chzn { + width: 15em; +} diff --git a/apps/files_versioning/js/settings.js b/apps/files_versioning/js/settings.js new file mode 100644 index 0000000000..8dd13bac03 --- /dev/null +++ b/apps/files_versioning/js/settings.js @@ -0,0 +1,25 @@ +$(document).ready(function(){ + $('#file_versioning_head').chosen(); + + $.getJSON(OC.filePath('files_versioning', 'ajax', 'gethead.php'), function(jsondata, status) { + + if (jsondata.head == 'HEAD') { + // Most recent commit, do nothing + } else { + $("#file_versioning_head").val(jsondata.head); + // Trigger the chosen update call + // See http://harvesthq.github.com/chosen/ + $("#file_versioning_head").trigger("liszt:updated"); + } + }); + + $('#file_versioning_head').change(function() { + + var data = $(this).serialize(); + $.post( OC.filePath('files_versioning', 'ajax', 'sethead.php'), data, function(data){ + if(data == 'error'){ + console.log('Saving new HEAD failed'); + } + }); + }); +}); diff --git a/apps/files_versioning/lib_granite.php b/apps/files_versioning/lib_granite.php new file mode 100644 index 0000000000..c69c62d9c4 --- /dev/null +++ b/apps/files_versioning/lib_granite.php @@ -0,0 +1,12 @@ +head(); +for ($i = 0; $i < 50; $i++) { + $commits[] = $commit; + $parents = $commit->parents(); + if (count($parents) > 0) { + $parent = $parents[0]; + } else { + break; + } + + $commit = $repository->factory('commit', $parent); +} + +$tmpl = new OC_Template( 'files_versioning', 'settings'); +$tmpl->assign('commits', $commits); +return $tmpl->fetchPage(); diff --git a/apps/files_versioning/templates/settings.php b/apps/files_versioning/templates/settings.php new file mode 100644 index 0000000000..17f4cc7f77 --- /dev/null +++ b/apps/files_versioning/templates/settings.php @@ -0,0 +1,12 @@ +
+ Versioning and Backup
+

Please note: Backing up large files (around 16MB+) will cause your backup history to grow very large, very quickly.

+ + +
diff --git a/apps/files_versioning/versionstorage.php b/apps/files_versioning/versionstorage.php new file mode 100644 index 0000000000..d083e623df --- /dev/null +++ b/apps/files_versioning/versionstorage.php @@ -0,0 +1,386 @@ +. + */ + +// Include Granite +require_once('lib_granite.php'); + +// Create a top-level 'Backup' directory if it does not already exist +$user = OC_User::getUser(); +if (OC_Filesystem::$loaded and !OC_Filesystem::is_dir('/Backup')) { + OC_Filesystem::mkdir('/Backup'); + OC_Preferences::setValue(OC_User::getUser(), 'files_versioning', 'head', 'HEAD'); +} + +// Generate the repository path (currently using 'full' repositories, as opposed to bare ones) +$repo_path = DIRECTORY_SEPARATOR + . OC_User::getUser() + . DIRECTORY_SEPARATOR + . 'files' + . DIRECTORY_SEPARATOR + . 'Backup'; + +// Mount the 'Backup' folder using the versioned storage provider below +OC_Filesystem::mount('OC_Filestorage_Versioned', array('repo'=>$repo_path), $repo_path . DIRECTORY_SEPARATOR); + +class OC_Filestorage_Versioned extends OC_Filestorage { + + /** + * Holds an instance of Granite\Git\Repository + */ + protected $repo; + + /** + * Constructs a new OC_Filestorage_Versioned instance, expects an associative + * array with a `repo` key set to the path of the repository's `.git` folder + * + * @param array $parameters An array containing the key `repo` pointing to the + * repository path. + */ + public function __construct($parameters) { + // Get the full path to the repository folder + $path = OC_Config::getValue('datadirectory', OC::$SERVERROOT.'/data') + . $parameters['repo'] + . DIRECTORY_SEPARATOR + . '.git' + . DIRECTORY_SEPARATOR; + + try { + // Attempt to load the repository + $this->repo = new Granite\Git\Repository($path); + } catch (InvalidArgumentException $e) { + // $path is not a valid Git repository, we must create one + Granite\Git\Repository::init($path); + + // Load the newly-initialised repository + $this->repo = new Granite\Git\Repository($path); + + /** + * Create an initial commit with a README file + * FIXME: This functionality should be transferred to the Granite library + */ + $blob = new Granite\Git\Blob($this->repo->path()); + $blob->content('Your Backup directory is now ready for use.'); + + // Create a new tree to hold the README file + $tree = $this->repo->factory('tree'); + // Create a tree node to represent the README blob + $tree_node = new Granite\Git\Tree\Node('README', '100644', $blob->sha()); + $tree->nodes(array($tree_node->name() => $tree_node)); + + // Create an initial commit + $commit = new Granite\Git\Commit($this->repo->path()); + $user_string = OC_User::getUser() . ' ' . time() . ' +0000'; + $commit->author($user_string); + $commit->committer($user_string); + $commit->message('Initial commit'); + $commit->tree($tree); + + // Write it all to disk + $blob->write(); + $tree->write(); + $commit->write(); + + // Update the HEAD for the 'master' branch + $this->repo->head('master', $commit->sha()); + } + + // Update the class pointer to the HEAD + $head = OC_Preferences::getValue(OC_User::getUser(), 'files_versioning', 'head', 'HEAD'); + + // Load the most recent commit if the preference is not set + if ($head == 'HEAD') { + $this->head = $this->repo->head()->sha(); + } else { + $this->head = $head; + } + } + + public function mkdir($path) { + if (mkdir("versioned:/{$this->repo->path()}$path#{$this->head}")) { + $this->head = $this->repo->head()->sha(); + OC_Preferences::setValue(OC_User::getUser(), 'files_versioning', 'head', $head); + return true; + } + + return false; + } + + public function rmdir($path) { + + } + + /** + * Returns a directory handle to the requested path, or FALSE on failure + * + * @param string $path The directory path to open + * + * @return boolean|resource A directory handle, or FALSE on failure + */ + public function opendir($path) { + return opendir("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + /** + * Returns TRUE if $path is a directory, or FALSE if not + * + * @param string $path The path to check + * + * @return boolean + */ + public function is_dir($path) { + return $this->filetype($path) == 'dir'; + } + + /** + * Returns TRUE if $path is a file, or FALSE if not + * + * @param string $path The path to check + * + * @return boolean + */ + public function is_file($path) { + return $this->filetype($path) == 'file'; + } + + public function stat($path) + { + return stat("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + /** + * Returns the strings 'dir' or 'file', depending on the type of $path + * + * @param string $path The path to check + * + * @return string Returns 'dir' if a directory, 'file' otherwise + */ + public function filetype($path) { + if ($path == "" || $path == "/") { + return 'dir'; + } else { + if (substr($path, -1) == '/') { + $path = substr($path, 0, -1); + } + + $node = $this->tree_search($this->repo, $this->repo->factory('commit', $this->head)->tree(), $path); + + // Does it exist, or is it new? + if ($node == null) { + // New file + return 'file'; + } else { + // Is it a tree? + try { + $this->repo->factory('tree', $node); + return 'dir'; + } catch (InvalidArgumentException $e) { + // Nope, must be a blob + return 'file'; + } + } + } + } + + public function filesize($path) { + return filesize("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + /** + * Returns a boolean value representing whether $path is readable + * + * @param string $path The path to check + *( + * @return boolean Whether or not the path is readable + */ + public function is_readable($path) { + return true; + } + + /** + * Returns a boolean value representing whether $path is writable + * + * @param string $path The path to check + *( + * @return boolean Whether or not the path is writable + */ + public function is_writable($path) { + + $head = OC_Preferences::getValue(OC_User::getUser(), 'files_versioning', 'head', 'HEAD'); + if ($head !== 'HEAD' && $head !== $this->repo->head()->sha()) { + // Cannot modify previous commits + return false; + } + return true; + } + + /** + * Returns a boolean value representing whether $path exists + * + * @param string $path The path to check + *( + * @return boolean Whether or not the path exists + */ + public function file_exists($path) { + return file_exists("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + /** + * Returns an integer value representing the inode change time + * (NOT IMPLEMENTED) + * + * @param string $path The path to check + *( + * @return int Timestamp of the last inode change + */ + public function filectime($path) { + return -1; + } + + /** + * Returns an integer value representing the file modification time + * + * @param string $path The path to check + *( + * @return int Timestamp of the last file modification + */ + public function filemtime($path) { + return filemtime("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + public function file_get_contents($path) { + return file_get_contents("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + public function file_put_contents($path, $data) { + $success = file_put_contents("versioned:/{$this->repo->path()}$path#{$this->head}", $data); + if ($success !== false) { + // Update the HEAD in the preferences + OC_Preferences::setValue(OC_User::getUser(), 'files_versioning', 'head', $this->repo->head()->sha()); + return $success; + } + + return false; + } + + public function unlink($path) { + + } + + public function rename($path1, $path2) { + + } + + public function copy($path1, $path2) { + + } + + public function fopen($path, $mode) { + return fopen("versioned:/{$this->repo->path()}$path#{$this->head}", $mode); + } + + public function getMimeType($path) { + if ($this->filetype($path) == 'dir') { + return 'httpd/unix-directory'; + } elseif ($this->filesize($path) == 0) { + // File's empty, returning text/plain allows opening in the web editor + return 'text/plain'; + } else { + $finfo = new finfo(FILEINFO_MIME_TYPE); + /** + * We need to represent the repository path, the file path, and the + * revision, which can be simply achieved with a convention of using + * `.git` in the repository directory (bare or not) and the '#part' + * segment of a URL to specify the revision. For example + * + * versioned://var/www/myrepo.git/docs/README.md#HEAD ('bare' repo) + * versioned://var/www/myrepo/.git/docs/README.md#HEAD ('full' repo) + * versioned://var/www/myrepo/.git/docs/README.md#6a8f...8a54 ('full' repo and SHA-1 commit ID) + */ + $mime = $finfo->buffer(file_get_contents("versioned:/{$this->repo->path()}$path#{$this->head}")); + return $mime; + } + } + + /** + * Generates a hash based on the file contents + * + * @param string $type The hashing algorithm to use (e.g. 'md5', 'sha256', etc.) + * @param string $path The file to be hashed + * @param boolean $raw Outputs binary data if true, lowercase hex digits otherwise + * + * @return string Hashed string representing the file contents + */ + public function hash($type, $path, $raw) { + return hash($type, file_get_contents($path), $raw); + } + + public function free_space($path) { + } + + public function search($query) { + + } + + public function touch($path, $mtime=null) { + + } + + + public function getLocalFile($path) { + } + + /** + * Recursively searches a tree for a path, returning FALSE if is not found + * or an SHA-1 id if it is found. + * + * @param string $repo The repository containing the tree object + * @param string $tree The tree object to search + * @param string $path The path to search for (relative to the tree) + * @param int $depth The depth of the current search (for recursion) + * + * @return string|boolean The SHA-1 id of the sub-tree + */ + private function tree_search($repo, $tree, $path, $depth = 0) + { + $paths = array_values(explode(DIRECTORY_SEPARATOR, $path)); + + $current_path = $paths[$depth]; + + $nodes = $tree->nodes(); + foreach ($nodes as $node) { + if ($node->name() == $current_path) { + + if (count($paths)-1 == $depth) { + // Stop, found it + return $node->sha(); + } + + // Recurse if necessary + if ($node->isDirectory()) { + $tree = $this->repo->factory('tree', $node->sha()); + return $this->tree_search($repo, $tree, $path, $depth + 1); + } + } + } + + return false; + } + +} diff --git a/apps/files_versioning/versionwrapper.php b/apps/files_versioning/versionwrapper.php new file mode 100644 index 0000000000..b83a4fd3b2 --- /dev/null +++ b/apps/files_versioning/versionwrapper.php @@ -0,0 +1,686 @@ +tree); + return true; + } + + /** + * Open directory handle + */ + public function dir_opendir($path, $options) { + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($path); + + if ($repo_file == '' || $repo_file == '/') { + // Set the tree property for the future `readdir()` etc. calls + $this->tree = array_values($this->commit->tree()->nodes()); + return true; + } elseif ($this->tree_search($this->repo, $this->commit->tree(), $repo_file) !== false) { + // Something exists at this path, is it a directory though? + try { + $tree = $this->repo->factory( + 'tree', + $this->tree_search($this->repo, $this->commit->tree(), $repo_file) + ); + $this->tree = array_values($tree->nodes()); + return true; + } catch (InvalidArgumentException $e) { + // Trying to call `opendir()` on a file, return false below + } + } + + // Unable to find the directory, return false + return false; + } + + /** + * Read entry from directory handle + */ + public function dir_readdir() { + return isset($this->tree[$this->dir_position]) + ? $this->tree[$this->dir_position++]->name() + : false; + } + + /** + * Rewind directory handle + */ + public function dir_rewinddir() { + $this->dir_position = 0; + } + + /** + * Create a directory + * Git doesn't track empty directories, so a ".empty" file is added instead + */ + public function mkdir($path, $mode, $options) { + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($path); + + // Create an empty file for Git + $empty = new Granite\Git\Blob($this->repo->path()); + $empty->content(''); + $empty->write(); + + if (dirname($repo_file) == '.') { + // Adding a new directory to the root tree + $tree = $this->repo->head()->tree(); + } else { + $tree = $this->repo->factory('tree', $this->tree_search( + $this->repo, $this->repo->head()->tree(), dirname($repo_file) + ) + ); + } + + // Create our new tree, with our empty file + $dir = $this->repo->factory('tree'); + $nodes = array(); + $nodes[self::EMPTYFILE] = new Granite\Git\Tree\Node(self::EMPTYFILE, '100644', $empty->sha()); + $dir->nodes($nodes); + $dir->write(); + + // Add our new tree to its parent + $nodes = $tree->nodes(); + $nodes[basename($repo_file)] = new Granite\Git\Tree\Node(basename($repo_file), '040000', $dir->sha()); + $tree->nodes($nodes); + $tree->write(); + + // We need to recursively update each parent tree, since they are all + // hashed and the changes will cascade back up the chain + + // So, we're currently at the bottom-most directory + $current_dir = dirname($repo_file); + $previous_tree = $tree; + + if ($current_dir !== '.') { + do { + // Determine the parent directory + $previous_dir = $current_dir; + $current_dir = dirname($current_dir); + + $current_tree = $current_dir !== '.' + ? $this->repo->factory( + 'tree', $this->tree_search( + $this->repo, + $this->repo->head()->tree(), + $current_dir + ) + ) + : $this->repo->head()->tree(); + + $current_nodes = $current_tree->nodes(); + $current_nodes[basename($previous_dir)] = new Granite\Git\Tree\Node( + basename($previous_dir), '040000', $previous_tree->sha() + ); + $current_tree->nodes($current_nodes); + $current_tree->write(); + + $previous_tree = $current_tree; + } while ($current_dir !== '.'); + + $tree = $previous_tree; + } + + // Create a new commit to represent this write + $commit = $this->repo->factory('commit'); + $username = OC_User::getUser(); + $user_string = $username . ' ' . time() . ' +0000'; + $commit->author($user_string); + $commit->committer($user_string); + $commit->message("$username created the `$repo_file` directory, " . date('d F Y H:i', time()) . '.'); + $commit->parents(array($this->repo->head()->sha())); + $commit->tree($tree); + + // Write it to disk + $commit->write(); + + // Update the HEAD for the 'master' branch + $this->repo->head('master', $commit->sha()); + + return true; + } + + /** + * Renames a file or directory + */ + public function rename($path_from, $path_to) { + + } + + /** + * Removes a directory + */ + public function rmdir($path, $options) { + + } + + /** + * Retrieve the underlaying resource (NOT IMPLEMENTED) + */ + public function stream_cast($cast_as) { + return false; + } + + /** + * Close a resource + */ + public function stream_close() { + unset($this->blob); + return true; + } + + /** + * Tests for end-of-file on a file pointer + */ + public function stream_eof() { + return !($this->file_position < strlen($this->blob)); + } + + /** + * Flushes the output (NOT IMPLEMENTED) + */ + public function stream_flush() { + return false; + } + + /** + * Advisory file locking (NOT IMPLEMENTED) + */ + public function stream_lock($operation) { + return false; + } + + /** + * Change stream options (NOT IMPLEMENTED) + * Called in response to `chgrp()`, `chown()`, `chmod()` and `touch()` + */ + public function stream_metadata($path, $option, $var) { + return false; + } + + /** + * Opens file or URL + */ + public function stream_open($path, $mode, $options, &$opened_path) { + // Store the path, so we can use it later in `stream_write()` if necessary + $this->path = $path; + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($path); + + $file = $this->tree_search($this->repo, $this->commit->tree(), $repo_file); + if ($file !== false) { + try { + $this->blob = $this->repo->factory('blob', $file)->content(); + return true; + } catch (InvalidArgumentException $e) { + // Trying to open a directory, return false below + } + } elseif ($mode !== 'r') { + // All other modes allow opening for reading and writing, clearly + // some 'write' files may not exist yet... + return true; + } + + // File could not be found or is not actually a file + return false; + } + + /** + * Read from stream + */ + public function stream_read($count) { + // Fetch the remaining set of bytes + $bytes = substr($this->blob, $this->file_position, $count); + + // If EOF or empty string, return false + if ($bytes == '' || $bytes == false) { + return false; + } + + // If $count does not extend past EOF, add $count to stream offset + if ($this->file_position + $count < strlen($this->blob)) { + $this->file_position += $count; + } else { + // Otherwise return all remaining bytes + $this->file_position = strlen($this->blob); + } + + return $bytes; + } + + /** + * Seeks to specific location in a stream + */ + public function stream_seek($offset, $whence = SEEK_SET) { + $new_offset = false; + + switch ($whence) + { + case SEEK_SET: + $new_offset = $offset; + break; + case SEEK_CUR: + $new_offset = $this->file_position += $offset; + break; + case SEEK_END: + $new_offset = strlen($this->blob) + $offset; + break; + } + + $this->file_position = $offset; + + return ($new_offset !== false); + } + + /** + * Change stream options (NOT IMPLEMENTED) + */ + public function stream_set_option($option, $arg1, $arg2) { + return false; + } + + /** + * Retrieve information about a file resource (NOT IMPLEMENTED) + */ + public function stream_stat() { + + } + + /** + * Retrieve the current position of a stream + */ + public function stream_tell() { + return $this->file_position; + } + + /** + * Truncate stream + */ + public function stream_truncate($new_size) { + + } + + /** + * Write to stream + * FIXME: Could use heavy refactoring + */ + public function stream_write($data) { + /** + * FIXME: This also needs to be added to Granite, in the form of `add()`, + * `rm()` and `commit()` calls + */ + + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($this->path); + + $node = $this->tree_search($this->repo, $this->commit->tree(), $repo_file); + + if ($node !== false) { + // File already exists, attempting modification of existing tree + try { + $this->repo->factory('blob', $node); + + // Create our new blob with the provided $data + $blob = $this->repo->factory('blob'); + $blob->content($data); + $blob->write(); + + // We know the tree exists, so strip the filename from the path and + // find it... + + if (dirname($repo_file) == '.' || dirname($repo_file) == '') { + // Root directory + $tree = $this->repo->head()->tree(); + } else { + // Sub-directory + $tree = $this->repo->factory('tree', $this->tree_search( + $this->repo, + $this->repo->head()->tree(), + dirname($repo_file) + ) + ); + } + + // Replace the old blob with our newly modified one + $tree_nodes = $tree->nodes(); + $tree_nodes[basename($repo_file)] = new Granite\Git\Tree\Node( + basename($repo_file), '100644', $blob->sha() + ); + $tree->nodes($tree_nodes); + $tree->write(); + + // We need to recursively update each parent tree, since they are all + // hashed and the changes will cascade back up the chain + + // So, we're currently at the bottom-most directory + $current_dir = dirname($repo_file); + $previous_tree = $tree; + + if ($current_dir !== '.') { + do { + // Determine the parent directory + $previous_dir = $current_dir; + $current_dir = dirname($current_dir); + + $current_tree = $current_dir !== '.' + ? $this->repo->factory( + 'tree', $this->tree_search( + $this->repo, + $this->repo->head()->tree(), + $current_dir + ) + ) + : $this->repo->head()->tree(); + + $current_nodes = $current_tree->nodes(); + $current_nodes[basename($previous_dir)] = new Granite\Git\Tree\Node( + basename($previous_dir), '040000', $previous_tree->sha() + ); + $current_tree->nodes($current_nodes); + $current_tree->write(); + + $previous_tree = $current_tree; + } while ($current_dir !== '.'); + } + + // Create a new commit to represent this write + $commit = $this->repo->factory('commit'); + $username = OC_User::getUser(); + $user_string = $username . ' ' . time() . ' +0000'; + $commit->author($user_string); + $commit->committer($user_string); + $commit->message("$username modified the `$repo_file` file, " . date('d F Y H:i', time()) . '.'); + $commit->parents(array($this->repo->head()->sha())); + $commit->tree($previous_tree); + + // Write it to disk + $commit->write(); + + // Update the HEAD for the 'master' branch + $this->repo->head('master', $commit->sha()); + + // If we made it this far, write was successful - update the stream + // position and return the number of bytes written + $this->file_position += strlen($data); + return strlen($data); + + } catch (InvalidArgumentException $e) { + // Attempting to write to a directory or other error, fail + return 0; + } + } else { + // File does not exist, needs to be created + + // Create our new blob with the provided $data + $blob = $this->repo->factory('blob'); + $blob->content($data); + $blob->write(); + + if (dirname($repo_file) == '.') { + // Trying to add a new file to the root tree, nice and easy + $tree = $this->repo->head()->tree(); + $tree_nodes = $tree->nodes(); + $tree_nodes[basename($repo_file)] = new Granite\Git\Tree\Node( + basename($repo_file), '100644', $blob->sha() + ); + $tree->nodes($tree_nodes); + $tree->write(); + } else { + // Trying to add a new file to a subdirectory, try and find it + $tree = $this->repo->factory('tree', $this->tree_search( + $this->repo, $this->repo->head()->tree(), dirname($repo_file) + ) + ); + + // Add the blob to the tree + $nodes = $tree->nodes(); + $nodes[basename($repo_file)] = new Granite\Git\Tree\Node( + basename($repo_file), '100644', $blob->sha() + ); + $tree->nodes($nodes); + $tree->write(); + + // We need to recursively update each parent tree, since they are all + // hashed and the changes will cascade back up the chain + + // So, we're currently at the bottom-most directory + $current_dir = dirname($repo_file); + $previous_tree = $tree; + + if ($current_dir !== '.') { + do { + // Determine the parent directory + $previous_dir = $current_dir; + $current_dir = dirname($current_dir); + + $current_tree = $current_dir !== '.' + ? $this->repo->factory( + 'tree', $this->tree_search( + $this->repo, + $this->repo->head()->tree(), + $current_dir + ) + ) + : $this->repo->head()->tree(); + + $current_nodes = $current_tree->nodes(); + $current_nodes[basename($previous_dir)] = new Granite\Git\Tree\Node( + basename($previous_dir), '040000', $previous_tree->sha() + ); + $current_tree->nodes($current_nodes); + $current_tree->write(); + + $previous_tree = $current_tree; + } while ($current_dir !== '.'); + + $tree = $previous_tree; + } + } + + // Create a new commit to represent this write + $commit = $this->repo->factory('commit'); + $username = OC_User::getUser(); + $user_string = $username . ' ' . time() . ' +0000'; + $commit->author($user_string); + $commit->committer($user_string); + $commit->message("$username created the `$repo_file` file, " . date('d F Y H:i', time()) . '.'); + $commit->parents(array($this->repo->head()->sha())); + $commit->tree($tree); // Top-level tree (NOT the newly modified tree) + + // Write it to disk + $commit->write(); + + // Update the HEAD for the 'master' branch + $this->repo->head('master', $commit->sha()); + + // If we made it this far, write was successful - update the stream + // position and return the number of bytes written + $this->file_position += strlen($data); + return strlen($data); + } + + // Write failed + return 0; + } + + /** + * Delete a file + */ + public function unlink($path) { + + } + + /** + * Retrieve information about a file + */ + public function url_stat($path, $flags) { + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($path); + + $node = $this->tree_search($this->repo, $this->commit->tree(), $repo_file); + + if ($node == false && $this->commit->sha() == $this->repo->head()->sha()) { + // A new file - no information available + $size = 0; + $mtime = -1; + } else { + + // Is it a directory? + try { + $this->repo->factory('tree', $node); + $size = 4096; // FIXME + } catch (InvalidArgumentException $e) { + // Must be a file + $size = strlen(file_get_contents($path)); + } + + // Parse the timestamp from the commit message + preg_match('/[0-9]{10}+/', $this->commit->committer(), $matches); + $mtime = $matches[0]; + } + + $stat["dev"] = ""; + $stat["ino"] = ""; + $stat["mode"] = ""; + $stat["nlink"] = ""; + $stat["uid"] = ""; + $stat["gid"] = ""; + $stat["rdev"] = ""; + $stat["size"] = $size; + $stat["atime"] = $mtime; + $stat["mtime"] = $mtime; + $stat["ctime"] = $mtime; + $stat["blksize"] = ""; + $stat["blocks"] = ""; + + return $stat; + } + + /** + * Debug function for development purposes + */ + private function debug($message, $level = OC_Log::DEBUG) + { + if ($this->debug) { + OC_Log::write('files_versioning', $message, $level); + } + } + + /** + * Parses a URL of the form: + * `versioned://path/to/git/repository/.git/path/to/file#SHA-1-commit-id` + * FIXME: Will throw an InvalidArgumentException if $path is invaid + * + * @param string $path The path to parse + * + * @return array An array containing an instance of Granite\Git\Repository, + * the file path, and an instance of Granite\Git\Commit + * @throws InvalidArgumentException If the repository cannot be loaded + */ + private function parse_url($path) + { + preg_match('/\/([A-Za-z0-9\/]+\.git\/)([A-Za-z0-9\/\.\/]*)(#([A-Fa-f0-9]+))*/', $path, $matches); + + // Load up the repo + $repo = new \Granite\Git\Repository($matches[1]); + // Parse the filename (stripping any trailing slashes) + $repo_file = $matches[2]; + if (substr($repo_file, -1) == '/') { + $repo_file = substr($repo_file, 0, -1); + } + + // Default to HEAD if no commit is provided + $repo_commit = isset($matches[4]) + ? $matches[4] + : $repo->head()->sha(); + + // Load the relevant commit + $commit = $repo->factory('commit', $repo_commit); + + return array($repo, $repo_file, $commit); + } + + /** + * Recursively searches a tree for a path, returning FALSE if is not found + * or an SHA-1 id if it is found. + * + * @param string $repo The repository containing the tree object + * @param string $tree The tree object to search + * @param string $path The path to search for (relative to the tree) + * @param int $depth The depth of the current search (for recursion) + * + * @return string|boolean The SHA-1 id of the sub-tree + */ + private function tree_search($repo, $tree, $path, $depth = 0) + { + $paths = array_values(explode(DIRECTORY_SEPARATOR, $path)); + + $current_path = $paths[$depth]; + + $nodes = $tree->nodes(); + foreach ($nodes as $node) { + if ($node->name() == $current_path) { + + if (count($paths)-1 == $depth) { + // Stop, found it + return $node->sha(); + } + + // Recurse if necessary + if ($node->isDirectory()) { + $tree = $this->repo->factory('tree', $node->sha()); + return $this->tree_search($repo, $tree, $path, $depth + 1); + } + } + } + + return false; + } + +}