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