fix crash when renaming file
[phorkie.git] / src / phorkie / Repository.php
1 <?php
2 namespace phorkie;
3
4
5 class Repository
6 {
7     /**
8      * Repository ID (number in repositories directory)
9      *
10      * @var integer
11      */
12     public $id;
13
14     /**
15      * Full path to the .git repository
16      *
17      * @var string
18      */
19     public $gitDir;
20
21     /**
22      * Full path to the work tree directory
23      *
24      * @var string
25      */
26     public $workDir;
27
28     /**
29      * Revision of the repository that shall be shown
30      *
31      * @var string
32      */
33     public $hash;
34
35     /**
36      * Commit message of the last (or current) revision
37      *
38      * @var string
39      */
40     public $message;
41
42
43     /**
44      * Load Repository data from GET-Request
45      *
46      * @return void
47      *
48      * @throws Exception When something is wrong
49      */
50     public function loadFromRequest()
51     {
52         if (!isset($_GET['id'])) {
53             throw new Exception_Input('Paste ID missing');
54         }
55         if (!is_numeric($_GET['id'])) {
56             throw new Exception_Input('Paste ID not numeric');
57         }
58         if (isset($_GET['rev'])) {
59             $this->hash = $_GET['rev'];
60         }
61
62         $this->id = (int)$_GET['id'];
63         $this->loadDirs();
64         $this->loadHash();
65         $this->loadMessage();
66     }
67
68     public function loadById($id)
69     {
70         if (!is_numeric($id)) {
71             throw new Exception_Input('Paste ID not numeric');
72         }
73         $this->id = (int)$id;
74         $this->loadDirs();
75         $this->loadHash();
76     }
77
78     protected function loadDirs()
79     {
80         $gitDir = $GLOBALS['phorkie']['cfg']['gitdir'] . '/' . $this->id . '.git';
81         if (!is_dir($gitDir)) {
82             throw new Exception_NotFound(
83                 sprintf('Paste %d .git dir not found', $this->id)
84             );
85         }
86         $this->gitDir = $gitDir;
87
88         $workDir = $GLOBALS['phorkie']['cfg']['workdir'] . '/' . $this->id;
89         if (!is_dir($workDir)) {
90             throw new Exception_NotFound(
91                 sprintf('Paste %d work dir not found', $this->id)
92             );
93         }
94         $this->workDir = $workDir;
95     }
96
97     public function loadHash()
98     {
99         if ($this->hash !== null) {
100             return;
101         }
102
103         $output = $this->getVc()->getCommand('log')
104             ->setOption('pretty', 'format:%H')
105             ->setOption('max-count', 1)
106             ->execute();
107         $output = trim($output);
108         if (strlen($output) !== 40) {
109             throw new Exception(
110                 'Loading commit hash failed: ' . $output
111             );
112         }
113         $this->hash = $output;
114     }
115
116     public function reloadHash()
117     {
118         $this->hash = null;
119         return $this->loadHash();
120     }
121
122     /**
123      * Populates $this->message
124      *
125      * @return void
126      */
127     public function loadMessage()
128     {
129         $rev = (isset($this->hash)) ? $this->hash : 'HEAD';
130         $output = $this->getVc()->getCommand('log')
131             ->setOption('oneline')
132             ->addArgument('-1')
133             ->addArgument($rev)
134             ->execute();
135         $output = trim($output);
136         if (strpos($output, ' ') > 0) {
137             $output = substr($output, strpos($output, ' '), strlen($output));
138             $this->message = trim($output);
139         } else {
140             $this->message = "This commit message intentionally left blank.";
141         }
142     }
143
144     public function getVc()
145     {
146         return new \VersionControl_Git($this->gitDir);
147     }
148
149     /**
150      * Loads the list of files in this repository
151      *
152      * @return File[] Array of file objects
153      */
154     public function getFiles()
155     {
156         $files = $this->getFilePaths();
157         $arFiles = array();
158         foreach ($files as $name) {
159             $arFiles[] = new File($name, $this);
160         }
161         return $arFiles;
162     }
163
164     /**
165      * Decodes unicode characters in git filenames
166      * They begin and end with double quote characters, and may contain
167      * backslash + 3 letter octal code numbers representing the character.
168      *
169      * For example,
170      * > "t\303\244st.txt"
171      * means
172      * > täst.txt
173      *
174      * On the shell, you can pipe them into "printf" and have them decoded.
175      *
176      * @param string Encoded git file name
177      *
178      * @return string Decoded file name
179      */
180     protected function decodeFileName($name)
181     {
182         $name = substr($name, 1, -1);
183         $name = str_replace('\"', '"', $name);
184         $name = preg_replace_callback(
185             '#\\\\[0-7]{3}#',
186             function ($ar) {
187                 return chr(octdec(substr($ar[0], 1)));
188             },
189             $name
190         );
191         return $name;
192     }
193
194     /**
195      * Return array with all file paths in this repository
196      *
197      * @return array
198      */
199     protected function getFilePaths()
200     {
201         if ($this->hash === null) {
202             $hash = 'HEAD';
203         } else {
204             $hash = $this->hash;
205         }
206         $output = $this->getVc()->getCommand('ls-tree')
207             ->setOption('r')
208             ->setOption('name-only')
209             ->addArgument($hash)
210             ->execute();
211         $files = explode("\n", trim($output));
212         foreach ($files as &$file) {
213             if ($file{0} == '"') {
214                 $file = $this->decodeFileName($file);
215             }
216         }
217         return $files;
218     }
219
220     public function getFileByName($name, $bHasToExist = true)
221     {
222         $name = Tools::sanitizeFilename($name);
223         if ($name == '') {
224             throw new Exception_Input('Empty file name given');
225         }
226
227         if ($bHasToExist) {
228             $files = $this->getFilePaths();
229             if (array_search($name, $files) === false) {
230                 throw new Exception_Input('File does not exist');
231             }
232         }
233         return new File($name, $this);
234     }
235
236     public function hasFile($name)
237     {
238         try {
239             $this->getFileByName($name);
240         } catch (Exception $e) {
241             return false;
242         }
243         return true;
244     }
245
246     /**
247      * Permanently deletes the paste repository without any way to get
248      * it back.
249      *
250      * @return boolean True if all went well, false if not
251      */
252     public function delete()
253     {
254         $db = new Database();
255         $db->getIndexer()->deleteRepo($this);
256
257         $bOk = Tools::recursiveDelete($this->workDir)
258             && Tools::recursiveDelete($this->gitDir);
259
260         $not = new Notificator();
261         $not->delete($this);
262
263         return $bOk;
264     }
265
266     public function getTitle()
267     {
268         $desc = $this->getDescription();
269         if (trim($desc) != '') {
270             return $desc;
271         }
272
273         return 'paste #' . $this->id;
274     }
275
276     public function getDescription()
277     {
278         if (!is_readable($this->gitDir . '/description')) {
279             return null;
280         }
281         return file_get_contents($this->gitDir . '/description');
282     }
283
284     public function setDescription($description)
285     {
286         file_put_contents($this->gitDir . '/description', $description);
287     }
288
289     /**
290      * @return array Array with keys "email" and "name"
291      */
292     public function getOwner()
293     {
294         try {
295             $name = $this->getVc()->getCommand('config')
296                 ->addArgument('owner.name')->execute();
297         } catch (\VersionControl_Git_Exception $e) {
298             $name = $GLOBALS['phorkie']['auth']['anonymousName'];
299         }
300         try {
301             $email = $this->getVc()->getCommand('config')
302                 ->addArgument('owner.email')->execute();
303         } catch (\VersionControl_Git_Exception $e) {
304             $email = $GLOBALS['phorkie']['auth']['anonymousEmail'];
305         }
306
307         return array('name' => trim($name), 'email' => trim($email));
308     }
309
310     /**
311      * Get a link to the repository
312      *
313      * @param string  $type   Link type. Supported are:
314      *                        - "edit"
315      *                        - "delete"
316      *                        - "delete-confirm"
317      *                        - "display"
318      *                        - "embed"
319      *                        - "fork"
320      *                        - "revision"
321      * @param string  $option Additional link option, e.g. revision number
322      * @param boolean $full   Return full URL or normal relative
323      *
324      * @return string
325      */
326     public function getLink($type, $option = null, $full = false)
327     {
328         if ($type == 'edit') {
329             $link = $this->id . '/edit';
330             if ($option !== null) {
331                 $link .= '/' . urlencode($option);
332             }
333         } else if ($type == 'display') {
334             $link = $this->id;
335         } else if ($type == 'fork') {
336             $link = $this->id . '/fork';
337         } else if ($type == 'doap') {
338             $link = $this->id . '/doap';
339         } else if ($type == 'delete') {
340             $link = $this->id . '/delete';
341         } else if ($type == 'delete-confirm') {
342             $link = $this->id . '/delete/confirm';
343         } else if ($type == 'embed') {
344             $link = $this->id . '/embed';
345         } else if ($type == 'oembed-json') {
346             $link = 'oembed.php?format=json&url='
347                 . urlencode($this->getLink('display', null, true));
348         } else if ($type == 'oembed-xml') {
349             $link = 'oembed.php?format=xml&url='
350                 . urlencode($this->getLink('display', null, true));
351         } else if ($type == 'remotefork') {
352             return 'web+fork:' . $this->getLink('display', null, true);
353         } else if ($type == 'revision') {
354             $link = $this->id . '/rev/' . $option;
355         } else if ($type == 'linkback') {
356             $link = $this->id . '/linkback';
357         } else {
358             throw new Exception('Unknown link type');
359         }
360
361         if ($full) {
362             $link = Tools::fullUrl($link);
363         }
364         return $link;
365     }
366
367     public function getCloneURL($public = true)
368     {
369         $var = $public ? 'public' : 'private';
370         if (isset($GLOBALS['phorkie']['cfg']['git'][$var])) {
371             return $GLOBALS['phorkie']['cfg']['git'][$var] . $this->id . '.git';
372         }
373         return null;
374     }
375
376     /**
377      * Returns the history of the repository.
378      * We don't use VersionControl_Git's rev list fetcher since it does not
379      * give us separate email addresses and names, and it does not give us
380      * the amount of changed (added/deleted) lines.
381      *
382      * @return array Array of history objects
383      */
384     public function getHistory()
385     {
386         $output = $this->getVc()->getCommand('log')
387             ->setOption('pretty', 'format:commit %H%n%at%n%an%n%ae')
388             ->setOption('max-count', 10)
389             ->setOption('shortstat')
390             ->execute();
391
392         $arCommits = array();
393         $arOutput = explode("\n", $output);
394         $lines = count($arOutput);
395         $current = 0;
396         while ($current < $lines) {
397             $commit = new Repository_Commit();
398             list($name,$commit->hash) = explode(' ', $arOutput[$current]);
399             if ($name !== 'commit') {
400                 throw new Exception(
401                     'Git log output format not as expected: ' . $arOutput[$current]
402                 );
403             }
404             $commit->committerTime  = $arOutput[$current + 1];
405             $commit->committerName  = $arOutput[$current + 2];
406             $commit->committerEmail = $arOutput[$current + 3];
407
408             if (substr($arOutput[$current + 4], 0, 1) != ' ') {
409                 //commit without changed lines
410                 $arCommits[] = $commit;
411                 $current += 4;
412                 continue;
413             }
414
415             $arLineParts = explode(' ', trim($arOutput[$current + 4]));
416             $commit->filesChanged = $arLineParts[0];
417             $commit->linesAdded   = $arLineParts[3];
418             if (isset($arLineParts[5])) {
419                 $commit->linesDeleted = $arLineParts[5];
420             }
421
422             $current += 6;
423
424             $arCommits[] = $commit;
425         }
426
427         return $arCommits;
428     }
429
430     /**
431      * @return Repository_ConnectionInfo
432      */
433     public function getConnectionInfo()
434     {
435         return new Repository_ConnectionInfo($this);
436     }
437 }
438
439 ?>