Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.65% covered (warning)
67.65%
115 / 170
18.75% covered (danger)
18.75%
3 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
GitInfo
68.05% covered (warning)
68.05%
115 / 169
18.75% covered (danger)
18.75%
3 / 16
224.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 getCacheFilePath
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 repo
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isSHA1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHead
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 getHeadSHA1
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 getHeadCommitDate
40.00% covered (danger)
40.00%
10 / 25
0.00% covered (danger)
0.00%
0 / 1
17.58
 getCurrentBranch
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 getHeadViewUrl
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 getRemoteUrl
37.50% covered (danger)
37.50%
6 / 16
0.00% covered (danger)
0.00%
0 / 1
28.78
 cacheIsComplete
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 precomputeValues
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 headSHA1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 currentBranch
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 headViewUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getViewers
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Utils;
8
9use MediaWiki\Config\ServiceOptions;
10use MediaWiki\HookContainer\HookRunner;
11use MediaWiki\Json\FormatJson;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\MainConfigNames;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Shell\Shell;
16use Psr\Log\LoggerInterface;
17use RuntimeException;
18
19/**
20 * Fetch status information from a local git repository
21 *
22 * This is used by Special:Version. It can also be used by developers
23 * in their LocalSettings.php to ease testing of a branch you work on
24 * for a longer period of time. For example:
25 *
26 *     if ( GitInfo::currentBranch() === 'myrewriteproject' ) {
27 *     }
28 *
29 * @newable
30 * @note marked as newable in 1.35 for lack of a better alternative,
31 *       but should become a stateless service eventually.
32 */
33class GitInfo {
34
35    /** @var self|null Singleton for the repo at $IP */
36    protected static $repo = null;
37
38    /** @var string|null Location of the .git directory */
39    protected $basedir;
40
41    /** @var string Location of the repository */
42    protected $repoDir;
43
44    /** @var string|null Path to JSON cache file for pre-computed git information */
45    protected $cacheFile;
46
47    /** @var array Cached git information */
48    protected $cache = [];
49
50    /**
51     * @var array|false Map of repo URLs to viewer URLs. Access via method getViewers().
52     */
53    private static $viewers = false;
54
55    /** Configuration options needed */
56    private const CONSTRUCTOR_OPTIONS = [
57        MainConfigNames::CacheDirectory,
58        MainConfigNames::GitBin,
59        MainConfigNames::GitInfoCacheDirectory,
60        MainConfigNames::GitRepositoryViewers,
61    ];
62
63    private LoggerInterface $logger;
64    private ServiceOptions $options;
65    private HookRunner $hookRunner;
66
67    /**
68     * @stable to call
69     * @param string $repoDir The root directory of the repo where .git can be found
70     * @param bool $usePrecomputed Use precomputed information if available
71     * @see precomputeValues
72     */
73    public function __construct( $repoDir, $usePrecomputed = true ) {
74        $this->repoDir = $repoDir;
75        $services = MediaWikiServices::getInstance();
76        $this->options = new ServiceOptions(
77            self::CONSTRUCTOR_OPTIONS, $services->getMainConfig()
78        );
79        $this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
80        // $this->options must be set before using getCacheFilePath()
81        $this->cacheFile = $this->getCacheFilePath( $repoDir );
82        $this->logger = LoggerFactory::getInstance( 'gitinfo' );
83        $this->logger->debug(
84            "Candidate cacheFile={$this->cacheFile} for {$repoDir}"
85        );
86        $this->hookRunner = new HookRunner( $services->getHookContainer() );
87        if ( $usePrecomputed &&
88            $this->cacheFile !== null &&
89            is_readable( $this->cacheFile )
90        ) {
91            $this->cache = FormatJson::decode(
92                file_get_contents( $this->cacheFile ),
93                true
94            );
95            $this->logger->debug( "Loaded git data from cache for {$repoDir}" );
96        }
97
98        if ( !$this->cacheIsComplete() ) {
99            $this->logger->debug( "Cache incomplete for {$repoDir}" );
100            $this->basedir = $repoDir . DIRECTORY_SEPARATOR . '.git';
101            if ( is_readable( $this->basedir ) && !is_dir( $this->basedir ) ) {
102                $GITfile = file_get_contents( $this->basedir );
103                if ( strlen( $GITfile ) > 8 &&
104                    substr( $GITfile, 0, 8 ) === 'gitdir: '
105                ) {
106                    $path = rtrim( substr( $GITfile, 8 ), "\r\n" );
107                    if ( $path[0] === '/' || substr( $path, 1, 1 ) === ':' ) {
108                        // Path from GITfile is absolute
109                        $this->basedir = $path;
110                    } else {
111                        $this->basedir = $repoDir . DIRECTORY_SEPARATOR . $path;
112                    }
113                }
114            }
115        }
116    }
117
118    /**
119     * Compute the path to the cache file for a given directory.
120     *
121     * @param string $repoDir The root directory of the repo where .git can be found
122     * @return string Path to GitInfo cache file in $wgGitInfoCacheDirectory or
123     * fallback in the extension directory itself
124     * @since 1.24
125     */
126    private function getCacheFilePath( $repoDir ) {
127        $gitInfoCacheDirectory = $this->options->get( MainConfigNames::GitInfoCacheDirectory );
128        if ( $gitInfoCacheDirectory === false ) {
129            $gitInfoCacheDirectory = $this->options->get( MainConfigNames::CacheDirectory ) . '/gitinfo';
130        }
131        if ( $gitInfoCacheDirectory ) {
132            // Convert both MW_INSTALL_PATH and $repoDir to canonical paths
133            $repoName = realpath( $repoDir );
134            if ( $repoName === false ) {
135                // Unit tests use fake path names
136                $repoName = $repoDir;
137            }
138            $realIP = realpath( MW_INSTALL_PATH );
139            if ( str_starts_with( $repoName, $realIP ) ) {
140                // Strip MW_INSTALL_PATH from path
141                $repoName = substr( $repoName, strlen( $realIP ) );
142            }
143            // Transform git repo path to something we can safely embed in a filename
144            // Windows supports both backslash and forward slash, ensure both are substituted.
145            $repoName = strtr( $repoName, [ '/' => '-' ] );
146            $repoName = strtr( $repoName, [ DIRECTORY_SEPARATOR => '-' ] );
147            $fileName = 'info' . $repoName . '.json';
148            $cachePath = "{$gitInfoCacheDirectory}/{$fileName}";
149            if ( is_readable( $cachePath ) ) {
150                return $cachePath;
151            }
152        }
153
154        return "$repoDir/gitinfo.json";
155    }
156
157    /**
158     * Get the singleton for the repo at MW_INSTALL_PATH
159     *
160     * @return GitInfo
161     */
162    public static function repo() {
163        if ( self::$repo === null ) {
164            self::$repo = new self( MW_INSTALL_PATH );
165        }
166        return self::$repo;
167    }
168
169    /**
170     * Check if a string looks like a hex encoded SHA1 hash
171     *
172     * @param string $str The string to check
173     * @return bool Whether or not the string looks like a SHA1
174     */
175    public static function isSHA1( $str ) {
176        return (bool)preg_match( '/^[0-9A-F]{40}$/i', $str );
177    }
178
179    /**
180     * Get the HEAD of the repo (without any opening "ref: ")
181     *
182     * @return string|false The HEAD (git reference or SHA1) or false
183     */
184    public function getHead() {
185        if ( !isset( $this->cache['head'] ) ) {
186            $headFile = "{$this->basedir}/HEAD";
187            $head = false;
188
189            if ( is_readable( $headFile ) ) {
190                $head = file_get_contents( $headFile );
191
192                if ( preg_match( "/ref: (.*)/", $head, $m ) ) {
193                    $head = rtrim( $m[1] );
194                } else {
195                    $head = rtrim( $head );
196                }
197            }
198            $this->cache['head'] = $head;
199        }
200        return $this->cache['head'];
201    }
202
203    /**
204     * Get the SHA1 for the current HEAD of the repo
205     *
206     * @return string|false A SHA1 or false
207     */
208    public function getHeadSHA1() {
209        if ( !isset( $this->cache['headSHA1'] ) ) {
210            $head = $this->getHead();
211            $sha1 = false;
212
213            // If detached HEAD may be a SHA1
214            if ( self::isSHA1( $head ) ) {
215                $sha1 = $head;
216            } else {
217                // If not a SHA1 it may be a ref:
218                $refFile = "{$this->basedir}/{$head}";
219                $packedRefs = "{$this->basedir}/packed-refs";
220                $headRegex = preg_quote( $head, '/' );
221                if ( is_readable( $refFile ) ) {
222                    $sha1 = rtrim( file_get_contents( $refFile ) );
223                } elseif ( is_readable( $packedRefs ) &&
224                    preg_match( "/^([0-9A-Fa-f]{40}) $headRegex$/m", file_get_contents( $packedRefs ), $matches )
225                ) {
226                    $sha1 = $matches[1];
227                }
228            }
229            $this->cache['headSHA1'] = $sha1;
230        }
231        return $this->cache['headSHA1'];
232    }
233
234    /**
235     * Get the commit date of HEAD entry of the git code repository
236     *
237     * @since 1.22
238     * @return int|false Commit date (UNIX timestamp) or false
239     */
240    public function getHeadCommitDate() {
241        $gitBin = $this->options->get( MainConfigNames::GitBin );
242
243        if ( !isset( $this->cache['headCommitDate'] ) ) {
244            $date = false;
245
246            // Suppress warnings about any open_basedir restrictions affecting $wgGitBin (T74445).
247            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
248            $isFile = @is_file( $gitBin );
249            if ( $isFile &&
250                is_executable( $gitBin ) &&
251                !Shell::isDisabled() &&
252                $this->getHead() !== false
253            ) {
254                $cmd = [
255                    $gitBin,
256                    'show',
257                    '-s',
258                    '--format=format:%ct',
259                    'HEAD',
260                ];
261                $gitDir = realpath( $this->basedir );
262                $result = Shell::command( $cmd )
263                    ->environment( [ 'GIT_DIR' => $gitDir ] )
264                    ->restrict( Shell::RESTRICT_DEFAULT | Shell::NO_NETWORK )
265                    ->allowPath( $gitDir, $this->repoDir )
266                    ->execute();
267
268                if ( $result->getExitCode() === 0 ) {
269                    $date = (int)$result->getStdout();
270                }
271            }
272            $this->cache['headCommitDate'] = $date;
273        }
274        return $this->cache['headCommitDate'];
275    }
276
277    /**
278     * Get the name of the current branch, or HEAD if not found
279     *
280     * @return string|false The branch name, HEAD, or false
281     */
282    public function getCurrentBranch() {
283        if ( !isset( $this->cache['branch'] ) ) {
284            $branch = $this->getHead();
285            if ( $branch &&
286                preg_match( "#^refs/heads/(.*)$#", $branch, $m )
287            ) {
288                $branch = $m[1];
289            }
290            $this->cache['branch'] = $branch;
291        }
292        return $this->cache['branch'];
293    }
294
295    /**
296     * Get an URL to a web viewer link to the HEAD revision.
297     *
298     * @return string|false String if a URL is available or false otherwise
299     */
300    public function getHeadViewUrl() {
301        $url = $this->getRemoteUrl();
302        if ( $url === false ) {
303            return false;
304        }
305        foreach ( $this->getViewers() as $repo => $viewer ) {
306            $pattern = '#^' . $repo . '$#';
307            if ( preg_match( $pattern, $url, $matches ) ) {
308                $viewerUrl = preg_replace( $pattern, $viewer, $url );
309                $headSHA1 = $this->getHeadSHA1();
310                $replacements = [
311                    '%h' => substr( $headSHA1, 0, 7 ),
312                    '%H' => $headSHA1,
313                    '%r' => urlencode( $matches[1] ),
314                    '%R' => $matches[1],
315                ];
316                return strtr( $viewerUrl, $replacements );
317            }
318        }
319        return false;
320    }
321
322    /**
323     * Get the URL of the remote origin.
324     * @return string|false String if a URL is available or false otherwise.
325     */
326    protected function getRemoteUrl() {
327        if ( !isset( $this->cache['remoteURL'] ) ) {
328            $config = "{$this->basedir}/config";
329            $url = false;
330            if ( is_readable( $config ) ) {
331                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
332                $configArray = @parse_ini_file( $config, true );
333                $remote = false;
334
335                // Use the "origin" remote repo if available or any other repo if not.
336                if ( isset( $configArray['remote origin'] ) ) {
337                    $remote = $configArray['remote origin'];
338                } elseif ( is_array( $configArray ) ) {
339                    foreach ( $configArray as $sectionName => $sectionConf ) {
340                        if ( str_starts_with( $sectionName, 'remote' ) ) {
341                            $remote = $sectionConf;
342                        }
343                    }
344                }
345
346                if ( $remote !== false && isset( $remote['url'] ) ) {
347                    $url = $remote['url'];
348                }
349            }
350            $this->cache['remoteURL'] = $url;
351        }
352        return $this->cache['remoteURL'];
353    }
354
355    /**
356     * Check to see if the current cache is fully populated.
357     *
358     * Note: This method is public only to make unit testing easier. There's
359     * really no strong reason that anything other than a test should want to
360     * call this method.
361     *
362     * @return bool True if all expected cache keys exist, false otherwise
363     */
364    public function cacheIsComplete() {
365        return isset( $this->cache['head'] ) &&
366            isset( $this->cache['headSHA1'] ) &&
367            isset( $this->cache['headCommitDate'] ) &&
368            isset( $this->cache['branch'] ) &&
369            isset( $this->cache['remoteURL'] );
370    }
371
372    /**
373     * Precompute and cache git information.
374     *
375     * Creates a JSON file in the cache directory associated with this
376     * GitInfo instance. This cache file will be used by subsequent GitInfo objects referencing
377     * the same directory to avoid needing to examine the .git directory again.
378     *
379     * @since 1.24
380     */
381    public function precomputeValues() {
382        if ( $this->cacheFile !== null ) {
383            // Try to completely populate the cache
384            $this->getHead();
385            $this->getHeadSHA1();
386            $this->getHeadCommitDate();
387            $this->getCurrentBranch();
388            $this->getRemoteUrl();
389
390            if ( !$this->cacheIsComplete() ) {
391                $this->logger->debug(
392                    "Failed to compute GitInfo for \"{$this->basedir}\""
393                );
394                return;
395            }
396
397            $cacheDir = dirname( $this->cacheFile );
398            if ( !file_exists( $cacheDir ) &&
399                !wfMkdirParents( $cacheDir, null, __METHOD__ )
400            ) {
401                throw new RuntimeException( "Unable to create GitInfo cache \"{$cacheDir}\"" );
402            }
403
404            file_put_contents( $this->cacheFile, FormatJson::encode( $this->cache ) );
405        }
406    }
407
408    /**
409     * @see self::getHeadSHA1
410     * @return string
411     */
412    public static function headSHA1() {
413        return self::repo()->getHeadSHA1();
414    }
415
416    /**
417     * @see self::getCurrentBranch
418     * @return string
419     */
420    public static function currentBranch() {
421        return self::repo()->getCurrentBranch();
422    }
423
424    /**
425     * @see self::getHeadViewUrl()
426     * @return string|false
427     */
428    public static function headViewUrl() {
429        return self::repo()->getHeadViewUrl();
430    }
431
432    /**
433     * Gets the list of repository viewers
434     * @return array
435     */
436    private function getViewers() {
437        if ( self::$viewers === false ) {
438            self::$viewers = $this->options->get( MainConfigNames::GitRepositoryViewers );
439            $this->hookRunner->onGitViewers( self::$viewers );
440        }
441
442        return self::$viewers;
443    }
444}
445
446/** @deprecated class alias since 1.41 */
447class_alias( GitInfo::class, 'GitInfo' );