MediaWiki  master
GitInfo.php
Go to the documentation of this file.
1 <?php
32 use Psr\Log\LoggerInterface;
33 use Wikimedia\AtEase\AtEase;
34 
40 class GitInfo {
41 
45  protected static $repo = null;
46 
50  protected $basedir;
51 
55  protected $repoDir;
56 
60  protected $cacheFile;
61 
65  protected $cache = [];
66 
70  private static $viewers = false;
71 
73  private const CONSTRUCTOR_OPTIONS = [
74  MainConfigNames::BaseDirectory,
75  MainConfigNames::CacheDirectory,
76  MainConfigNames::GitBin,
77  MainConfigNames::GitInfoCacheDirectory,
78  MainConfigNames::GitRepositoryViewers,
79  ];
80 
82  private $logger;
83 
85  private $options;
86 
88  private $hookRunner;
89 
96  public function __construct( $repoDir, $usePrecomputed = true ) {
97  $this->repoDir = $repoDir;
98  $this->options = new ServiceOptions(
99  self::CONSTRUCTOR_OPTIONS,
100  MediaWikiServices::getInstance()->getMainConfig()
101  );
102  $this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
103  // $this->options must be set before using getCacheFilePath()
104  $this->cacheFile = $this->getCacheFilePath( $repoDir );
105  $this->logger = LoggerFactory::getInstance( 'gitinfo' );
106  $this->logger->debug(
107  "Candidate cacheFile={$this->cacheFile} for {$repoDir}"
108  );
109  $this->hookRunner = Hooks::runner();
110  if ( $usePrecomputed &&
111  $this->cacheFile !== null &&
112  is_readable( $this->cacheFile )
113  ) {
114  $this->cache = FormatJson::decode(
115  file_get_contents( $this->cacheFile ),
116  true
117  );
118  $this->logger->debug( "Loaded git data from cache for {$repoDir}" );
119  }
120 
121  if ( !$this->cacheIsComplete() ) {
122  $this->logger->debug( "Cache incomplete for {$repoDir}" );
123  $this->basedir = $repoDir . DIRECTORY_SEPARATOR . '.git';
124  if ( is_readable( $this->basedir ) && !is_dir( $this->basedir ) ) {
125  $GITfile = file_get_contents( $this->basedir );
126  if ( strlen( $GITfile ) > 8 &&
127  substr( $GITfile, 0, 8 ) === 'gitdir: '
128  ) {
129  $path = rtrim( substr( $GITfile, 8 ), "\r\n" );
130  if ( $path[0] === '/' || substr( $path, 1, 1 ) === ':' ) {
131  // Path from GITfile is absolute
132  $this->basedir = $path;
133  } else {
134  $this->basedir = $repoDir . DIRECTORY_SEPARATOR . $path;
135  }
136  }
137  }
138  }
139  }
140 
149  private function getCacheFilePath( $repoDir ) {
150  $gitInfoCacheDirectory = $this->options->get( MainConfigNames::GitInfoCacheDirectory );
151  if ( $gitInfoCacheDirectory === false ) {
152  $gitInfoCacheDirectory = $this->options->get( MainConfigNames::CacheDirectory ) . '/gitinfo';
153  }
154  $baseDir = $this->options->get( MainConfigNames::BaseDirectory );
155  if ( $gitInfoCacheDirectory ) {
156  // Convert both $IP and $repoDir to canonical paths to protect against
157  // $IP having changed between the settings files and runtime.
158  $realIP = realpath( $baseDir );
159  $repoName = realpath( $repoDir );
160  if ( $repoName === false ) {
161  // Unit tests use fake path names
162  $repoName = $repoDir;
163  }
164  if ( strpos( $repoName, $realIP ) === 0 ) {
165  // Strip $IP from path
166  $repoName = substr( $repoName, strlen( $realIP ) );
167  }
168  // Transform path to git repo to something we can safely embed in
169  // a filename
170  $repoName = strtr( $repoName, DIRECTORY_SEPARATOR, '-' );
171  $fileName = 'info' . $repoName . '.json';
172  $cachePath = "{$gitInfoCacheDirectory}/{$fileName}";
173  if ( is_readable( $cachePath ) ) {
174  return $cachePath;
175  }
176  }
177 
178  return "$repoDir/gitinfo.json";
179  }
180 
186  public static function repo() {
187  if ( self::$repo === null ) {
188  self::$repo = new self( MW_INSTALL_PATH );
189  }
190  return self::$repo;
191  }
192 
199  public static function isSHA1( $str ) {
200  return (bool)preg_match( '/^[0-9A-F]{40}$/i', $str );
201  }
202 
208  public function getHead() {
209  if ( !isset( $this->cache['head'] ) ) {
210  $headFile = "{$this->basedir}/HEAD";
211  $head = false;
212 
213  if ( is_readable( $headFile ) ) {
214  $head = file_get_contents( $headFile );
215 
216  if ( preg_match( "/ref: (.*)/", $head, $m ) ) {
217  $head = rtrim( $m[1] );
218  } else {
219  $head = rtrim( $head );
220  }
221  }
222  $this->cache['head'] = $head;
223  }
224  return $this->cache['head'];
225  }
226 
232  public function getHeadSHA1() {
233  if ( !isset( $this->cache['headSHA1'] ) ) {
234  $head = $this->getHead();
235  $sha1 = false;
236 
237  // If detached HEAD may be a SHA1
238  if ( self::isSHA1( $head ) ) {
239  $sha1 = $head;
240  } else {
241  // If not a SHA1 it may be a ref:
242  $refFile = "{$this->basedir}/{$head}";
243  $packedRefs = "{$this->basedir}/packed-refs";
244  $headRegex = preg_quote( $head, '/' );
245  if ( is_readable( $refFile ) ) {
246  $sha1 = rtrim( file_get_contents( $refFile ) );
247  } elseif ( is_readable( $packedRefs ) &&
248  preg_match( "/^([0-9A-Fa-f]{40}) $headRegex$/m", file_get_contents( $packedRefs ), $matches )
249  ) {
250  $sha1 = $matches[1];
251  }
252  }
253  $this->cache['headSHA1'] = $sha1;
254  }
255  return $this->cache['headSHA1'];
256  }
257 
264  public function getHeadCommitDate() {
265  $gitBin = $this->options->get( MainConfigNames::GitBin );
266 
267  if ( !isset( $this->cache['headCommitDate'] ) ) {
268  $date = false;
269 
270  // Suppress warnings about any open_basedir restrictions affecting $wgGitBin (T74445).
271  $isFile = AtEase::quietCall( 'is_file', $gitBin );
272  if ( $isFile &&
273  is_executable( $gitBin ) &&
274  !Shell::isDisabled() &&
275  $this->getHead() !== false
276  ) {
277  $cmd = [
278  $gitBin,
279  'show',
280  '-s',
281  '--format=format:%ct',
282  'HEAD',
283  ];
284  $gitDir = realpath( $this->basedir );
285  $result = Shell::command( $cmd )
286  ->environment( [ 'GIT_DIR' => $gitDir ] )
287  ->restrict( Shell::RESTRICT_DEFAULT | Shell::NO_NETWORK )
288  ->allowPath( $gitDir, $this->repoDir )
289  ->execute();
290 
291  if ( $result->getExitCode() === 0 ) {
292  $date = (int)$result->getStdout();
293  }
294  }
295  $this->cache['headCommitDate'] = $date;
296  }
297  return $this->cache['headCommitDate'];
298  }
299 
305  public function getCurrentBranch() {
306  if ( !isset( $this->cache['branch'] ) ) {
307  $branch = $this->getHead();
308  if ( $branch &&
309  preg_match( "#^refs/heads/(.*)$#", $branch, $m )
310  ) {
311  $branch = $m[1];
312  }
313  $this->cache['branch'] = $branch;
314  }
315  return $this->cache['branch'];
316  }
317 
323  public function getHeadViewUrl() {
324  $url = $this->getRemoteUrl();
325  if ( $url === false ) {
326  return false;
327  }
328  foreach ( $this->getViewers() as $repo => $viewer ) {
329  $pattern = '#^' . $repo . '$#';
330  if ( preg_match( $pattern, $url, $matches ) ) {
331  $viewerUrl = preg_replace( $pattern, $viewer, $url );
332  $headSHA1 = $this->getHeadSHA1();
333  $replacements = [
334  '%h' => substr( $headSHA1, 0, 7 ),
335  '%H' => $headSHA1,
336  '%r' => urlencode( $matches[1] ),
337  '%R' => $matches[1],
338  ];
339  return strtr( $viewerUrl, $replacements );
340  }
341  }
342  return false;
343  }
344 
349  protected function getRemoteUrl() {
350  if ( !isset( $this->cache['remoteURL'] ) ) {
351  $config = "{$this->basedir}/config";
352  $url = false;
353  if ( is_readable( $config ) ) {
354  AtEase::suppressWarnings();
355  $configArray = parse_ini_file( $config, true );
356  AtEase::restoreWarnings();
357  $remote = false;
358 
359  // Use the "origin" remote repo if available or any other repo if not.
360  if ( isset( $configArray['remote origin'] ) ) {
361  $remote = $configArray['remote origin'];
362  } elseif ( is_array( $configArray ) ) {
363  foreach ( $configArray as $sectionName => $sectionConf ) {
364  if ( substr( $sectionName, 0, 6 ) == 'remote' ) {
365  $remote = $sectionConf;
366  }
367  }
368  }
369 
370  if ( $remote !== false && isset( $remote['url'] ) ) {
371  $url = $remote['url'];
372  }
373  }
374  $this->cache['remoteURL'] = $url;
375  }
376  return $this->cache['remoteURL'];
377  }
378 
388  public function cacheIsComplete() {
389  return isset( $this->cache['head'] ) &&
390  isset( $this->cache['headSHA1'] ) &&
391  isset( $this->cache['headCommitDate'] ) &&
392  isset( $this->cache['branch'] ) &&
393  isset( $this->cache['remoteURL'] );
394  }
395 
405  public function precomputeValues() {
406  if ( $this->cacheFile !== null ) {
407  // Try to completely populate the cache
408  $this->getHead();
409  $this->getHeadSHA1();
410  $this->getHeadCommitDate();
411  $this->getCurrentBranch();
412  $this->getRemoteUrl();
413 
414  if ( !$this->cacheIsComplete() ) {
415  $this->logger->debug(
416  "Failed to compute GitInfo for \"{$this->basedir}\""
417  );
418  return;
419  }
420 
421  $cacheDir = dirname( $this->cacheFile );
422  if ( !file_exists( $cacheDir ) &&
423  !wfMkdirParents( $cacheDir, null, __METHOD__ )
424  ) {
425  throw new RuntimeException( "Unable to create GitInfo cache \"{$cacheDir}\"" );
426  }
427 
428  file_put_contents( $this->cacheFile, FormatJson::encode( $this->cache ) );
429  }
430  }
431 
436  public static function headSHA1() {
437  return self::repo()->getHeadSHA1();
438  }
439 
444  public static function currentBranch() {
445  return self::repo()->getCurrentBranch();
446  }
447 
452  public static function headViewUrl() {
453  return self::repo()->getHeadViewUrl();
454  }
455 
460  private function getViewers() {
461  if ( self::$viewers === false ) {
462  self::$viewers = $this->options->get( MainConfigNames::GitRepositoryViewers );
463  $this->hookRunner->onGitViewers( self::$viewers );
464  }
465 
466  return self::$viewers;
467  }
468 }
wfMkdirParents( $dir, $mode=null, $caller=null)
Make directory, and make all parent directories if they don't exist.
$matches
static encode( $value, $pretty=false, $escaping=0)
Returns the JSON representation of a value.
Definition: FormatJson.php:96
static decode( $value, $assoc=false)
Decodes a JSON string.
Definition: FormatJson.php:146
static repo()
Get the singleton for the repo at MW_INSTALL_PATH.
Definition: GitInfo.php:186
getHead()
Get the HEAD of the repo (without any opening "ref: ")
Definition: GitInfo.php:208
$cacheFile
Path to JSON cache file for pre-computed git information.
Definition: GitInfo.php:60
$basedir
Location of the .git directory.
Definition: GitInfo.php:50
getRemoteUrl()
Get the URL of the remote origin.
Definition: GitInfo.php:349
static headSHA1()
Definition: GitInfo.php:436
getHeadCommitDate()
Get the commit date of HEAD entry of the git code repository.
Definition: GitInfo.php:264
static headViewUrl()
Definition: GitInfo.php:452
precomputeValues()
Precompute and cache git information.
Definition: GitInfo.php:405
$cache
Cached git information.
Definition: GitInfo.php:65
cacheIsComplete()
Check to see if the current cache is fully populated.
Definition: GitInfo.php:388
static $repo
Singleton for the repo at $IP.
Definition: GitInfo.php:45
$repoDir
Location of the repository.
Definition: GitInfo.php:55
static currentBranch()
Definition: GitInfo.php:444
getHeadViewUrl()
Get an URL to a web viewer link to the HEAD revision.
Definition: GitInfo.php:323
__construct( $repoDir, $usePrecomputed=true)
Definition: GitInfo.php:96
static isSHA1( $str)
Check if a string looks like a hex encoded SHA1 hash.
Definition: GitInfo.php:199
getHeadSHA1()
Get the SHA1 for the current HEAD of the repo.
Definition: GitInfo.php:232
getCurrentBranch()
Get the name of the current branch, or HEAD if not found.
Definition: GitInfo.php:305
static runner()
Get a HookRunner instance for calling hooks using the new interfaces.
Definition: Hooks.php:172
A class for passing options to services.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:568
PSR-3 logger instance factory.
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Executes shell commands.
Definition: Shell.php:46