1: <?php declare(strict_types=1);
2: /**
3: * +------------------------------------------------------------+
4: * | apnscp |
5: * +------------------------------------------------------------+
6: * | Copyright (c) Apis Networks |
7: * +------------------------------------------------------------+
8: * | Licensed under Artistic License 2.0 |
9: * +------------------------------------------------------------+
10: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
11: * +------------------------------------------------------------+
12: */
13:
14: /**
15: * Git management
16: *
17: * @package core
18: */
19: class Git_Module extends Module_Skeleton
20: {
21: protected $exportedFunctions = ['*' => PRIVILEGE_SITE | PRIVILEGE_USER];
22:
23: private function gitCommand(string $cmd, array $args, array $env = []): array
24: {
25: return $this->pman_run('ulimit -f unlimited ; ' . $cmd, $args, $env);
26: }
27:
28: /**
29: * Clone a repositroy
30: *
31: * @param string $repo
32: * @param string $target
33: * @param array $opts
34: * @return bool
35: */
36: public function clone(string $repo, string $target, array $opts): bool
37: {
38: $opts = array_key_map(static function ($k, $v) {
39: $rhand = '';
40: if ($v !== null) {
41: $rhand = '=' . escapeshellarg((string)$v);
42: }
43:
44: return (isset($k[1]) ? '--' : '-') . escapeshellarg($k) . $rhand;
45: }, $opts);
46: $ret = $this->gitCommand('git clone ' . implode(' ', $opts) . ' %(repo)s %(target)s',
47: [
48: 'repo' => $repo,
49: 'target' => $target
50: ]
51: );
52:
53: return $ret['success'] ?: error($ret['stderr']);
54: }
55:
56: /**
57: * Clean repository of stray files
58: *
59: * @param string $path
60: * @param bool $dry
61: * @param bool $dir remove directory as well
62: * @return bool|array
63: */
64: public function clean(string $path, bool $dir = true, bool $dry = false)
65: {
66: $ret = $this->gitCommand('cd %(path)s && git clean -f %(dry)s %(dir)s',
67: [
68: 'path' => $path,
69: 'dry' => $dry ? '-n' : '-q',
70: 'dir' => $dir ? '-d' : null
71: ]
72: );
73: if (!$ret['success']) {
74: error('Failed to clean repo: %s', $ret['stderr']);
75: }
76: if ($dry) {
77: $lines = rtrim($ret['stdout']);
78: if (!$lines) {
79: return [];
80: }
81: return array_map(static function ($line) {
82: if (0 === strpos($line, "Would remove ")) {
83: return substr($line, 13);
84: }
85:
86: return $line;
87: }, explode("\n", $lines));
88: }
89: return $ret['success'];
90: }
91:
92: /**
93: * Path is valid git repository
94: *
95: * @param string $path
96: * @return bool
97: */
98: public function valid(string $path): bool
99: {
100: if (!IS_CLI) {
101: return $this->query('git_valid', $path);
102: }
103:
104: return file_exists($this->domain_fs_path($path . '/.git/HEAD'));
105: }
106:
107: /**
108: * Stash pending changes into new commit
109: *
110: * @param string $path
111: * @param string|null $message
112: * @return string|null
113: */
114: public function stash(string $path, string $message = null): ?string
115: {
116: $ret = $this->gitCommand('cd %(path)s && git stash save -q %(message)s',
117: ['path' => $path, 'message' => $message]);
118: if (!$ret['success']) {
119: error('Failed to stash repo: %s', $ret['stderr']);
120: }
121: return (string)$this->file_get_file_contents("{$path}/.git/stash");
122: }
123:
124: /**
125: * Reset repository to commit
126: *
127: * @param string $path
128: * @param string|null $commit
129: * @param bool $hard
130: * @return bool
131: */
132: public function reset(string $path, ?string $commit = null, bool $hard = true): bool
133: {
134: if ($commit && !ctype_xdigit($commit)) {
135: return error("Invalid commit `%s'", $commit);
136: }
137: $ret = $this->gitCommand('cd %(path)s && git reset -q %(hard)s %(commit)s',
138: ['path' => $path, 'hard' => $hard ? '--hard' : '--mixed', 'commit' => $commit]);
139:
140: return $ret['success'] ?: error('Failed to reset repo: %s', $ret['stderr']);
141: }
142:
143: /**
144: * List tags for repository
145: *
146: * @param string $path
147: * @return array|null
148: */
149: public function tag(string $path): ?array
150: {
151: $ret = $this->gitCommand('cd %(path)s && git tag', ['path' => $path]);
152: if (!$ret['success']) {
153: error('Failed to enumerate tags: %s', $ret['stderr']);
154:
155: return null;
156: }
157:
158: return explode("\n", rtrim($ret['stdout']));
159: }
160:
161: /**
162: * Initialize a git repository
163: *
164: * @param string $path
165: * @param bool $bare
166: * @return bool
167: */
168: public function init(string $path, bool $bare = true): bool
169: {
170: $ret = $this->pman_run('git init %(bare)s %(path)s',
171: [
172: 'bare' => $bare ? '--bare' : null,
173: 'path' => $path
174: ]);
175:
176: if (!$ret['success']) {
177: return error($ret['stderr']);
178: }
179: $ret = $this->pman_run(
180: 'cd %(path)s && git config user.email "%(email)s" && git config user.name "%(name)s"',
181: [
182: 'path' => $path,
183: 'email' => $this->common_get_email(),
184: 'name' => array_get($this->user_getpwnam(), 'gecos') ?: (PANEL_BRAND . ' commit bot')
185: ]
186: );
187: return $ret['success'] ?: error($ret['stderr']);
188: }
189:
190: /**
191: * Download objects and refs from another repository
192: *
193: * @param string $path
194: * @param array $opts
195: * @return bool
196: */
197: public function fetch(string $path, array $opts = []): bool
198: {
199: $opts = implode(' ', array_key_map(static function ($k, $v) {
200: $k = (isset($k[1]) ? '--' : '-') . escapeshellarg($k);
201: if (null === $v) {
202: return $k;
203: }
204:
205: return $k . '=' . escapeshellarg($v);
206: }, $opts));
207: $ret = $this->gitCommand('cd %(path)s && git fetch ' . $opts, ['path' => $path]);
208:
209: return $ret['success'] ?: error('Failed to fetch: %s', $ret['stderr']);
210: }
211:
212: /**
213: * Add files to an existing repo
214: *
215: * @param string $path repo path
216: * @param array|null $files files relative to repo; files are not escaped
217: * @return bool
218: */
219: public function add(string $path, ?array $files = []): bool
220: {
221: $fileStr = $files === null ? '-A --ignore-errors' : implode(' ', array_map('escapeshellarg', $files));
222: $ret = $this->gitCommand('cd %(path)s && git add ' . $fileStr, [
223: 'path' => $path,
224: ], ['LANGUAGE' => 'en_US']);
225: if ($files === null && !$ret['success'] && false !== strpos($ret['stderr'], 'Permission denied')) {
226: return warn('Failed to add files: %s', $ret['stderr']);
227: }
228: return $ret['success'] ?: error('Failed to add files: %s', $ret['stderr']);
229: }
230:
231: /**
232: * Get head commit
233: *
234: * @param string $path
235: * @return string|null|bool
236: */
237: public function head(string $path)
238: {
239: if (!$this->valid($path)) {
240: return null;
241: }
242:
243: $ret = $this->gitCommand('cd %(path)s && git rev-parse HEAD', ['path' => $path]);
244: if (!$ret['success']) {
245: error("Failed to fetch HEAD in `%s': %s", $path, $ret['stderr']);
246: return false;
247: }
248:
249: return rtrim($ret['stdout']);
250: }
251:
252: /**
253: * Add files to ignore
254: *
255: * @param string $path
256: * @param $files
257: * @return bool
258: */
259: public function add_ignore(string $path, $files): bool
260: {
261: if (!$this->valid($path)) {
262: return false;
263: }
264:
265: $entries = $this->list_ignored_files($path);
266: $gitPath = "{$path}/.gitignore";
267:
268: foreach ($files as $line) {
269: if (in_array((string)$line, $entries, true)) {
270: warn('%(line)s already listed in %(path)s', ['line' => $line, 'path' => $gitPath]);
271: continue;
272: }
273: $entries[] = $line;
274: }
275:
276: return $this->file_put_file_contents($gitPath, implode("\n", $entries));
277: }
278:
279: /**
280: * List ignored files
281: *
282: * @param string $path
283: * @return array
284: * @throws FileError
285: */
286: public function list_ignored_files(string $path): array
287: {
288: $gitPath = "{$path}/.gitignore";
289: if (!$this->valid($path) || !$this->file_exists($gitPath)) {
290: return [];
291: }
292:
293: return preg_split('/\R+/m', $this->file_get_file_contents($gitPath));
294: }
295:
296: /**
297: * Get last n commits
298: *
299: * @param string $path
300: * @param int|null $max
301: * @return array
302: */
303: public function list_commits(string $path, ?int $max = 5): array
304: {
305: if (!$this->valid($path)) {
306: return [];
307: }
308:
309: if ( $max !== null && ($max < 0 || $max > 999999) ) {
310: error('Commit limit out of range');
311: return [];
312: }
313: //git log -n 15 --oneline --format="%h %H %ct %s"
314: $ret = $this->gitCommand("cd %(path)s && git log %(hasFlag)s %(max)d --format='%%h %%H %%ct %%s'",
315: ['path' => $path, 'hasFlag' => null !== $max ? '-n' : '', 'max' => $max]);
316: if (!$ret['success']) {
317: error('Failed to run git log: %s', $ret['stderr']);
318: }
319: $commits = [];
320: $hash = strtok($ret['stdout'], ' ');
321: while ($hash) {
322: $commits[$hash] = [
323: 'hash' => strtok(' '),
324: 'ts' => (int)strtok(' '),
325: 'subject' => strtok("\n")
326: ];
327: $hash = strtok(' ');
328: }
329:
330: return $commits;
331: }
332:
333: /**
334: * Commit staged transaction
335: *
336: * @param string $path
337: * @param string $msg
338: * @return string|null commit hash or null
339: */
340: public function commit(string $path, string $msg): ?string
341: {
342: $ret = $this->gitCommand('cd %(path)s && git commit -qm %(msg)s && git rev-parse HEAD', [
343: 'path' => $path,
344: 'msg' => $msg
345: ]);
346:
347: if (!$ret['success']) {
348: if (false !== strpos($ret['stdout'], 'nothing to commit')) {
349: warn('No changes to save');
350: return null;
351: }
352: error('Failed to commit: %s', coalesce($ret['stderr'], $ret['stdout']));
353: return null;
354: }
355:
356: return trim($ret['stdout']);
357: }
358:
359: /**
360: * Checkout ref/tag
361: *
362: * @param string $path
363: * @param string|null $ref
364: * @param array|null $files optional files
365: * @return bool
366: */
367: public function checkout(string $path, ?string $ref, array $files = null): bool
368: {
369: if ($files) {
370: $files = implode(' ', array_map('escapeshellarg', $files));
371: }
372: $ret = $this->gitCommand("cd %(path)s && git checkout %(ref)s $files", [
373: 'path' => $path,
374: 'ref' => $ref,
375: ]);
376:
377: return $ret['success'] ?: error("Failed to checkout `%(ref)s': %(err)s",
378: ['ref' => $ref, 'err' => $ret['stderr']]);
379: }
380:
381: /**
382: * git version
383: *
384: * @return string
385: */
386: public function version(): string
387: {
388: $cache = \Cache_Global::spawn();
389: $key = 'git.version';
390: if (false === ($version = $cache->get($key))) {
391: $ret = \Util_Process::exec('git --version');
392: $version = rtrim(substr($ret['stdout'], strrpos($ret['stdout'],' ')+1));
393: $cache->set($key, $version);
394: }
395:
396: return $version;
397: }
398: }