Translated using Weblate (Portuguese (Brazil))
[phpmyadmin.git] / src / Git.php
blob25416874cb4909060928737fad18b03af2122c56
1 <?php
3 declare(strict_types=1);
5 namespace PhpMyAdmin;
7 use DateTimeImmutable;
8 use DateTimeZone;
9 use DirectoryIterator;
10 use PhpMyAdmin\Utils\HttpRequest;
11 use stdClass;
13 use function array_key_exists;
14 use function array_shift;
15 use function basename;
16 use function bin2hex;
17 use function count;
18 use function explode;
19 use function fclose;
20 use function file_exists;
21 use function file_get_contents;
22 use function fopen;
23 use function fread;
24 use function fseek;
25 use function function_exists;
26 use function gzuncompress;
27 use function implode;
28 use function in_array;
29 use function intval;
30 use function is_bool;
31 use function is_dir;
32 use function is_file;
33 use function json_decode;
34 use function ord;
35 use function preg_match;
36 use function str_contains;
37 use function str_ends_with;
38 use function str_replace;
39 use function str_starts_with;
40 use function strlen;
41 use function strtolower;
42 use function substr;
43 use function trim;
44 use function unpack;
46 use const DIRECTORY_SEPARATOR;
48 /**
49 * Git class to manipulate Git data
51 class Git
53 /**
54 * The path where the to search for .git folders
56 private string $baseDir;
58 /**
59 * Git has been found and the data fetched
61 private bool $hasGit = false;
63 /** @param bool $showGitRevision Enable Git information search and process */
64 public function __construct(private bool $showGitRevision, string|null $baseDir = null)
66 $this->baseDir = $baseDir ?? ROOT_PATH;
69 public function hasGitInformation(): bool
71 return $this->hasGit;
74 /**
75 * detects if Git revision
77 * @param string|null $gitLocation (optional) verified git directory
79 public function isGitRevision(string|null &$gitLocation = null): bool
81 if (! $this->showGitRevision) {
82 return false;
85 // caching
86 if (isset($_SESSION['is_git_revision']) && array_key_exists('git_location', $_SESSION)) {
87 // Define location using cached value
88 $gitLocation = $_SESSION['git_location'];
90 return (bool) $_SESSION['is_git_revision'];
93 // find out if there is a .git folder
94 // or a .git file (--separate-git-dir)
95 $git = $this->baseDir . '.git';
96 if (is_dir($git)) {
97 if (! @is_file($git . '/config')) {
98 $_SESSION['git_location'] = null;
99 $_SESSION['is_git_revision'] = false;
101 return false;
104 $gitLocation = $git;
105 } elseif (is_file($git)) {
106 $contents = (string) file_get_contents($git);
107 $gitmatch = [];
108 // Matches expected format
109 if (! preg_match('/^gitdir: (.*)$/', $contents, $gitmatch)) {
110 $_SESSION['git_location'] = null;
111 $_SESSION['is_git_revision'] = false;
113 return false;
116 if (! @is_dir($gitmatch[1])) {
117 $_SESSION['git_location'] = null;
118 $_SESSION['is_git_revision'] = false;
120 return false;
123 //Detected git external folder location
124 $gitLocation = $gitmatch[1];
125 } else {
126 $_SESSION['git_location'] = null;
127 $_SESSION['is_git_revision'] = false;
129 return false;
132 // Define session for caching
133 $_SESSION['git_location'] = $gitLocation;
134 $_SESSION['is_git_revision'] = true;
136 return true;
139 private function readPackFile(string $packFile, int $packOffset): string|null
141 // open pack file
142 $packFileRes = fopen($packFile, 'rb');
143 if ($packFileRes === false) {
144 return null;
147 // seek to start
148 fseek($packFileRes, $packOffset);
150 // parse header
151 $headerData = fread($packFileRes, 1);
152 if ($headerData === false) {
153 return null;
156 $header = ord($headerData);
157 $type = ($header >> 4) & 7;
158 $hasnext = ($header & 128) >> 7;
159 $size = $header & 0xf;
160 $offset = 4;
162 while ($hasnext) {
163 $readData = fread($packFileRes, 1);
164 if ($readData === false) {
165 return null;
168 $byte = ord($readData);
169 $size |= ($byte & 0x7f) << $offset;
170 $hasnext = ($byte & 128) >> 7;
171 $offset += 7;
174 // we care only about commit objects
175 if ($type != 1) {
176 return null;
179 // read data
180 $commit = fread($packFileRes, $size);
181 fclose($packFileRes);
183 if ($commit === false) {
184 return null;
187 return $commit;
190 private function getPackOffset(string $packFile, string $hash): int|null
192 // load index
193 $indexData = @file_get_contents($packFile);
194 if ($indexData === false) {
195 return null;
198 // check format
199 if (! str_starts_with($indexData, "\377tOc")) {
200 return null;
203 // check version
204 $version = unpack('N', substr($indexData, 4, 4));
205 if ($version[1] != 2) {
206 return null;
209 // parse fanout table
210 $fanout = unpack(
211 'N*',
212 substr($indexData, 8, 256 * 4),
215 // find where we should search
216 $firstbyte = intval(substr($hash, 0, 2), 16);
217 // array is indexed from 1 and we need to get
218 // previous entry for start
219 $start = $firstbyte == 0 ? 0 : $fanout[$firstbyte];
221 $end = $fanout[$firstbyte + 1];
223 // stupid linear search for our sha
224 $found = false;
225 $offset = 8 + (256 * 4);
226 for ($position = $start; $position < $end; $position++) {
227 $sha = strtolower(
228 bin2hex(
229 substr($indexData, $offset + ($position * 20), 20),
232 if ($sha === $hash) {
233 $found = true;
234 break;
238 if (! $found) {
239 return null;
242 // read pack offset
243 $offset = 8 + (256 * 4) + (24 * $fanout[256]);
244 $packOffsets = unpack(
245 'N',
246 substr($indexData, $offset + ($position * 4), 4),
249 return $packOffsets[1];
253 * Un pack a commit with gzuncompress
255 * @param string $gitFolder The Git folder
256 * @param string $hash The commit hash
258 private function unPackGz(string $gitFolder, string $hash): array|false|null
260 $commit = false;
262 $gitFileName = $gitFolder . '/objects/'
263 . substr($hash, 0, 2) . '/' . substr($hash, 2);
264 if (@file_exists($gitFileName)) {
265 $commit = @file_get_contents($gitFileName);
267 if ($commit === false) {
268 $this->hasGit = false;
270 return null;
273 $commitData = gzuncompress($commit);
274 if ($commitData === false) {
275 return null;
278 $commit = explode("\0", $commitData, 2);
279 $commit = explode("\n", $commit[1]);
280 $_SESSION['PMA_VERSION_COMMITDATA_' . $hash] = $commit;
281 } else {
282 $packNames = [];
283 // work with packed data
284 $packsFile = $gitFolder . '/objects/info/packs';
285 $packs = '';
287 if (@file_exists($packsFile)) {
288 $packs = @file_get_contents($packsFile);
291 if ($packs) {
292 // File exists. Read it, parse the file to get the names of the
293 // packs. (to look for them in .git/object/pack directory later)
294 foreach (explode("\n", $packs) as $line) {
295 // skip blank lines
296 if (strlen(trim($line)) == 0) {
297 continue;
300 // skip non pack lines
301 if ($line[0] !== 'P') {
302 continue;
305 // parse names
306 $packNames[] = substr($line, 2);
308 } else {
309 // '.git/objects/info/packs' file can be missing
310 // (at least in mysGit)
311 // File missing. May be we can look in the .git/object/pack
312 // directory for all the .pack files and use that list of
313 // files instead
314 $dirIterator = new DirectoryIterator($gitFolder . '/objects/pack');
315 foreach ($dirIterator as $fileInfo) {
316 $fileName = $fileInfo->getFilename();
317 // if this is a .pack file
318 if (! $fileInfo->isFile() || ! str_ends_with($fileName, '.pack')) {
319 continue;
322 $packNames[] = $fileName;
326 $hash = strtolower($hash);
327 foreach ($packNames as $packName) {
328 $indexName = str_replace('.pack', '.idx', $packName);
330 $packOffset = $this->getPackOffset($gitFolder . '/objects/pack/' . $indexName, $hash);
331 if ($packOffset === null) {
332 continue;
335 $commit = $this->readPackFile($gitFolder . '/objects/pack/' . $packName, $packOffset);
336 if ($commit !== null) {
337 $commit = gzuncompress($commit);
338 if ($commit !== false) {
339 $commit = explode("\n", $commit);
343 $_SESSION['PMA_VERSION_COMMITDATA_' . $hash] = $commit;
347 return $commit;
351 * Extract committer, author and message from commit body
353 * @param mixed[] $commit The commit body
355 * @return array{
356 * array{name: string, email: string, date: string},
357 * array{name: string, email: string, date: string},
358 * string
361 private function extractDataFormTextBody(array $commit): array
363 $author = ['name' => '', 'email' => '', 'date' => ''];
364 $committer = ['name' => '', 'email' => '', 'date' => ''];
366 do {
367 $dataline = array_shift($commit);
368 $datalinearr = explode(' ', $dataline, 2);
369 $linetype = $datalinearr[0];
370 if (! in_array($linetype, ['author', 'committer'], true)) {
371 continue;
374 $user = $datalinearr[1];
375 preg_match('/([^<]+)<([^>]+)> ([0-9]+)( [^ ]+)?/', $user, $user);
376 $timezone = new DateTimeZone($user[4] ?? '+0000');
377 $date = (new DateTimeImmutable())->setTimestamp((int) $user[3])->setTimezone($timezone);
379 $user2 = ['name' => trim($user[1]), 'email' => trim($user[2]), 'date' => $date->format('Y-m-d H:i:s O')];
381 if ($linetype === 'author') {
382 $author = $user2;
383 } else {
384 $committer = $user2;
386 } while ($dataline != '');
388 $message = trim(implode(' ', $commit));
390 return [$author, $committer, $message];
394 * Is the commit remote
396 * @param mixed $commit The commit
397 * @param bool $isRemoteCommit Is the commit remote ?, will be modified by reference
398 * @param string $hash The commit hash
400 * @return stdClass|null The commit body from the GitHub API
402 private function isRemoteCommit(mixed $commit, bool &$isRemoteCommit, string $hash): stdClass|null
404 $httpRequest = new HttpRequest();
406 // check if commit exists in Github
407 if ($commit !== false && isset($_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash])) {
408 $isRemoteCommit = (bool) $_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash];
410 return null;
413 $link = 'https://www.phpmyadmin.net/api/commit/' . $hash . '/';
414 $isFound = $httpRequest->create($link, 'GET');
415 if ($isFound === false) {
416 $isRemoteCommit = false;
417 $_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash] = false;
419 return null;
422 if ($isFound === null) {
423 // no remote link for now, but don't cache this as GitHub is down
424 $isRemoteCommit = false;
426 return null;
429 $isRemoteCommit = true;
430 $_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash] = true;
431 if ($commit === false) {
432 // if no local commit data, try loading from Github
433 return json_decode((string) $isFound);
436 return null;
439 /** @return array{string|null, string|false|null} */
440 private function getHashFromHeadRef(string $gitFolder, string $refHead): array
442 // are we on any branch?
443 if (! str_contains($refHead, '/')) {
444 return [trim($refHead), false];
447 // remove ref: prefix
448 $refHead = substr(trim($refHead), 5);
449 if (str_starts_with($refHead, 'refs/heads/')) {
450 $branch = substr($refHead, 11);
451 } else {
452 $branch = basename($refHead);
455 $hash = null;
456 $refFile = $gitFolder . '/' . $refHead;
457 if (@file_exists($refFile)) {
458 $hash = @file_get_contents($refFile);
459 if ($hash === false) {
460 $this->hasGit = false;
462 return [null, null];
465 return [trim($hash), $branch];
468 // deal with packed refs
469 $packedRefs = @file_get_contents($gitFolder . '/packed-refs');
470 if ($packedRefs === false) {
471 $this->hasGit = false;
473 return [null, null];
476 // split file to lines
477 $refLines = explode("\n", $packedRefs);
478 foreach ($refLines as $line) {
479 // skip comments
480 if ($line[0] === '#') {
481 continue;
484 // parse line
485 $parts = explode(' ', $line);
486 // care only about named refs
487 if (count($parts) != 2) {
488 continue;
491 // have found our ref?
492 if ($parts[1] === $refHead) {
493 $hash = $parts[0];
494 break;
498 if (! isset($hash)) {
499 $this->hasGit = false;
501 // Could not find ref
502 return [null, null];
505 return [$hash, $branch];
508 private function getCommonDirContents(string $gitFolder): string|null
510 if (! is_file($gitFolder . '/commondir')) {
511 return null;
514 $commonDirContents = @file_get_contents($gitFolder . '/commondir');
515 if ($commonDirContents === false) {
516 return null;
519 return trim($commonDirContents);
523 * detects Git revision, if running inside repo
525 * @return array{
526 * hash: string,
527 * branch: string|false,
528 * message: string,
529 * author: array{name: string, email: string, date: string},
530 * committer: array{name: string, email: string, date: string},
531 * is_remote_commit: bool,
532 * is_remote_branch: bool,
533 * }|null
535 public function checkGitRevision(): array|null
537 // find out if there is a .git folder
538 $gitFolder = '';
539 if (! $this->isGitRevision($gitFolder)) {
540 $this->hasGit = false;
542 return null;
545 $refHead = @file_get_contents($gitFolder . '/HEAD');
547 if ($refHead === '' || $refHead === false) {
548 $this->hasGit = false;
550 return null;
553 $commonDirContents = $this->getCommonDirContents($gitFolder);
554 if ($commonDirContents !== null) {
555 $gitFolder .= DIRECTORY_SEPARATOR . $commonDirContents;
558 [$hash, $branch] = $this->getHashFromHeadRef($gitFolder, $refHead);
559 if ($hash === null || $branch === null) {
560 return null;
563 $commit = false;
564 if (! preg_match('/^[0-9a-f]{40}$/i', $hash)) {
565 $commit = false;
566 } elseif (isset($_SESSION['PMA_VERSION_COMMITDATA_' . $hash])) {
567 $commit = $_SESSION['PMA_VERSION_COMMITDATA_' . $hash];
568 } elseif (function_exists('gzuncompress')) {
569 $commit = $this->unPackGz($gitFolder, $hash);
570 if ($commit === null) {
571 return null;
575 $isRemoteCommit = false;
576 $commitJson = $this->isRemoteCommit(
577 $commit, // Will be modified if necessary by the function
578 $isRemoteCommit, // Will be modified if necessary by the function
579 $hash,
582 $isRemoteBranch = false;
583 if ($isRemoteCommit && $branch !== false) {
584 // check if branch exists in Github
585 if (isset($_SESSION['PMA_VERSION_REMOTEBRANCH_' . $hash])) {
586 $isRemoteBranch = (bool) $_SESSION['PMA_VERSION_REMOTEBRANCH_' . $hash];
587 } else {
588 $httpRequest = new HttpRequest();
589 $link = 'https://www.phpmyadmin.net/api/tree/' . $branch . '/';
590 $isFound = $httpRequest->create($link, 'GET', true);
591 if (is_bool($isFound)) {
592 $isRemoteBranch = $isFound;
593 $_SESSION['PMA_VERSION_REMOTEBRANCH_' . $hash] = $isFound;
596 if ($isFound === null) {
597 // no remote link for now, but don't cache this as Github is down
598 $isRemoteBranch = false;
603 if ($commit !== false) {
604 [$author, $committer, $message] = $this->extractDataFormTextBody($commit);
605 } elseif (isset($commitJson->author, $commitJson->committer, $commitJson->message)) {
606 $author = [
607 'name' => (string) $commitJson->author->name,
608 'email' => (string) $commitJson->author->email,
609 'date' => (string) $commitJson->author->date,
611 $committer = [
612 'name' => (string) $commitJson->committer->name,
613 'email' => (string) $commitJson->committer->email,
614 'date' => (string) $commitJson->committer->date,
616 $message = trim($commitJson->message);
617 } else {
618 $this->hasGit = false;
620 return null;
623 $this->hasGit = true;
625 return [
626 'hash' => $hash,
627 'branch' => $branch,
628 'message' => $message,
629 'author' => $author,
630 'committer' => $committer,
631 'is_remote_commit' => $isRemoteCommit,
632 'is_remote_branch' => $isRemoteBranch,