Translated using Weblate (Portuguese)
[phpmyadmin.git] / src / Git.php
blob210b012f906d67c8ca29de926be4851f1c00f398
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 strtolower;
41 use function substr;
42 use function trim;
43 use function unpack;
45 use const DIRECTORY_SEPARATOR;
47 /**
48 * Git class to manipulate Git data
50 class Git
52 /**
53 * The path where the to search for .git folders
55 private string $baseDir;
57 /**
58 * Git has been found and the data fetched
60 private bool $hasGit = false;
62 /** @param bool $showGitRevision Enable Git information search and process */
63 public function __construct(private bool $showGitRevision, string|null $baseDir = null)
65 $this->baseDir = $baseDir ?? ROOT_PATH;
68 public function hasGitInformation(): bool
70 return $this->hasGit;
73 /**
74 * detects if Git revision
76 * @param string|null $gitLocation (optional) verified git directory
78 public function isGitRevision(string|null &$gitLocation = null): bool
80 if (! $this->showGitRevision) {
81 return false;
84 // caching
85 if (isset($_SESSION['is_git_revision']) && array_key_exists('git_location', $_SESSION)) {
86 // Define location using cached value
87 $gitLocation = $_SESSION['git_location'];
89 return (bool) $_SESSION['is_git_revision'];
92 // find out if there is a .git folder
93 // or a .git file (--separate-git-dir)
94 $git = $this->baseDir . '.git';
95 if (is_dir($git)) {
96 if (! @is_file($git . '/config')) {
97 $_SESSION['git_location'] = null;
98 $_SESSION['is_git_revision'] = false;
100 return false;
103 $gitLocation = $git;
104 } elseif (is_file($git)) {
105 $contents = (string) file_get_contents($git);
106 $gitmatch = [];
107 // Matches expected format
108 if (! preg_match('/^gitdir: (.*)$/', $contents, $gitmatch)) {
109 $_SESSION['git_location'] = null;
110 $_SESSION['is_git_revision'] = false;
112 return false;
115 if (! @is_dir($gitmatch[1])) {
116 $_SESSION['git_location'] = null;
117 $_SESSION['is_git_revision'] = false;
119 return false;
122 //Detected git external folder location
123 $gitLocation = $gitmatch[1];
124 } else {
125 $_SESSION['git_location'] = null;
126 $_SESSION['is_git_revision'] = false;
128 return false;
131 // Define session for caching
132 $_SESSION['git_location'] = $gitLocation;
133 $_SESSION['is_git_revision'] = true;
135 return true;
138 private function readPackFile(string $packFile, int $packOffset): string|null
140 // open pack file
141 $packFileRes = fopen($packFile, 'rb');
142 if ($packFileRes === false) {
143 return null;
146 // seek to start
147 fseek($packFileRes, $packOffset);
149 // parse header
150 $headerData = fread($packFileRes, 1);
151 if ($headerData === false) {
152 return null;
155 $header = ord($headerData);
156 $type = ($header >> 4) & 7;
157 $hasnext = ($header & 128) >> 7;
158 $size = $header & 0xf;
159 $offset = 4;
161 while ($hasnext) {
162 $readData = fread($packFileRes, 1);
163 if ($readData === false) {
164 return null;
167 $byte = ord($readData);
168 $size |= ($byte & 0x7f) << $offset;
169 $hasnext = ($byte & 128) >> 7;
170 $offset += 7;
173 // we care only about commit objects
174 if ($type !== 1) {
175 return null;
178 // read data
179 $commit = fread($packFileRes, $size);
180 fclose($packFileRes);
182 if ($commit === false) {
183 return null;
186 return $commit;
189 private function getPackOffset(string $packFile, string $hash): int|null
191 // load index
192 $indexData = @file_get_contents($packFile);
193 if ($indexData === false) {
194 return null;
197 // check format
198 if (! str_starts_with($indexData, "\377tOc")) {
199 return null;
202 // check version
203 $version = unpack('N', substr($indexData, 4, 4));
204 if ($version[1] != 2) {
205 return null;
208 // parse fanout table
209 $fanout = unpack(
210 'N*',
211 substr($indexData, 8, 256 * 4),
214 // find where we should search
215 $firstbyte = intval(substr($hash, 0, 2), 16);
216 // array is indexed from 1 and we need to get
217 // previous entry for start
218 $start = $firstbyte == 0 ? 0 : $fanout[$firstbyte];
220 $end = $fanout[$firstbyte + 1];
222 // stupid linear search for our sha
223 $found = false;
224 $offset = 8 + (256 * 4);
225 for ($position = $start; $position < $end; $position++) {
226 $sha = strtolower(
227 bin2hex(
228 substr($indexData, $offset + ($position * 20), 20),
231 if ($sha === $hash) {
232 $found = true;
233 break;
237 if (! $found) {
238 return null;
241 // read pack offset
242 $offset = 8 + (256 * 4) + (24 * $fanout[256]);
243 $packOffsets = unpack(
244 'N',
245 substr($indexData, $offset + ($position * 4), 4),
248 return $packOffsets[1];
252 * Un pack a commit with gzuncompress
254 * @param string $gitFolder The Git folder
255 * @param string $hash The commit hash
257 private function unPackGz(string $gitFolder, string $hash): array|false|null
259 $commit = false;
261 $gitFileName = $gitFolder . '/objects/'
262 . substr($hash, 0, 2) . '/' . substr($hash, 2);
263 if (@file_exists($gitFileName)) {
264 $commit = @file_get_contents($gitFileName);
266 if ($commit === false) {
267 $this->hasGit = false;
269 return null;
272 $commitData = gzuncompress($commit);
273 if ($commitData === false) {
274 return null;
277 $commit = explode("\0", $commitData, 2);
278 $commit = explode("\n", $commit[1]);
279 $_SESSION['PMA_VERSION_COMMITDATA_' . $hash] = $commit;
280 } else {
281 $packNames = [];
282 // work with packed data
283 $packsFile = $gitFolder . '/objects/info/packs';
284 $packs = '';
286 if (@file_exists($packsFile)) {
287 $packs = @file_get_contents($packsFile);
290 if ($packs) {
291 // File exists. Read it, parse the file to get the names of the
292 // packs. (to look for them in .git/object/pack directory later)
293 foreach (explode("\n", $packs) as $line) {
294 // skip blank lines
295 if (trim($line) === '') {
296 continue;
299 // skip non pack lines
300 if ($line[0] !== 'P') {
301 continue;
304 // parse names
305 $packNames[] = substr($line, 2);
307 } else {
308 // '.git/objects/info/packs' file can be missing
309 // (at least in mysGit)
310 // File missing. May be we can look in the .git/object/pack
311 // directory for all the .pack files and use that list of
312 // files instead
313 $dirIterator = new DirectoryIterator($gitFolder . '/objects/pack');
314 foreach ($dirIterator as $fileInfo) {
315 $fileName = $fileInfo->getFilename();
316 // if this is a .pack file
317 if (! $fileInfo->isFile() || ! str_ends_with($fileName, '.pack')) {
318 continue;
321 $packNames[] = $fileName;
325 $hash = strtolower($hash);
326 foreach ($packNames as $packName) {
327 $indexName = str_replace('.pack', '.idx', $packName);
329 $packOffset = $this->getPackOffset($gitFolder . '/objects/pack/' . $indexName, $hash);
330 if ($packOffset === null) {
331 continue;
334 $commit = $this->readPackFile($gitFolder . '/objects/pack/' . $packName, $packOffset);
335 if ($commit !== null) {
336 $commit = gzuncompress($commit);
337 if ($commit !== false) {
338 $commit = explode("\n", $commit);
342 $_SESSION['PMA_VERSION_COMMITDATA_' . $hash] = $commit;
346 return $commit;
350 * Extract committer, author and message from commit body
352 * @param mixed[] $commit The commit body
354 * @return array{
355 * array{name: string, email: string, date: string},
356 * array{name: string, email: string, date: string},
357 * string
360 private function extractDataFormTextBody(array $commit): array
362 $author = ['name' => '', 'email' => '', 'date' => ''];
363 $committer = ['name' => '', 'email' => '', 'date' => ''];
365 do {
366 $dataline = array_shift($commit);
367 $datalinearr = explode(' ', $dataline, 2);
368 $linetype = $datalinearr[0];
369 if (! in_array($linetype, ['author', 'committer'], true)) {
370 continue;
373 $user = $datalinearr[1];
374 preg_match('/([^<]+)<([^>]+)> ([0-9]+)( [^ ]+)?/', $user, $user);
375 $timezone = new DateTimeZone($user[4] ?? '+0000');
376 $date = (new DateTimeImmutable())->setTimestamp((int) $user[3])->setTimezone($timezone);
378 $user2 = ['name' => trim($user[1]), 'email' => trim($user[2]), 'date' => $date->format('Y-m-d H:i:s O')];
380 if ($linetype === 'author') {
381 $author = $user2;
382 } else {
383 $committer = $user2;
385 } while ($dataline != '');
387 $message = trim(implode(' ', $commit));
389 return [$author, $committer, $message];
393 * Is the commit remote
395 * @param mixed $commit The commit
396 * @param bool $isRemoteCommit Is the commit remote ?, will be modified by reference
397 * @param string $hash The commit hash
399 * @return stdClass|null The commit body from the GitHub API
401 private function isRemoteCommit(mixed $commit, bool &$isRemoteCommit, string $hash): stdClass|null
403 $httpRequest = new HttpRequest();
405 // check if commit exists in Github
406 if ($commit !== false && isset($_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash])) {
407 $isRemoteCommit = (bool) $_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash];
409 return null;
412 $link = 'https://www.phpmyadmin.net/api/commit/' . $hash . '/';
413 $isFound = $httpRequest->create($link, 'GET');
414 if ($isFound === false) {
415 $isRemoteCommit = false;
416 $_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash] = false;
418 return null;
421 if ($isFound === null) {
422 // no remote link for now, but don't cache this as GitHub is down
423 $isRemoteCommit = false;
425 return null;
428 $isRemoteCommit = true;
429 $_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash] = true;
430 if ($commit === false) {
431 // if no local commit data, try loading from Github
432 return json_decode((string) $isFound);
435 return null;
438 /** @return array{string|null, string|false|null} */
439 private function getHashFromHeadRef(string $gitFolder, string $refHead): array
441 // are we on any branch?
442 if (! str_contains($refHead, '/')) {
443 return [trim($refHead), false];
446 // remove ref: prefix
447 $refHead = substr(trim($refHead), 5);
448 if (str_starts_with($refHead, 'refs/heads/')) {
449 $branch = substr($refHead, 11);
450 } else {
451 $branch = basename($refHead);
454 $hash = null;
455 $refFile = $gitFolder . '/' . $refHead;
456 if (@file_exists($refFile)) {
457 $hash = @file_get_contents($refFile);
458 if ($hash === false) {
459 $this->hasGit = false;
461 return [null, null];
464 return [trim($hash), $branch];
467 // deal with packed refs
468 $packedRefs = @file_get_contents($gitFolder . '/packed-refs');
469 if ($packedRefs === false) {
470 $this->hasGit = false;
472 return [null, null];
475 // split file to lines
476 $refLines = explode("\n", $packedRefs);
477 foreach ($refLines as $line) {
478 // skip comments
479 if ($line[0] === '#') {
480 continue;
483 // parse line
484 $parts = explode(' ', $line);
485 // care only about named refs
486 if (count($parts) != 2) {
487 continue;
490 // have found our ref?
491 if ($parts[1] === $refHead) {
492 $hash = $parts[0];
493 break;
497 if (! isset($hash)) {
498 $this->hasGit = false;
500 // Could not find ref
501 return [null, null];
504 return [$hash, $branch];
507 private function getCommonDirContents(string $gitFolder): string|null
509 if (! is_file($gitFolder . '/commondir')) {
510 return null;
513 $commonDirContents = @file_get_contents($gitFolder . '/commondir');
514 if ($commonDirContents === false) {
515 return null;
518 return trim($commonDirContents);
522 * detects Git revision, if running inside repo
524 * @return array{
525 * hash: string,
526 * branch: string|false,
527 * message: string,
528 * author: array{name: string, email: string, date: string},
529 * committer: array{name: string, email: string, date: string},
530 * is_remote_commit: bool,
531 * is_remote_branch: bool,
532 * }|null
534 public function checkGitRevision(): array|null
536 // find out if there is a .git folder
537 $gitFolder = '';
538 if (! $this->isGitRevision($gitFolder)) {
539 $this->hasGit = false;
541 return null;
544 $refHead = @file_get_contents($gitFolder . '/HEAD');
546 if ($refHead === '' || $refHead === false) {
547 $this->hasGit = false;
549 return null;
552 $commonDirContents = $this->getCommonDirContents($gitFolder);
553 if ($commonDirContents !== null) {
554 $gitFolder .= DIRECTORY_SEPARATOR . $commonDirContents;
557 [$hash, $branch] = $this->getHashFromHeadRef($gitFolder, $refHead);
558 if ($hash === null || $branch === null) {
559 return null;
562 $commit = false;
563 if (! preg_match('/^[0-9a-f]{40}$/i', $hash)) {
564 $commit = false;
565 } elseif (isset($_SESSION['PMA_VERSION_COMMITDATA_' . $hash])) {
566 $commit = $_SESSION['PMA_VERSION_COMMITDATA_' . $hash];
567 } elseif (function_exists('gzuncompress')) {
568 $commit = $this->unPackGz($gitFolder, $hash);
569 if ($commit === null) {
570 return null;
574 $isRemoteCommit = false;
575 $commitJson = $this->isRemoteCommit(
576 $commit, // Will be modified if necessary by the function
577 $isRemoteCommit, // Will be modified if necessary by the function
578 $hash,
581 $isRemoteBranch = false;
582 if ($isRemoteCommit && $branch !== false) {
583 // check if branch exists in Github
584 if (isset($_SESSION['PMA_VERSION_REMOTEBRANCH_' . $hash])) {
585 $isRemoteBranch = (bool) $_SESSION['PMA_VERSION_REMOTEBRANCH_' . $hash];
586 } else {
587 $httpRequest = new HttpRequest();
588 $link = 'https://www.phpmyadmin.net/api/tree/' . $branch . '/';
589 $isFound = $httpRequest->create($link, 'GET', true);
590 if (is_bool($isFound)) {
591 $isRemoteBranch = $isFound;
592 $_SESSION['PMA_VERSION_REMOTEBRANCH_' . $hash] = $isFound;
595 if ($isFound === null) {
596 // no remote link for now, but don't cache this as Github is down
597 $isRemoteBranch = false;
602 if ($commit !== false) {
603 [$author, $committer, $message] = $this->extractDataFormTextBody($commit);
604 } elseif (isset($commitJson->author, $commitJson->committer, $commitJson->message)) {
605 $author = [
606 'name' => (string) $commitJson->author->name,
607 'email' => (string) $commitJson->author->email,
608 'date' => (string) $commitJson->author->date,
610 $committer = [
611 'name' => (string) $commitJson->committer->name,
612 'email' => (string) $commitJson->committer->email,
613 'date' => (string) $commitJson->committer->date,
615 $message = trim($commitJson->message);
616 } else {
617 $this->hasGit = false;
619 return null;
622 $this->hasGit = true;
624 return [
625 'hash' => $hash,
626 'branch' => $branch,
627 'message' => $message,
628 'author' => $author,
629 'committer' => $committer,
630 'is_remote_commit' => $isRemoteCommit,
631 'is_remote_branch' => $isRemoteBranch,