MediaWiki master
GitInfo.php
Go to the documentation of this file.
1<?php
7namespace MediaWiki\Utils;
8
16use Psr\Log\LoggerInterface;
17use RuntimeException;
18
33class GitInfo {
34
36 protected static $repo = null;
37
39 protected $basedir;
40
42 protected $repoDir;
43
45 protected $cacheFile;
46
48 protected $cache = [];
49
53 private static $viewers = false;
54
56 private const CONSTRUCTOR_OPTIONS = [
61 ];
62
63 private LoggerInterface $logger;
64 private ServiceOptions $options;
65 private HookRunner $hookRunner;
66
73 public function __construct( $repoDir, $usePrecomputed = true ) {
74 $this->repoDir = $repoDir;
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
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
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
175 public static function isSHA1( $str ) {
176 return (bool)preg_match( '/^[0-9A-F]{40}$/i', $str );
177 }
178
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
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
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 ) &&
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
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
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
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
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
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
412 public static function headSHA1() {
413 return self::repo()->getHeadSHA1();
414 }
415
420 public static function currentBranch() {
421 return self::repo()->getCurrentBranch();
422 }
423
428 public static function headViewUrl() {
429 return self::repo()->getHeadViewUrl();
430 }
431
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
447class_alias( GitInfo::class, 'GitInfo' );
wfMkdirParents( $dir, $mode=null, $caller=null)
Make directory, and make all parent directories if they don't exist.
A class for passing options to services.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
JSON formatter wrapper class.
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.
isDisabled()
Check whether a message does not exist, is an empty string, or is "-".
Definition Message.php:1207
Executes shell commands.
Definition Shell.php:32
Fetch status information from a local git repository.
Definition GitInfo.php:33
string null $basedir
Location of the .git directory.
Definition GitInfo.php:39
getHeadCommitDate()
Get the commit date of HEAD entry of the git code repository.
Definition GitInfo.php:240
string null $cacheFile
Path to JSON cache file for pre-computed git information.
Definition GitInfo.php:45
getHeadViewUrl()
Get an URL to a web viewer link to the HEAD revision.
Definition GitInfo.php:300
cacheIsComplete()
Check to see if the current cache is fully populated.
Definition GitInfo.php:364
static repo()
Get the singleton for the repo at MW_INSTALL_PATH.
Definition GitInfo.php:162
getHeadSHA1()
Get the SHA1 for the current HEAD of the repo.
Definition GitInfo.php:208
precomputeValues()
Precompute and cache git information.
Definition GitInfo.php:381
array $cache
Cached git information.
Definition GitInfo.php:48
getCurrentBranch()
Get the name of the current branch, or HEAD if not found.
Definition GitInfo.php:282
getRemoteUrl()
Get the URL of the remote origin.
Definition GitInfo.php:326
static isSHA1( $str)
Check if a string looks like a hex encoded SHA1 hash.
Definition GitInfo.php:175
static self null $repo
Singleton for the repo at $IP.
Definition GitInfo.php:36
__construct( $repoDir, $usePrecomputed=true)
Definition GitInfo.php:73
getHead()
Get the HEAD of the repo (without any opening "ref: ")
Definition GitInfo.php:184
string $repoDir
Location of the repository.
Definition GitInfo.php:42