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