GitDownloader.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. <?php
  2. /*
  3. * This file is part of Composer.
  4. *
  5. * (c) Nils Adermann <naderman@naderman.de>
  6. * Jordi Boggiano <j.boggiano@seld.be>
  7. *
  8. * For the full copyright and license information, please view the LICENSE
  9. * file that was distributed with this source code.
  10. */
  11. namespace Composer\Downloader;
  12. use Composer\Package\PackageInterface;
  13. use Composer\Util\GitHub;
  14. use Composer\Util\Git as GitUtil;
  15. use Composer\Util\ProcessExecutor;
  16. use Composer\IO\IOInterface;
  17. use Composer\Util\Filesystem;
  18. use Composer\Config;
  19. /**
  20. * @author Jordi Boggiano <j.boggiano@seld.be>
  21. */
  22. class GitDownloader extends VcsDownloader
  23. {
  24. private $hasStashedChanges = false;
  25. private $gitUtil;
  26. public function __construct(IOInterface $io, Config $config, ProcessExecutor $process = null, Filesystem $fs = null)
  27. {
  28. parent::__construct($io, $config, $process, $fs);
  29. $this->gitUtil = new GitUtil($this->io, $this->config, $this->process, $this->filesystem);
  30. }
  31. /**
  32. * {@inheritDoc}
  33. */
  34. public function doDownload(PackageInterface $package, $path, $url)
  35. {
  36. GitUtil::cleanEnv();
  37. $path = $this->normalizePath($path);
  38. $ref = $package->getSourceReference();
  39. $flag = defined('PHP_WINDOWS_VERSION_MAJOR') ? '/D ' : '';
  40. $command = 'git clone --no-checkout %s %s && cd '.$flag.'%2$s && git remote add composer %1$s && git fetch composer';
  41. $this->io->write(" Cloning ".$ref);
  42. $commandCallable = function ($url) use ($ref, $path, $command) {
  43. return sprintf($command, ProcessExecutor::escape($url), ProcessExecutor::escape($path), ProcessExecutor::escape($ref));
  44. };
  45. $this->gitUtil->runCommand($commandCallable, $url, $path, true);
  46. $this->setPushUrl($path, $url);
  47. if ($newRef = $this->updateToCommit($path, $ref, $package->getPrettyVersion(), $package->getReleaseDate())) {
  48. if ($package->getDistReference() === $package->getSourceReference()) {
  49. $package->setDistReference($newRef);
  50. }
  51. $package->setSourceReference($newRef);
  52. }
  53. }
  54. /**
  55. * {@inheritDoc}
  56. */
  57. public function doUpdate(PackageInterface $initial, PackageInterface $target, $path, $url)
  58. {
  59. GitUtil::cleanEnv();
  60. $path = $this->normalizePath($path);
  61. if (!is_dir($path.'/.git')) {
  62. throw new \RuntimeException('The .git directory is missing from '.$path.', see http://getcomposer.org/commit-deps for more information');
  63. }
  64. $ref = $target->getSourceReference();
  65. $this->io->write(" Checking out ".$ref);
  66. $command = 'git remote set-url composer %s && git fetch composer && git fetch --tags composer';
  67. $commandCallable = function ($url) use ($command) {
  68. return sprintf($command, ProcessExecutor::escape ($url));
  69. };
  70. $this->gitUtil->runCommand($commandCallable, $url, $path);
  71. if ($newRef = $this->updateToCommit($path, $ref, $target->getPrettyVersion(), $target->getReleaseDate())) {
  72. if ($target->getDistReference() === $target->getSourceReference()) {
  73. $target->setDistReference($newRef);
  74. }
  75. $target->setSourceReference($newRef);
  76. }
  77. }
  78. /**
  79. * {@inheritDoc}
  80. */
  81. public function getLocalChanges(PackageInterface $package, $path)
  82. {
  83. GitUtil::cleanEnv();
  84. $path = $this->normalizePath($path);
  85. if (!is_dir($path.'/.git')) {
  86. return;
  87. }
  88. $command = 'git status --porcelain --untracked-files=no';
  89. if (0 !== $this->process->execute($command, $output, $path)) {
  90. throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput());
  91. }
  92. return trim($output) ?: null;
  93. }
  94. /**
  95. * {@inheritDoc}
  96. */
  97. protected function cleanChanges(PackageInterface $package, $path, $update)
  98. {
  99. GitUtil::cleanEnv();
  100. $path = $this->normalizePath($path);
  101. if (!$changes = $this->getLocalChanges($package, $path)) {
  102. return;
  103. }
  104. if (!$this->io->isInteractive()) {
  105. $discardChanges = $this->config->get('discard-changes');
  106. if (true === $discardChanges) {
  107. return $this->discardChanges($path);
  108. }
  109. if ('stash' === $discardChanges) {
  110. if (!$update) {
  111. return parent::cleanChanges($package, $path, $update);
  112. }
  113. return $this->stashChanges($path);
  114. }
  115. return parent::cleanChanges($package, $path, $update);
  116. }
  117. $changes = array_map(function ($elem) {
  118. return ' '.$elem;
  119. }, preg_split('{\s*\r?\n\s*}', $changes));
  120. $this->io->write(' <error>The package has modified files:</error>');
  121. $this->io->write(array_slice($changes, 0, 10));
  122. if (count($changes) > 10) {
  123. $this->io->write(' <info>'.count($changes) - 10 . ' more files modified, choose "v" to view the full list</info>');
  124. }
  125. while (true) {
  126. switch ($this->io->ask(' <info>Discard changes [y,n,v,'.($update ? 's,' : '').'?]?</info> ', '?')) {
  127. case 'y':
  128. $this->discardChanges($path);
  129. break 2;
  130. case 's':
  131. if (!$update) {
  132. goto help;
  133. }
  134. $this->stashChanges($path);
  135. break 2;
  136. case 'n':
  137. throw new \RuntimeException('Update aborted');
  138. case 'v':
  139. $this->io->write($changes);
  140. break;
  141. case '?':
  142. default:
  143. help:
  144. $this->io->write(array(
  145. ' y - discard changes and apply the '.($update ? 'update' : 'uninstall'),
  146. ' n - abort the '.($update ? 'update' : 'uninstall').' and let you manually clean things up',
  147. ' v - view modified files',
  148. ));
  149. if ($update) {
  150. $this->io->write(' s - stash changes and try to reapply them after the update');
  151. }
  152. $this->io->write(' ? - print help');
  153. break;
  154. }
  155. }
  156. }
  157. /**
  158. * {@inheritDoc}
  159. */
  160. protected function reapplyChanges($path)
  161. {
  162. $path = $this->normalizePath($path);
  163. if ($this->hasStashedChanges) {
  164. $this->hasStashedChanges = false;
  165. $this->io->write(' <info>Re-applying stashed changes</info>');
  166. if (0 !== $this->process->execute('git stash pop', $output, $path)) {
  167. throw new \RuntimeException("Failed to apply stashed changes:\n\n".$this->process->getErrorOutput());
  168. }
  169. }
  170. }
  171. /**
  172. * Updates the given path to the given commit ref
  173. *
  174. * @param string $path
  175. * @param string $reference
  176. * @param string $branch
  177. * @param \DateTime $date
  178. * @return null|string if a string is returned, it is the commit reference that was checked out if the original could not be found
  179. *
  180. * @throws \RuntimeException
  181. */
  182. protected function updateToCommit($path, $reference, $branch, $date)
  183. {
  184. $template = 'git checkout %s && git reset --hard %1$s';
  185. $branch = preg_replace('{(?:^dev-|(?:\.x)?-dev$)}i', '', $branch);
  186. $branches = null;
  187. if (0 === $this->process->execute('git branch -r', $output, $path)) {
  188. $branches = $output;
  189. }
  190. // check whether non-commitish are branches or tags, and fetch branches with the remote name
  191. $gitRef = $reference;
  192. if (!preg_match('{^[a-f0-9]{40}$}', $reference)
  193. && $branches
  194. && preg_match('{^\s+composer/'.preg_quote($reference).'$}m', $branches)
  195. ) {
  196. $command = sprintf('git checkout -B %s %s && git reset --hard %2$s', ProcessExecutor::escape($branch), ProcessExecutor::escape('composer/'.$reference));
  197. if (0 === $this->process->execute($command, $output, $path)) {
  198. return;
  199. }
  200. }
  201. // try to checkout branch by name and then reset it so it's on the proper branch name
  202. if (preg_match('{^[a-f0-9]{40}$}', $reference)) {
  203. // add 'v' in front of the branch if it was stripped when generating the pretty name
  204. if (!preg_match('{^\s+composer/'.preg_quote($branch).'$}m', $branches) && preg_match('{^\s+composer/v'.preg_quote($branch).'$}m', $branches)) {
  205. $branch = 'v' . $branch;
  206. }
  207. $command = sprintf('git checkout %s', ProcessExecutor::escape($branch));
  208. $fallbackCommand = sprintf('git checkout -B %s %s', ProcessExecutor::escape($branch), ProcessExecutor::escape('composer/'.$branch));
  209. if (0 === $this->process->execute($command, $output, $path)
  210. || 0 === $this->process->execute($fallbackCommand, $output, $path)
  211. ) {
  212. $command = sprintf('git reset --hard %s', ProcessExecutor::escape($reference));
  213. if (0 === $this->process->execute($command, $output, $path)) {
  214. return;
  215. }
  216. }
  217. }
  218. $command = sprintf($template, ProcessExecutor::escape($gitRef));
  219. if (0 === $this->process->execute($command, $output, $path)) {
  220. return;
  221. }
  222. // reference was not found (prints "fatal: reference is not a tree: $ref")
  223. if ($date && false !== strpos($this->process->getErrorOutput(), $reference)) {
  224. $date = $date->format('U');
  225. // guess which remote branch to look at first
  226. $command = 'git branch -r';
  227. if (0 !== $this->process->execute($command, $output, $path)) {
  228. throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput());
  229. }
  230. $guessTemplate = 'git log --until=%s --date=raw -n1 --pretty=%%H %s';
  231. foreach ($this->process->splitLines($output) as $line) {
  232. if (preg_match('{^composer/'.preg_quote($branch).'(?:\.x)?$}i', trim($line))) {
  233. // find the previous commit by date in the given branch
  234. if (0 === $this->process->execute(sprintf($guessTemplate, $date, ProcessExecutor::escape(trim($line))), $output, $path)) {
  235. $newReference = trim($output);
  236. }
  237. break;
  238. }
  239. }
  240. if (empty($newReference)) {
  241. // no matching branch found, find the previous commit by date in all commits
  242. if (0 !== $this->process->execute(sprintf($guessTemplate, $date, '--all'), $output, $path)) {
  243. throw new \RuntimeException('Failed to execute ' . GitUtil::sanitizeUrl($command) . "\n\n" . $this->process->getErrorOutput());
  244. }
  245. $newReference = trim($output);
  246. }
  247. // checkout the new recovered ref
  248. $command = sprintf($template, ProcessExecutor::escape($newReference));
  249. if (0 === $this->process->execute($command, $output, $path)) {
  250. $this->io->write(' '.$reference.' is gone (history was rewritten?), recovered by checking out '.$newReference);
  251. return $newReference;
  252. }
  253. }
  254. throw new \RuntimeException('Failed to execute ' . GitUtil::sanitizeUrl($command) . "\n\n" . $this->process->getErrorOutput());
  255. }
  256. protected function setPushUrl($path, $url)
  257. {
  258. // set push url for github projects
  259. if (preg_match('{^(?:https?|git)://'.GitUtil::getGitHubDomainsRegex($this->config).'/([^/]+)/([^/]+?)(?:\.git)?$}', $url, $match)) {
  260. $protocols = $this->config->get('github-protocols');
  261. $pushUrl = 'git@'.$match[1].':'.$match[2].'/'.$match[3].'.git';
  262. if ($protocols[0] !== 'git') {
  263. $pushUrl = 'https://' . $match[1] . '/'.$match[2].'/'.$match[3].'.git';
  264. }
  265. $cmd = sprintf('git remote set-url --push origin %s', ProcessExecutor::escape($pushUrl));
  266. $this->process->execute($cmd, $ignoredOutput, $path);
  267. }
  268. }
  269. /**
  270. * {@inheritDoc}
  271. */
  272. protected function getCommitLogs($fromReference, $toReference, $path)
  273. {
  274. $path = $this->normalizePath($path);
  275. $command = sprintf('git log %s..%s --pretty=format:"%%h - %%an: %%s"', $fromReference, $toReference);
  276. if (0 !== $this->process->execute($command, $output, $path)) {
  277. throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput());
  278. }
  279. return $output;
  280. }
  281. /**
  282. * @param $path
  283. * @throws \RuntimeException
  284. */
  285. protected function discardChanges($path)
  286. {
  287. $path = $this->normalizePath($path);
  288. if (0 !== $this->process->execute('git reset --hard', $output, $path)) {
  289. throw new \RuntimeException("Could not reset changes\n\n:".$this->process->getErrorOutput());
  290. }
  291. }
  292. /**
  293. * @param $path
  294. * @throws \RuntimeException
  295. */
  296. protected function stashChanges($path)
  297. {
  298. $path = $this->normalizePath($path);
  299. if (0 !== $this->process->execute('git stash', $output, $path)) {
  300. throw new \RuntimeException("Could not stash changes\n\n:".$this->process->getErrorOutput());
  301. }
  302. $this->hasStashedChanges = true;
  303. }
  304. protected function normalizePath($path)
  305. {
  306. if (defined('PHP_WINDOWS_VERSION_MAJOR') && strlen($path) > 0) {
  307. $basePath = $path;
  308. $removed = array();
  309. while (!is_dir($basePath) && $basePath !== '\\') {
  310. array_unshift($removed, basename($basePath));
  311. $basePath = dirname($basePath);
  312. }
  313. if ($basePath === '\\') {
  314. return $path;
  315. }
  316. $path = rtrim(realpath($basePath) . '/' . implode('/', $removed), '/');
  317. }
  318. return $path;
  319. }
  320. }