<?php require_once("vcs.interface.php"); require_once __DIR__ . '/../../vendor/axelhahn/ahcache/cache.class.php'; /** * version control system :: GIT * implements vcs interface * * * @author hahn * * Axel: <axel.hahn@unibe.ch> * (...) * 2024-08-28 Axel php8 only; added variable types; short array syntax */ class vcs implements iVcs { // class vcs { /** * configuration * @var array */ private array $_aCfg = []; /** * temp dir to fetch repo version and ommit message; its value will be * generated in set_config() * @var string */ private string $_sTempDir = ''; // /** * filename of ssh key file with complete path * @var string */ private string $_sKeyfile = ''; /** * filename of ssh wrapper script with complete path * @var string */ private string $_sWrapper = ''; /** * flat array with remote branch names * @var array */ private array $_aRemoteBranches = []; /** * name of the default remote branch to access * @var string */ private string $_sCurrentBranch = ''; /** * Constructor * @param array $aRepoConfig */ public function __construct(array $aRepoConfig = []) { $this->setConfig($aRepoConfig); $this->getRemoteBranches(); // to fill the cache } /** * Add a log messsage * @global object $oLog * * @param string $sMessage messeage text * @param string $sLevel warnlevel of the given message * @return bool */ private function log(string $sMessage, string $sLevel = "info"): bool { global $oCLog; return $oCLog->add(basename(__FILE__) . " class " . __CLASS__ . " - " . $sMessage, $sLevel); } /** * Set a config and update internal (private) variables * * @param array $aRepoConfig * @return boolean */ public function setConfig(array $aRepoConfig = []): bool { // checks // foreach (["type", "url"] as $key) { foreach (["type"] as $key) { if (!isset($aRepoConfig[$key])) { die("ERROR: key $key does not exist in config <pre>" . print_r($aRepoConfig, true) . "</pre>"); } if (!$aRepoConfig[$key]) { die("ERROR: key $key in config exists but is empty<pre>" . print_r($aRepoConfig, true) . "</pre>"); } } if ($aRepoConfig["type"] !== "git") { die("ERROR: type is not git<pre>" . print_r($aRepoConfig, true) . "</pre>"); } // set config array $this->_aCfg = $aRepoConfig; // define temp dir $this->_sKeyfile = $this->_aCfg["dataDir"] . "/sshkeys/" . $this->_aCfg["auth"]; $this->_sWrapper = $this->_aCfg["appRootDir"] . "/shellscripts/gitsshwrapper.sh"; $this->_setTempdir(); $this->_aCfg = $aRepoConfig; return true; } /** * Get directory für current branch of a project below tempdir * If it does not exist yet it will be created and the repository will be initialized. * * @return string */ private function _setTempdir(): string { $this->_sTempDir = $this->_aCfg["url"]; $this->_sTempDir = preg_replace('/[\@\.\:\/]/', '_', $this->_sTempDir); $this->_sTempDir = $this->_aCfg["tmpDir"] . '/checkout_vcsgit_' . $this->_sTempDir . '/'; $this->_sTempDir .= preg_replace('/[\@\.\:\/]/', '_', ($this->_sCurrentBranch ? $this->_sCurrentBranch : '__no-branch__')) . '/'; if (!is_dir($this->_sTempDir . ".git")) { $this->log(__FUNCTION__ . " does not exist yet: " . $this->_sTempDir . ".git"); $sGitCmd = 'export GIT_SSH="' . $this->_sWrapper . '" ; export PKEY="' . $this->_sKeyfile . '" ; '; $sGitCmd .= 'mkdir -p "' . $this->_sTempDir . '" && cd "' . $this->_sTempDir . '" && '; $sGitCmd .= 'git init >/dev/null && '; $sGitCmd .= 'git remote add origin "' . $this->getUrl() . '" 2>&1 '; // $sGitCmd='time ('.$sGitCmd.')'; // exec($sGitCmd, $aOutput, $iRc); $this->log(__FUNCTION__ . " start command <code>$sGitCmd</code>"); exec($sGitCmd, $aOutput, $iRc); $this->log(__FUNCTION__ . " command ended with rc=$iRc " . '<pre>' . implode("\n", $aOutput) . '</pre>', ($iRc == 0 ? 'info' : 'error')); } return $this->_sTempDir; } /** * Set the current branch * * @param string $sBranchname name of the branch * @return void */ public function setCurrentBranch($sBranchname): void { $this->_sCurrentBranch = $sBranchname; $this->_setTempdir(); } /** * helper: dump values * @return boolean */ public function dump(): bool { echo "<h3>Dump class " . __CLASS__ . "</h3>"; echo "config array: <pre>" . print_r($this->_aCfg, true) . "</pre>"; echo "temp dir to read revision: " . $this->_sTempDir . "<br>"; return true; } /** * cleanup unneeded files and directories in a checked out directory * and remove all vcs specific files and directories. * This method works on linux only * * @param string $sWorkDir path of the build directory to cleanup the git meta data from * @return bool */ public function cleanupWorkdir(string $sWorkDir): bool { if (!is_dir($sWorkDir)) { return false; } shell_exec('rm -rf "' . $sWorkDir . '/.git"'); @unlink($sWorkDir . "/.gitignore"); return true; } /** * Get the current branch * * @param bool $bReturnMasterIfEmpty flag: if there is no current branch then detect a master branch * @return string */ public function getCurrentBranch(bool $bReturnMasterIfEmpty = false): string { if (!$this->_sCurrentBranch) { if ($bReturnMasterIfEmpty) { $this->_sCurrentBranch = $this->_getMasterbranchname(); } } return $this->_sCurrentBranch; } /** * Detect an existing master branch ... and return one of 'origin/main' | 'origin/master' * * @return string */ protected function _getMasterbranchname(): string { $sMasterBranch = ''; $aMasternames = ['origin/main', 'origin/master']; $aAllBranches = $this->getRemoteBranches(); if (count($aAllBranches)) { foreach ($aMasternames as $sBranchToTest) { if (isset($aAllBranches[$sBranchToTest])) { $sMasterBranch = $sBranchToTest; break; } } } return $sMasterBranch; } /** * Get the build type, i.e. git|svn|cvs| * @return string */ public function getBuildType(): string { return $this->_aCfg["type"] ?? ''; } /** * Get a nice name for a cache module based on repo url * * @return string */ private function _getNameOfCacheModule(): string { return preg_replace('/([^0-9a-z])/i', "", $this->getUrl()); } /** * Cleanup cache data for this project (revisions, list of branches+tags) * * @param int $iAge max age in sec; older items will be deleted * @return bool */ public function cleanupCache(int $iAge): string { $this->log(__FUNCTION__ . " start"); $oCache = new AhCache($this->_getNameOfCacheModule()); ob_start(); $oCache->cleanup((int) $iAge); $sOut = ob_get_contents(); ob_end_clean(); return $sOut; } /** * helper: store hash with all branches in cache * It saves 1.5 sec for reading 300 branches * * @return boolean */ private function _cacheRemoteBranches(): bool { $iTtl = 300; $oCache = new AhCache($this->_getNameOfCacheModule(), "RemoteBranches"); // $this->log(__FUNCTION__." <pre>".print_r($this->_aRemoteBranches, 1)."</pre>"); $oCache->write($this->_aRemoteBranches, $iTtl); return true; } /** * Read remote repository and get an array with names and revisions of * all branches and tags * per branch you get an array element with the keys revision, name, type * It returns false if there is no git url * * @param bool $bIgnoreCache flag to overrde cache * @return array */ private function _fetchRemoteBranches($bIgnoreCache = false): bool|array { $this->log(__FUNCTION__ . "(bIgnoreCache = " . ($bIgnoreCache ? 'true' : 'false') . ") start"); $aReturn = []; $sGitUrl = $this->getUrl(); if (!$sGitUrl) { return false; } $oCache = new AhCache($this->_getNameOfCacheModule(), "RemoteBranches"); $aOutput = []; $iRc = false; // list of cached branch keys if ($oCache->isExpired() || $bIgnoreCache) { // workdir is on level of set project ... going 1 level up means to leave the dir with the current branch $sWorkdir = dirname($this->_sTempDir) . '/fetchRemoteBranches/'; $this->log(__FUNCTION__ . " - sWorkdir = $sWorkdir"); $sGitCmd = 'export GIT_SSH="' . $this->_sWrapper . '" ; export PKEY="' . $this->_sKeyfile . '" ; '; if (is_dir($sWorkdir . ".git")) { // if a subdir .git exists: // Verify if git remote -v contains the current git url // If not, we delete it $sPreCmd = 'cd "' . $sWorkdir . '" 2>&1 && git remote -v 2>&1 | grep -F "' . $sGitUrl . '" >/dev/null || ( echo "DELETING .git dir..."; rm -rf .git && rc=$?; echo "rc=$rc"; sleep 1; exit $rc) '; $this->log(__FUNCTION__ . " - start PRE command <code>$sPreCmd</code>"); exec($sPreCmd, $aPreLines, $iRc); if (!$iRc == 0) { $this->log(__FUNCTION__ . " <code>" . print_r($aPreLines, 1) . "</code> rc=$iRc"); } } if (!is_dir($sWorkdir . ".git")) { $sGitCmd .= 'mkdir -p "' . $sWorkdir . '" && cd "' . $sWorkdir . '" && '; $sGitCmd .= 'git init >/dev/null && '; $sGitCmd .= 'git remote add origin "' . $sGitUrl . '" 2>&1 && '; } else { $sGitCmd .= 'cd "' . $sWorkdir . '" 2>&1 && '; } $sGitCmd .= 'git ls-remote --heads --tags origin 2>&1 ;'; $this->log(__FUNCTION__ . " - start command <code>$sGitCmd</code>"); exec($sGitCmd, $aOutput, $iRc); $this->log(__FUNCTION__ . " - command ended with rc=$iRc " . '<pre>' . implode("\n", $aOutput) . '</pre>', ($iRc == 0 ? 'info' : 'error')); if ($iRc == 0) { $this->log(__FUNCTION__ . ' start reading all branches'); // $this->log(__FUNCTION__ . ' data from cache: <pre>'.print_r($this->_aRemoteBranches, 1).'</pre>'); /** * $aOutput = Array * ( * [0] => cd7b238a75fe3df3f53ca3c258078d6736cc5bec refs/heads/foreman-integration * [1] => 68cef2c74b58db8e13413c1c54104e060d8ffbb3 refs/heads/master * [2] => cae3a1eaee180b05fff883d1cfb36d09778dec2c refs/heads/task-1726-gitssh-extensions * ) */ foreach ($aOutput as $sBranchLine) { // $this->log(__FUNCTION__ . ' loop over output of git ls-remote <pre>'.print_r($sBranchLine, 1).'</pre>'); $aTmp = explode("\t", $sBranchLine); $sBranchPath = preg_replace('#^refs/#', '', $aTmp[1]); $sBranch = preg_replace('#^[^/]*/#', '', $sBranchPath); // skip dereferences // http://stackoverflow.com/questions/15472107/when-listing-git-ls-remote-why-theres-after-the-tag-name if (!preg_match('/\^\{\}$/', $sBranch)) { $sRevision = $aTmp[0]; $sType = preg_replace('#/.*$#', '', $sBranchPath); $sName = ($sType == "heads") ? "origin/" . $sBranch : $sBranch; $sBranchKey = $sName; // $this->log(__FUNCTION__ . ' $sBranchKey = '.$sBranchKey); // $sMessage = $this->getCommitmessageByBranch($sName, $sRevision); $aReturn[$sBranchKey] = [ // 'debug'=> $aTmp, 'revision' => $sRevision, 'name' => $sName, 'shortname' => $sBranch, 'label' => $sType . ': ' . $sBranch, 'type' => $sType, // 'message' => $sMessage ]; } } $this->_aRemoteBranches = $aReturn; $this->log(__FUNCTION__ . ' ' . count($aReturn) . ' branches: <pre>' . print_r($this->_aRemoteBranches, 1) . '</pre>'); $this->_cacheRemoteBranches(); } else { // $this->_aRemoteBranches = $oCache->read(); $this->log(__FUNCTION__ . " - No git access? --> deleting cache of former fetched branches..."); $oCache->delete(); $this->_aRemoteBranches = []; } } else { // use cache that getCommitmessageByBranch can access it $this->_aRemoteBranches = $oCache->read(); } return $this->_aRemoteBranches; } /** * Get a flat array with names of all remote branches * @param bool $bIgnoreCache flag: ignore caching; default: use cache * @return array */ public function getRemoteBranches(bool $bIgnoreCache = false): array { $this->log(__FUNCTION__ . "($bIgnoreCache) start"); if (!$this->_aRemoteBranches || $bIgnoreCache) { $this->log(__FUNCTION__ . "($bIgnoreCache) --> fetching fresh data"); $this->_fetchRemoteBranches($bIgnoreCache); } else { $this->log(__FUNCTION__ . "($bIgnoreCache) --> returning cached data"); } return $this->_aRemoteBranches; } /** * Get current revision and commit message from remote repository * @see $this::getRevision * It returns false if no branch is set * * @param boolean $bRefresh optional: refresh data; default: use cache * @return bool|array */ public function getRepoRevision(bool $bRefresh = false): bool|array { $this->log(__FUNCTION__ . "($bRefresh) start"); if (!$this->_sCurrentBranch) { return false; } $sMessage = $this->getCommitmessageByBranch(false, $bRefresh ? 'dummy_to_force_refresh' : false); if ($sMessage) { $aReturn = [ 'branch' => $this->_sCurrentBranch, 'shortname' => $this->_aRemoteBranches[$this->_sCurrentBranch]['shortname'], 'revision' => $this->_aRemoteBranches[$this->_sCurrentBranch]['revision'], 'type' => $this->_aRemoteBranches[$this->_sCurrentBranch]['type'], 'message' => $sMessage, '_data' => $this->_aRemoteBranches[$this->_sCurrentBranch], ]; } else { $aReturn = $this->getRevision(false); } return $aReturn; } /** * Get a commit message of a given branch. * It reurns if no branch was found in meta infos * * @param string $sBranch name of a branch * @param string $sVerifyRevision optional: revision to verify if it is the newsest * @return string */ public function getCommitmessageByBranch(string $sBranch = '', string $sVerifyRevision = ''): bool|string { $this->log(__FUNCTION__ . "($sBranch, $sVerifyRevision) start"); if (!$sBranch) { $sBranch = $this->_sCurrentBranch; } // try to get infos from the cache if ( is_array($this->_aRemoteBranches) && ( isset($this->_aRemoteBranches[$sBranch]) && $sVerifyRevision && $this->_aRemoteBranches[$sBranch]['revision'] == $sVerifyRevision || isset($this->_aRemoteBranches[$sBranch]) && !$sVerifyRevision ) && isset($this->_aRemoteBranches[$sBranch]['message']) ) { // it is up to date - doing nothing $this->log(__FUNCTION__ . " return cached data"); return $this->_aRemoteBranches[$sBranch]['message']; } // ok, then I need to read it $this->log(__FUNCTION__ . " return fresh data"); if ($this->_sCurrentBranch != $sBranch) { $sSaveBranch = $this->_sCurrentBranch; $this->setCurrentBranch($sBranch); $a = $this->getRevision(false); $this->setCurrentBranch($sSaveBranch); } else { $a = $this->getRevision(false); } if (!isset($a['branch'])) { return false; } // merge with cached info ... to add type and label if (isset($this->_aRemoteBranches[$a['branch']])) { $this->_aRemoteBranches[$a['branch']] = array_merge($this->_aRemoteBranches[$a['branch']], $a); } else { $this->_aRemoteBranches[$a['branch']] = $a; } // store in cache $this->_cacheRemoteBranches(); return $a['message']; } /** * Get current revision and commit message from an existing directory or a * remote repository * the return will fill $this->_aData["phases"]["source"] in project class * (array keys are revision, message or error) * if ok: * [ * "branch" => $sRevision, * "revision" => $sRevision, * "message" => $sCommitmessage * ]; * * on error: * [ * "error" => $sErrormessage, * ]; * * @param string $sWorkDir optional: local directory with initialized git repo * @return bool|array */ public function getRevision(string $sWorkDir = ''): bool|array { $this->log(__FUNCTION__ . " start"); $aReturn = []; $aOutput = []; $iRc = false; if (!$this->getUrl()) { return false; } $sGitCmd = 'export GIT_SSH="' . $this->_sWrapper . '" ; export PKEY="' . $this->_sKeyfile . '" ; '; // Luki: // git clone -b <branch_or_tag> --single-branch <repo_url> --depth 1 --bare <dir> /* $sWorkDir=$sWorkDir ? $sWorkDir : $this->_sTempDir; $sWorkDir='/dev/shm/abc'; $sGitCmd.='git clone -b '.$this->_sCurrentBranch.' --single-branch '.$this->getUrl().' --depth 1 --bare "' . $sWorkDir . '" 2>&1; rm -rf "' . $sWorkDir . '"'; */ if ($sWorkDir) { $sGitCmd .= 'cd "' . $sWorkDir . '" && '; } else { if (!file_exists($this->_sTempDir . ".git")) { $this->log(__FUNCTION__ . " does not exist yet: " . $this->_sTempDir . ".git"); $sGitCmd .= 'mkdir -p "' . $this->_sTempDir . '" && cd "' . $this->_sTempDir . '" && '; $sGitCmd .= 'git init >/dev/null 2>&1 && '; $sGitCmd .= 'git remote add origin "' . $this->getUrl() . '" 2>&1 && '; } else { $sGitCmd .= 'cd "' . $this->_sTempDir . '" && '; } // TODO: git 1.9 does needs only the line with --tags // #7706 add --force $sGitCmd .= ' ( ' // . 'git fetch --update-head-ok --tags --depth 1 2>&1 ; ' // 1.5 s . 'git fetch --update-head-ok --tags --depth 1 --force 2>&1 ; ' // 1.5 s //. 'git fetch --update-head-ok --depth 1 2>&1 ' // 1.5 s . ') && '; } $sGitCmd .= 'git log -1 "' . $this->_sCurrentBranch . '" 2>&1 ; '; // 0.0 s // $sGitCmd.='git log -1 2>&1 ; '; // 0.0 s $this->log(__FUNCTION__ . " start command <code>$sGitCmd</code>"); // $sLoginfo = shell_exec($sGitCmd); exec($sGitCmd, $aOutput, $iRc); $this->log(__FUNCTION__ . " command ended with rc=$iRc " . '<pre>' . implode("\n", $aOutput) . '</pre>', ($iRc == 0 ? 'info' : 'error')); $sLoginfo = implode("\n", $aOutput); /* * * example output: From gitlab.iml.unibe.ch:admins/imldeployment * [new branch] master -> origin/master commit 0b0dbe0dee80ca71ff43a54641d616c131e6fd8a Author: Axel Hahn Date: Fri Dec 12 16:35:38 2014 +0100 - added: skip dereferenced tags in git (tags ending ^{} ) - added: modal infobox in build page if you switch the branch - added: git uses a cache for taglist and revision infos (ttl is 5 min) * */ // parse revision $sRevision = false; $aRev = []; if (preg_match('#commit\ (.*)#', $sLoginfo, $aRev)) { $sRevision = $aRev[1]; } if ($sRevision) { $sCommitMsg = $sLoginfo; $sCommitMsg = preg_replace('/From\ .*\n/', '', $sCommitMsg); $sCommitMsg = preg_replace('/\ \*.*\n/', '', $sCommitMsg); $sCommitMsg = preg_replace('/commit.*\n/', '', $sCommitMsg); // keep these to see them in the output: // $sCommitMsg=preg_replace('/Author:\ .*\n/', '', $sCommitMsg); // $sCommitMsg=preg_replace('/Date:\ .*\n/', '', $sCommitMsg); $aReturn = [ "branch" => $this->_sCurrentBranch, "revision" => $sRevision, "message" => $sCommitMsg // ."\n". microtime(true), ]; } else { if (!$sLoginfo) { $sLoginfo = $sGitCmd; } // echo "DEBUG: error on reading git revision<br>"; $aReturn = [ "error" => '<pre>' . $sLoginfo . '<hr>' . $sGitCmd . '</pre>' ]; } // $this->log(__FUNCTION__ . ' return is <pre>'.print_r($aReturn, 1).'</pre>'); return $aReturn; } /** * Get sources from vsc and check them out in given directory * It returns false if the workdir was not found or the url for git repo is not set * Otherwise it retunrs the output of git clone + git checkout * * @param string $sWorkDir working dir where to check out source url * @return bool|string */ public function getSources(string $sWorkDir): bool|string { $this->log(__FUNCTION__ . " start"); if (!$sWorkDir || !is_dir($sWorkDir)) { return false; } if (!$this->getUrl()) { return false; } $sBranchname = str_replace("origin/", "", $this->_sCurrentBranch); $sGitCmd = 'export GIT_SSH="' . $this->_sWrapper . '" ; export PKEY="' . $this->_sKeyfile . '" ; '; $aOutput = []; $iRc = false; // this does not checkout tags in git v1.7 - only branches: // $sGitCmd .= 'echo git clone --depth 1 --recursive --branch "' . $sBranchname . '" "' . $this->getUrl() . '" "' . $sWorkDir . '" ; '; // $sGitCmd .= ' git clone --depth 1 --recursive --branch "' . $sBranchname . '" "' . $this->getUrl() . '" "' . $sWorkDir . '" 2>&1; '; // $sGitCmd .= 'echo git clone "' . $this->getUrl() . '" "' . $sWorkDir . '" 2>&1 \&\& cd "' . $sWorkDir . '" \&\& git checkout "' . $sBranchname . '" ; '; $sGitCmd .= ' git clone "' . $this->getUrl() . '" "' . $sWorkDir . '" 2>&1 && cd "' . $sWorkDir . '" && git checkout "' . $sBranchname . '" 2>&1 '; $this->log(__FUNCTION__ . " start command <code>$sGitCmd</code>"); // $sReturn = shell_exec($sGitCmd); exec($sGitCmd, $aOutput, $iRc); $this->log(__FUNCTION__ . " command ended with rc=$iRc " . '<pre>' . implode("\n", $aOutput) . '</pre>', ($iRc == 0 ? 'info' : 'error')); return implode("\n", $aOutput) . "\nrc=$iRc"; } /** * Get url to vcs sources * * @return string */ public function getUrl(): string { $this->log(__FUNCTION__ . " --> " . $this->_aCfg["url"]); return $this->_aCfg["url"] ?? ''; } /** * Get url to view sources in webrowser to generate an infolink * * @return string */ public function getWebGuiUrl(): string { $this->log(__FUNCTION__ . " start"); return $this->_aCfg["webaccess"] ?? ''; } }