import
[web.mtrack] / inc / scm / git.php
1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
3
4 /* Git SCM browsing */
5
6 class MTrackSCMFileGit extends MTrackSCMFile {
7   public $name;
8   public $rev;
9   public $is_dir;
10   public $repo;
11
12   function __construct(MTrackSCM $repo, $name, $rev, $is_dir = false)
13   {
14     $this->repo = $repo;
15     $this->name = $name;
16     $this->rev = $rev;
17     $this->is_dir = $is_dir;
18   }
19
20   public function getChangeEvent()
21   {
22     list($ent) = $this->repo->history($this->name, 1, 'rev', $this->rev);
23     return $ent;
24   }
25
26   function cat()
27   {
28     // There may be a better way...
29     // ls-tree to determine the hash of the file from this change:
30     $fp = $this->repo->git('ls-tree', $this->rev, $this->name);
31     $line = fgets($fp);
32     $fp = null;
33     list($mode, $type, $hash, $name) = preg_split("/\s+/", $line);
34
35     // now we can cat that blob
36     return $this->repo->git('cat-file', 'blob', $hash);
37   }
38
39   function annotate($include_line_content = false)
40   {
41     if ($this->repo->gitdir == $this->repo->repopath) {
42       // For bare repos, we can't run annotate, so we need to make a clone
43       // with a work tree.  This relies on local clones being a cheap operation
44       $wc = new MTrackWCGit($this->repo);
45       $wc->push = false;
46       $fp = $wc->git('annotate', '-p', $this->name, $this->rev);
47     } else {
48       $fp = $this->repo->git('annotate', '-p', $this->name, $this->rev);
49     }
50     $i = 1;
51     $ann = array();
52     $meta = array();
53     while ($line = fgets($fp)) {
54 //      echo htmlentities($line), "<br>\n";
55       if (!strncmp($line, "\t", 1)) {
56         $A = new MTrackSCMAnnotation;
57         if (isset($meta['author-mail']) &&
58             strpos($meta['author-mail'], '@')) {
59           $A->changeby = $meta['author'] . ' ' . $meta['author-mail'];
60         } else {
61           $A->changeby = $meta['author'];
62         }
63         $A->rev = $meta['rev'];
64         if ($include_line_content) {
65           $A->line = substr($line, 1);
66         }
67         $ann[$i++] = $A;
68         continue;
69       }
70       if (preg_match("/^([a-f0-9]+)\s[a-f0-9]+\s[a-f0-9]+\s[a-f0-9]+$/",
71           $line, $M)) {
72         $meta['rev'] = $M[1];
73       } else if (preg_match("/^(\S+)\s*(.*)$/", $line, $M)) {
74         $name = $M[1];
75         $value = $M[2];
76         $meta[$name] = $value;
77       }
78     }
79     return $ann;
80   }
81 }
82
83 class MTrackWCGit extends MTrackSCMWorkingCopy {
84   private $repo;
85   public $push = true;
86
87   function __construct(MTrackRepo $repo) {
88     $this->dir = mtrack_make_temp_dir();
89     $this->repo = $repo;
90
91     mtrack_run_tool('git', 'string',
92         array('clone', $this->repo->repopath, $this->dir)
93     );
94   }
95
96   function __destruct() {
97     if ($this->push) {
98       echo stream_get_contents($this->git('push'));
99     }
100     mtrack_rmdir($this->dir);
101   }
102
103   function getFile($path)
104   {
105     return $this->repo->file($path);
106   }
107
108   function addFile($path)
109   {
110     $this->git('add', $path);
111   }
112
113   function delFile($path)
114   {
115     $this->git('rm', '-f', $path);
116   }
117
118   function commit(MTrackChangeset $CS)
119   {
120     if ($CS->when) {
121       $d = strtotime($CS->when);
122       putenv("GIT_AUTHOR_DATE=$d -0000");
123     } else {
124       putenv("GIT_AUTHOR_DATE=");
125     }
126     $reason = trim($CS->reason);
127     if (!strlen($reason)) {
128       $reason = 'Changed';
129     }
130     putenv("GIT_AUTHOR_NAME=$CS->who");
131     putenv("GIT_AUTHOR_EMAIL=$CS->who");
132     stream_get_contents($this->git('commit', '-a',
133       '-m', $reason
134       )
135     );
136   }
137
138   function git()
139   {
140     $args = func_get_args();
141     $a = array("--git-dir=$this->dir/.git", "--work-tree=$this->dir");
142     foreach ($args as $arg) {
143       $a[] = $arg;
144     }
145
146     return mtrack_run_tool('git', 'read', $a);
147   }
148 }
149
150 class MTrackSCMGit extends MTrackRepo {
151   protected $branches = null;
152   protected $tags = null;
153   public $gitdir = null;
154
155   public function getSCMMetaData() {
156     return array(
157       'name' => 'Git',
158       'tools' => array('git'),
159     );
160   }
161
162   function __construct($id = null) {
163     parent::__construct($id);
164     if ($id !== null) {
165       /* transparently handle bare vs. non bare repos */
166       $this->gitdir = $this->repopath;
167       if (is_dir("$this->repopath/.git")) {
168         $this->gitdir .= "/.git";
169       }
170     }
171   }
172
173   function getServerURL() {
174     $url = parent::getServerURL();
175     if ($url) return $url;
176     $url = MTrackConfig::get('repos', 'serverurl');
177     if ($url) {
178       return "$url:" . $this->getBrowseRootName();
179     }
180     return null;
181   }
182
183   public function reconcileRepoSettings(MTrackSCM $r = null) {
184     if ($r == null) {
185       $r = $this;
186     }
187
188     if (!is_dir($r->repopath)) {
189       $userdata = MTrackAuth::getUserData(MTrackAuth::whoami());
190       $who = $userdata['email'];
191       putenv("GIT_AUTHOR_NAME=$who");
192       putenv("GIT_AUTHOR_EMAIL=$who");
193
194       if ($r->clonedfrom) {
195         $S = MTrackRepo::loadById($r->clonedfrom);
196
197         $stm = mtrack_run_tool('git', 'read',
198             array('clone', '--bare', $S->repopath, $r->repopath));
199         $out = stream_get_contents($stm);
200         if (pclose($stm)) {
201           throw new Exception("git init failed: $out");
202         }
203
204       } else {
205         /* a little peculiar, but bear with it.
206          * We need to have a bare repo so that git doesn't mess around
207          * trying to deal with a checkout in the repo dir.
208          * So we need to create two repos; one bare, one not bare.
209          * We populate the non-bare repo with a dummy file just to have
210          * something to commit, then push non-bare -> bare, and remove non-bare.
211          */
212
213         $stm = mtrack_run_tool('git', 'read',
214             array('init', '--bare', $r->repopath));
215         $out = stream_get_contents($stm);
216         if (pclose($stm)) {
217           throw new Exception("git init failed: $out");
218         }
219
220         $alt = "$r->repopath.MTRACKINIT";
221
222         $stm = mtrack_run_tool('git', 'read',
223             array('init', $alt));
224         $out = stream_get_contents($stm);
225         if (pclose($stm)) {
226           throw new Exception("git init failed: $out");
227         }
228
229         $dir = getcwd();
230         chdir($alt);
231
232         file_put_contents("$alt/.gitignore", "#\n");
233         $stm = mtrack_run_tool('git', 'read',
234             array('add', '.gitignore'));
235         $out = stream_get_contents($stm);
236         if (pclose($stm)) {
237           throw new Exception("git add .gitignore failed: $out");
238         }
239         $stm = mtrack_run_tool('git', 'read',
240             array('commit', '-a', '-m', 'init'));
241         $out = stream_get_contents($stm);
242         if (pclose($stm)) {
243           throw new Exception("git commit failed: $out");
244         }
245         $stm = mtrack_run_tool('git', 'read',
246             array('push', $r->repopath, 'master'));
247         $out = stream_get_contents($stm);
248         if (pclose($stm)) {
249           throw new Exception("git push failed: $out");
250         }
251         chdir($dir);
252         system("rm -rf $alt");
253       }
254
255       $php = MTrackConfig::get('tools', 'php');
256       $hook = realpath(dirname(__FILE__) . '/../../bin/git-commit-hook');
257       $conffile = realpath(MTrackConfig::getLocation());
258       foreach (array('pre', 'post') as $step) {
259         $script = <<<HOOK
260 #!/bin/sh
261 exec $php $hook $step $conffile
262
263 HOOK;
264         $target = "$r->repopath/hooks/$step-receive";
265         if (file_put_contents("$target.mtrack", $script)) {
266           chmod("$target.mtrack", 0755);
267           rename("$target.mtrack", $target);
268         }
269       }
270     }
271
272     system("chmod -R 02777 $r->repopath");
273   }
274
275   function canFork() {
276     return true;
277   }
278
279
280   public function getBranches()
281   {
282     if ($this->branches !== null) {
283       return $this->branches;
284     }
285     $this->branches = array();
286     $fp = $this->git('branch', '--no-color', '--verbose');
287     while ($line = fgets($fp)) {
288       // * master 61e7e7d oneliner
289       $line = substr($line, 2);
290       list($branch, $rev) = preg_split('/\s+/', $line);
291       $this->branches[$branch] = $rev;
292     }
293     $fp = null;
294     return $this->branches;
295   }
296
297   public function getTags()
298   {
299     if ($this->tags !== null) {
300       return $this->tags;
301     }
302     $this->tags = array();
303     $fp = $this->git('tag');
304     while ($line = fgets($fp)) {
305       $line = trim($line);
306       $this->tags[$line] = $line;
307     }
308     $fp = null;
309     return $this->tags;
310   }
311
312   public function readdir($path, $object = null, $ident = null)
313   {
314     $res = array();
315
316     if ($object === null) {
317       $object = 'branch';
318       $ident = 'master';
319     }
320     $rev = $this->resolveRevision(null, $object, $ident);
321
322     if (strlen($path)) {
323       $path = rtrim($path, '/') . '/';
324     }
325
326     $fp = $this->git('ls-tree', $rev, $path);
327
328     $dirs = array();
329
330     while ($line = fgets($fp)) {
331       list($mode, $type, $hash, $name) = preg_split("/\s+/", $line);
332
333       $res[] = new MTrackSCMFileGit($this, "$name", $rev, $type == 'tree');
334     }
335     return $res;
336   }
337
338   public function file($path, $object = null, $ident = null)
339   {
340     if ($object == null) {
341       $branches = $this->getBranches();
342       if (isset($branches['master'])) {
343         $object = 'branch';
344         $ident = 'master';
345       } else {
346         // fresh/empty repo
347         return null;
348       }
349     }
350     $rev = $this->resolveRevision(null, $object, $ident);
351     return new MTrackSCMFileGit($this, $path, $rev);
352   }
353
354   public function history($path, $limit = null, $object = null, $ident = null)
355   {
356     $res = array();
357
358     $args = array();
359     if ($object !== null) {
360       $rev = $this->resolveRevision(null, $object, $ident);
361       $args[] = "$rev";
362     } else {
363       $args[] = "master";
364     }
365     if ($limit !== null) {
366       if (is_int($limit)) {
367         $args[] = "--max-count=$limit";
368       } else {
369         $args[] = "--since=$limit";
370       }
371     }
372     $args[] = "--no-color";
373     $args[] = "--name-status";
374     $args[] = "--date=rfc";
375
376     $path = ltrim($path, '/');
377
378     $fp = $this->git('log', $args, '--', $path);
379
380     $commits = array();
381     $commit = null;
382     while (true) {
383       $line = fgets($fp);
384       if ($line === false) {
385         if ($commit !== null) {
386           $commits[] = $commit;
387         }
388         break;
389       }
390       if (preg_match("/^commit/", $line)) {
391         if ($commit !== null) {
392           $commits[] = $commit;
393         }
394         $commit = $line;
395         continue;
396       }
397       $commit .= $line;
398     }
399
400     foreach ($commits as $commit) {
401       $ent = new MTrackSCMEvent;
402       $lines = explode("\n", $commit);
403       $line = array_shift($lines);
404
405       if (!preg_match("/^commit\s+(\S+)$/", $line, $M)) {
406         break;
407       }
408       $ent->rev = $M[1];
409
410       $ent->branches = array(); // FIXME
411       $ent->tags = array(); // FIXME
412       $ent->files = array();
413
414       while (count($lines)) {
415         $line = array_shift($lines);
416         if (!strlen($line)) {
417           break;
418         }
419         if (preg_match("/^(\S+):\s+(.*)\s*$/", $line, $M)) {
420           $k = $M[1];
421           $v = $M[2];
422
423           switch ($k) {
424             case 'Author':
425               $ent->changeby = $v;
426               break;
427             case 'Date':
428               $ts = strtotime($v);
429               $ent->ctime = MTrackDB::unixtime($ts);
430               break;
431           }
432         }
433       }
434
435       $ent->changelog = "";
436
437       if ($lines[0] == '') {
438         array_shift($lines);
439       }
440
441       while (count($lines)) {
442         $line = array_shift($lines);
443         if (strncmp($line, '    ', 4)) {
444           array_unshift($lines, $line);
445           break;
446         }
447         $line = substr($line, 4);
448         $ent->changelog .= $line . "\n";
449       }
450
451       if ($lines[0] == '') {
452         array_shift($lines);
453       }
454       foreach ($lines as $line) {
455         if (preg_match("/^(.+)\s+(\S+)\s*$/", $line, $M)) {
456           $f = new MTrackSCMFileEvent;
457           $f->name = $M[2];
458           $f->status = $M[1];
459           $ent->files[] = $f;
460         }
461       }
462
463       if (!count($ent->branches)) {
464         $ent->branches[] = 'master';
465       }
466
467       $res[] = $ent;
468     }
469     $fp = null;
470     return $res;
471   }
472
473   public function diff($path, $from = null, $to = null)
474   {
475     if ($path instanceof MTrackSCMFile) {
476       if ($from === null) {
477         $from = $path->rev;
478       }
479       $path = $path->name;
480     }
481     if ($to !== null) {
482       return $this->git('diff', "$from..$to", '--', $path);
483     }
484     return $this->git('diff', "$from^..$from", '--', $path);
485   }
486
487   public function getWorkingCopy()
488   {
489     return new MTrackWCGit($this);
490   }
491
492   public function getRelatedChanges($revision)
493   {
494     $parents = array();
495     $kids = array();
496
497     $fp = $this->git('rev-parse', "$revision^");
498     while (($line = fgets($fp)) !== false) {
499       $parents[] = trim($line);
500     }
501
502     // Ugh!: http://stackoverflow.com/questions/1761825/referencing-the-child-of-a-commit-in-git
503     $fp = $this->git('rev-list', '--all', '--parents');
504     while (($line = fgets($fp)) !== false) {
505       $hashes = preg_split("/\s+/", $line);
506       $kid = array_shift($hashes);
507       if (in_array($revision, $hashes)) {
508         $kids[] = $kid;
509       }
510     }
511
512     return array($parents, $kids);
513   }
514
515   function git()
516   {
517     $args = func_get_args();
518     $a = array(
519       "--git-dir=$this->gitdir"
520     );
521
522     if ($this->gitdir != $this->repopath) {
523       $a[] = "--work-tree=$this->repopath";
524     }
525     foreach ($args as $arg) {
526       $a[] = $arg;
527     }
528
529     return mtrack_run_tool('git', 'read', $a);
530   }
531 }
532
533 MTrackRepo::registerSCM('git', 'MTrackSCMGit');
534