GitInfo.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. <?php
  2. /**
  3. * A class to help return information about a git repo MediaWiki may be inside
  4. * This is used by Special:Version and is also useful for the LocalSettings.php
  5. * of anyone working on large branches in git to setup config that show up only
  6. * when specific branches are currently checked out.
  7. *
  8. * This program is free software; you can redistribute it and/or modify
  9. * it under the terms of the GNU General Public License as published by
  10. * the Free Software Foundation; either version 2 of the License, or
  11. * (at your option) any later version.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU General Public License along
  19. * with this program; if not, write to the Free Software Foundation, Inc.,
  20. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  21. * http://www.gnu.org/copyleft/gpl.html
  22. *
  23. * @file
  24. */
  25. use MediaWiki\Shell\Shell;
  26. class GitInfo {
  27. /**
  28. * Singleton for the repo at $IP
  29. */
  30. protected static $repo = null;
  31. /**
  32. * Location of the .git directory
  33. */
  34. protected $basedir;
  35. /**
  36. * Location of the repository
  37. */
  38. protected $repoDir;
  39. /**
  40. * Path to JSON cache file for pre-computed git information.
  41. */
  42. protected $cacheFile;
  43. /**
  44. * Cached git information.
  45. */
  46. protected $cache = [];
  47. /**
  48. * @var array|false Map of repo URLs to viewer URLs. Access via static method getViewers().
  49. */
  50. private static $viewers = false;
  51. /**
  52. * @param string $repoDir The root directory of the repo where .git can be found
  53. * @param bool $usePrecomputed Use precomputed information if available
  54. * @see precomputeValues
  55. */
  56. public function __construct( $repoDir, $usePrecomputed = true ) {
  57. $this->repoDir = $repoDir;
  58. $this->cacheFile = self::getCacheFilePath( $repoDir );
  59. wfDebugLog( 'gitinfo',
  60. "Candidate cacheFile={$this->cacheFile} for {$repoDir}"
  61. );
  62. if ( $usePrecomputed &&
  63. $this->cacheFile !== null &&
  64. is_readable( $this->cacheFile )
  65. ) {
  66. $this->cache = FormatJson::decode(
  67. file_get_contents( $this->cacheFile ),
  68. true
  69. );
  70. wfDebugLog( 'gitinfo', "Loaded git data from cache for {$repoDir}" );
  71. }
  72. if ( !$this->cacheIsComplete() ) {
  73. wfDebugLog( 'gitinfo', "Cache incomplete for {$repoDir}" );
  74. $this->basedir = $repoDir . DIRECTORY_SEPARATOR . '.git';
  75. if ( is_readable( $this->basedir ) && !is_dir( $this->basedir ) ) {
  76. $GITfile = file_get_contents( $this->basedir );
  77. if ( strlen( $GITfile ) > 8 &&
  78. substr( $GITfile, 0, 8 ) === 'gitdir: '
  79. ) {
  80. $path = rtrim( substr( $GITfile, 8 ), "\r\n" );
  81. if ( $path[0] === '/' || substr( $path, 1, 1 ) === ':' ) {
  82. // Path from GITfile is absolute
  83. $this->basedir = $path;
  84. } else {
  85. $this->basedir = $repoDir . DIRECTORY_SEPARATOR . $path;
  86. }
  87. }
  88. }
  89. }
  90. }
  91. /**
  92. * Compute the path to the cache file for a given directory.
  93. *
  94. * @param string $repoDir The root directory of the repo where .git can be found
  95. * @return string Path to GitInfo cache file in $wgGitInfoCacheDirectory or
  96. * fallback in the extension directory itself
  97. * @since 1.24
  98. */
  99. protected static function getCacheFilePath( $repoDir ) {
  100. global $IP, $wgGitInfoCacheDirectory;
  101. if ( $wgGitInfoCacheDirectory ) {
  102. // Convert both $IP and $repoDir to canonical paths to protect against
  103. // $IP having changed between the settings files and runtime.
  104. $realIP = realpath( $IP );
  105. $repoName = realpath( $repoDir );
  106. if ( $repoName === false ) {
  107. // Unit tests use fake path names
  108. $repoName = $repoDir;
  109. }
  110. if ( strpos( $repoName, $realIP ) === 0 ) {
  111. // Strip $IP from path
  112. $repoName = substr( $repoName, strlen( $realIP ) );
  113. }
  114. // Transform path to git repo to something we can safely embed in
  115. // a filename
  116. $repoName = strtr( $repoName, DIRECTORY_SEPARATOR, '-' );
  117. $fileName = 'info' . $repoName . '.json';
  118. $cachePath = "{$wgGitInfoCacheDirectory}/{$fileName}";
  119. if ( is_readable( $cachePath ) ) {
  120. return $cachePath;
  121. }
  122. }
  123. return "$repoDir/gitinfo.json";
  124. }
  125. /**
  126. * Get the singleton for the repo at $IP
  127. *
  128. * @return GitInfo
  129. */
  130. public static function repo() {
  131. if ( is_null( self::$repo ) ) {
  132. global $IP;
  133. self::$repo = new self( $IP );
  134. }
  135. return self::$repo;
  136. }
  137. /**
  138. * Check if a string looks like a hex encoded SHA1 hash
  139. *
  140. * @param string $str The string to check
  141. * @return bool Whether or not the string looks like a SHA1
  142. */
  143. public static function isSHA1( $str ) {
  144. return (bool)preg_match( '/^[0-9A-F]{40}$/i', $str );
  145. }
  146. /**
  147. * Get the HEAD of the repo (without any opening "ref: ")
  148. *
  149. * @return string|bool The HEAD (git reference or SHA1) or false
  150. */
  151. public function getHead() {
  152. if ( !isset( $this->cache['head'] ) ) {
  153. $headFile = "{$this->basedir}/HEAD";
  154. $head = false;
  155. if ( is_readable( $headFile ) ) {
  156. $head = file_get_contents( $headFile );
  157. if ( preg_match( "/ref: (.*)/", $head, $m ) ) {
  158. $head = rtrim( $m[1] );
  159. } else {
  160. $head = rtrim( $head );
  161. }
  162. }
  163. $this->cache['head'] = $head;
  164. }
  165. return $this->cache['head'];
  166. }
  167. /**
  168. * Get the SHA1 for the current HEAD of the repo
  169. *
  170. * @return string|bool A SHA1 or false
  171. */
  172. public function getHeadSHA1() {
  173. if ( !isset( $this->cache['headSHA1'] ) ) {
  174. $head = $this->getHead();
  175. $sha1 = false;
  176. // If detached HEAD may be a SHA1
  177. if ( self::isSHA1( $head ) ) {
  178. $sha1 = $head;
  179. } else {
  180. // If not a SHA1 it may be a ref:
  181. $refFile = "{$this->basedir}/{$head}";
  182. $packedRefs = "{$this->basedir}/packed-refs";
  183. $headRegex = preg_quote( $head, '/' );
  184. if ( is_readable( $refFile ) ) {
  185. $sha1 = rtrim( file_get_contents( $refFile ) );
  186. } elseif ( is_readable( $packedRefs ) &&
  187. preg_match( "/^([0-9A-Fa-f]{40}) $headRegex$/m", file_get_contents( $packedRefs ), $matches )
  188. ) {
  189. $sha1 = $matches[1];
  190. }
  191. }
  192. $this->cache['headSHA1'] = $sha1;
  193. }
  194. return $this->cache['headSHA1'];
  195. }
  196. /**
  197. * Get the commit date of HEAD entry of the git code repository
  198. *
  199. * @since 1.22
  200. * @return int|bool Commit date (UNIX timestamp) or false
  201. */
  202. public function getHeadCommitDate() {
  203. global $wgGitBin;
  204. if ( !isset( $this->cache['headCommitDate'] ) ) {
  205. $date = false;
  206. if ( is_file( $wgGitBin ) &&
  207. is_executable( $wgGitBin ) &&
  208. !Shell::isDisabled() &&
  209. $this->getHead() !== false
  210. ) {
  211. $cmd = [
  212. $wgGitBin,
  213. 'show',
  214. '-s',
  215. '--format=format:%ct',
  216. 'HEAD',
  217. ];
  218. $gitDir = realpath( $this->basedir );
  219. $result = Shell::command( $cmd )
  220. ->environment( [ 'GIT_DIR' => $gitDir ] )
  221. ->restrict( Shell::RESTRICT_DEFAULT | Shell::NO_NETWORK )
  222. ->whitelistPaths( [ $gitDir, $this->repoDir ] )
  223. ->execute();
  224. if ( $result->getExitCode() === 0 ) {
  225. $date = (int)$result->getStdout();
  226. }
  227. }
  228. $this->cache['headCommitDate'] = $date;
  229. }
  230. return $this->cache['headCommitDate'];
  231. }
  232. /**
  233. * Get the name of the current branch, or HEAD if not found
  234. *
  235. * @return string|bool The branch name, HEAD, or false
  236. */
  237. public function getCurrentBranch() {
  238. if ( !isset( $this->cache['branch'] ) ) {
  239. $branch = $this->getHead();
  240. if ( $branch &&
  241. preg_match( "#^refs/heads/(.*)$#", $branch, $m )
  242. ) {
  243. $branch = $m[1];
  244. }
  245. $this->cache['branch'] = $branch;
  246. }
  247. return $this->cache['branch'];
  248. }
  249. /**
  250. * Get an URL to a web viewer link to the HEAD revision.
  251. *
  252. * @return string|bool String if a URL is available or false otherwise
  253. */
  254. public function getHeadViewUrl() {
  255. $url = $this->getRemoteUrl();
  256. if ( $url === false ) {
  257. return false;
  258. }
  259. foreach ( self::getViewers() as $repo => $viewer ) {
  260. $pattern = '#^' . $repo . '$#';
  261. if ( preg_match( $pattern, $url, $matches ) ) {
  262. $viewerUrl = preg_replace( $pattern, $viewer, $url );
  263. $headSHA1 = $this->getHeadSHA1();
  264. $replacements = [
  265. '%h' => substr( $headSHA1, 0, 7 ),
  266. '%H' => $headSHA1,
  267. '%r' => urlencode( $matches[1] ),
  268. '%R' => $matches[1],
  269. ];
  270. return strtr( $viewerUrl, $replacements );
  271. }
  272. }
  273. return false;
  274. }
  275. /**
  276. * Get the URL of the remote origin.
  277. * @return string|bool String if a URL is available or false otherwise.
  278. */
  279. protected function getRemoteUrl() {
  280. if ( !isset( $this->cache['remoteURL'] ) ) {
  281. $config = "{$this->basedir}/config";
  282. $url = false;
  283. if ( is_readable( $config ) ) {
  284. Wikimedia\suppressWarnings();
  285. $configArray = parse_ini_file( $config, true );
  286. Wikimedia\restoreWarnings();
  287. $remote = false;
  288. // Use the "origin" remote repo if available or any other repo if not.
  289. if ( isset( $configArray['remote origin'] ) ) {
  290. $remote = $configArray['remote origin'];
  291. } elseif ( is_array( $configArray ) ) {
  292. foreach ( $configArray as $sectionName => $sectionConf ) {
  293. if ( substr( $sectionName, 0, 6 ) == 'remote' ) {
  294. $remote = $sectionConf;
  295. }
  296. }
  297. }
  298. if ( $remote !== false && isset( $remote['url'] ) ) {
  299. $url = $remote['url'];
  300. }
  301. }
  302. $this->cache['remoteURL'] = $url;
  303. }
  304. return $this->cache['remoteURL'];
  305. }
  306. /**
  307. * Check to see if the current cache is fully populated.
  308. *
  309. * Note: This method is public only to make unit testing easier. There's
  310. * really no strong reason that anything other than a test should want to
  311. * call this method.
  312. *
  313. * @return bool True if all expected cache keys exist, false otherwise
  314. */
  315. public function cacheIsComplete() {
  316. return isset( $this->cache['head'] ) &&
  317. isset( $this->cache['headSHA1'] ) &&
  318. isset( $this->cache['headCommitDate'] ) &&
  319. isset( $this->cache['branch'] ) &&
  320. isset( $this->cache['remoteURL'] );
  321. }
  322. /**
  323. * Precompute and cache git information.
  324. *
  325. * Creates a JSON file in the cache directory associated with this
  326. * GitInfo instance. This cache file will be used by subsequent GitInfo objects referencing
  327. * the same directory to avoid needing to examine the .git directory again.
  328. *
  329. * @since 1.24
  330. */
  331. public function precomputeValues() {
  332. if ( $this->cacheFile !== null ) {
  333. // Try to completely populate the cache
  334. $this->getHead();
  335. $this->getHeadSHA1();
  336. $this->getHeadCommitDate();
  337. $this->getCurrentBranch();
  338. $this->getRemoteUrl();
  339. if ( !$this->cacheIsComplete() ) {
  340. wfDebugLog( 'gitinfo',
  341. "Failed to compute GitInfo for \"{$this->basedir}\""
  342. );
  343. return;
  344. }
  345. $cacheDir = dirname( $this->cacheFile );
  346. if ( !file_exists( $cacheDir ) &&
  347. !wfMkdirParents( $cacheDir, null, __METHOD__ )
  348. ) {
  349. throw new MWException( "Unable to create GitInfo cache \"{$cacheDir}\"" );
  350. }
  351. file_put_contents( $this->cacheFile, FormatJson::encode( $this->cache ) );
  352. }
  353. }
  354. /**
  355. * @see self::getHeadSHA1
  356. * @return string
  357. */
  358. public static function headSHA1() {
  359. return self::repo()->getHeadSHA1();
  360. }
  361. /**
  362. * @see self::getCurrentBranch
  363. * @return string
  364. */
  365. public static function currentBranch() {
  366. return self::repo()->getCurrentBranch();
  367. }
  368. /**
  369. * @see self::getHeadViewUrl()
  370. * @return bool|string
  371. */
  372. public static function headViewUrl() {
  373. return self::repo()->getHeadViewUrl();
  374. }
  375. /**
  376. * Gets the list of repository viewers
  377. * @return array
  378. */
  379. protected static function getViewers() {
  380. global $wgGitRepositoryViewers;
  381. if ( self::$viewers === false ) {
  382. self::$viewers = $wgGitRepositoryViewers;
  383. Hooks::run( 'GitViewers', [ &self::$viewers ] );
  384. }
  385. return self::$viewers;
  386. }
  387. }