MediaWiki master
GitInfo.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Utils;
22
23use FormatJson;
30use Psr\Log\LoggerInterface;
31use RuntimeException;
32
47class GitInfo {
48
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
67 private static $viewers = false;
68
70 private const CONSTRUCTOR_OPTIONS = [
75 ];
76
77 private LoggerInterface $logger;
78 private ServiceOptions $options;
79 private HookRunner $hookRunner;
80
87 public function __construct( $repoDir, $usePrecomputed = true ) {
88 $this->repoDir = $repoDir;
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
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
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
189 public static function isSHA1( $str ) {
190 return (bool)preg_match( '/^[0-9A-F]{40}$/i', $str );
191 }
192
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
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
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
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
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
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
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
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
426 public static function headSHA1() {
427 return self::repo()->getHeadSHA1();
428 }
429
434 public static function currentBranch() {
435 return self::repo()->getCurrentBranch();
436 }
437
442 public static function headViewUrl() {
443 return self::repo()->getHeadViewUrl();
444 }
445
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
461class_alias( GitInfo::class, 'GitInfo' );
wfMkdirParents( $dir, $mode=null, $caller=null)
Make directory, and make all parent directories if they don't exist.
JSON formatter wrapper class.
A class for passing options to services.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const CacheDirectory
Name constant for the CacheDirectory setting, for use with Config::get()
const GitRepositoryViewers
Name constant for the GitRepositoryViewers setting, for use with Config::get()
const GitBin
Name constant for the GitBin setting, for use with Config::get()
const GitInfoCacheDirectory
Name constant for the GitInfoCacheDirectory setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Executes shell commands.
Definition Shell.php:46
Fetch status information from a local git repository.
Definition GitInfo.php:47
getHeadCommitDate()
Get the commit date of HEAD entry of the git code repository.
Definition GitInfo.php:254
getHeadViewUrl()
Get an URL to a web viewer link to the HEAD revision.
Definition GitInfo.php:314
static $repo
Singleton for the repo at $IP.
Definition GitInfo.php:50
cacheIsComplete()
Check to see if the current cache is fully populated.
Definition GitInfo.php:378
static repo()
Get the singleton for the repo at MW_INSTALL_PATH.
Definition GitInfo.php:176
getHeadSHA1()
Get the SHA1 for the current HEAD of the repo.
Definition GitInfo.php:222
precomputeValues()
Precompute and cache git information.
Definition GitInfo.php:395
getCurrentBranch()
Get the name of the current branch, or HEAD if not found.
Definition GitInfo.php:296
getRemoteUrl()
Get the URL of the remote origin.
Definition GitInfo.php:340
static isSHA1( $str)
Check if a string looks like a hex encoded SHA1 hash.
Definition GitInfo.php:189
__construct( $repoDir, $usePrecomputed=true)
Definition GitInfo.php:87
getHead()
Get the HEAD of the repo (without any opening "ref: ")
Definition GitInfo.php:198