| 1 | <?php␊ |
| 2 | /* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */␊ |
| 3 | /*␊ |
| 4 | # ***** BEGIN LICENSE BLOCK *****␊ |
| 5 | # This file is part of InDefero, an open source project management application.␊ |
| 6 | # Copyright (C) 2008 CĂ©ondo Ltd and contributors.␊ |
| 7 | #␊ |
| 8 | # InDefero is free software; you can redistribute it and/or modify␊ |
| 9 | # it under the terms of the GNU General Public License as published by␊ |
| 10 | # the Free Software Foundation; either version 2 of the License, or␊ |
| 11 | # (at your option) any later version.␊ |
| 12 | #␊ |
| 13 | # InDefero is distributed in the hope that it will be useful,␊ |
| 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of␊ |
| 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the␊ |
| 16 | # GNU General Public License for more details.␊ |
| 17 | #␊ |
| 18 | # You should have received a copy of the GNU General Public License␊ |
| 19 | # along with this program; if not, write to the Free Software␊ |
| 20 | # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA␊ |
| 21 | #␊ |
| 22 | # ***** END LICENSE BLOCK ***** */␊ |
| 23 | ␊ |
| 24 | /**␊ |
| 25 | * Git utils.␊ |
| 26 | *␊ |
| 27 | */␊ |
| 28 | class IDF_Scm_Git␊ |
| 29 | {␊ |
| 30 | public $repo = '';␊ |
| 31 | public $mediumtree_fmt = 'commit %H%nAuthor: %an <%ae>%nTree: %T%nDate: %ai%n%n%s%n%n%b';␊ |
| 32 | ␊ |
| 33 | public function __construct($repo)␊ |
| 34 | {␊ |
| 35 | $this->repo = $repo;␊ |
| 36 | }␊ |
| 37 | ␊ |
| 38 | /**␊ |
| 39 | * Given the string describing the author from the log find the␊ |
| 40 | * author in the database.␊ |
| 41 | *␊ |
| 42 | * @param string Author␊ |
| 43 | * @return mixed Pluf_User or null␊ |
| 44 | */␊ |
| 45 | public function findAuthor($author)␊ |
| 46 | {␊ |
| 47 | // We extract the email.␊ |
| 48 | $match = array();␊ |
| 49 | if (!preg_match('/<(.*)>/', $author, $match)) {␊ |
| 50 | return null;␊ |
| 51 | }␊ |
| 52 | $sql = new Pluf_SQL('email=%s', array($match[1]));␊ |
| 53 | $users = Pluf::factory('Pluf_User')->getList(array('filter'=>$sql->gen()));␊ |
| 54 | return ($users->count() > 0) ? $users[0] : null;␊ |
| 55 | }␊ |
| 56 | ␊ |
| 57 | ␊ |
| 58 | /**␊ |
| 59 | * Returns the URL of the git daemon.␊ |
| 60 | *␊ |
| 61 | * @param IDF_Project␊ |
| 62 | * @return string URL␊ |
| 63 | */␊ |
| 64 | public static function getRemoteAccessUrl($project)␊ |
| 65 | {␊ |
| 66 | return sprintf(Pluf::f('git_remote_url'), $project->shortname);␊ |
| 67 | }␊ |
| 68 | ␊ |
| 69 | /**␊ |
| 70 | * Returns the URL for SSH access␊ |
| 71 | *␊ |
| 72 | * @param IDF_Project␊ |
| 73 | * @return string URL␊ |
| 74 | */␊ |
| 75 | public static function getWriteRemoteAccessUrl($project)␊ |
| 76 | {␊ |
| 77 | return sprintf(Pluf::f('git_write_remote_url'), $project->shortname);␊ |
| 78 | }␊ |
| 79 | ␊ |
| 80 | /**␊ |
| 81 | * Returns this object correctly initialized for the project.␊ |
| 82 | *␊ |
| 83 | * @param IDF_Project␊ |
| 84 | * @return IDF_Scm_Git␊ |
| 85 | */␊ |
| 86 | public static function factory($project)␊ |
| 87 | {␊ |
| 88 | $rep = sprintf(Pluf::f('git_repositories'), $project->shortname);␊ |
| 89 | return new IDF_Scm_Git($rep);␊ |
| 90 | }␊ |
| 91 | ␊ |
| 92 | /**␊ |
| 93 | * Test a given object hash.␊ |
| 94 | *␊ |
| 95 | * @param string Object hash.␊ |
| 96 | * @param null to be svn client compatible␊ |
| 97 | * @return mixed false if not valid or 'blob', 'tree', 'commit'␊ |
| 98 | */␊ |
| 99 | public function testHash($hash, $dummy=null)␊ |
| 100 | {␊ |
| 101 | $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' cat-file -t %s',␊ |
| 102 | escapeshellarg($this->repo),␊ |
| 103 | escapeshellarg($hash));␊ |
| 104 | $ret = 0; $out = array();␊ |
| 105 | IDF_Scm::exec($cmd, $out, $ret);␊ |
| 106 | if ($ret != 0) return false;␊ |
| 107 | return trim($out[0]);␊ |
| 108 | }␊ |
| 109 | ␊ |
| 110 | /**␊ |
| 111 | * Given a commit hash returns an array of files in it.␊ |
| 112 | *␊ |
| 113 | * A file is a class with the following properties:␊ |
| 114 | *␊ |
| 115 | * 'perm', 'type', 'size', 'hash', 'file'␊ |
| 116 | *␊ |
| 117 | * @param string Commit ('HEAD')␊ |
| 118 | * @param string Base folder ('')␊ |
| 119 | * @return array ␊ |
| 120 | */␊ |
| 121 | public function filesAtCommit($commit='HEAD', $folder='')␊ |
| 122 | {␊ |
| 123 | // now we grab the info about this commit including its tree.␊ |
| 124 | $co = $this->getCommit($commit);␊ |
| 125 | if ($folder) {␊ |
| 126 | // As we are limiting to a given folder, we need to find␊ |
| 127 | // the tree corresponding to this folder.␊ |
| 128 | $found = false;␊ |
| 129 | foreach ($this->getTreeInfo($co->tree, true, $folder) as $file) {␊ |
| 130 | if ($file->type == 'tree' and $file->file == $folder) {␊ |
| 131 | $found = true;␊ |
| 132 | $tree = $file->hash;␊ |
| 133 | break;␊ |
| 134 | }␊ |
| 135 | }␊ |
| 136 | if (!$found) {␊ |
| 137 | throw new Exception(sprintf(__('Folder %1$s not found in commit %2$s.'), $folder, $commit));␊ |
| 138 | }␊ |
| 139 | } else {␊ |
| 140 | $tree = $co->tree;␊ |
| 141 | }␊ |
| 142 | $res = array();␊ |
| 143 | // get the raw log corresponding to this commit to find the␊ |
| 144 | // origin of each file.␊ |
| 145 | $rawlog = array();␊ |
| 146 | $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' log --raw --abbrev=40 --pretty=oneline -5000 %s',␊ |
| 147 | escapeshellarg($this->repo), escapeshellarg($commit));␊ |
| 148 | IDF_Scm::exec($cmd, $rawlog);␊ |
| 149 | // We reverse the log to be able to use a fixed efficient␊ |
| 150 | // regex without back tracking.␊ |
| 151 | $rawlog = implode("\n", array_reverse($rawlog));␊ |
| 152 | foreach ($this->getTreeInfo($tree, false) as $file) {␊ |
| 153 | // Now we grab the files in the current tree with as much␊ |
| 154 | // information as possible.␊ |
| 155 | $matches = array();␊ |
| 156 | if ($file->type == 'blob' and preg_match('/^\:\d{6} \d{6} [0-9a-f]{40} '.$file->hash.' .*^([0-9a-f]{40})/msU',␊ |
| 157 | $rawlog, $matches)) {␊ |
| 158 | $fc = $this->getCommit($matches[1]);␊ |
| 159 | $file->date = $fc->date;␊ |
| 160 | $file->log = $fc->title;␊ |
| 161 | $file->author = $fc->author;␊ |
| 162 | } else if ($file->type == 'blob') {␊ |
| 163 | $file->date = $co->date;␊ |
| 164 | $file->log = '----'; ␊ |
| 165 | $file->author = 'Unknown';␊ |
| 166 | }␊ |
| 167 | $file->fullpath = ($folder) ? $folder.'/'.$file->file : $file->file;␊ |
| 168 | $res[] = $file;␊ |
| 169 | }␊ |
| 170 | return $res;␊ |
| 171 | }␊ |
| 172 | ␊ |
| 173 | /**␊ |
| 174 | * Get the tree info.␊ |
| 175 | *␊ |
| 176 | * @param string Tree hash ␊ |
| 177 | * @param bool Do we recurse in subtrees (true)␊ |
| 178 | * @return array Array of file information.␊ |
| 179 | */␊ |
| 180 | public function getTreeInfo($tree, $recurse=true, $folder='')␊ |
| 181 | {␊ |
| 182 | if ('tree' != $this->testHash($tree)) {␊ |
| 183 | throw new Exception(sprintf(__('Not a valid tree: %s.'), $tree));␊ |
| 184 | }␊ |
| 185 | $cmd_tmpl = 'GIT_DIR=%s '.Pluf::f('git_path', 'git').' ls-tree%s -t -l %s %s';␊ |
| 186 | $cmd = sprintf($cmd_tmpl, ␊ |
| 187 | escapeshellarg($this->repo), ␊ |
| 188 | ($recurse) ? ' -r' : '',␊ |
| 189 | escapeshellarg($tree), escapeshellarg($folder));␊ |
| 190 | $out = array();␊ |
| 191 | $res = array();␊ |
| 192 | IDF_Scm::exec($cmd, $out);␊ |
| 193 | foreach ($out as $line) {␊ |
| 194 | list($perm, $type, $hash, $size, $file) = preg_split('/ |\t/', $line, 5, PREG_SPLIT_NO_EMPTY);␊ |
| 195 | $res[] = (object) array('perm' => $perm, 'type' => $type, ␊ |
| 196 | 'size' => $size, 'hash' => $hash, ␊ |
| 197 | 'file' => $file);␊ |
| 198 | }␊ |
| 199 | return $res;␊ |
| 200 | }␊ |
| 201 | ␊ |
| 202 | ␊ |
| 203 | /**␊ |
| 204 | * Get the file info.␊ |
| 205 | *␊ |
| 206 | * @param string File␊ |
| 207 | * @param string Commit ('HEAD')␊ |
| 208 | * @return false Information␊ |
| 209 | */␊ |
| 210 | public function getFileInfo($totest, $commit='HEAD')␊ |
| 211 | {␊ |
| 212 | $cmd_tmpl = 'GIT_DIR=%s '.Pluf::f('git_path', 'git').' ls-tree -r -t -l %s';␊ |
| 213 | $cmd = sprintf($cmd_tmpl, ␊ |
| 214 | escapeshellarg($this->repo), ␊ |
| 215 | escapeshellarg($commit));␊ |
| 216 | $out = array();␊ |
| 217 | IDF_Scm::exec($cmd, $out);␊ |
| 218 | foreach ($out as $line) {␊ |
| 219 | list($perm, $type, $hash, $size, $file) = preg_split('/ |\t/', $line, 5, PREG_SPLIT_NO_EMPTY);␊ |
| 220 | if ($totest == $file) {␊ |
| 221 | return (object) array('perm' => $perm, 'type' => $type, ␊ |
| 222 | 'size' => $size, 'hash' => $hash, ␊ |
| 223 | 'file' => $file);␊ |
| 224 | }␊ |
| 225 | }␊ |
| 226 | return false;␊ |
| 227 | }␊ |
| 228 | ␊ |
| 229 | /**␊ |
| 230 | * Get a blob.␊ |
| 231 | *␊ |
| 232 | * @param string request_file_info␊ |
| 233 | * @param null to be svn client compatible␊ |
| 234 | * @return string Raw blob␊ |
| 235 | */␊ |
| 236 | public function getBlob($request_file_info, $dummy=null)␊ |
| 237 | {␊ |
| 238 | return shell_exec(sprintf(Pluf::f('idf_exec_cmd_prefix', '').␊ |
| 239 | 'GIT_DIR=%s '.Pluf::f('git_path', 'git').' cat-file blob %s',␊ |
| 240 | escapeshellarg($this->repo), ␊ |
| 241 | escapeshellarg($request_file_info->hash)));␊ |
| 242 | }␊ |
| 243 | ␊ |
| 244 | /**␊ |
| 245 | * Get the branches.␊ |
| 246 | *␊ |
| 247 | * @return array Branches.␊ |
| 248 | */␊ |
| 249 | public function getBranches()␊ |
| 250 | {␊ |
| 251 | $out = array();␊ |
| 252 | IDF_Scm::exec(sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' branch', ␊ |
| 253 | escapeshellarg($this->repo)), $out);␊ |
| 254 | $res = array();␊ |
| 255 | foreach ($out as $b) {␊ |
| 256 | $res[] = substr($b, 2);␊ |
| 257 | }␊ |
| 258 | return $res;␊ |
| 259 | }␊ |
| 260 | ␊ |
| 261 | /**␊ |
| 262 | * Get commit details.␊ |
| 263 | *␊ |
| 264 | * @param string Commit ('HEAD').␊ |
| 265 | * @param bool Get commit diff (false).␊ |
| 266 | * @return array Changes.␊ |
| 267 | */␊ |
| 268 | public function getCommit($commit='HEAD', $getdiff=false)␊ |
| 269 | {␊ |
| 270 | if ($getdiff) {␊ |
| 271 | $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' show --date=iso --pretty=format:%s %s',␊ |
| 272 | escapeshellarg($this->repo), ␊ |
| 273 | "'".$this->mediumtree_fmt."'", ␊ |
| 274 | escapeshellarg($commit));␊ |
| 275 | } else {␊ |
| 276 | $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' log -1 --date=iso --pretty=format:%s %s',␊ |
| 277 | escapeshellarg($this->repo), ␊ |
| 278 | "'".$this->mediumtree_fmt."'", ␊ |
| 279 | escapeshellarg($commit));␊ |
| 280 | }␊ |
| 281 | $out = array();␊ |
| 282 | IDF_Scm::exec($cmd, $out);␊ |
| 283 | $log = array();␊ |
| 284 | $change = array();␊ |
| 285 | $inchange = false;␊ |
| 286 | foreach ($out as $line) {␊ |
| 287 | if (!$inchange and 0 === strpos($line, 'diff --git a')) {␊ |
| 288 | $inchange = true;␊ |
| 289 | }␊ |
| 290 | if ($inchange) {␊ |
| 291 | $change[] = $line;␊ |
| 292 | } else {␊ |
| 293 | $log[] = $line;␊ |
| 294 | }␊ |
| 295 | }␊ |
| 296 | $out = self::parseLog($log, 4);␊ |
| 297 | $out[0]->changes = implode("\n", $change);␊ |
| 298 | return $out[0];␊ |
| 299 | }␊ |
| 300 | ␊ |
| 301 | /**␊ |
| 302 | * Check if a commit is big.␊ |
| 303 | *␊ |
| 304 | * @param string Commit ('HEAD')␊ |
| 305 | * @return bool The commit is big␊ |
| 306 | */␊ |
| 307 | public function isCommitLarge($commit='HEAD')␊ |
| 308 | {␊ |
| 309 | $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' log --numstat -1 --pretty=format:%s %s',␊ |
| 310 | escapeshellarg($this->repo), ␊ |
| 311 | "'commit %H%n'", ␊ |
| 312 | escapeshellarg($commit));␊ |
| 313 | $out = array();␊ |
| 314 | IDF_Scm::exec($cmd, $out);␊ |
| 315 | $affected = count($out) - 2;␊ |
| 316 | $added = 0;␊ |
| 317 | $removed = 0;␊ |
| 318 | $c=0;␊ |
| 319 | foreach ($out as $line) {␊ |
| 320 | $c++;␊ |
| 321 | if ($c < 3) {␊ |
| 322 | continue;␊ |
| 323 | }␊ |
| 324 | list($a, $r, $f) = preg_split("/[\s]+/", $line, 3, PREG_SPLIT_NO_EMPTY);␊ |
| 325 | $added+=$a;␊ |
| 326 | $removed+=$r;␊ |
| 327 | }␊ |
| 328 | return ($affected > 100 or ($added + $removed) > 20000);␊ |
| 329 | }␊ |
| 330 | ␊ |
| 331 | /**␊ |
| 332 | * Get latest changes.␊ |
| 333 | *␊ |
| 334 | * @param string Commit ('HEAD').␊ |
| 335 | * @param int Number of changes (10).␊ |
| 336 | * @return array Changes.␊ |
| 337 | */␊ |
| 338 | public function getChangeLog($commit='HEAD', $n=10)␊ |
| 339 | {␊ |
| 340 | if ($n === null) $n = '';␊ |
| 341 | else $n = ' -'.$n;␊ |
| 342 | $cmd = sprintf('GIT_DIR=%s '.Pluf::f('git_path', 'git').' log%s --date=iso --pretty=format:\'%s\' %s',␊ |
| 343 | escapeshellarg($this->repo), $n, $this->mediumtree_fmt, ␊ |
| 344 | escapeshellarg($commit));␊ |
| 345 | $out = array();␊ |
| 346 | IDF_Scm::exec($cmd, $out);␊ |
| 347 | return self::parseLog($out, 4);␊ |
| 348 | }␊ |
| 349 | ␊ |
| 350 | /**␊ |
| 351 | * Parse the log lines of a --pretty=medium log output.␊ |
| 352 | *␊ |
| 353 | * @param array Lines.␊ |
| 354 | * @param int Number of lines in the headers (3)␊ |
| 355 | * @return array Change log.␊ |
| 356 | */␊ |
| 357 | public static function parseLog($lines, $hdrs=3)␊ |
| 358 | {␊ |
| 359 | $res = array();␊ |
| 360 | $c = array();␊ |
| 361 | $hdrs += 2;␊ |
| 362 | $inheads = true;␊ |
| 363 | $next_is_title = false;␊ |
| 364 | foreach ($lines as $line) {␊ |
| 365 | if (preg_match('/^commit (\w{40})$/', $line)) {␊ |
| 366 | if (count($c) > 0) {␊ |
| 367 | $c['full_message'] = trim($c['full_message']);␊ |
| 368 | $res[] = (object) $c;␊ |
| 369 | }␊ |
| 370 | $c = array();␊ |
| 371 | $c['commit'] = trim(substr($line, 7, 40));␊ |
| 372 | $c['full_message'] = '';␊ |
| 373 | $inheads = true;␊ |
| 374 | $next_is_title = false;␊ |
| 375 | continue;␊ |
| 376 | }␊ |
| 377 | if ($next_is_title) {␊ |
| 378 | $c['title'] = trim($line);␊ |
| 379 | $next_is_title = false;␊ |
| 380 | continue;␊ |
| 381 | }␊ |
| 382 | $match = array();␊ |
| 383 | if ($inheads and preg_match('/(\S+)\s*:\s*(.*)/', $line, $match)) {␊ |
| 384 | $match[1] = strtolower($match[1]);␊ |
| 385 | $c[$match[1]] = trim($match[2]);␊ |
| 386 | if ($match[1] == 'date') {␊ |
| 387 | $c['date'] = gmdate('Y-m-d H:i:s', strtotime($match[2]));␊ |
| 388 | }␊ |
| 389 | continue;␊ |
| 390 | }␊ |
| 391 | if ($inheads and !$next_is_title and $line == '') {␊ |
| 392 | $next_is_title = true;␊ |
| 393 | $inheads = false;␊ |
| 394 | }␊ |
| 395 | if (!$inheads) {␊ |
| 396 | $c['full_message'] .= trim($line)."\n";␊ |
| 397 | continue;␊ |
| 398 | }␊ |
| 399 | }␊ |
| 400 | $c['full_message'] = trim($c['full_message']);␊ |
| 401 | $res[] = (object) $c;␊ |
| 402 | return $res;␊ |
| 403 | }␊ |
| 404 | ␊ |
| 405 | /**␊ |
| 406 | * Generate the command to create a zip archive at a given commit.␊ |
| 407 | *␊ |
| 408 | * @param string Commit␊ |
| 409 | * @param string Prefix ('git-repo-dump')␊ |
| 410 | * @return string Command␊ |
| 411 | */␊ |
| 412 | public function getArchiveCommand($commit, $prefix='git-repo-dump/')␊ |
| 413 | {␊ |
| 414 | return sprintf(Pluf::f('idf_exec_cmd_prefix', '').␊ |
| 415 | 'GIT_DIR=%s '.Pluf::f('git_path', 'git').' archive --format=zip --prefix=%s %s',␊ |
| 416 | escapeshellarg($this->repo),␊ |
| 417 | escapeshellarg($prefix),␊ |
| 418 | escapeshellarg($commit));␊ |
| 419 | }␊ |
| 420 | ␊ |
| 421 | } |