260 lines
9.0 KiB
PHP
260 lines
9.0 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the Symfony package.
|
|
*
|
|
* (c) Fabien Potencier <fabien@symfony.com>
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace Symfony\Flex\Update;
|
|
|
|
use Composer\IO\IOInterface;
|
|
use Composer\Util\ProcessExecutor;
|
|
use Symfony\Component\Filesystem\Exception\IOException;
|
|
use Symfony\Component\Filesystem\Filesystem;
|
|
|
|
class RecipePatcher
|
|
{
|
|
private $rootDir;
|
|
private $filesystem;
|
|
private $io;
|
|
private $processExecutor;
|
|
|
|
public function __construct(string $rootDir, IOInterface $io)
|
|
{
|
|
$this->rootDir = $rootDir;
|
|
$this->filesystem = new Filesystem();
|
|
$this->io = $io;
|
|
$this->processExecutor = new ProcessExecutor($io);
|
|
}
|
|
|
|
/**
|
|
* Applies the patch. If it fails unexpectedly, an exception will be thrown.
|
|
*
|
|
* @return bool returns true if fully successful, false if conflicts were encountered
|
|
*/
|
|
public function applyPatch(RecipePatch $patch): bool
|
|
{
|
|
$withConflicts = $this->_applyPatchFile($patch);
|
|
|
|
foreach ($patch->getDeletedFiles() as $deletedFile) {
|
|
if (file_exists($this->rootDir.'/'.$deletedFile)) {
|
|
$this->execute(sprintf('git rm %s', ProcessExecutor::escape($deletedFile)), $this->rootDir);
|
|
}
|
|
}
|
|
|
|
return $withConflicts;
|
|
}
|
|
|
|
public function generatePatch(array $originalFiles, array $newFiles): RecipePatch
|
|
{
|
|
$ignoredFiles = $this->getIgnoredFiles(array_keys($originalFiles) + array_keys($newFiles));
|
|
|
|
// null implies "file does not exist"
|
|
$originalFiles = array_filter($originalFiles, function ($file, $fileName) use ($ignoredFiles) {
|
|
return null !== $file && !\in_array($fileName, $ignoredFiles);
|
|
}, \ARRAY_FILTER_USE_BOTH);
|
|
|
|
$newFiles = array_filter($newFiles, function ($file, $fileName) use ($ignoredFiles) {
|
|
return null !== $file && !\in_array($fileName, $ignoredFiles);
|
|
}, \ARRAY_FILTER_USE_BOTH);
|
|
|
|
$deletedFiles = [];
|
|
// find removed files & record that they are deleted
|
|
// unset them from originalFiles to avoid unnecessary blobs being added
|
|
foreach ($originalFiles as $file => $contents) {
|
|
if (!isset($newFiles[$file])) {
|
|
$deletedFiles[] = $file;
|
|
unset($originalFiles[$file]);
|
|
}
|
|
}
|
|
|
|
// If a file is being modified, but does not exist in the current project,
|
|
// it cannot be patched. We generate the diff for these, but then remove
|
|
// it from the patch (and optionally report this diff to the user).
|
|
$modifiedFiles = array_intersect_key(array_keys($originalFiles), array_keys($newFiles));
|
|
$deletedModifiedFiles = [];
|
|
foreach ($modifiedFiles as $modifiedFile) {
|
|
if (!file_exists($this->rootDir.'/'.$modifiedFile) && $originalFiles[$modifiedFile] !== $newFiles[$modifiedFile]) {
|
|
$deletedModifiedFiles[] = $modifiedFile;
|
|
}
|
|
}
|
|
|
|
// Use git binary to get project path from repository root
|
|
$prefix = trim($this->execute('git rev-parse --show-prefix', $this->rootDir));
|
|
$tmpPath = sys_get_temp_dir().'/_flex_recipe_update'.uniqid(mt_rand(), true);
|
|
$this->filesystem->mkdir($tmpPath);
|
|
|
|
try {
|
|
$this->execute('git init', $tmpPath);
|
|
$this->execute('git config commit.gpgsign false', $tmpPath);
|
|
$this->execute('git config user.name "Flex Updater"', $tmpPath);
|
|
$this->execute('git config user.email ""', $tmpPath);
|
|
|
|
$blobs = [];
|
|
if (\count($originalFiles) > 0) {
|
|
$this->writeFiles($originalFiles, $tmpPath);
|
|
$this->execute('git add -A', $tmpPath);
|
|
$this->execute('git commit -m "original files"', $tmpPath);
|
|
|
|
$blobs = $this->generateBlobs($originalFiles, $tmpPath);
|
|
}
|
|
|
|
$this->writeFiles($newFiles, $tmpPath);
|
|
$this->execute('git add -A', $tmpPath);
|
|
|
|
$patchString = $this->execute(sprintf('git diff --cached --src-prefix "a/%s" --dst-prefix "b/%s"', $prefix, $prefix), $tmpPath);
|
|
$removedPatches = [];
|
|
$patchString = DiffHelper::removeFilesFromPatch($patchString, $deletedModifiedFiles, $removedPatches);
|
|
|
|
return new RecipePatch(
|
|
$patchString,
|
|
$blobs,
|
|
$deletedFiles,
|
|
$removedPatches
|
|
);
|
|
} finally {
|
|
try {
|
|
$this->filesystem->remove($tmpPath);
|
|
} catch (IOException $e) {
|
|
// this can sometimes fail due to git file permissions
|
|
// if that happens, just leave it: we're in the temp directory anyways
|
|
}
|
|
}
|
|
}
|
|
|
|
private function writeFiles(array $files, string $directory): void
|
|
{
|
|
foreach ($files as $filename => $contents) {
|
|
$path = $directory.'/'.$filename;
|
|
if (null === $contents) {
|
|
if (file_exists($path)) {
|
|
unlink($path);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (!file_exists(\dirname($path))) {
|
|
$this->filesystem->mkdir(\dirname($path));
|
|
}
|
|
file_put_contents($path, $contents);
|
|
}
|
|
}
|
|
|
|
private function execute(string $command, string $cwd): string
|
|
{
|
|
$output = '';
|
|
$statusCode = $this->processExecutor->execute($command, $output, $cwd);
|
|
|
|
if (0 !== $statusCode) {
|
|
throw new \LogicException(sprintf('Command "%s" failed: "%s". Output: "%s".', $command, $this->processExecutor->getErrorOutput(), $output));
|
|
}
|
|
|
|
return $output;
|
|
}
|
|
|
|
/**
|
|
* Adds git blobs for each original file.
|
|
*
|
|
* For patching to work, each original file & contents needs to be
|
|
* available to git as a blob. This is because the patch contains
|
|
* the ref to the original blob, and git uses that to find the
|
|
* original file (which is needed for the 3-way merge).
|
|
*/
|
|
private function addMissingBlobs(array $blobs): array
|
|
{
|
|
$addedBlobs = [];
|
|
foreach ($blobs as $hash => $contents) {
|
|
$blobPath = $this->getBlobPath($this->rootDir, $hash);
|
|
if (file_exists($blobPath)) {
|
|
continue;
|
|
}
|
|
|
|
$addedBlobs[] = $blobPath;
|
|
if (!file_exists(\dirname($blobPath))) {
|
|
$this->filesystem->mkdir(\dirname($blobPath));
|
|
}
|
|
file_put_contents($blobPath, $contents);
|
|
}
|
|
|
|
return $addedBlobs;
|
|
}
|
|
|
|
private function generateBlobs(array $originalFiles, string $originalFilesRoot): array
|
|
{
|
|
$addedBlobs = [];
|
|
foreach ($originalFiles as $filename => $contents) {
|
|
// if the file didn't originally exist, no blob needed
|
|
if (!file_exists($originalFilesRoot.'/'.$filename)) {
|
|
continue;
|
|
}
|
|
|
|
$hash = trim($this->execute('git hash-object '.ProcessExecutor::escape($filename), $originalFilesRoot));
|
|
$addedBlobs[$hash] = file_get_contents($this->getBlobPath($originalFilesRoot, $hash));
|
|
}
|
|
|
|
return $addedBlobs;
|
|
}
|
|
|
|
private function getBlobPath(string $gitRoot, string $hash): string
|
|
{
|
|
$gitDir = trim($this->execute('git rev-parse --absolute-git-dir', $gitRoot));
|
|
|
|
$hashStart = substr($hash, 0, 2);
|
|
$hashEnd = substr($hash, 2);
|
|
|
|
return $gitDir.'/objects/'.$hashStart.'/'.$hashEnd;
|
|
}
|
|
|
|
private function _applyPatchFile(RecipePatch $patch)
|
|
{
|
|
if (!$patch->getPatch()) {
|
|
// nothing to do!
|
|
return true;
|
|
}
|
|
|
|
$addedBlobs = $this->addMissingBlobs($patch->getBlobs());
|
|
|
|
$patchPath = $this->rootDir.'/_flex_recipe_update.patch';
|
|
file_put_contents($patchPath, $patch->getPatch());
|
|
|
|
try {
|
|
$this->execute('git update-index --refresh', $this->rootDir);
|
|
|
|
$output = '';
|
|
$statusCode = $this->processExecutor->execute('git apply "_flex_recipe_update.patch" -3', $output, $this->rootDir);
|
|
|
|
if (0 === $statusCode) {
|
|
// successful with no conflicts
|
|
return true;
|
|
}
|
|
|
|
if (false !== strpos($this->processExecutor->getErrorOutput(), 'with conflicts')) {
|
|
// successful with conflicts
|
|
return false;
|
|
}
|
|
|
|
throw new \LogicException('Error applying the patch: '.$this->processExecutor->getErrorOutput());
|
|
} finally {
|
|
unlink($patchPath);
|
|
// clean up any temporary blobs
|
|
foreach ($addedBlobs as $filename) {
|
|
unlink($filename);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function getIgnoredFiles(array $fileNames): array
|
|
{
|
|
$args = implode(' ', array_map([ProcessExecutor::class, 'escape'], $fileNames));
|
|
$output = '';
|
|
$this->processExecutor->execute(sprintf('git check-ignore %s', $args), $output, $this->rootDir);
|
|
|
|
return $this->processExecutor->splitLines($output);
|
|
}
|
|
}
|