xref: /plugin/gitbacked/classes/GitRepo.php (revision 2762023dfb29a64197cb442f664aa321f9f5bc87)
1<?php
2
3namespace woolfg\dokuwiki\plugin\gitbacked;
4
5// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
6if (__FILE__ == $_SERVER['SCRIPT_FILENAME']) die('Bad load order');
7
8/**
9 * Git Repository Interface Class
10 *
11 * This class enables the creating, reading, and manipulation
12 * of a git repository
13 *
14 * @class  GitRepo
15 */
16class GitRepo
17{
18    // This regex will filter a probable password from any string containing a Git URL.
19    // Limitation: it will work for the first git URL occurrence in a string.
20    // Used https://regex101.com/ for evaluating!
21    public const REGEX_GIT_URL_FILTER_PWD = "/^(.*)((http:)|(https:))([^:]+)(:[^@]*)?(.*)/im";
22    public const REGEX_GIT_URL_FILTER_PWD_REPLACE_PATTERN = "$1$2$5$7";
23
24    protected $repo_path = null;
25    protected $bare = false;
26    protected $envopts = array();
27    // Fix for PHP <=7.3 compatibility: Type declarations for properties work since PHP >= 7.4 only.
28    // protected ?\action_plugin_gitbacked_editcommit $plugin = null;
29    protected $plugin = null;
30
31    /**
32     * Create a new git repository
33     *
34     * Accepts a creation path, and, optionally, a source path
35     *
36     * @access  public
37     * @param   string  repository path
38     * @param   \action_plugin_gitbacked_editcommit plugin
39     * @param   string  directory to source
40     * @param   string  reference path
41     * @return  GitRepo  or null in case of an error
42     */
43    public static function &createNew(
44        $repo_path,
45        \action_plugin_gitbacked_editcommit $plugin = null,
46        $source = null,
47        $remote_source = false,
48        $reference = null
49    ) {
50        if (is_dir($repo_path) && file_exists($repo_path . "/.git") && is_dir($repo_path . "/.git")) {
51            throw new \Exception(self::handleCreateNewError(
52                $repo_path,
53                $reference,
54                '"' . $repo_path . '" is already a git repository',
55                $plugin
56            ));
57        } else {
58            $repo = new self($repo_path, $plugin, true, false);
59            if (is_string($source)) {
60                if ($remote_source) {
61                    if (!is_dir($reference) || !is_dir($reference . '/.git')) {
62                        throw new \Exception(self::handleCreateNewError(
63                            $repo_path,
64                            $reference,
65                            '"' . $reference . '" is not a git repository. Cannot use as reference.',
66                            $plugin
67                        ));
68                    } elseif (strlen($reference)) {
69                        $reference = realpath($reference);
70                        $reference = "--reference $reference";
71                    }
72                    $repo->cloneRemote($source, $reference);
73                } else {
74                    $repo->cloneFrom($source);
75                }
76            } else {
77                $repo->run('init');
78            }
79            return $repo;
80        }
81    }
82
83    /**
84     * Constructor
85     *
86     * Accepts a repository path
87     *
88     * @access  public
89     * @param   string  repository path
90     * @param   \action_plugin_gitbacked_editcommit plugin
91     * @param   bool    create if not exists?
92     * @return  void
93     */
94    public function __construct(
95        $repo_path = null,
96        \action_plugin_gitbacked_editcommit $plugin = null,
97        $create_new = false,
98        $_init = true
99    ) {
100        $this->plugin = $plugin;
101        if (is_string($repo_path)) {
102            $this->setRepoPath($repo_path, $create_new, $_init);
103        }
104    }
105
106    /**
107     * Set the repository's path
108     *
109     * Accepts the repository path
110     *
111     * @access  public
112     * @param   string  repository path
113     * @param   bool    create if not exists?
114     * @param   bool    initialize new Git repo if not exists?
115     * @return  void
116     */
117    public function setRepoPath($repo_path, $create_new = false, $_init = true)
118    {
119        if (is_string($repo_path)) {
120            if ($new_path = realpath($repo_path)) {
121                $repo_path = $new_path;
122                if (is_dir($repo_path)) {
123                    // Is this a work tree?
124                    if (file_exists($repo_path . "/.git") && is_dir($repo_path . "/.git")) {
125                        $this->repo_path = $repo_path;
126                        $this->bare = false;
127                        // Is this a bare repo?
128                    } elseif (is_file($repo_path . "/config")) {
129                        $parse_ini = parse_ini_file($repo_path . "/config");
130                        if ($parse_ini['bare']) {
131                            $this->repo_path = $repo_path;
132                            $this->bare = true;
133                        }
134                    } else {
135                        if ($create_new) {
136                            $this->repo_path = $repo_path;
137                            if ($_init) {
138                                $this->run('init');
139                            }
140                        } else {
141                            throw new \Exception($this->handleRepoPathError(
142                                $repo_path,
143                                '"' . $repo_path . '" is not a git repository'
144                            ));
145                        }
146                    }
147                } else {
148                    throw new \Exception($this->handleRepoPathError(
149                        $repo_path,
150                        '"' . $repo_path . '" is not a directory'
151                    ));
152                }
153            } else {
154                if ($create_new) {
155                    if ($parent = realpath(dirname($repo_path))) {
156                        mkdir($repo_path);
157                        $this->repo_path = $repo_path;
158                        if ($_init) $this->run('init');
159                    } else {
160                        throw new \Exception($this->handleRepoPathError(
161                            $repo_path,
162                            'cannot create repository in non-existent directory'
163                        ));
164                    }
165                } else {
166                    throw new \Exception($this->handleRepoPathError(
167                        $repo_path,
168                        '"' . $repo_path . '" does not exist'
169                    ));
170                }
171            }
172        }
173    }
174
175    /**
176     * Get the path to the git repo directory (eg. the ".git" directory)
177     *
178     * @access public
179     * @return string
180     */
181    public function gitDirectoryPath()
182    {
183        return ($this->bare) ? $this->repo_path : $this->repo_path . "/.git";
184    }
185
186    /**
187     * Tests if git is installed
188     *
189     * @access  public
190     * @return  bool
191     */
192    public function testGit()
193    {
194        $descriptorspec = array(
195            1 => array('pipe', 'w'),
196            2 => array('pipe', 'w'),
197        );
198        $pipes = array();
199        $resource = proc_open(Git::getBin(), $descriptorspec, $pipes);
200
201        $stdout = stream_get_contents($pipes[1]);
202        $stderr = stream_get_contents($pipes[2]);
203        foreach ($pipes as $pipe) {
204            fclose($pipe);
205        }
206
207        $status = trim(proc_close($resource));
208        return ($status != 127);
209    }
210
211    /**
212     * Run a command in the git repository
213     *
214     * Accepts a shell command to run
215     *
216     * @access  protected
217     * @param   string  command to run
218     * @return  string  or null in case of an error
219     */
220    protected function runCommand($command)
221    {
222        //dbglog("Git->runCommand(command=[".$command."])");
223        $descriptorspec = array(
224            1 => array('pipe', 'w'),
225            2 => array('pipe', 'w'),
226        );
227        $pipes = array();
228        $cwd = $this->repo_path;
229        //dbglog("GitBacked - cwd: [".$cwd."]");
230        /* Provide any $this->envopts via putenv
231         * and call proc_open with env=null to inherit the rest
232         * of env variables from the original process of the system.
233         * Note: Variables set by putenv live for a
234         * single PHP request run only. These variables
235         * are visible "locally". They are NOT listed by getenv(),
236         * but they are visible to the process forked by proc_open().
237         */
238        foreach ($this->envopts as $k => $v) {
239            putenv(sprintf("%s=%s", $k, $v));
240        }
241        $resource = proc_open($command, $descriptorspec, $pipes, $cwd, null);
242
243        $stdout = stream_get_contents($pipes[1]);
244        $stderr = stream_get_contents($pipes[2]);
245        foreach ($pipes as $pipe) {
246            fclose($pipe);
247        }
248
249        $status = trim(proc_close($resource));
250        //dbglog("GitBacked: runCommand status: ".$status);
251        if ($status) {
252            //dbglog("GitBacked - stderr: [".$stderr."]");
253            // Remove a probable password from the Git URL, if the URL is contained in the error message
254            $error_message = preg_replace(
255                $this::REGEX_GIT_URL_FILTER_PWD,
256                $this::REGEX_GIT_URL_FILTER_PWD_REPLACE_PATTERN,
257                $stderr
258            );
259            //dbglog("GitBacked - error_message: [".$error_message."]");
260            throw new \Exception($this->handleCommandError(
261                $this->repo_path,
262                $cwd,
263                $command,
264                $status,
265                $error_message
266            ));
267        } else {
268            $this->handleCommandSuccess($this->repo_path, $cwd, $command);
269        }
270
271        return $stdout;
272    }
273
274    /**
275     * Run a git command in the git repository
276     *
277     * Accepts a git command to run
278     *
279     * @access  public
280     * @param   string  command to run
281     * @return  string
282     */
283    public function run($command)
284    {
285        return $this->runCommand(Git::getBin() . " " . $command);
286    }
287
288    /**
289     * Handles error on create_new
290     *
291     * @access  protected
292     * @param   string  repository path
293     * @param   string  error message
294     * @return  string  error message
295     */
296    protected static function handleCreateNewError($repo_path, $reference, $error_message, $plugin)
297    {
298        if ($plugin instanceof \action_plugin_gitbacked_editcommit) {
299            $plugin->notifyCreateNewError($repo_path, $reference, $error_message);
300        }
301        return $error_message;
302    }
303
304    /**
305     * Handles error on setting the repo path
306     *
307     * @access  protected
308     * @param   string  repository path
309     * @param   string  error message
310     * @return  string  error message
311     */
312    protected function handleRepoPathError($repo_path, $error_message)
313    {
314        if ($this->plugin instanceof \action_plugin_gitbacked_editcommit) {
315            $this->plugin->notifyRepoPathError($repo_path, $error_message);
316        }
317        return $error_message;
318    }
319
320    /**
321     * Handles error on git command
322     *
323     * @access  protected
324     * @param   string  repository path
325     * @param   string  current working dir
326     * @param   string  command line
327     * @param   int     exit code of command (status)
328     * @param   string  error message
329     * @return  string  error message
330     */
331    protected function handleCommandError($repo_path, $cwd, $command, $status, $error_message)
332    {
333        if ($this->plugin instanceof \action_plugin_gitbacked_editcommit) {
334            $this->plugin->notifyCommandError($repo_path, $cwd, $command, $status, $error_message);
335        }
336        return $error_message;
337    }
338
339    /**
340     * Handles success on git command
341     *
342     * @access  protected
343     * @param   string  repository path
344     * @param   string  current working dir
345     * @param   string  command line
346     * @return  void
347     */
348    protected function handleCommandSuccess($repo_path, $cwd, $command)
349    {
350        if ($this->plugin instanceof \action_plugin_gitbacked_editcommit) {
351            $this->plugin->notifyCommandSuccess($repo_path, $cwd, $command);
352        }
353    }
354
355    /**
356     * Runs a 'git status' call
357     *
358     * Accept a convert to HTML bool
359     *
360     * @access public
361     * @param bool  return string with <br />
362     * @return string
363     */
364    public function status($html = false)
365    {
366        $msg = $this->run("status");
367        if ($html == true) {
368            $msg = str_replace("\n", "<br />", $msg);
369        }
370        return $msg;
371    }
372
373    /**
374     * Runs a `git add` call
375     *
376     * Accepts a list of files to add
377     *
378     * @access  public
379     * @param   mixed   files to add
380     * @return  string
381     */
382    public function add($files = "*")
383    {
384        if (is_array($files)) {
385            $files = '"' . implode('" "', $files) . '"';
386        }
387        return $this->run("add $files -v");
388    }
389
390    /**
391     * Runs a `git rm` call
392     *
393     * Accepts a list of files to remove
394     *
395     * @access  public
396     * @param   mixed    files to remove
397     * @param   Boolean  use the --cached flag?
398     * @return  string
399     */
400    public function rm($files = "*", $cached = false)
401    {
402        if (is_array($files)) {
403            $files = '"' . implode('" "', $files) . '"';
404        }
405        return $this->run("rm " . ($cached ? '--cached ' : '') . $files);
406    }
407
408
409    /**
410     * Runs a `git commit` call
411     *
412     * Accepts a commit message string
413     *
414     * @access  public
415     * @param   string  commit message
416     * @param   boolean  should all files be committed automatically (-a flag)
417     * @return  string
418     */
419    public function commit($message = "", $commit_all = true)
420    {
421        $flags = $commit_all ? '-av' : '-v';
422        $msgfile = GitBackedUtil::createMessageFile($message);
423        try {
424            return $this->run("commit --allow-empty " . $flags . " --file=" . $msgfile);
425        } finally {
426            unlink($msgfile);
427        }
428    }
429
430    /**
431     * Runs a `git clone` call to clone the current repository
432     * into a different directory
433     *
434     * Accepts a target directory
435     *
436     * @access  public
437     * @param   string  target directory
438     * @return  string
439     */
440    public function cloneTo($target)
441    {
442        return $this->run("clone --local " . $this->repo_path . " $target");
443    }
444
445    /**
446     * Runs a `git clone` call to clone a different repository
447     * into the current repository
448     *
449     * Accepts a source directory
450     *
451     * @access  public
452     * @param   string  source directory
453     * @return  string
454     */
455    public function cloneFrom($source)
456    {
457        return $this->run("clone --local $source " . $this->repo_path);
458    }
459
460    /**
461     * Runs a `git clone` call to clone a remote repository
462     * into the current repository
463     *
464     * Accepts a source url
465     *
466     * @access  public
467     * @param   string  source url
468     * @param   string  reference path
469     * @return  string
470     */
471    public function cloneRemote($source, $reference)
472    {
473        return $this->run("clone $reference $source " . $this->repo_path);
474    }
475
476    /**
477     * Runs a `git clean` call
478     *
479     * Accepts a remove directories flag
480     *
481     * @access  public
482     * @param   bool    delete directories?
483     * @param   bool    force clean?
484     * @return  string
485     */
486    public function clean($dirs = false, $force = false)
487    {
488        return $this->run("clean" . (($force) ? " -f" : "") . (($dirs) ? " -d" : ""));
489    }
490
491    /**
492     * Runs a `git branch` call
493     *
494     * Accepts a name for the branch
495     *
496     * @access  public
497     * @param   string  branch name
498     * @return  string
499     */
500    public function createBranch($branch)
501    {
502        return $this->run("branch $branch");
503    }
504
505    /**
506     * Runs a `git branch -[d|D]` call
507     *
508     * Accepts a name for the branch
509     *
510     * @access  public
511     * @param   string  branch name
512     * @return  string
513     */
514    public function deleteBranch($branch, $force = false)
515    {
516        return $this->run("branch " . (($force) ? '-D' : '-d') . " $branch");
517    }
518
519    /**
520     * Runs a `git branch` call
521     *
522     * @access  public
523     * @param   bool    keep asterisk mark on active branch
524     * @return  array
525     */
526    public function listBranches($keep_asterisk = false)
527    {
528        $branchArray = explode("\n", $this->run("branch"));
529        foreach ($branchArray as $i => &$branch) {
530            $branch = trim($branch);
531            if (! $keep_asterisk) {
532                $branch = str_replace("* ", "", $branch);
533            }
534            if ($branch == "") {
535                unset($branchArray[$i]);
536            }
537        }
538        return $branchArray;
539    }
540
541    /**
542     * Lists remote branches (using `git branch -r`).
543     *
544     * Also strips out the HEAD reference (e.g. "origin/HEAD -> origin/master").
545     *
546     * @access  public
547     * @return  array
548     */
549    public function listRemoteBranches()
550    {
551        $branchArray = explode("\n", $this->run("branch -r"));
552        foreach ($branchArray as $i => &$branch) {
553            $branch = trim($branch);
554            if ($branch == "" || strpos($branch, 'HEAD -> ') !== false) {
555                unset($branchArray[$i]);
556            }
557        }
558        return $branchArray;
559    }
560
561    /**
562     * Returns name of active branch
563     *
564     * @access  public
565     * @param   bool    keep asterisk mark on branch name
566     * @return  string
567     */
568    public function activeBranch($keep_asterisk = false)
569    {
570        $branchArray = $this->listBranches(true);
571        $activeBranch = preg_grep("/^\*/", $branchArray);
572        reset($activeBranch);
573        if ($keep_asterisk) {
574            return current($activeBranch);
575        } else {
576            return str_replace("* ", "", current($activeBranch));
577        }
578    }
579
580    /**
581     * Runs a `git checkout` call
582     *
583     * Accepts a name for the branch
584     *
585     * @access  public
586     * @param   string  branch name
587     * @return  string
588     */
589    public function checkout($branch)
590    {
591        return $this->run("checkout $branch");
592    }
593
594
595    /**
596     * Runs a `git merge` call
597     *
598     * Accepts a name for the branch to be merged
599     *
600     * @access  public
601     * @param   string $branch
602     * @return  string
603     */
604    public function merge($branch)
605    {
606        return $this->run("merge $branch --no-ff");
607    }
608
609
610    /**
611     * Runs a git fetch on the current branch
612     *
613     * @access  public
614     * @return  string
615     */
616    public function fetch()
617    {
618        return $this->run("fetch");
619    }
620
621    /**
622     * Add a new tag on the current position
623     *
624     * Accepts the name for the tag and the message
625     *
626     * @param string $tag
627     * @param string $message
628     * @return string
629     */
630    public function addTag($tag, $message = null)
631    {
632        if ($message === null) {
633            $message = $tag;
634        }
635        $msgfile = GitBackedUtil::createMessageFile($message);
636        try {
637            return $this->run("tag -a $tag --file=" . $msgfile);
638        } finally {
639            unlink($msgfile);
640        }
641    }
642
643    /**
644     * List all the available repository tags.
645     *
646     * Optionally, accept a shell wildcard pattern and return only tags matching it.
647     *
648     * @access  public
649     * @param   string $pattern Shell wildcard pattern to match tags against.
650     * @return  array           Available repository tags.
651     */
652    public function listTags($pattern = null)
653    {
654        $tagArray = explode("\n", $this->run("tag -l $pattern"));
655        foreach ($tagArray as $i => &$tag) {
656            $tag = trim($tag);
657            if ($tag == '') {
658                unset($tagArray[$i]);
659            }
660        }
661
662        return $tagArray;
663    }
664
665    /**
666     * Push specific branch to a remote
667     *
668     * Accepts the name of the remote and local branch
669     *
670     * @param string $remote
671     * @param string $branch
672     * @return string
673     */
674    public function push($remote, $branch)
675    {
676        return $this->run("push --tags $remote $branch");
677    }
678
679    /**
680     * Pull specific branch from remote
681     *
682     * Accepts the name of the remote and local branch
683     *
684     * @param string $remote
685     * @param string $branch
686     * @return string
687     */
688    public function pull($remote, $branch)
689    {
690        return $this->run("pull $remote $branch");
691    }
692
693    /**
694     * List log entries.
695     *
696     * @param strgin $format
697     * @return string
698     */
699    public function log($format = null)
700    {
701        if ($format === null) {
702            return $this->run('log');
703        } else {
704            return $this->run('log --pretty=format:"' . $format . '"');
705        }
706    }
707
708    /**
709     * Sets the project description.
710     *
711     * @param string $new
712     */
713    public function setDescription($new)
714    {
715        $path = $this->gitDirectoryPath();
716        file_put_contents($path . "/description", $new);
717    }
718
719    /**
720     * Gets the project description.
721     *
722     * @return string
723     */
724    public function getDescription()
725    {
726        $path = $this->gitDirectoryPath();
727        return file_get_contents($path . "/description");
728    }
729
730    /**
731     * Sets custom environment options for calling Git
732     *
733     * @param string key
734     * @param string value
735     */
736    public function setenv($key, $value)
737    {
738        $this->envopts[$key] = $value;
739    }
740}
741
742/* End of file */
743