1:   2:   3:   4:   5:   6:   7:   8:   9:  10:  11:  12:  13:  14:  15:  16:  17:  18:  19:  20:  21:  22:  23:  24:  25:  26:  27:  28:  29:  30:  31:  32:  33:  34:  35:  36:  37:  38:  39:  40:  41:  42:  43:  44:  45:  46:  47:  48:  49:  50:  51:  52:  53:  54:  55:  56:  57:  58:  59:  60:  61:  62:  63:  64:  65:  66:  67:  68:  69:  70:  71:  72:  73:  74:  75:  76:  77:  78:  79:  80:  81:  82:  83:  84:  85:  86:  87:  88:  89:  90:  91:  92:  93:  94:  95:  96:  97:  98:  99: 100: 101: 102: 103: 104: 105: 106: 107: 108: 109: 110: 111: 112: 113: 114: 115: 116: 117: 118: 119: 120: 121: 122: 123: 124: 125: 126: 127: 128: 129: 130: 131: 132: 133: 134: 135: 136: 137: 138: 139: 140: 141: 142: 143: 144: 145: 146: 147: 148: 149: 150: 151: 152: 153: 154: 155: 156: 157: 158: 159: 160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170: 171: 172: 173: 174: 175: 176: 177: 178: 179: 180: 181: 182: 183: 184: 185: 186: 187: 188: 189: 190: 191: 192: 193: 194: 195: 196: 197: 198: 199: 200: 201: 202: 203: 204: 205: 206: 207: 208: 209: 210: 211: 212: 213: 214: 215: 216: 217: 218: 219: 220: 221: 222: 223: 224: 225: 226: 227: 228: 229: 230: 231: 232: 233: 234: 235: 236: 237: 238: 239: 240: 241: 242: 243: 244: 245: 246: 247: 248: 249: 250: 251: 252: 253: 254: 255: 256: 257: 258: 259: 260: 261: 262: 263: 264: 265: 266: 267: 268: 269: 270: 271: 272: 273: 274: 275: 276: 277: 278: 279: 280: 281: 282: 283: 284: 285: 286: 287: 288: 289: 290: 291: 292: 293: 294: 295: 296: 297: 298: 299: 300: 301: 302: 303: 304: 305: 306: 307: 308: 309: 310: 311: 312: 313: 314: 315: 316: 317: 318: 319: 320: 321: 322: 323: 324: 325: 326: 327: 328: 329: 330: 331: 332: 333: 334: 335: 336: 337: 338: 339: 340: 341: 342: 343: 344: 345: 346: 347: 348: 349: 350: 351: 352: 353: 354: 355: 356: 357: 358: 359: 360: 361: 362: 363: 364: 365: 366: 367: 368: 369: 370: 371: 372: 373: 374: 375: 
<?php declare(strict_types=1);
    /**
     *  +------------------------------------------------------------+
     *  | apnscp                                                     |
     *  +------------------------------------------------------------+
     *  | Copyright (c) Apis Networks                                |
     *  +------------------------------------------------------------+
     *  | Licensed under Artistic License 2.0                        |
     *  +------------------------------------------------------------+
     *  | Author: Matt Saladna (msaladna@apisnetworks.com)           |
     *  +------------------------------------------------------------+
     */

    /**
     * Git management
     *
     * @package core
     */
    class Git_Module extends Module_Skeleton
    {
        protected $exportedFunctions = ['*' => PRIVILEGE_SITE | PRIVILEGE_USER];

        /**
         * Clone a repositroy
         *
         * @param string $repo
         * @param string $target
         * @param array  $opts
         * @return bool
         */
        public function clone(string $repo, string $target, array $opts): bool
        {
            $opts = array_key_map(static function ($k, $v) {
                $rhand = '';
                if ($v !== null) {
                    $rhand = '=' . escapeshellarg((string)$v);
                }

                return (isset($k[1]) ? '--' : '-') . escapeshellarg($k) . $rhand;
            }, $opts);
            $ret = $this->pman_run('git clone ' . implode(' ', $opts) . ' %(repo)s %(target)s',
                [
                    'repo'   => $repo,
                    'target' => $target
                ]
            );

            return $ret['success'] ?: error($ret['stderr']);
        }

        /**
         * Clean repository of stray files
         *
         * @param string     $path
         * @param bool       $dry
         * @param bool       $dir remove directory as well
         * @return bool|array
         */
        public function clean(string $path, bool $dir = true, bool $dry = false)
        {
            $ret = $this->pman_run('cd %(path)s && git clean -f %(dry)s %(dir)s',
                [
                    'path' => $path,
                    'dry'  => $dry ? '-n' : '-q',
                    'dir'  => $dir ? '-d' : null
                ]
            );
            if (!$ret['success']) {
                error('Failed to clean repo: %s', $ret['stderr']);
            }
            if ($dry) {
                $lines = rtrim($ret['stdout']);
                if (!$lines) {
                    return [];
                }
                return array_map(static function ($line) {
                    if (0 === strpos($line, "Would remove ")) {
                        return substr($line, 13);
                    }

                    return $line;
                }, explode("\n", $lines));
            }
            return $ret['success'];
        }

        /**
         * Path is valid git repository
         *
         * @param string $path
         * @return bool
         */
        public function valid(string $path): bool
        {
            if (!IS_CLI) {
                return $this->query('git_valid', $path);
            }

            return file_exists($this->domain_fs_path($path . '/.git/HEAD'));
        }

        /**
         * Stash pending changes into new commit
         *
         * @param string      $path
         * @param string|null $message
         * @return string|null
         */
        public function stash(string $path, string $message = null): ?string
        {
            $ret = $this->pman_run('cd %(path)s && git stash save -q %(message)s',
                ['path' => $path, 'message' => $message]);
            if (!$ret['success']) {
                error('Failed to stash repo: %s', $ret['stderr']);
            }
            return (string)$this->file_get_file_contents("${path}/.git/stash");
        }

        /**
         * Reset repository to commit
         *
         * @param string      $path
         * @param string|null $commit
         * @param bool        $hard
         * @return bool
         */
        public function reset(string $path, ?string $commit = null, bool $hard = true): bool
        {
            if ($commit && !ctype_xdigit($commit)) {
                return error("Invalid commit `%s'", $commit);
            }
            $ret = $this->pman_run('cd %(path)s && git reset -q %(hard)s %(commit)s',
                ['path' => $path, 'hard' => $hard ? '--hard' : '--mixed', 'commit' => $commit]);

            return $ret['success'] ?: error('Failed to reset repo: %s', $ret['stderr']);
        }

        /**
         * List tags for repository
         *
         * @param string $path
         * @return array|null
         */
        public function tag(string $path): ?array
        {
            $ret = $this->pman_run('cd %(path)s && git tag', ['path' => $path]);
            if (!$ret['success']) {
                error('Failed to enumerate tags: %s', $ret['stderr']);

                return null;
            }

            return explode("\n", rtrim($ret['stdout']));
        }

        /**
         * Initialize a git repository
         *
         * @param string $path
         * @param bool   $bare
         * @return bool
         */
        public function init(string $path, bool $bare = true): bool
        {
            $ret = $this->pman_run('git init %(bare)s %(path)s',
                [
                    'bare' => $bare ? '--bare' : null,
                    'path' => $path
                ]);

            if (!$ret['success']) {
                return error($ret['stderr']);
            }
            $ret = $this->pman_run(
                'cd %(path)s && git config user.email "%(email)s" && git config user.name "%(name)s"',
                [
                    'path' => $path,
                    'email' => $this->common_get_email(),
                    'name'  => array_get($this->user_getpwnam(), 'gecos') ?: PANEL_BRAND . ' commit bot'
                ]
            );
            return $ret['success'] ?: error($ret['stderr']);
        }

        /**
         * Download objects and refs from another repository
         *
         * @param string $path
         * @param array  $opts
         * @return bool
         */
        public function fetch(string $path, array $opts = []): bool
        {
            $opts = implode(' ', array_key_map(static function ($k, $v) {
                $k = (isset($k[1]) ? '--' : '-') . escapeshellarg($k);
                if (null === $v) {
                    return $k;
                }

                return $k . '=' . escapeshellarg($v);
            }, $opts));
            $ret = $this->pman_run('cd %(path)s && git fetch ' . $opts, ['path' => $path]);

            return $ret['success'] ?: error('Failed to fetch: %s', $ret['stderr']);
        }

        /**
         * Add files to an existing repo
         *
         * @param string     $path  repo path
         * @param array|null $files files relative to repo; files are not escaped
         * @return bool
         */
        public function add(string $path, ?array $files = []): bool
        {
            $fileStr = $files === null ? '-A --ignore-errors' : implode(' ', array_map('escapeshellarg', $files));
            $ret = $this->pman_run('cd %(path)s && git add ' . $fileStr, [
                'path' => $path,
            ], ['LANGUAGE' => 'en_US']);
            if ($files === null && !$ret['success'] && false !== strpos($ret['stderr'], 'Permission denied')) {
                return warn('Failed to add files: %s', $ret['stderr']);
            }
            return $ret['success'] ?: error('Failed to add files: %s', $ret['stderr']);
        }

        /**
         * Get head commit
         *
         * @param string $path
         * @return string|null|bool
         */
        public function head(string $path)
        {
            if (!$this->valid($path)) {
                return null;
            }

            $ret = $this->pman_run('cd %(path)s && git rev-parse HEAD', ['path' => $path]);
            if (!$ret['success']) {
                error("Failed to fetch HEAD in `%s': %s", $path, $ret['stderr']);
                return false;
            }

            return rtrim($ret['stdout']);
        }

        /**
         * Add files to ignore
         *
         * @param string $path
         * @param        $files
         * @return bool
         */
        public function add_ignore(string $path, $files): bool
        {
            if (!$this->valid($path)) {
                return false;
            }

            $entries = $this->list_ignored_files($path);
            $gitPath = "${path}/.gitignore";

            foreach ($files as $line) {
                if (in_array((string)$line, $entries, true)) {
                    warn('%(line)s already listed in %(path)s', ['line' => $line, 'path' => $gitPath]);
                    continue;
                }
                $entries[] = $line;
            }

            return $this->file_put_file_contents($gitPath, implode("\n", $entries));
        }

        /**
         * List ignored files
         *
         * @param string $path
         * @return array
         * @throws FileError
         */
        public function list_ignored_files(string $path): array
        {
            $gitPath = "${path}/.gitignore";
            if (!$this->valid($path) || !$this->file_exists($gitPath)) {
                return [];
            }

            return preg_split('/\R+/m', $this->file_get_file_contents($gitPath));
        }

        /**
         * Get last n commits
         *
         * @param string   $path
         * @param int|null $max
         * @return array
         */
        public function list_commits(string $path, ?int $max = 5): array
        {
            if (!$this->valid($path)) {
                return [];
            }

            if ( $max !== null && ($max < 0 || $max > 999999) ) {
                error('Commit limit out of range');
                return [];
            }
            //git log -n 15 --oneline --format="%h %H %ct %s"
            $ret = $this->pman_run("cd %(path)s && git log %(hasFlag)s %(max)d --format='%%h %%H %%ct %%s'",
                ['path' => $path, 'hasFlag' => null !== $max ? '-n' : '', 'max' => $max]);
            if (!$ret['success']) {
                error('Failed to run git log: %s', $ret['stderr']);
            }
            $commits = [];
            $hash = strtok($ret['stdout'], ' ');
            while ($hash) {
                $commits[$hash] = [
                    'hash'    => strtok(' '),
                    'ts'      => (int)strtok(' '),
                    'subject' => strtok("\n")
                ];
                $hash = strtok(' ');
            }

            return $commits;
        }

        /**
         * Commit staged transaction
         *
         * @param string $path
         * @param string $msg
         * @return string|null commit hash or null
         */
        public function commit(string $path, string $msg): ?string
        {
            $ret = $this->pman_run('cd %(path)s && git commit -qm %(msg)s && git rev-parse HEAD', [
                'path'  => $path,
                'msg'   => $msg
            ]);

            if (!$ret['success']) {
                if (false !== strpos($ret['stdout'], 'nothing to commit')) {
                    warn('No changes to save');
                    return null;
                }
                error('Failed to commit: %s', coalesce($ret['stderr'], $ret['stdout']));
                return null;
            }

            return trim($ret['stdout']);
        }

        /**
         * Checkout ref/tag
         *
         * @param string $path
         * @param string|null $ref
         * @param array|null $files optional files
         * @return bool
         */
        public function checkout(string $path, ?string $ref, array $files = null): bool
        {
            if ($files) {
                $files = implode(' ', array_map('escapeshellarg', $files));
            }
            $ret = $this->pman_run("cd %(path)s && git checkout %(ref)s $files", [
                'path' => $path,
                'ref'  => $ref,
            ]);

            return $ret['success'] ?: error("Failed to checkout `%(ref)s': %(err)s",
                ['ref' => $ref, 'err' => $ret['stderr']]);
        }
    }