From 9fd4334dec58e292e9a3c4e3c6ab776a6ee8d07c Mon Sep 17 00:00:00 2001 From: Thomas Keller Date: Mon, 26 Apr 2010 23:56:25 +0200 Subject: [PATCH] Start on monotone support for indefero. The SCM backend is about 30% done, interesting pieces like getTree() are not finished yet. --- src/IDF/Scm/Monotone.php | 651 ++++++++++++++++++ .../templates/idf/source/mtn/changelog.html | 17 + src/IDF/templates/idf/source/mtn/file.html | 44 ++ src/IDF/templates/idf/source/mtn/help.html | 38 + src/IDF/templates/idf/source/mtn/tree.html | 69 ++ 5 files changed, 819 insertions(+) create mode 100644 src/IDF/Scm/Monotone.php create mode 100644 src/IDF/templates/idf/source/mtn/changelog.html create mode 100644 src/IDF/templates/idf/source/mtn/file.html create mode 100644 src/IDF/templates/idf/source/mtn/help.html create mode 100644 src/IDF/templates/idf/source/mtn/tree.html diff --git a/src/IDF/Scm/Monotone.php b/src/IDF/Scm/Monotone.php new file mode 100644 index 0000000..8c8b83f --- /dev/null +++ b/src/IDF/Scm/Monotone.php @@ -0,0 +1,651 @@ +%nTree: %T%nDate: %ai%n%n%s%n%n%b'; + + /* ============================================== * + * * + * Common Methods Implemented By All The SCMs * + * * + * ============================================== */ + + public function __construct($repo, $project=null) + { + $this->repo = $repo; + $this->project = $project; + } + + public function getRepositorySize() + { + if (!file_exists($this->repo)) { + return 0; + } + $cmd = Pluf::f('idf_exec_cmd_prefix', '').'du -sk ' + .escapeshellarg($this->repo); + $out = explode(' ', + self::shell_exec('IDF_Scm_Monotone::getRepositorySize', $cmd), + 2); + return (int) $out[0]*1024; + } + + public function isAvailable() + { + try { + $branches = $this->getBranches(); + } catch (IDF_Scm_Exception $e) { + return false; + } + return (count($branches) > 0); + } + + public function getBranches() + { + if (isset($this->cache['branches'])) { + return $this->cache['branches']; + } + // FIXME: introduce handling of suspended branches + $cmd = Pluf::f('idf_exec_cmd_prefix', '') + .sprintf("%s -d %s automate branches", + Pluf::f('mtn_path', 'mtn'), + escapeshellarg($this->repo)); + self::exec('IDF_Scm_Monotone::getBranches', + $cmd, $out, $return); + if ($return != 0) { + throw new IDF_Scm_Exception(sprintf($this->error_tpl, + $cmd, $return, + implode("\n", $out))); + } + $res = array(); + // FIXME: we could expand each branch with one of its head revisions + // here, but these would soon become bogus anyway and we cannot + // map multiple head revisions here either, so we just use the + // selector as placeholder + foreach ($out as $b) { + $res["h:$b"] = $b; + } + $this->cache['branches'] = $res; + return $res; + } + + /** + * monotone has no concept of a "main" branch, so just return + * the first one (the branch list is already sorted) + * + * @return string + */ + public function getMainBranch() + { + $branches = $this->getBranches(); + return key($branches); + } + + /** + * expands a selector or a partial revision id to zero, one or + * multiple 40 byte revision ids + * + * @param string $selector + * @return array + */ + private static function _resolveSelector($selector) + { + $cmd = Pluf::f('idf_exec_cmd_prefix', '') + .sprintf("%s -d %s automate select %s", + Pluf::f('mtn_path', 'mtn'), + escapeshellarg($this->repo), + escapeshellarg($selector)); + self::exec('IDF_Scm_Monotone::_resolveSelector', + $cmd, $out, $return); + return $out; + } + + /** + * Parses monotone's basic_io format + * + * @param string $in + * @return array of arrays + */ + private static function _parseBasicIO($in) + { + $pos = 0; + $stanzas = array(); + + while ($pos < strlen($in)) + { + $stanza = array(); + while ($pos < strlen($in)) + { + if ($in[$pos] == "\n") break; + + $stanzaLine = array("key" => "", "values" => array(), "hash" => null); + while ($pos < strlen($in)) + { + $ch = $in[$pos]; + if ($ch == '"' || $ch == '[') break; + ++$pos; + if ($ch == ' ') continue; + $stanzaLine['key'] .= $ch; + } + + if ($in[$pos] == '[') + { + ++$pos; // opening square bracket + $stanzaLine['hash'] = substr($in, $pos, 40); + $pos += 40; + ++$pos; // closing square bracket + } + else + { + $valCount = 0; + while ($in[$pos] == '"') + { + ++$pos; // opening quote + $stanzaLine['values'][$valCount] = ""; + while ($pos < strlen($in)) + { + $ch = $in[$pos]; $pr = $in[$pos-1]; + if ($ch == '"' && $pr != '\\') break; + ++$pos; + $stanzaLine['values'][$valCount] .= $ch; + } + ++$pos; // closing quote + + if ($in[$pos] == ' ') + { + ++$pos; // space + ++$valCount; + } + } + } + + $stanza[] = $stanzaLine; + ++$pos; // newline + } + $stanzas[] = $stanza; + ++$pos; // newline + } + return $stanzas; + } + + private static function _getUniqueCertValuesFor($revs, $certName) + { + $certValues = array(); + foreach ($revs as $rev) + { + $cmd = Pluf::f('idf_exec_cmd_prefix', '') + .sprintf("%s -d %s automate certs %s", + Pluf::f('mtn_path', 'mtn'), + escapeshellarg($this->repo), + escapeshellarg($rev)); + self::exec('IDF_Scm_Monotone::inBranches', + $cmd, $out, $return); + + $stanzas = self::_parseBasicIO(implode('\n', $out)); + foreach ($stanzas as $stanza) + { + foreach ($stanza as $stanzaline) + { + // luckily, name always comes before value + if ($stanzaline['key'] == "name" && + $stanzaline['values'][0] != $certName) + { + break; + } + if ($stanzaline['key'] == "value") + { + $certValues[] = $stanzaline['values'][0]; + break; + } + } + } + } + return array_unique($certValues); + } + + /** + * @see IDF_Scm::inBranches() + **/ + public function inBranches($commit, $path) + { + $revs = self::_resolveSelector($commit); + if (count($revs) == 0) return array(); + return self::_getUniqueCertValuesFor($revs, "branch"); + } + + /** + * @see IDF_Scm::getTags() + **/ + public function getTags() + { + if (isset($this->cache['tags'])) { + return $this->cache['tags']; + } + $cmd = Pluf::f('idf_exec_cmd_prefix', '') + .sprintf("%s -d %s automate tags", + Pluf::f('mtn_path', 'mtn'), + escapeshellarg($this->repo)); + self::exec('IDF_Scm_Monotone::getTags', $cmd, $out, $return); + + $tags = array(); + $stanzas = self::parseBasicIO(implode('\n', $out)); + foreach ($stanzas as $stanza) + { + foreach ($stanza as $stanzaline) + { + if ($stanzaline['key'] == "tag") + { + $tags[] = $stanzaline['values'][0]; + break; + } + } + } + + $this->cache['tags'] = $tags; + return $tags; + } + + /** + * @see IDF_Scm::inTags() + **/ + public function inTags($commit, $path) + { + $revs = self::_resolveSelector($commit); + if (count($revs) == 0) return array(); + return self::_getUniqueCertValuesFor($revs, "tag"); + } + + /** + * @see IDF_Scm::getTree() + */ + public function getTree($commit, $folder='/', $branch=null) + { + $revs = self::_resolveSelector($commit); + if ($revs != 1) + { + throw new Exception(sprintf( + __('Commit %1$s does not (uniquely) identify a revision.'), + $commit + )); + } + + $cmd = Pluf::f('idf_exec_cmd_prefix', '') + .sprintf("%s -d %s automate get_manifest_of %s", + Pluf::f('mtn_path', 'mtn'), + escapeshellarg($this->repo), + escapeshellarg($revs[0])); + self::exec('IDF_Scm_Monotone::getTree', $cmd, $out, $return); + + $files = array(); + $stanzas = self::parseBasicIO(implode('\n', $out)); + $folder = $folder == '/' || empty($folder) ? '' : $folder.'/'; + + foreach ($stanzas as $stanza) + { + if ($stanza[0]['key'] == "format_version") + continue; + + $path = $stanza[0]['values'][0]; + if (!preg_match('#^'.$folder.'([^/]+)$#', $path, $m)) + continue; + + $file = array(); + $file['file'] = $m[1]; + $file['fullpath'] = $path; + $file['efullpath'] = self::smartEncode($path); + + if ($stanza[0]['key'] == "dir") + $file['type'] == "tree"; + else + $file['type'] == "blob"; + + /* + $file['date'] = gmdate('Y-m-d H:i:s', + strtotime((string) $entry->commit->date)); + $file['rev'] = (string) $entry->commit['revision']; + $file['log'] = $this->getCommitMessage($file['rev']); + // Get the size if the type is blob + if ($file['type'] == 'blob') { + $file['size'] = (string) $entry->size; + } + $file['author'] = (string) $entry->commit->author; + */ + $file['perm'] = ''; + $files[] = (object) $file; + } + return $files; + } + + /** + * Given the string describing the author from the log find the + * author in the database. + * + * @param string Author + * @return mixed Pluf_User or null + */ + public function findAuthor($author) + { + // We extract the email. + $match = array(); + if (!preg_match('/<(.*)>/', $author, $match)) { + return null; + } + foreach (array('email', 'login') as $what) { + $sql = new Pluf_SQL($what.'=%s', array($match[1])); + $users = Pluf::factory('Pluf_User')->getList(array('filter'=>$sql->gen())); + if ($users->count() > 0) { + return $users[0]; + } + } + return null; + } + + public static function getAnonymousAccessUrl($project) + { + return sprintf( + Pluf::f('mtn_remote_url'), + $project->shortname, + Pluf::f('mtn_branch_prefix'), + $project->shortname + ); + } + + public static function getAuthAccessUrl($project, $user) + { + return self::getAnonymousAccessUrl($project); + } + + /** + * Returns this object correctly initialized for the project. + * + * @param IDF_Project + * @return IDF_Scm_Monotone + */ + public static function factory($project) + { + $rep = sprintf(Pluf::f('git_repositories'), $project->shortname); + return new IDF_Scm_Monotone($rep, $project); + } + + public function isValidRevision($commit) + { + $type = $this->testHash($commit); + return ('commit' == $type || 'tag' == $type); + } + + /** + * Test a given object hash. + * + * @param string Object hash. + * @return mixed false if not valid or 'blob', 'tree', 'commit', 'tag' + */ + public function testHash($hash) + { + $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' cat-file -t %s', + escapeshellarg($this->repo), + escapeshellarg($hash)); + $ret = 0; $out = array(); + $cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd; + self::exec('IDF_Scm_Monotone::testHash', $cmd, $out, $ret); + if ($ret != 0) return false; + return trim($out[0]); + } + + /** + * Get the tree info. + * + * @param string Tree hash + * @param bool Do we recurse in subtrees (true) + * @param string Folder in which we want to get the info ('') + * @return array Array of file information. + */ + public function getTreeInfo($tree, $folder='') + { + if (!in_array($this->testHash($tree), array('tree', 'commit', 'tag'))) { + throw new Exception(sprintf(__('Not a valid tree: %s.'), $tree)); + } + $cmd_tmpl = 'GIT_DIR=%s '.Pluf::f('git_path', 'git').' ls-tree -l %s %s'; + $cmd = Pluf::f('idf_exec_cmd_prefix', '') + .sprintf($cmd_tmpl, escapeshellarg($this->repo), + escapeshellarg($tree), escapeshellarg($folder)); + $out = array(); + $res = array(); + self::exec('IDF_Scm_Monotone::getTreeInfo', $cmd, $out); + foreach ($out as $line) { + list($perm, $type, $hash, $size, $file) = preg_split('/ |\t/', $line, 5, PREG_SPLIT_NO_EMPTY); + $res[] = (object) array('perm' => $perm, 'type' => $type, + 'size' => $size, 'hash' => $hash, + 'file' => $file); + } + return $res; + } + + /** + * Get the file info. + * + * @param string File + * @param string Commit ('HEAD') + * @return false Information + */ + public function getPathInfo($totest, $commit='HEAD') + { + $cmd_tmpl = 'GIT_DIR=%s '.Pluf::f('git_path', 'git').' ls-tree -r -t -l %s'; + $cmd = sprintf($cmd_tmpl, + escapeshellarg($this->repo), + escapeshellarg($commit)); + $out = array(); + $cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd; + self::exec('IDF_Scm_Monotone::getPathInfo', $cmd, $out); + foreach ($out as $line) { + list($perm, $type, $hash, $size, $file) = preg_split('/ |\t/', $line, 5, PREG_SPLIT_NO_EMPTY); + if ($totest == $file) { + $pathinfo = pathinfo($file); + return (object) array('perm' => $perm, 'type' => $type, + 'size' => $size, 'hash' => $hash, + 'fullpath' => $file, + 'file' => $pathinfo['basename']); + } + } + return false; + } + + public function getFile($def, $cmd_only=false) + { + $cmd = sprintf(Pluf::f('idf_exec_cmd_prefix', ''). + 'GIT_DIR=%s '.Pluf::f('git_path', 'git').' cat-file blob %s', + escapeshellarg($this->repo), + escapeshellarg($def->hash)); + return ($cmd_only) + ? $cmd : self::shell_exec('IDF_Scm_Monotone::getFile', $cmd); + } + + /** + * Get commit details. + * + * @param string Commit + * @param bool Get commit diff (false) + * @return array Changes + */ + public function getCommit($commit, $getdiff=false) + { + if ($getdiff) { + $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' show --date=iso --pretty=format:%s %s', + escapeshellarg($this->repo), + "'".$this->mediumtree_fmt."'", + escapeshellarg($commit)); + } else { + $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' log -1 --date=iso --pretty=format:%s %s', + escapeshellarg($this->repo), + "'".$this->mediumtree_fmt."'", + escapeshellarg($commit)); + } + $out = array(); + $cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd; + self::exec('IDF_Scm_Monotone::getCommit', $cmd, $out, $ret); + if ($ret != 0 or count($out) == 0) { + return false; + } + if ($getdiff) { + $log = array(); + $change = array(); + $inchange = false; + foreach ($out as $line) { + if (!$inchange and 0 === strpos($line, 'diff --git a')) { + $inchange = true; + } + if ($inchange) { + $change[] = $line; + } else { + $log[] = $line; + } + } + $out = self::parseLog($log); + $out[0]->changes = implode("\n", $change); + } else { + $out = self::parseLog($out); + $out[0]->changes = ''; + } + return $out[0]; + } + + /** + * Check if a commit is big. + * + * @param string Commit ('HEAD') + * @return bool The commit is big + */ + public function isCommitLarge($commit='HEAD') + { + $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' log --numstat -1 --pretty=format:%s %s', + escapeshellarg($this->repo), + "'commit %H%n'", + escapeshellarg($commit)); + $out = array(); + $cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd; + self::exec('IDF_Scm_Monotone::isCommitLarge', $cmd, $out); + $affected = count($out) - 2; + $added = 0; + $removed = 0; + $c=0; + foreach ($out as $line) { + $c++; + if ($c < 3) { + continue; + } + list($a, $r, $f) = preg_split("/[\s]+/", $line, 3, PREG_SPLIT_NO_EMPTY); + $added+=$a; + $removed+=$r; + } + return ($affected > 100 or ($added + $removed) > 20000); + } + + /** + * Get latest changes. + * + * @param string Commit ('HEAD'). + * @param int Number of changes (10). + * @return array Changes. + */ + public function getChangeLog($commit='HEAD', $n=10) + { + if ($n === null) $n = ''; + else $n = ' -'.$n; + $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' log%s --date=iso --pretty=format:\'%s\' %s', + escapeshellarg($this->repo), $n, $this->mediumtree_fmt, + escapeshellarg($commit)); + $out = array(); + $cmd = Pluf::f('idf_exec_cmd_prefix', '').$cmd; + self::exec('IDF_Scm_Monotone::getChangeLog', $cmd, $out); + return self::parseLog($out); + } + + /** + * Parse the log lines of a --pretty=medium log output. + * + * @param array Lines. + * @return array Change log. + */ + public static function parseLog($lines) + { + $res = array(); + $c = array(); + $inheads = true; + $next_is_title = false; + foreach ($lines as $line) { + if (preg_match('/^commit (\w{40})$/', $line)) { + if (count($c) > 0) { + $c['full_message'] = trim($c['full_message']); + $c['full_message'] = IDF_Commit::toUTF8($c['full_message']); + $c['title'] = IDF_Commit::toUTF8($c['title']); + $res[] = (object) $c; + } + $c = array(); + $c['commit'] = trim(substr($line, 7, 40)); + $c['full_message'] = ''; + $inheads = true; + $next_is_title = false; + continue; + } + if ($next_is_title) { + $c['title'] = trim($line); + $next_is_title = false; + continue; + } + $match = array(); + if ($inheads and preg_match('/(\S+)\s*:\s*(.*)/', $line, $match)) { + $match[1] = strtolower($match[1]); + $c[$match[1]] = trim($match[2]); + if ($match[1] == 'date') { + $c['date'] = gmdate('Y-m-d H:i:s', strtotime($match[2])); + } + continue; + } + if ($inheads and !$next_is_title and $line == '') { + $next_is_title = true; + $inheads = false; + } + if (!$inheads) { + $c['full_message'] .= trim($line)."\n"; + continue; + } + } + $c['full_message'] = !empty($c['full_message']) ? trim($c['full_message']) : ''; + $c['full_message'] = IDF_Commit::toUTF8($c['full_message']); + $c['title'] = IDF_Commit::toUTF8($c['title']); + $res[] = (object) $c; + return $res; + } + + public function getArchiveCommand($commit, $prefix='repository/') + { + return sprintf(Pluf::f('idf_exec_cmd_prefix', ''). + 'GIT_DIR=%s '.Pluf::f('git_path', 'git').' archive --format=zip --prefix=%s %s', + escapeshellarg($this->repo), + escapeshellarg($prefix), + escapeshellarg($commit)); + } +} \ No newline at end of file diff --git a/src/IDF/templates/idf/source/mtn/changelog.html b/src/IDF/templates/idf/source/mtn/changelog.html new file mode 100644 index 0000000..fa92f24 --- /dev/null +++ b/src/IDF/templates/idf/source/mtn/changelog.html @@ -0,0 +1,17 @@ +{extends "idf/source/changelog.html"} +{block context} +

{trans 'Branches:'}
+{foreach $branches as $branch => $path} +{aurl 'url', 'IDF_Views_Source::changeLog', array($project.shortname, $branch)} +{if $path}{$path}{else}{$branch}{/if}
+{/foreach} +

+{if $tags} +

{trans 'Tags:'}
+{foreach $tags as $tag => $path} +{aurl 'url', 'IDF_Views_Source::changeLog', array($project.shortname, $tag)} +{if $path}{$path}{else}{$tag}{/if}
+{/foreach} +

+{/if} +{/block} diff --git a/src/IDF/templates/idf/source/mtn/file.html b/src/IDF/templates/idf/source/mtn/file.html new file mode 100644 index 0000000..77b33d8 --- /dev/null +++ b/src/IDF/templates/idf/source/mtn/file.html @@ -0,0 +1,44 @@ +{extends "idf/source/base.html"} +{block extraheader}{/block} +{block docclass}yui-t1{assign $inSourceTree=true}{/block} +{block body} +

{trans 'Root'}/{if $breadcrumb}{$breadcrumb|safe}{/if}

+ + +{if !$tree_in and !$tags_in} +{aurl 'url', 'IDF_Views_Source::commit', array($project.shortname, $commit)} + + + +{/if} + +{$file} + +
{blocktrans}Source at commit {$commit} created {$cobject.date|dateago}.{/blocktrans}
+{blocktrans}By {$cobject.author|strip_tags|trim}, {$cobject.title}{/blocktrans} +
+{aurl 'url', 'IDF_Views_Source::getFile', array($project.shortname, $commit, $fullpath)} +

{trans 'Archive'} {trans 'Download this file'}

+ +{/block} +{block context} +

{trans 'Branches:'}
+{foreach $branches as $branch => $path} +{aurl 'url', 'IDF_Views_Source::treeBase', array($project.shortname, $branch)} +{if $path}{$path}{else}{$branch}{/if}
+{/foreach} +

+{if $tags} +

{trans 'Tags:'}
+{foreach $tags as $tag => $path} +{aurl 'url', 'IDF_Views_Source::treeBase', array($project.shortname, $tag)} +{if $path}{$path}{else}{$tag}{/if}
+{/foreach} +

+{/if} +{/block} + +{block javascript} + + +{/block} diff --git a/src/IDF/templates/idf/source/mtn/help.html b/src/IDF/templates/idf/source/mtn/help.html new file mode 100644 index 0000000..9e72ba9 --- /dev/null +++ b/src/IDF/templates/idf/source/mtn/help.html @@ -0,0 +1,38 @@ +{extends "idf/source/base.html"} +{block docclass}yui-t2{assign $inHelp=true}{/block} +{block body} + +

{blocktrans}The team behind {$project} is using +the git software to manage the source +code.{/blocktrans}

+ +

{trans 'Command-Line Access'}

+ +

git clone {$project.getSourceAccessUrl($user)}

+ +{aurl 'url', 'IDF_Views_User::myAccount'} +

{blocktrans}You may need to provide your SSH key. The synchronization of your SSH key can take a couple of minutes. You can learn more about SSH key authentification.{/blocktrans}

+ +{if $isOwner or $isMember} +

{trans 'First Commit'}

+ +

{blocktrans}To make a first commit in the repository, perform the following steps:{/blocktrans}

+ +
+git init
+git add .
+git commit -m "initial import"
+git remote add origin {$project.getWriteRemoteAccessUrl($url)}
+git push origin master
+
+ +{/if} + +{/block} +{block context} +
+

{blocktrans}Find here more details on how to access {$project} source code.{/blocktrans}

+
+{/block} + + diff --git a/src/IDF/templates/idf/source/mtn/tree.html b/src/IDF/templates/idf/source/mtn/tree.html new file mode 100644 index 0000000..859a249 --- /dev/null +++ b/src/IDF/templates/idf/source/mtn/tree.html @@ -0,0 +1,69 @@ +{extends "idf/source/base.html"} +{block docclass}yui-t1{assign $inSourceTree=true}{/block} +{block body} +

{trans 'Root'}/{if $breadcrumb}{$breadcrumb|safe}{/if}

+ + + + + + + + + +{if !$tree_in and !$tags_in} +{aurl 'url', 'IDF_Views_Source::commit', array($project.shortname, $commit)} + + + +{/if} +{if $base} + + + + + +{/if} +{foreach $files as $file} +{aurl 'url', 'IDF_Views_Source::tree', array($project.shortname, $commit, $file.efullpath)} + + +{if $file.type != 'extern'} +{$file.file}{else}{/if} +{if $file.type == 'blob'} +{if isset($file.date) and $file.log != '----'} + + +{else}{/if} +{/if} +{if $file.type == 'extern'} + +{/if} + +{/foreach} + +
{trans 'File'}{trans 'Age'}{trans 'Message'}{trans 'Size'}
{blocktrans}Source at commit {$commit} created {$cobject.date|dateago}.{/blocktrans}
+{blocktrans}By {$cobject.author|strip_tags|trim}, {$cobject.title}{/blocktrans} +
  +..
{$file.type}{$file.file}{$file.date|dateago:"wihtout"}{$file.author|strip_tags|trim}{trans ':'} {issuetext $file.log, $request, true, false}{$file.size|size}{$file.extern}
+{aurl 'url', 'IDF_Views_Source::download', array($project.shortname, $commit)} +

{trans 'Archive'} {trans 'Download this version'} {trans 'or'} git clone {$project.getSourceAccessUrl($user)} {trans 'Help'}

+ + +{/block} +{block context} +

{trans 'Branches:'}
+{foreach $branches as $branch => $path} +{aurl 'url', 'IDF_Views_Source::treeBase', array($project.shortname, $branch)} +{if $path}{$path}{else}{$branch}{/if}
+{/foreach} +

+{if $tags} +

{trans 'Tags:'}
+{foreach $tags as $tag => $path} +{aurl 'url', 'IDF_Views_Source::treeBase', array($project.shortname, $tag)} +{if $path}{$path}{else}{$tag}{/if}
+{/foreach} +

+{/if} +{/block}