import
[web.mtrack] / inc / scm.php
1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
3
4 class MTrackSCMEvent {
5   /** Revision or changeset identifier for this particular file */
6   public $rev;
7
8   /** commit message associated with this revision */
9   public $changelog;
10
11   /** who committed this revision */
12   public $changeby;
13
14   /** when this revision was committed */
15   public $ctime;
16
17   /** files affected in this event; may be null, but otherwise
18    * will be an array of MTrackSCMFileEvent */
19   public $files;
20 }
21
22 class MTrackSCMFileEvent {
23   /** Name of affected file */
24   public $name;
25   /** Change status indicator */
26   public $status;
27
28   /** when used in a string context, just return the filename.
29    * This simplifies explicit object vs. string interpretation
30    * throughout the SCM layer */
31   function __toString() {
32     return $this->name;
33   }
34 }
35
36 class MTrackSCMAnnotation {
37   /** Revision of changeset identifier for when line was changed */
38   public $rev;
39
40   /** who made the change */
41   public $changeby;
42
43   /** the content from that line of the file.
44    * This is null unless $include_line_content was set to true when annotate()
45    * was called */
46   public $line;
47 }
48
49 abstract class MTrackSCMFile {
50   /** reference to the associated MTrackSCM object */
51   public $repo;
52
53   /** full path to file, with a leading slash (which represents
54    * the root of its respective repo */
55   public $name;
56
57   /** if true, this file represents a directory */
58   public $is_dir = false;
59
60   /** revision */
61   public $rev;
62
63   function __construct(MTrackSCM $repo, $name, $rev, $is_dir = false)
64   {
65     $this->repo = $repo;
66     $this->name = $name;
67     $this->rev = $rev;
68     $this->is_dir = $is_dir;
69   }
70
71   /** Returns an MTrackSCMEvent corresponding to this revision of
72    * the file */
73   abstract public function getChangeEvent();
74
75   /** Returns a stream representing the contents of the file at
76    * this revision */
77   abstract public function cat();
78
79   /** Returns an array of MTrackSCMAnnotation objects that correspond to
80    * each line of file content, annotating when the line was last
81    * changed.  The array is keyed by line number, 1-based. */
82   abstract public function annotate($include_line_content = false);
83 }
84
85 abstract class MTrackSCMWorkingCopy {
86   public $dir;
87
88   /** returns the root dir of the working copy */
89   function getDir() {
90     return $this->dir;
91   }
92
93   /** add a file to the working copy */
94   abstract function addFile($path);
95   /** removes a file from the working copy */
96   abstract function delFile($path);
97   /** commit changes that are pending in the working copy */
98   abstract function commit(MTrackChangeset $CS);
99   /** get an MTrackSCMFile representation of a file */
100   abstract function getFile($path);
101
102   /** enumerates files in a path in the working copy */
103   function enumFiles($path)
104   {
105     return scandir($this->dir . DIRECTORY_SEPARATOR . $path);
106   }
107
108   /** determines if a file exists in the working copy */
109   function file_exists($path)
110   {
111     return file_exists($this->dir . DIRECTORY_SEPARATOR . $path);
112   }
113
114   function __destruct()
115   {
116     if (strlen($this->dir) > 1) {
117       mtrack_rmdir($this->dir);
118     }
119   }
120 }
121
122 abstract class MTrackSCM {
123   static $repos = array();
124
125   static function factory(&$repopath) {
126     /* [ / owner type rest ] */
127     $bits = explode('/', $repopath, 4);
128     if (count($bits) < 3) {
129       throw new Exception("Invalid repo $repopath");
130     }
131     array_shift($bits);
132     list($owner, $type) = $bits;
133     $repo = "$owner/$type";
134
135     $r = MTrackRepo::loadByName($repo);
136     if (!$r) {
137       throw new Exception("invalid repo $repo");
138     }
139     $repopath = isset($bits[2]) ? $bits[2] : '';
140     return $r;
141   }
142
143   /** Returns an array keyed by possible branch names.
144    * The data associated with the branches is implementation
145    * defined.
146    * If the SCM does not have a concept of first-class branch
147    * objects, this function returns null */
148   abstract public function getBranches();
149
150   /** Returns an array keyed by possible tag names.
151    * The data associated with the tags is implementation
152    * defined.
153    * If the SCM does not have a concept of first-class tag
154    * objects, this function returns null */
155   abstract public function getTags();
156
157   /** Enumerates the files/dirs that are present in the specified
158    * location of the repository that match the specified revision,
159    * branch or tag information.  If no revision, branch or tag is
160    * specified, then the appropriate default is assumed.
161    *
162    * The second and third parameters are optional; the second
163    * parameter is one of 'rev', 'branch', or 'tag', and if specifed
164    * the third parameter must be the corresponding revision, branch
165    * or tag identifier.
166    *
167    * The return value is an array of MTrackSCMFile objects present
168    * at that location/revision of the repository.
169    */
170   abstract public function readdir($path, $object = null, $ident = null);
171
172   /** Queries information on a specific file in the repository.
173    *
174    * Parameters are as for readdir() above.
175    *
176    * This function returns a single MTrackSCMFile for the location
177    * in question.
178    */
179   abstract public function file($path, $object = null, $ident = null);
180
181   /** Queries history for a particular location in the repo.
182    *
183    * Parameters are as for readdir() above, except that path can be
184    * left unspecified to query the history for the entire repo.
185    *
186    * The limit parameter limits the number of entries returned; it it is
187    * a number, it specifies the number of events, otherwise it is assumed
188    * to be a date in the past; only events since that date will be returned.
189    *
190    * Returns an array of MTrackSCMEvent objects.
191    */
192   abstract public function history($path, $limit = null, $object = null,
193     $ident = null);
194
195   /** Obtain the diff text representing a change to a file.
196    *
197    * You may optionally provide one or two revisions as context.
198    *
199    * If no revisions are passed in, then the change associated
200    * with the location will be assumed.
201    *
202    * If one revision is passed, then the change associated with
203    * that event will be assumed.
204    *
205    * If two revisions are passed, then the difference between
206    * the two events will be assumed.
207    */
208   abstract public function diff($path, $from = null, $to = null);
209
210   /** Determine the next and previous revisions for a given
211    * changeset.
212    *
213    * Returns an array: the 0th element is an array of prior revisions,
214    * and the 1st element is an array of successor revisions.
215    *
216    * There will usually be one prior and one successor revision for a
217    * given change, but some SCMs will return multiples in the case of
218    * merges.
219    */
220   abstract public function getRelatedChanges($revision);
221
222   /** Returns a working copy object for the repo
223    *
224    * The intended purpose is to support wiki page modifications, and
225    * as such, is not meant to be an especially efficient means to do so.
226    */
227   abstract public function getWorkingCopy();
228
229   /** Returns the default 'root' location in the repository.
230    * For SCMs that have a concept of branches, this is the empty string.
231    * For SCMs like SVN, this is the trunk dir */
232   public function getDefaultRoot() {
233     return '';
234   }
235
236   /** Returns meta information about the SCM type; this is used in the
237    * UI and tooling to let the user know their options.
238    *
239    * Returns an array with the following keys:
240    * 'name' => 'Mercurial', // human displayable name
241    * 'tools' => array('hg'), // list of tools to find during setup
242    */
243   abstract public function getSCMMetaData();
244
245   /* takes an MTrackSCM as a parameter because in some bootstrapping
246    * cases, we're actually MTrackRepo and not the end-class.
247    * MTrackRepo calls the end-class method and passes itself in for
248    * context */
249   public function reconcileRepoSettings(MTrackSCM $r) {
250     throw new Exception(
251       "Creating/updating a repo of type $this->scmtype is not implemented");
252   }
253
254   static function makeBreadcrumbs($pi) {
255     if (!strlen($pi)) {
256       $pi = '/';
257     }
258     if ($pi == '/') {
259       $crumbs = array('');
260     } else {
261       $crumbs = explode('/', $pi);
262     }
263     return $crumbs;
264   }
265
266   static function makeDisplayName($data) {
267     $parent = '';
268     $name = '';
269     if (is_object($data)) {
270       $parent = $data->parent;
271       $name = $data->shortname;
272     } else if (is_array($data)) {
273       $parent = $data['parent'];
274       $name = $data['shortname'];
275     }
276     if ($parent) {
277       list($type, $owner) = explode(':', $parent);
278       return "$owner/$name";
279     }
280     return "default/$name";
281   }
282
283   public function getBrowseRootName() {
284     return self::makeDisplayName($this);
285   }
286
287   public function resolveRevision($rev, $object, $ident) {
288     if ($rev !== null) {
289       return $rev;
290     }
291     if ($object === null) {
292       return null;
293     }
294     switch ($object) {
295       case 'rev':
296         $rev = $ident;
297         break;
298       case 'branch':
299         $branches = $this->getBranches();
300         $rev = isset($branches[$ident]) ? $branches[$ident] : null;
301         break;
302       case 'tag':
303         $tags = $this->getTags();
304         $rev = isset($tags[$ident]) ? $tags[$ident] : null;
305         break;
306     }
307     if ($rev === null) {
308       throw new Exception(
309         "don't know which revision to use ($rev,$object,$ident)");
310     }
311     return $rev;
312   }
313 }
314 MTrackACL::registerAncestry('repo', 'Browser');
315 MTrackWatch::registerEventTypes('repo', array(
316   'ticket' => 'Tickets',
317   'changeset' => 'Code changes'
318 ));
319
320 class MTrackRepo extends MTrackSCM {
321   public $repoid = null;
322   public $shortname = null;
323   public $scmtype = null;
324   public $repopath = null;
325   public $browserurl = null;
326   public $browsertype = null;
327   public $description = null;
328   public $parent = '';
329   public $clonedfrom = null;
330   public $serverurl = null;
331   private $links_to_add = array();
332   private $links_to_remove = array();
333   private $links = null;
334   static $scms = array();
335
336   static function registerSCM($scmtype, $classname) {
337     self::$scms[$scmtype] = $classname;
338   }
339   static function getAvailableSCMs() {
340     $ret = array();
341     foreach (self::$scms as $t => $classname) {
342       $o = new $classname;
343       $ret[$t] = $o;
344     }
345     return $ret;
346   }
347
348   public function reconcileRepoSettings() {
349     if (!isset(self::$scms[$this->scmtype])) {
350       throw new Exception("invalid scm type $this->scmtype");
351     }
352     $c = self::$scms[$this->scmtype];
353     $s = new $c;
354     $s->reconcileRepoSettings($this);
355   }
356
357   public function getSCMMetaData() {
358     return null;
359   }
360
361   static function loadById($id) {
362     list($row) = MTrackDB::q(
363       'select repoid, scmtype from repos where repoid = ?',
364       $id)->fetchAll();
365     if (isset($row[0])) {
366       $type = $row[1];
367       if (isset(self::$scms[$type])) {
368         $class = self::$scms[$type];
369         return new $class($row[0]);
370       }
371       throw new Exception("unsupported repo type $type");
372     }
373     return null;
374   }
375
376   static function loadByName($name) {
377     $bits = explode('/', $name);
378     if (count($bits) > 1 && $bits[0] == 'default') {
379       array_shift($bits);
380       $name = $bits[0];
381     }
382     if (count($bits) > 1) {
383       /* wez/reponame -> per user repo */
384       $u = "user:$bits[0]";
385       $p = "project:$bits[0]";
386       $rows = MTrackDB::q(
387         'select repoid, scmtype from repos where shortname = ? and (parent = ? OR parent = ?)',
388         $bits[1], $u, $p)->fetchAll();
389     } else {
390       $rows = MTrackDB::q(
391         "select repoid, scmtype from repos where shortname = ? and parent =''",
392         $name)->fetchAll();
393     }
394     if (is_array($rows) && isset($rows[0])) {
395       $row = $rows[0];
396       if (isset($row[0])) {
397         $type = $row[1];
398         if (isset(self::$scms[$type])) {
399           $class = self::$scms[$type];
400           return new $class($row[0]);
401         }
402         throw new Exception("unsupported repo type $type");
403       }
404     }
405     return null;
406   }
407
408   function getServerURL() {
409     if ($this->serverurl) {
410       return $this->serverurl;
411     }
412     $url = MTrackConfig::get('repos', "$this->scmtype.serverurl");
413     if ($url) {
414       return $url . $this->getBrowseRootName();
415     }
416     return null;
417   }
418
419   function getCheckoutCommand() {
420     $url = $this->getServerURL();
421     if (strlen($url)) {
422       return $this->scmtype . ' clone ' . $this->getServerURL();
423     }
424     return null;
425   }
426
427   function canFork() {
428     return false;
429   }
430
431   static function loadByLocation($path) {
432     list($row) = MTrackDB::q('select repoid, scmtype from repos where repopath = ?', $path)->fetchAll();
433     if (isset($row[0])) {
434       $type = $row[1];
435       if (isset(self::$scms[$type])) {
436         $class = self::$scms[$type];
437         return new $class($row[0]);
438       }
439       throw new Exception("unsupported repo type $type");
440     }
441     return null;
442   }
443
444   public function getWorkingCopy() {
445     throw new Exception("cannot getWorkingCopy from a generic repo object");
446   }
447
448   function __construct($id = null) {
449     if ($id !== null) {
450       list($row) = MTrackDB::q(
451                     'select * from repos where repoid = ?',
452                     $id)->fetchAll();
453       if (isset($row[0])) {
454         $this->repoid = $row['repoid'];
455         $this->shortname = $row['shortname'];
456         $this->scmtype = $row['scmtype'];
457         $this->repopath = $row['repopath'];
458         $this->browserurl = $row['browserurl'];
459         $this->browsertype = $row['browsertype'];
460         $this->description = $row['description'];
461         $this->parent = $row['parent'];
462         $this->clonedfrom = $row['clonedfrom'];
463         $this->serverurl = $row['serverurl'];
464         return;
465       }
466       throw new Exception("unable to find repo with id = $id");
467     }
468   }
469
470   function deleteRepo(MTrackChangeset $CS) {
471     MTrackDB::q('delete from repos where repoid = ?', $this->repoid);
472     mtrack_rmdir($this->repopath);
473   }
474
475   function save(MTrackChangeset $CS) {
476     if (!isset(self::$scms[$this->scmtype])) {
477       throw new Exception("unsupported repo type " . $this->scmtype);
478     }
479
480     if ($this->repoid) {
481       list($row) = MTrackDB::q(
482                     'select * from repos where repoid = ?',
483                     $this->repoid)->fetchAll();
484       $old = $row;
485       MTrackDB::q(
486           'update repos set shortname = ?, scmtype = ?, repopath = ?,
487             browserurl = ?, browsertype = ?, description = ?,
488             parent = ?, serverurl = ?, clonedfrom = ? where repoid = ?',
489           $this->shortname, $this->scmtype, $this->repopath,
490           $this->browserurl, $this->browsertype, $this->description,
491           $this->parent, $this->serverurl, $this->clonedfrom, $this->repoid);
492     } else {
493       $acl = null;
494
495       if (!strlen($this->repopath)) {
496         if (!MTrackConfig::get('repos', 'allow_user_repo_creation')) {
497           throw new Exception("configuration does not allow repo creation");
498         }
499         $repodir = MTrackConfig::get('repos', 'basedir');
500         if ($repodir == null) {
501           $repodir = MTrackConfig::get('core', 'vardir') . '/repos';
502         }
503         if (!is_dir($repodir)) {
504           mkdir($repodir);
505         }
506
507         if (!$this->parent) {
508           $owner = mtrack_canon_username(MTrackAuth::whoami());
509           $this->parent = 'user:' . $owner;
510         } else {
511           list($type, $owner) = explode(':', $this->parent, 2);
512           switch ($type) {
513             case 'project':
514               $P = MTrackProject::loadByName($owner);
515               if (!$P) {
516                 throw new Exception("invalid project $owner");
517               }
518               MTrackACL::requireAllRights("project:$P->projid", 'modify');
519               break;
520             case 'user':
521               if ($owner != mtrack_canon_username(MTrackAuth::whoami())) {
522                 throw new Exception("can't make a repo for another user");
523               }
524               break;
525             default:
526               throw new Exception("invalid parent ($this->parent)");
527           }
528         }
529         if (preg_match("/[^a-zA-Z0-9_.-]/", $owner)) {
530           throw new Exception("$owner must not contain special characters");
531         }
532         $this->repopath = $repodir . DIRECTORY_SEPARATOR . $owner;
533         if (!is_dir($this->repopath)) {
534           mkdir($this->repopath);
535         }
536         $this->repopath .= DIRECTORY_SEPARATOR . $this->shortname;
537
538         /* default ACL is allow user all rights, block everybody else */
539         $acl = array(
540           array($owner, 'read', 1),
541           array($owner, 'modify', 1),
542           array($owner, 'delete', 1),
543           array($owner, 'checkout', 1),
544           array($owner, 'commit', 1),
545           array('*', 'read', 0),
546           array('*', 'modify', 0),
547           array('*', 'delete', 0),
548           array('*', 'checkout', 0),
549           array('*', 'commit', 0),
550         );
551       }
552
553       MTrackDB::q('insert into repos (shortname, scmtype,
554           repopath, browserurl, browsertype, description, parent,
555           serverurl, clonedfrom)
556           values (?, ?, ?, ?, ?, ?, ?, ?, ?)',
557           $this->shortname, $this->scmtype, $this->repopath,
558           $this->browserurl, $this->browsertype, $this->description,
559           $this->parent, $this->serverurl, $this->clonedfrom);
560
561       $this->repoid = MTrackDB::lastInsertId('repos', 'repoid');
562       $old = null;
563
564       if ($acl !== null) {
565         MTrackACL::setACL("repo:$this->repoid", 0, $acl);
566         $me = mtrack_canon_username(MTrackAuth::whoami());
567         foreach (array('ticket', 'changeset') as $e) {
568           MTrackDB::q(
569             'insert into watches (otype, oid, userid, medium, event, active) values (?, ?, ?, ?, ?, 1)',
570           'repo', $this->repoid, $me, 'email', $e);
571         }
572       }
573     }
574     $this->reconcileRepoSettings();
575     if (!$this->parent) {
576       /* for SSH access, populate a symlink from the repos basedir to the
577        * actual path for this repo */
578       $repodir = MTrackConfig::get('repos', 'basedir');
579       if ($repodir == null) {
580         $repodir = MTrackConfig::get('core', 'vardir') . '/repos';
581       }
582       if (!is_dir($repodir)) {
583         mkdir($repodir);
584       }
585       $repodir .= '/default';
586       if (!is_dir($repodir)) {
587         mkdir($repodir);
588       }
589       $repodir .= '/' . $this->shortname;
590       if (!file_exists($repodir)) {
591         symlink($this->repopath, $repodir);
592       } else if (is_link($repodir) && readlink($repodir) != $this->repopath) {
593         unlink($repodir);
594         symlink($this->repopath, $repodir);
595       }
596     }
597     $CS->add("repo:" . $this->repoid . ":shortname", $old['shortname'], $this->shortname);
598     $CS->add("repo:" . $this->repoid . ":scmtype", $old['scmtype'], $this->scmtype);
599     $CS->add("repo:" . $this->repoid . ":repopath", $old['repopath'], $this->repopath);
600     $CS->add("repo:" . $this->repoid . ":browserurl", $old['browserurl'], $this->browserurl);
601     $CS->add("repo:" . $this->repoid . ":browsertype", $old['browsertype'], $this->browsertype);
602     $CS->add("repo:" . $this->repoid . ":description", $old['description'], $this->description);
603     $CS->add("repo:" . $this->repoid . ":parent", $old['parent'], $this->parent);
604     $CS->add("repo:" . $this->repoid . ":clonedfrom", $old['clonedfrom'], $this->clonedfrom);
605     $CS->add("repo:" . $this->repoid . ":serverurl", $old['serverurl'], $this->serverurl);
606
607     foreach ($this->links_to_add as $link) {
608       MTrackDB::q('insert into project_repo_link (projid, repoid, repopathregex) values (?, ?, ?)', $link[0], $this->repoid, $link[1]);
609     }
610     foreach ($this->links_to_remove as $linkid) {
611       MTrackDB::q('delete from project_repo_link where repoid = ? and linkid = ?', $this->repoid, $linkid);
612     }
613   }
614
615   function getLinks()
616   {
617     if ($this->links === null) {
618       $this->links = array();
619       foreach (MTrackDB::q('select linkid, projid, repopathregex
620           from project_repo_link where repoid = ? order by repopathregex',
621           $this->repoid)->fetchAll() as $row) {
622         $this->links[$row[0]] = array($row[1], $row[2]);
623       }
624     }
625     return $this->links;
626   }
627
628   function addLink($proj, $regex)
629   {
630     if ($proj instanceof MTrackProject) {
631       $this->links_to_add[] = array($proj->projid, $regex);
632     } else {
633       $this->links_to_add[] = array($proj, $regex);
634     }
635   }
636
637   function removeLink($linkid)
638   {
639     $this->links_to_remove[$linkid] = $linkid;
640   }
641
642   public function getBranches() {}
643   public function getTags() {}
644   public function readdir($path, $object = null, $ident = null) {}
645   public function file($path, $object = null, $ident = null) {}
646   public function history($path, $limit = null, $object = null, $ident = null){}
647   public function diff($path, $from = null, $to = null) {}
648   public function getRelatedChanges($revision) {}
649
650   function projectFromPath($filename) {
651     static $links = array();
652     if (!isset($links[$this->repoid]) || $links[$this->repoid] === null) {
653       $links[$this->repoid] = array();
654       foreach (MTrackDB::q(
655         'select projid, repopathregex from project_repo_link where repoid = ?',
656             $this->repoid) as $row) {
657         $re = str_replace('/', '\\/', $row[1]);
658         $links[$this->repoid][] = array($row[0], "/$re/");
659       }
660     }
661     if (is_array($filename)) {
662       $proj_incidence = array();
663       foreach ($filename as $file) {
664         $proj = $this->projectFromPath($file);
665         if ($proj === null) continue;
666         if (isset($proj_incidence[$proj])) {
667           $proj_incidence[$proj]++;
668         } else {
669           $proj_incidence[$proj] = 1;
670         }
671       }
672       $the_proj = null;
673       $the_proj_count = 0;
674       foreach ($proj_incidence as $proj => $count) {
675         if ($count > $the_proj_count) {
676           $the_proj_count = $count;
677           $the_proj = $proj;
678         }
679       }
680       return $the_proj;
681     }
682
683     if ($filename instanceof MTrackSCMFileEvent) {
684       $filename = $filename->name;
685     }
686
687     // walk through the regexes; take the longest match as definitive
688     $longest = null;
689     $longest_id = null;
690     if ($filename[0] != '/') {
691       $filename = '/' . $filename;
692     }
693     foreach ($links[$this->repoid] as $link) {
694       if (preg_match($link[1], $filename, $M)) {
695         if (strlen($M[0]) > strlen($longest)) {
696           $longest = $M[0];
697           $longest_id = $link[0];
698         }
699       }
700     }
701     return $longest_id;
702   }
703 }