19 | */
20 | class Options
21 | {
22 | private $options;
23 | private $writtenFiles = [];
24 | private $io;
25 | private $lockData;
26 |
27 | public function __construct(array $options = [], ?IOInterface $io = null, ?Lock $lock = null)
28 | {
29 | $this->options = $options;
30 | $this->io = $io;
31 | $this->lockData = $lock?->all() ?? [];
32 | }
33 |
34 | public function get(string $name)
35 | {
36 | return $this->options[$name] ?? null;
37 | }
38 |
39 | public function expandTargetDir(string $target): string
40 | {
41 | $result = preg_replace_callback('{%(.+?)%}', function ($matches) {
42 | $option = str_replace('_', '-', strtolower($matches[1]));
43 | if (!isset($this->options[$option])) {
44 | return $matches[0];
45 | }
46 |
47 | return rtrim($this->options[$option], '/');
48 | }, $target);
49 |
50 | $phpunitDistFiles = [
51 | 'phpunit.xml.dist' => true,
52 | 'phpunit.dist.xml' => true,
53 | ];
54 |
55 | $rootDir = $this->get('root-dir');
56 |
57 | if (null === $rootDir || !isset($phpunitDistFiles[$result]) || !is_dir($rootDir) || file_exists($rootDir.'/'.$result)) {
58 | return $result;
59 | }
60 |
61 | unset($phpunitDistFiles[$result]);
62 | $otherPhpunitDistFile = key($phpunitDistFiles);
63 |
64 | return file_exists($rootDir.'/'.$otherPhpunitDistFile) ? $otherPhpunitDistFile : $result;
65 | }
66 |
67 | public function shouldWriteFile(string $file, bool $overwrite, bool $skipQuestion): bool
68 | {
69 | if (isset($this->writtenFiles[$file])) {
70 | return false;
71 | }
72 | $this->writtenFiles[$file] = true;
73 |
74 | if (!file_exists($file)) {
75 | return true;
76 | }
77 |
78 | if (!$overwrite) {
79 | return false;
80 | }
81 |
82 | if (!filesize($file)) {
83 | return true;
84 | }
85 |
86 | if ($skipQuestion) {
87 | return true;
88 | }
89 |
90 | exec('git status --short --ignored --untracked-files=all -- '.ProcessExecutor::escape($file).' 2>&1', $output, $status);
91 |
92 | if (0 !== $status) {
93 | return $this->io && $this->io->askConfirmation(\sprintf('Cannot determine the state of the "%s" file, overwrite anyway? [y/N] ', $file), false);
94 | }
95 |
96 | if (empty($output[0]) || preg_match('/^[ AMDRCU][ D][ \t]/', $output[0])) {
97 | return true;
98 | }
99 |
100 | $name = basename($file);
101 | $name = \strlen($output[0]) - \strlen($name) === strrpos($output[0], $name) ? substr($output[0], 3) : $name;
102 |
103 | return $this->io && $this->io->askConfirmation(\sprintf('File "%s" has uncommitted changes, overwrite? [y/N] ', $name), false);
104 | }
105 |
106 | public function getRemovableFiles(Recipe $recipe, Lock $lock): array
107 | {
108 | if (null === $removableFiles = $this->lockData[$recipe->getName()]['files'] ?? null) {
109 | $removableFiles = [];
110 | foreach (array_keys($recipe->getFiles()) as $source => $target) {
111 | if (str_ends_with($source, '/')) {
112 | $removableFiles[] = $this->expandTargetDir($target);
113 | }
114 | }
115 | }
116 |
117 | unset($this->lockData[$recipe->getName()]);
118 | $lockedFiles = array_count_values(array_merge(...array_column($lock->all(), 'files')));
119 |
120 | $nonRemovableFiles = [];
121 | foreach ($removableFiles as $i => $file) {
122 | if (isset($lockedFiles[$file])) {
123 | $nonRemovableFiles[] = $file;
124 | unset($removableFiles[$i]);
125 | }
126 | }
127 |
128 | if ($nonRemovableFiles && $this->io) {
129 | $this->io?->writeError(' The following files are still referenced by other recipes, you might need to adjust them manually:');
130 | foreach ($nonRemovableFiles as $file) {
131 | $this->io?->writeError(' - '.$file);
132 | }
133 | }
134 |
135 | return array_values($removableFiles);
136 | }
137 |
138 | public function toArray(): array
139 | {
140 | return $this->options;
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/PackageFilter.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex;
13 |
14 | use Composer\IO\IOInterface;
15 | use Composer\Package\AliasPackage;
16 | use Composer\Package\PackageInterface;
17 | use Composer\Package\RootPackageInterface;
18 | use Composer\Semver\Constraint\Constraint;
19 | use Composer\Semver\Intervals;
20 | use Composer\Semver\VersionParser;
21 |
22 | /**
23 | * @author Nicolas Grekas
24 | */
25 | class PackageFilter
26 | {
27 | private $versions;
28 | private $versionParser;
29 | private $symfonyRequire;
30 | private $symfonyConstraints;
31 | private $downloader;
32 | private $io;
33 |
34 | public function __construct(IOInterface $io, string $symfonyRequire, Downloader $downloader)
35 | {
36 | $this->versionParser = new VersionParser();
37 | $this->symfonyRequire = $symfonyRequire;
38 | $this->symfonyConstraints = $this->versionParser->parseConstraints($symfonyRequire);
39 | $this->downloader = $downloader;
40 | $this->io = $io;
41 | }
42 |
43 | /**
44 | * @param PackageInterface[] $data
45 | * @param PackageInterface[] $lockedPackages
46 | *
47 | * @return PackageInterface[]
48 | */
49 | public function removeLegacyPackages(array $data, RootPackageInterface $rootPackage, array $lockedPackages): array
50 | {
51 | if (!$this->symfonyConstraints || !$data) {
52 | return $data;
53 | }
54 |
55 | $lockedVersions = [];
56 | foreach ($lockedPackages as $package) {
57 | $lockedVersions[$package->getName()] = [$package->getVersion()];
58 | if ($package instanceof AliasPackage) {
59 | $lockedVersions[$package->getName()][] = $package->getAliasOf()->getVersion();
60 | }
61 | }
62 |
63 | $rootConstraints = [];
64 | foreach ($rootPackage->getRequires() + $rootPackage->getDevRequires() as $name => $link) {
65 | $rootConstraints[$name] = $link->getConstraint();
66 | }
67 |
68 | $knownVersions = null;
69 | $filteredPackages = [];
70 | $symfonyPackages = [];
71 | $oneSymfony = false;
72 | foreach ($data as $package) {
73 | $name = $package->getName();
74 | $versions = [$package->getVersion()];
75 | if ($package instanceof AliasPackage) {
76 | $versions[] = $package->getAliasOf()->getVersion();
77 | }
78 |
79 | if ('symfony/symfony' !== $name && (
80 | array_intersect($versions, $lockedVersions[$name] ?? [])
81 | || (($knownVersions ??= $this->getVersions()) && !isset($knownVersions['splits'][$name]))
82 | || (isset($rootConstraints[$name]) && !Intervals::haveIntersections($this->symfonyConstraints, $rootConstraints[$name]))
83 | || ('symfony/psr-http-message-bridge' === $name && 6.4 > $versions[0])
84 | )) {
85 | $filteredPackages[] = $package;
86 | continue;
87 | }
88 |
89 | if (null !== $alias = $package->getExtra()['branch-alias'][$package->getVersion()] ?? null) {
90 | $versions[] = $this->versionParser->normalize($alias);
91 | }
92 |
93 | foreach ($versions as $version) {
94 | if ($this->symfonyConstraints->matches(new Constraint('==', $version))) {
95 | $filteredPackages[] = $package;
96 | $oneSymfony = $oneSymfony || 'symfony/symfony' === $name;
97 | continue 2;
98 | }
99 | }
100 |
101 | if ('symfony/symfony' === $name) {
102 | $symfonyPackages[] = $package;
103 | } elseif (null !== $this->io) {
104 | $this->io->writeError(\sprintf('Restricting packages listed in "symfony/symfony" to "%s">', $this->symfonyRequire));
105 | $this->io = null;
106 | }
107 | }
108 |
109 | if ($symfonyPackages && !$oneSymfony) {
110 | $filteredPackages = array_merge($filteredPackages, $symfonyPackages);
111 | }
112 |
113 | return $filteredPackages;
114 | }
115 |
116 | private function getVersions(): array
117 | {
118 | if (null !== $this->versions) {
119 | return $this->versions;
120 | }
121 |
122 | $versions = $this->downloader->getVersions();
123 | $this->downloader = null;
124 | $okVersions = [];
125 |
126 | if (!isset($versions['splits'])) {
127 | throw new \LogicException('The Flex index is missing a "splits" entry. Did you forget to add "flex://defaults" in the "extra.symfony.endpoint" array of your composer.json?');
128 | }
129 | foreach ($versions['splits'] as $name => $vers) {
130 | foreach ($vers as $i => $v) {
131 | if (!isset($okVersions[$v])) {
132 | $okVersions[$v] = false;
133 | $w = '.x' === substr($v, -2) ? $versions['next'] : $v;
134 |
135 | for ($j = 0; $j < 60; ++$j) {
136 | if ($this->symfonyConstraints->matches(new Constraint('==', $w.'.'.$j.'.0'))) {
137 | $okVersions[$v] = true;
138 | break;
139 | }
140 | }
141 | }
142 |
143 | if (!$okVersions[$v]) {
144 | unset($vers[$i]);
145 | }
146 | }
147 |
148 | if (!$vers || $vers === $versions['splits'][$name]) {
149 | unset($versions['splits'][$name]);
150 | }
151 | }
152 |
153 | return $this->versions = $versions;
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/PackageResolver.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex;
13 |
14 | use Composer\Factory;
15 | use Composer\Package\Version\VersionParser;
16 | use Composer\Repository\PlatformRepository;
17 | use Composer\Semver\Constraint\MatchAllConstraint;
18 |
19 | /**
20 | * @author Fabien Potencier
21 | */
22 | class PackageResolver
23 | {
24 | private static $SYMFONY_VERSIONS = ['lts', 'previous', 'stable', 'next', 'dev'];
25 | private $downloader;
26 |
27 | public function __construct(Downloader $downloader)
28 | {
29 | $this->downloader = $downloader;
30 | }
31 |
32 | public function resolve(array $arguments = [], bool $isRequire = false): array
33 | {
34 | // first pass split on : and = to resolve package names
35 | $packages = [];
36 | foreach ($arguments as $i => $argument) {
37 | if ((false !== $pos = strpos($argument, ':')) || (false !== $pos = strpos($argument, '='))) {
38 | $package = $this->resolvePackageName(substr($argument, 0, $pos), $i, $isRequire);
39 | $version = substr($argument, $pos + 1);
40 | $packages[] = $package.':'.$version;
41 | } else {
42 | $packages[] = $this->resolvePackageName($argument, $i, $isRequire);
43 | }
44 | }
45 |
46 | // second pass to resolve versions
47 | $versionParser = new VersionParser();
48 | $requires = [];
49 | $toGuess = [];
50 | foreach ($versionParser->parseNameVersionPairs($packages) as $package) {
51 | $version = $this->parseVersion($package['name'], $package['version'] ?? '', $isRequire);
52 | if ('' !== $version) {
53 | unset($toGuess[$package['name']]);
54 | } elseif (!isset($requires[$package['name']])) {
55 | $toGuess[$package['name']] = new MatchAllConstraint();
56 | }
57 | $requires[$package['name']] = $package['name'].$version;
58 | }
59 |
60 | if ($toGuess && $isRequire) {
61 | foreach ($this->downloader->getSymfonyPacks($toGuess) as $package) {
62 | $requires[$package] .= ':*';
63 | }
64 | }
65 |
66 | return array_values($requires);
67 | }
68 |
69 | public function parseVersion(string $package, string $version, bool $isRequire): string
70 | {
71 | $guess = 'guess' === ($version ?: 'guess');
72 |
73 | if (!str_starts_with($package, 'symfony/')) {
74 | return $guess ? '' : ':'.$version;
75 | }
76 |
77 | $versions = $this->downloader->getVersions();
78 |
79 | if (!isset($versions['splits'][$package])) {
80 | return $guess ? '' : ':'.$version;
81 | }
82 |
83 | if ($guess || '*' === $version) {
84 | try {
85 | $config = @json_decode(file_get_contents(Factory::getComposerFile()), true);
86 | } finally {
87 | if (!$isRequire || !isset($config['extra']['symfony']['require'])) {
88 | return '';
89 | }
90 | }
91 | $version = $config['extra']['symfony']['require'];
92 | } elseif ('dev' === $version) {
93 | $version = '^'.$versions['dev-name'].'@dev';
94 | } elseif ('next' === $version) {
95 | $version = '^'.$versions[$version].'@dev';
96 | } elseif (\in_array($version, self::$SYMFONY_VERSIONS, true)) {
97 | $version = '^'.$versions[$version];
98 | }
99 |
100 | return ':'.$version;
101 | }
102 |
103 | private function resolvePackageName(string $argument, int $position, bool $isRequire): string
104 | {
105 | $skippedPackages = ['mirrors', 'nothing', ''];
106 |
107 | if (!$isRequire) {
108 | $skippedPackages[] = 'lock';
109 | }
110 |
111 | if (str_contains($argument, '/') || preg_match(PlatformRepository::PLATFORM_PACKAGE_REGEX, $argument) || preg_match('{(?<=[a-z0-9_/-])\*|\*(?=[a-z0-9_/-])}i', $argument) || \in_array($argument, $skippedPackages)) {
112 | return $argument;
113 | }
114 |
115 | $aliases = $this->downloader->getAliases();
116 |
117 | if (isset($aliases[$argument])) {
118 | $argument = $aliases[$argument];
119 | } else {
120 | // is it a version or an alias that does not exist?
121 | try {
122 | $versionParser = new VersionParser();
123 | $versionParser->parseConstraints($argument);
124 | } catch (\UnexpectedValueException $e) {
125 | // is it a special Symfony version?
126 | if (!\in_array($argument, self::$SYMFONY_VERSIONS, true)) {
127 | $this->throwAlternatives($argument, $position);
128 | }
129 | }
130 | }
131 |
132 | return $argument;
133 | }
134 |
135 | /**
136 | * @throws \UnexpectedValueException
137 | */
138 | private function throwAlternatives(string $argument, int $position)
139 | {
140 | $alternatives = [];
141 | foreach ($this->downloader->getAliases() as $alias => $package) {
142 | $lev = levenshtein($argument, $alias);
143 | if ($lev <= \strlen($argument) / 3 || ('' !== $argument && str_contains($alias, $argument))) {
144 | $alternatives[$package][] = $alias;
145 | }
146 | }
147 |
148 | // First position can only be a package name, not a version
149 | if ($alternatives || 0 === $position) {
150 | $message = \sprintf('"%s" is not a valid alias.', $argument);
151 | if ($alternatives) {
152 | if (1 === \count($alternatives)) {
153 | $message .= " Did you mean this:\n";
154 | } else {
155 | $message .= " Did you mean one of these:\n";
156 | }
157 | foreach ($alternatives as $package => $aliases) {
158 | $message .= \sprintf(" \"%s\", supported aliases: \"%s\"\n", $package, implode('", "', $aliases));
159 | }
160 | }
161 | } else {
162 | $message = \sprintf('Could not parse version constraint "%s".', $argument);
163 | }
164 |
165 | throw new \UnexpectedValueException($message);
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/src/Path.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex;
13 |
14 | /**
15 | * @internal
16 | */
17 | class Path
18 | {
19 | private $workingDirectory;
20 |
21 | public function __construct($workingDirectory)
22 | {
23 | $this->workingDirectory = $workingDirectory;
24 | }
25 |
26 | public function relativize(string $absolutePath): string
27 | {
28 | $relativePath = str_replace($this->workingDirectory, '.', $absolutePath);
29 |
30 | return is_dir($absolutePath) ? rtrim($relativePath, '/').'/' : $relativePath;
31 | }
32 |
33 | public function concatenate(array $parts): string
34 | {
35 | $first = array_shift($parts);
36 |
37 | return array_reduce($parts, function (string $initial, string $next): string {
38 | return rtrim($initial, '/').'/'.ltrim($next, '/');
39 | }, $first);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Recipe.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex;
13 |
14 | use Composer\Package\PackageInterface;
15 |
16 | /**
17 | * @author Fabien Potencier
18 | */
19 | class Recipe
20 | {
21 | private $package;
22 | private $name;
23 | private $job;
24 | private $data;
25 | private $lock;
26 |
27 | public function __construct(PackageInterface $package, string $name, string $job, array $data, array $lock = [])
28 | {
29 | $this->package = $package;
30 | $this->name = $name;
31 | $this->job = $job;
32 | $this->data = $data;
33 | $this->lock = $lock;
34 | }
35 |
36 | public function getPackage(): PackageInterface
37 | {
38 | return $this->package;
39 | }
40 |
41 | public function getName(): string
42 | {
43 | return $this->name;
44 | }
45 |
46 | public function getJob(): string
47 | {
48 | return $this->job;
49 | }
50 |
51 | public function getManifest(): array
52 | {
53 | if (!isset($this->data['manifest'])) {
54 | throw new \LogicException(\sprintf('Manifest is not available for recipe "%s".', $this->name));
55 | }
56 |
57 | return $this->data['manifest'];
58 | }
59 |
60 | public function getFiles(): array
61 | {
62 | return $this->data['files'] ?? [];
63 | }
64 |
65 | public function getOrigin(): string
66 | {
67 | return $this->data['origin'] ?? '';
68 | }
69 |
70 | public function getFormattedOrigin(): string
71 | {
72 | if (!$this->getOrigin()) {
73 | return '';
74 | }
75 |
76 | // symfony/translation:3.3@github.com/symfony/recipes:branch
77 | if (!preg_match('/^([^:]++):([^@]++)@(.+)$/', $this->getOrigin(), $matches)) {
78 | return $this->getOrigin();
79 | }
80 |
81 | return \sprintf('%s> (>=%s>): From %s', $matches[1], $matches[2], 'auto-generated recipe' === $matches[3] ? ''.$matches[3].'>' : $matches[3]);
82 | }
83 |
84 | public function getURL(): string
85 | {
86 | if (!$this->data['origin']) {
87 | return '';
88 | }
89 |
90 | // symfony/translation:3.3@github.com/symfony/recipes:branch
91 | if (!preg_match('/^([^:]++):([^@]++)@([^:]++):(.+)$/', $this->data['origin'], $matches)) {
92 | // that excludes auto-generated recipes, which is what we want
93 | return '';
94 | }
95 |
96 | return \sprintf('https://%s/tree/%s/%s/%s', $matches[3], $matches[4], $matches[1], $matches[2]);
97 | }
98 |
99 | public function isContrib(): bool
100 | {
101 | return $this->data['is_contrib'] ?? false;
102 | }
103 |
104 | public function getRef()
105 | {
106 | return $this->lock['recipe']['ref'] ?? null;
107 | }
108 |
109 | public function isAuto(): bool
110 | {
111 | return !isset($this->lock['recipe']);
112 | }
113 |
114 | public function getVersion(): string
115 | {
116 | return $this->lock['recipe']['version'] ?? $this->lock['version'];
117 | }
118 |
119 | public function getLock(): array
120 | {
121 | return $this->lock;
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/Response.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex;
13 |
14 | /**
15 | * @author Fabien Potencier
16 | */
17 | class Response implements \JsonSerializable
18 | {
19 | private $body;
20 | private $origHeaders;
21 | private $headers;
22 | private $code;
23 |
24 | /**
25 | * @param mixed $body The response as JSON
26 | */
27 | public function __construct($body, array $headers = [], int $code = 200)
28 | {
29 | $this->body = $body;
30 | $this->origHeaders = $headers;
31 | $this->headers = $this->parseHeaders($headers);
32 | $this->code = $code;
33 | }
34 |
35 | public function getStatusCode(): int
36 | {
37 | return $this->code;
38 | }
39 |
40 | public function getHeader(string $name): string
41 | {
42 | return $this->headers[strtolower($name)][0] ?? '';
43 | }
44 |
45 | public function getHeaders(string $name): array
46 | {
47 | return $this->headers[strtolower($name)] ?? [];
48 | }
49 |
50 | public function getBody()
51 | {
52 | return $this->body;
53 | }
54 |
55 | public function getOrigHeaders(): array
56 | {
57 | return $this->origHeaders;
58 | }
59 |
60 | public static function fromJson(array $json): self
61 | {
62 | $response = new self($json['body']);
63 | $response->headers = $json['headers'];
64 |
65 | return $response;
66 | }
67 |
68 | #[\ReturnTypeWillChange]
69 | public function jsonSerialize()
70 | {
71 | return ['body' => $this->body, 'headers' => $this->headers];
72 | }
73 |
74 | private function parseHeaders(array $headers): array
75 | {
76 | $values = [];
77 | foreach (array_reverse($headers) as $header) {
78 | if (preg_match('{^([^:]++):\s*(.+?)\s*$}i', $header, $match)) {
79 | $values[strtolower($match[1])][] = $match[2];
80 | } elseif (preg_match('{^HTTP/}i', $header)) {
81 | break;
82 | }
83 | }
84 |
85 | return $values;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/ScriptExecutor.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex;
13 |
14 | use Composer\Composer;
15 | use Composer\EventDispatcher\ScriptExecutionException;
16 | use Composer\IO\IOInterface;
17 | use Composer\Semver\Constraint\MatchAllConstraint;
18 | use Composer\Util\ProcessExecutor;
19 | use Symfony\Component\Console\Output\OutputInterface;
20 | use Symfony\Component\Console\Output\StreamOutput;
21 | use Symfony\Component\Process\PhpExecutableFinder;
22 |
23 | /**
24 | * @author Fabien Potencier
25 | */
26 | class ScriptExecutor
27 | {
28 | private $composer;
29 | private $io;
30 | private $options;
31 | private $executor;
32 |
33 | public function __construct(Composer $composer, IOInterface $io, Options $options, ?ProcessExecutor $executor = null)
34 | {
35 | $this->composer = $composer;
36 | $this->io = $io;
37 | $this->options = $options;
38 | $this->executor = $executor ?: new ProcessExecutor();
39 | }
40 |
41 | /**
42 | * @throws ScriptExecutionException if the executed command returns a non-0 exit code
43 | */
44 | public function execute(string $type, string $cmd, array $arguments = [])
45 | {
46 | $parsedCmd = $this->options->expandTargetDir($cmd);
47 | if (null === $expandedCmd = $this->expandCmd($type, $parsedCmd, $arguments)) {
48 | return;
49 | }
50 |
51 | $cmdOutput = new StreamOutput(fopen('php://temp', 'rw'), OutputInterface::VERBOSITY_VERBOSE, $this->io->isDecorated());
52 | $outputHandler = function ($type, $buffer) use ($cmdOutput) {
53 | $cmdOutput->write($buffer, false, OutputInterface::OUTPUT_RAW);
54 | };
55 |
56 | $this->io->writeError(\sprintf('Executing script %s', $parsedCmd), $this->io->isVerbose());
57 | $exitCode = $this->executor->execute($expandedCmd, $outputHandler);
58 |
59 | $code = 0 === $exitCode ? ' [OK]>' : ' [KO]>';
60 |
61 | if ($this->io->isVerbose()) {
62 | $this->io->writeError(\sprintf('Executed script %s %s', $cmd, $code));
63 | } else {
64 | $this->io->writeError($code);
65 | }
66 |
67 | if (0 !== $exitCode) {
68 | $this->io->writeError(' [KO]>');
69 | $this->io->writeError(\sprintf('Script %s returned with error code %s>', $cmd, $exitCode));
70 | fseek($cmdOutput->getStream(), 0);
71 | foreach (explode("\n", stream_get_contents($cmdOutput->getStream())) as $line) {
72 | $this->io->writeError('!! '.$line);
73 | }
74 |
75 | throw new ScriptExecutionException($cmd, $exitCode);
76 | }
77 | }
78 |
79 | private function expandCmd(string $type, string $cmd, array $arguments)
80 | {
81 | switch ($type) {
82 | case 'symfony-cmd':
83 | return $this->expandSymfonyCmd($cmd, $arguments);
84 | case 'php-script':
85 | return $this->expandPhpScript($cmd, $arguments);
86 | case 'script':
87 | return $cmd;
88 | default:
89 | throw new \InvalidArgumentException(\sprintf('Invalid symfony/flex auto-script in composer.json: "%s" is not a valid type of command.', $type));
90 | }
91 | }
92 |
93 | private function expandSymfonyCmd(string $cmd, array $arguments)
94 | {
95 | $repo = $this->composer->getRepositoryManager()->getLocalRepository();
96 | if (!$repo->findPackage('symfony/console', new MatchAllConstraint())) {
97 | $this->io->writeError(\sprintf('Skipping "%s" (needs symfony/console to run).>', $cmd));
98 |
99 | return null;
100 | }
101 |
102 | $console = ProcessExecutor::escape($this->options->get('root-dir').'/'.$this->options->get('bin-dir').'/console');
103 | if ($this->io->isDecorated()) {
104 | $console .= ' --ansi';
105 | }
106 |
107 | return $this->expandPhpScript($console.' '.$cmd, $arguments);
108 | }
109 |
110 | private function expandPhpScript(string $cmd, array $scriptArguments): string
111 | {
112 | $phpFinder = new PhpExecutableFinder();
113 | if (!$php = $phpFinder->find(false)) {
114 | throw new \RuntimeException('The PHP executable could not be found, add it to your PATH and try again.');
115 | }
116 |
117 | $arguments = $phpFinder->findArguments();
118 |
119 | if ($env = (string) getenv('COMPOSER_ORIGINAL_INIS')) {
120 | $paths = explode(\PATH_SEPARATOR, $env);
121 | $ini = array_shift($paths);
122 | } else {
123 | $ini = php_ini_loaded_file();
124 | }
125 |
126 | if ($ini) {
127 | $arguments[] = '--php-ini='.$ini;
128 | }
129 |
130 | if ($memoryLimit = (string) getenv('COMPOSER_MEMORY_LIMIT')) {
131 | $arguments[] = "-d memory_limit={$memoryLimit}";
132 | }
133 |
134 | $phpArgs = implode(' ', array_map([ProcessExecutor::class, 'escape'], $arguments));
135 | $scriptArgs = implode(' ', array_map([ProcessExecutor::class, 'escape'], $scriptArguments));
136 |
137 | return ProcessExecutor::escape($php).($phpArgs ? ' '.$phpArgs : '').' '.$cmd.($scriptArgs ? ' '.$scriptArgs : '');
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/src/SymfonyBundle.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex;
13 |
14 | use Composer\Composer;
15 | use Composer\Package\PackageInterface;
16 |
17 | /**
18 | * @author Fabien Potencier
19 | */
20 | class SymfonyBundle
21 | {
22 | private $package;
23 | private $operation;
24 | private $vendorDir;
25 |
26 | public function __construct(Composer $composer, PackageInterface $package, string $operation)
27 | {
28 | $this->package = $package;
29 | $this->operation = $operation;
30 | $this->vendorDir = rtrim($composer->getConfig()->get('vendor-dir'), '/');
31 | }
32 |
33 | public function getClassNames(): array
34 | {
35 | $uninstall = 'uninstall' === $this->operation;
36 | $classes = [];
37 | $autoload = $this->package->getAutoload();
38 | $isSyliusPlugin = 'sylius-plugin' === $this->package->getType();
39 | foreach (['psr-4' => true, 'psr-0' => false] as $psr => $isPsr4) {
40 | if (!isset($autoload[$psr])) {
41 | continue;
42 | }
43 |
44 | foreach ($autoload[$psr] as $namespace => $paths) {
45 | if (!\is_array($paths)) {
46 | $paths = [$paths];
47 | }
48 | foreach ($paths as $path) {
49 | foreach ($this->extractClassNames($namespace, $isSyliusPlugin) as $class) {
50 | // we only check class existence on install as we do have the code available
51 | // in contrast to uninstall operation
52 | if (!$uninstall && !$this->isBundleClass($class, $path, $isPsr4)) {
53 | continue;
54 | }
55 |
56 | $classes[] = $class;
57 | }
58 | }
59 | }
60 | }
61 |
62 | return $classes;
63 | }
64 |
65 | private function extractClassNames(string $namespace, bool $isSyliusPlugin): array
66 | {
67 | $namespace = trim($namespace, '\\');
68 | $class = $namespace.'\\';
69 | $parts = explode('\\', $namespace);
70 | $suffix = $parts[\count($parts) - 1];
71 | $endOfWord = substr($suffix, -6);
72 |
73 | if ($isSyliusPlugin) {
74 | if ('Bundle' !== $endOfWord && 'Plugin' !== $endOfWord) {
75 | $suffix .= 'Bundle';
76 | }
77 | } elseif ('Bundle' !== $endOfWord) {
78 | $suffix .= 'Bundle';
79 | }
80 |
81 | $classes = [$class.$suffix];
82 | $acc = '';
83 | foreach (\array_slice($parts, 0, -1) as $part) {
84 | if ('Bundle' === $part || ($isSyliusPlugin && 'Plugin' === $part)) {
85 | continue;
86 | }
87 | $classes[] = $class.$part.$suffix;
88 | $acc .= $part;
89 | $classes[] = $class.$acc.$suffix;
90 | }
91 |
92 | return array_unique($classes);
93 | }
94 |
95 | private function isBundleClass(string $class, string $path, bool $isPsr4): bool
96 | {
97 | $classPath = ($this->vendorDir ? $this->vendorDir.'/' : '').$this->package->getPrettyName().'/'.$path.'/';
98 | $parts = explode('\\', $class);
99 | $class = $parts[\count($parts) - 1];
100 | if (!$isPsr4) {
101 | $classPath .= str_replace('\\', '', implode('/', \array_slice($parts, 0, -1))).'/';
102 | }
103 | $classPath .= str_replace('\\', '/', $class).'.php';
104 |
105 | if (!file_exists($classPath)) {
106 | return false;
107 | }
108 |
109 | // heuristic that should work in almost all cases
110 | $classContents = file_get_contents($classPath);
111 |
112 | return str_contains($classContents, 'Symfony\Component\HttpKernel\Bundle\Bundle')
113 | || str_contains($classContents, 'Symfony\Component\HttpKernel\Bundle\AbstractBundle');
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/SymfonyPackInstaller.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex;
13 |
14 | use Composer\Installer\MetapackageInstaller;
15 |
16 | class SymfonyPackInstaller extends MetapackageInstaller
17 | {
18 | public function supports($packageType): bool
19 | {
20 | return 'symfony-pack' === $packageType;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Unpack/Operation.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex\Unpack;
13 |
14 | class Operation
15 | {
16 | private $packages = [];
17 | private $unpack;
18 | private $sort;
19 |
20 | public function __construct(bool $unpack, bool $sort)
21 | {
22 | $this->unpack = $unpack;
23 | $this->sort = $sort;
24 | }
25 |
26 | public function addPackage(string $name, string $version, bool $dev)
27 | {
28 | $this->packages[] = [
29 | 'name' => $name,
30 | 'version' => $version,
31 | 'dev' => $dev,
32 | ];
33 | }
34 |
35 | public function getPackages(): array
36 | {
37 | return $this->packages;
38 | }
39 |
40 | public function shouldUnpack(): bool
41 | {
42 | return $this->unpack;
43 | }
44 |
45 | public function shouldSort(): bool
46 | {
47 | return $this->sort;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Unpack/Result.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex\Unpack;
13 |
14 | use Composer\Package\PackageInterface;
15 |
16 | class Result
17 | {
18 | private $unpacked = [];
19 | private $required = [];
20 |
21 | public function addUnpacked(PackageInterface $package): bool
22 | {
23 | $name = $package->getName();
24 |
25 | if (!isset($this->unpacked[$name])) {
26 | $this->unpacked[$name] = $package;
27 |
28 | return true;
29 | }
30 |
31 | return false;
32 | }
33 |
34 | /**
35 | * @return PackageInterface[]
36 | */
37 | public function getUnpacked(): array
38 | {
39 | return $this->unpacked;
40 | }
41 |
42 | public function addRequired(string $package)
43 | {
44 | $this->required[] = $package;
45 | }
46 |
47 | /**
48 | * @return string[]
49 | */
50 | public function getRequired(): array
51 | {
52 | // we need at least one package for the command to work properly
53 | return $this->required ?: ['symfony/flex'];
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Unpacker.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex;
13 |
14 | use Composer\Composer;
15 | use Composer\Config\JsonConfigSource;
16 | use Composer\Factory;
17 | use Composer\IO\IOInterface;
18 | use Composer\Json\JsonFile;
19 | use Composer\Json\JsonManipulator;
20 | use Composer\Package\Locker;
21 | use Composer\Package\Version\VersionSelector;
22 | use Composer\Repository\CompositeRepository;
23 | use Composer\Repository\RepositorySet;
24 | use Composer\Semver\VersionParser;
25 | use Symfony\Flex\Unpack\Operation;
26 | use Symfony\Flex\Unpack\Result;
27 |
28 | class Unpacker
29 | {
30 | private $composer;
31 | private $resolver;
32 | private $versionParser;
33 |
34 | public function __construct(Composer $composer, PackageResolver $resolver)
35 | {
36 | $this->composer = $composer;
37 | $this->resolver = $resolver;
38 | $this->versionParser = new VersionParser();
39 | }
40 |
41 | public function unpack(Operation $op, ?Result $result = null, &$links = [], bool $devRequire = false): Result
42 | {
43 | if (null === $result) {
44 | $result = new Result();
45 | }
46 |
47 | $localRepo = $this->composer->getRepositoryManager()->getLocalRepository();
48 | foreach ($op->getPackages() as $package) {
49 | $pkg = $localRepo->findPackage($package['name'], '*');
50 | $pkg = $pkg ?? $this->composer->getRepositoryManager()->findPackage($package['name'], $package['version'] ?: '*');
51 |
52 | // not unpackable or no --unpack flag or empty packs (markers)
53 | if (
54 | null === $pkg
55 | || 'symfony-pack' !== $pkg->getType()
56 | || !$op->shouldUnpack()
57 | || 0 === \count($pkg->getRequires()) + \count($pkg->getDevRequires())
58 | ) {
59 | $result->addRequired($package['name'].($package['version'] ? ':'.$package['version'] : ''));
60 |
61 | continue;
62 | }
63 |
64 | if (!$result->addUnpacked($pkg)) {
65 | continue;
66 | }
67 |
68 | $requires = [];
69 | foreach ($pkg->getRequires() as $link) {
70 | $requires[$link->getTarget()] = $link;
71 | }
72 | $devRequires = $pkg->getDevRequires();
73 |
74 | foreach ($devRequires as $i => $link) {
75 | if (!isset($requires[$link->getTarget()])) {
76 | throw new \RuntimeException(\sprintf('Symfony pack "%s" must duplicate all entries from "require-dev" into "require" but entry "%s" was not found.', $package['name'], $link->getTarget()));
77 | }
78 | $devRequires[$i] = $requires[$link->getTarget()];
79 | unset($requires[$link->getTarget()]);
80 | }
81 |
82 | $versionSelector = null;
83 | foreach ([$requires, $devRequires] as $dev => $requires) {
84 | $dev = $dev ?: $devRequire ?: $package['dev'];
85 |
86 | foreach ($requires as $link) {
87 | if ('php' === $linkName = $link->getTarget()) {
88 | continue;
89 | }
90 |
91 | $constraint = $link->getPrettyConstraint();
92 | $constraint = substr($this->resolver->parseVersion($linkName, $constraint, true), 1) ?: $constraint;
93 |
94 | if ($subPkg = $localRepo->findPackage($linkName, '*')) {
95 | if ('symfony-pack' === $subPkg->getType()) {
96 | $subOp = new Operation(true, $op->shouldSort());
97 | $subOp->addPackage($subPkg->getName(), $constraint, $dev);
98 | $result = $this->unpack($subOp, $result, $links, $dev);
99 | continue;
100 | }
101 |
102 | if ('*' === $constraint) {
103 | if (null === $versionSelector) {
104 | $pool = new RepositorySet($this->composer->getPackage()->getMinimumStability(), $this->composer->getPackage()->getStabilityFlags());
105 | $pool->addRepository(new CompositeRepository($this->composer->getRepositoryManager()->getRepositories()));
106 | $versionSelector = new VersionSelector($pool);
107 | }
108 |
109 | $constraint = $versionSelector->findRecommendedRequireVersion($subPkg);
110 | }
111 | }
112 |
113 | $linkType = $dev ? 'require-dev' : 'require';
114 | $constraint = $this->versionParser->parseConstraints($constraint);
115 |
116 | if (isset($links[$linkName])) {
117 | $links[$linkName]['constraints'][] = $constraint;
118 | if ('require' === $linkType) {
119 | $links[$linkName]['type'] = 'require';
120 | }
121 | } else {
122 | $links[$linkName] = [
123 | 'type' => $linkType,
124 | 'name' => $linkName,
125 | 'constraints' => [$constraint],
126 | ];
127 | }
128 | }
129 | }
130 | }
131 |
132 | if (1 < \func_num_args()) {
133 | return $result;
134 | }
135 |
136 | $jsonPath = Factory::getComposerFile();
137 | $jsonContent = file_get_contents($jsonPath);
138 | $jsonStored = json_decode($jsonContent, true);
139 | $jsonManipulator = new JsonManipulator($jsonContent);
140 |
141 | foreach ($result->getUnpacked() as $pkg) {
142 | $localRepo->removePackage($pkg);
143 | $localRepo->setDevPackageNames(array_diff($localRepo->getDevPackageNames(), [$pkg->getName()]));
144 | $jsonManipulator->removeSubNode('require', $pkg->getName());
145 | $jsonManipulator->removeSubNode('require-dev', $pkg->getName());
146 | }
147 |
148 | foreach ($links as $link) {
149 | // nothing to do, package is already present in the "require" section
150 | if (isset($jsonStored['require'][$link['name']])) {
151 | continue;
152 | }
153 |
154 | if (isset($jsonStored['require-dev'][$link['name']])) {
155 | // nothing to do, package is already present in the "require-dev" section
156 | if ('require-dev' === $link['type']) {
157 | continue;
158 | }
159 |
160 | // removes package from "require-dev", because it will be moved to "require"
161 | // save stored constraint
162 | $link['constraints'][] = $this->versionParser->parseConstraints($jsonStored['require-dev'][$link['name']]);
163 | $jsonManipulator->removeSubNode('require-dev', $link['name']);
164 | }
165 |
166 | $constraint = end($link['constraints']);
167 |
168 | if (!$jsonManipulator->addLink($link['type'], $link['name'], $constraint->getPrettyString(), $op->shouldSort())) {
169 | throw new \RuntimeException(\sprintf('Unable to unpack package "%s".', $link['name']));
170 | }
171 | }
172 |
173 | file_put_contents($jsonPath, $jsonManipulator->getContents());
174 |
175 | return $result;
176 | }
177 |
178 | public function updateLock(Result $result, IOInterface $io): void
179 | {
180 | $json = new JsonFile(Factory::getComposerFile());
181 | $manipulator = new JsonConfigSource($json);
182 | $locker = $this->composer->getLocker();
183 | $lockData = $locker->getLockData();
184 |
185 | foreach ($result->getUnpacked() as $package) {
186 | $manipulator->removeLink('require-dev', $package->getName());
187 | foreach ($lockData['packages-dev'] as $i => $pkg) {
188 | if ($package->getName() === $pkg['name']) {
189 | unset($lockData['packages-dev'][$i]);
190 | }
191 | }
192 | $manipulator->removeLink('require', $package->getName());
193 | foreach ($lockData['packages'] as $i => $pkg) {
194 | if ($package->getName() === $pkg['name']) {
195 | unset($lockData['packages'][$i]);
196 | }
197 | }
198 | }
199 | $jsonContent = file_get_contents($json->getPath());
200 | $lockData['packages'] = array_values($lockData['packages']);
201 | $lockData['packages-dev'] = array_values($lockData['packages-dev']);
202 | $lockData['content-hash'] = Locker::getContentHash($jsonContent);
203 | $lockFile = new JsonFile(substr($json->getPath(), 0, -4).'lock', null, $io);
204 |
205 | $lockFile->write($lockData);
206 |
207 | $locker = new Locker($io, $lockFile, $this->composer->getInstallationManager(), $jsonContent);
208 | $this->composer->setLocker($locker);
209 |
210 | $localRepo = $this->composer->getRepositoryManager()->getLocalRepository();
211 | $localRepo->write($localRepo->getDevMode() ?? true, $this->composer->getInstallationManager());
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/src/Update/DiffHelper.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex\Update;
13 |
14 | class DiffHelper
15 | {
16 | public static function removeFilesFromPatch(string $patch, array $files, array &$removedPatches): string
17 | {
18 | foreach ($files as $filename) {
19 | $start = strpos($patch, \sprintf('diff --git a/%s b/%s', $filename, $filename));
20 | if (false === $start) {
21 | throw new \LogicException(\sprintf('Could not find file "%s" in the patch.', $filename));
22 | }
23 |
24 | $end = strpos($patch, 'diff --git a/', $start + 1);
25 | $contentBefore = substr($patch, 0, $start);
26 | if (false === $end) {
27 | // last patch in the file
28 | $removedPatches[$filename] = rtrim(substr($patch, $start), "\n");
29 | $patch = rtrim($contentBefore, "\n");
30 |
31 | continue;
32 | }
33 |
34 | $removedPatches[$filename] = rtrim(substr($patch, $start, $end - $start), "\n");
35 | $patch = $contentBefore.substr($patch, $end);
36 | }
37 |
38 | // valid patches end with a blank line
39 | if ($patch && "\n" !== substr($patch, \strlen($patch) - 1, 1)) {
40 | $patch .= "\n";
41 | }
42 |
43 | return $patch;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Update/RecipePatch.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex\Update;
13 |
14 | class RecipePatch
15 | {
16 | private $patch;
17 | private $blobs;
18 | private $deletedFiles;
19 | private $removedPatches;
20 |
21 | public function __construct(string $patch, array $blobs, array $deletedFiles, array $removedPatches = [])
22 | {
23 | $this->patch = $patch;
24 | $this->blobs = $blobs;
25 | $this->deletedFiles = $deletedFiles;
26 | $this->removedPatches = $removedPatches;
27 | }
28 |
29 | public function getPatch(): string
30 | {
31 | return $this->patch;
32 | }
33 |
34 | public function getBlobs(): array
35 | {
36 | return $this->blobs;
37 | }
38 |
39 | public function getDeletedFiles(): array
40 | {
41 | return $this->deletedFiles;
42 | }
43 |
44 | /**
45 | * Patches for modified files that were removed because the file
46 | * has been deleted in the user's project.
47 | */
48 | public function getRemovedPatches(): array
49 | {
50 | return $this->removedPatches;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Update/RecipePatcher.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex\Update;
13 |
14 | use Composer\IO\IOInterface;
15 | use Composer\Util\ProcessExecutor;
16 | use Symfony\Component\Filesystem\Exception\IOException;
17 | use Symfony\Component\Filesystem\Filesystem;
18 |
19 | class RecipePatcher
20 | {
21 | private $rootDir;
22 | private $filesystem;
23 | private $io;
24 | private $processExecutor;
25 |
26 | public function __construct(string $rootDir, IOInterface $io)
27 | {
28 | $this->rootDir = $rootDir;
29 | $this->filesystem = new Filesystem();
30 | $this->io = $io;
31 | $this->processExecutor = new ProcessExecutor($io);
32 | }
33 |
34 | /**
35 | * Applies the patch. If it fails unexpectedly, an exception will be thrown.
36 | *
37 | * @return bool returns true if fully successful, false if conflicts were encountered
38 | */
39 | public function applyPatch(RecipePatch $patch): bool
40 | {
41 | $withConflicts = $this->_applyPatchFile($patch);
42 |
43 | foreach ($patch->getDeletedFiles() as $deletedFile) {
44 | if (file_exists($this->rootDir.'/'.$deletedFile)) {
45 | $this->execute(\sprintf('git rm %s', ProcessExecutor::escape($deletedFile)), $this->rootDir);
46 | }
47 | }
48 |
49 | return $withConflicts;
50 | }
51 |
52 | public function generatePatch(array $originalFiles, array $newFiles): RecipePatch
53 | {
54 | $ignoredFiles = $this->getIgnoredFiles(array_keys($originalFiles) + array_keys($newFiles));
55 |
56 | // null implies "file does not exist"
57 | $originalFiles = array_filter($originalFiles, function ($file, $fileName) use ($ignoredFiles) {
58 | return null !== $file && !\in_array($fileName, $ignoredFiles);
59 | }, \ARRAY_FILTER_USE_BOTH);
60 |
61 | $newFiles = array_filter($newFiles, function ($file, $fileName) use ($ignoredFiles) {
62 | return null !== $file && !\in_array($fileName, $ignoredFiles);
63 | }, \ARRAY_FILTER_USE_BOTH);
64 |
65 | $deletedFiles = [];
66 | // find removed files & record that they are deleted
67 | // unset them from originalFiles to avoid unnecessary blobs being added
68 | foreach ($originalFiles as $file => $contents) {
69 | if (!isset($newFiles[$file])) {
70 | $deletedFiles[] = $file;
71 | unset($originalFiles[$file]);
72 | }
73 | }
74 |
75 | // If a file is being modified, but does not exist in the current project,
76 | // it cannot be patched. We generate the diff for these, but then remove
77 | // it from the patch (and optionally report this diff to the user).
78 | $modifiedFiles = array_intersect_key(array_keys($originalFiles), array_keys($newFiles));
79 | $deletedModifiedFiles = [];
80 | foreach ($modifiedFiles as $modifiedFile) {
81 | if (!file_exists($this->rootDir.'/'.$modifiedFile) && $originalFiles[$modifiedFile] !== $newFiles[$modifiedFile]) {
82 | $deletedModifiedFiles[] = $modifiedFile;
83 | }
84 | }
85 |
86 | // Use git binary to get project path from repository root
87 | $prefix = trim($this->execute('git rev-parse --show-prefix', $this->rootDir));
88 | $tmpPath = sys_get_temp_dir().'/_flex_recipe_update'.uniqid(mt_rand(), true);
89 | $this->filesystem->mkdir($tmpPath);
90 |
91 | try {
92 | $this->execute('git init', $tmpPath);
93 | $this->execute('git config commit.gpgsign false', $tmpPath);
94 | $this->execute('git config user.name "Flex Updater"', $tmpPath);
95 | $this->execute('git config user.email ""', $tmpPath);
96 |
97 | $blobs = [];
98 | if (\count($originalFiles) > 0) {
99 | $this->writeFiles($originalFiles, $tmpPath);
100 | $this->execute('git add -A', $tmpPath);
101 | $this->execute('git commit -n -m "original files"', $tmpPath);
102 |
103 | $blobs = $this->generateBlobs($originalFiles, $tmpPath);
104 | }
105 |
106 | $this->writeFiles($newFiles, $tmpPath);
107 | $this->execute('git add -A', $tmpPath);
108 |
109 | $patchString = $this->execute(\sprintf('git diff --cached --src-prefix "a/%s" --dst-prefix "b/%s"', $prefix, $prefix), $tmpPath);
110 | $removedPatches = [];
111 | $patchString = DiffHelper::removeFilesFromPatch($patchString, $deletedModifiedFiles, $removedPatches);
112 |
113 | return new RecipePatch(
114 | $patchString,
115 | $blobs,
116 | $deletedFiles,
117 | $removedPatches
118 | );
119 | } finally {
120 | try {
121 | $this->filesystem->remove($tmpPath);
122 | } catch (IOException $e) {
123 | // this can sometimes fail due to git file permissions
124 | // if that happens, just leave it: we're in the temp directory anyways
125 | }
126 | }
127 | }
128 |
129 | private function writeFiles(array $files, string $directory): void
130 | {
131 | foreach ($files as $filename => $contents) {
132 | $path = $directory.'/'.$filename;
133 | if (null === $contents) {
134 | if (file_exists($path)) {
135 | unlink($path);
136 | }
137 |
138 | continue;
139 | }
140 |
141 | if (!file_exists(\dirname($path))) {
142 | $this->filesystem->mkdir(\dirname($path));
143 | }
144 | file_put_contents($path, $contents);
145 | }
146 | }
147 |
148 | private function execute(string $command, string $cwd): string
149 | {
150 | $output = '';
151 | $statusCode = $this->processExecutor->execute($command, $output, $cwd);
152 |
153 | if (0 !== $statusCode) {
154 | throw new \LogicException(\sprintf('Command "%s" failed: "%s". Output: "%s".', $command, $this->processExecutor->getErrorOutput(), $output));
155 | }
156 |
157 | return $output;
158 | }
159 |
160 | /**
161 | * Adds git blobs for each original file.
162 | *
163 | * For patching to work, each original file & contents needs to be
164 | * available to git as a blob. This is because the patch contains
165 | * the ref to the original blob, and git uses that to find the
166 | * original file (which is needed for the 3-way merge).
167 | */
168 | private function addMissingBlobs(array $blobs): array
169 | {
170 | $addedBlobs = [];
171 | foreach ($blobs as $hash => $contents) {
172 | $blobPath = $this->getBlobPath($this->rootDir, $hash);
173 | if (file_exists($blobPath)) {
174 | continue;
175 | }
176 |
177 | $addedBlobs[] = $blobPath;
178 | if (!file_exists(\dirname($blobPath))) {
179 | $this->filesystem->mkdir(\dirname($blobPath));
180 | }
181 | file_put_contents($blobPath, $contents);
182 | }
183 |
184 | return $addedBlobs;
185 | }
186 |
187 | private function generateBlobs(array $originalFiles, string $originalFilesRoot): array
188 | {
189 | $addedBlobs = [];
190 | foreach ($originalFiles as $filename => $contents) {
191 | // if the file didn't originally exist, no blob needed
192 | if (!file_exists($originalFilesRoot.'/'.$filename)) {
193 | continue;
194 | }
195 |
196 | $hash = trim($this->execute('git hash-object '.ProcessExecutor::escape($filename), $originalFilesRoot));
197 | $addedBlobs[$hash] = file_get_contents($this->getBlobPath($originalFilesRoot, $hash));
198 | }
199 |
200 | return $addedBlobs;
201 | }
202 |
203 | private function getBlobPath(string $gitRoot, string $hash): string
204 | {
205 | $gitDir = trim($this->execute('git rev-parse --absolute-git-dir', $gitRoot));
206 |
207 | $hashStart = substr($hash, 0, 2);
208 | $hashEnd = substr($hash, 2);
209 |
210 | return $gitDir.'/objects/'.$hashStart.'/'.$hashEnd;
211 | }
212 |
213 | private function _applyPatchFile(RecipePatch $patch)
214 | {
215 | if (!$patch->getPatch()) {
216 | // nothing to do!
217 | return true;
218 | }
219 |
220 | $addedBlobs = $this->addMissingBlobs($patch->getBlobs());
221 |
222 | $patchPath = $this->rootDir.'/_flex_recipe_update.patch';
223 | file_put_contents($patchPath, $patch->getPatch());
224 |
225 | try {
226 | $this->execute('git update-index --refresh', $this->rootDir);
227 |
228 | $output = '';
229 | $statusCode = $this->processExecutor->execute('git apply "_flex_recipe_update.patch" -3', $output, $this->rootDir);
230 |
231 | if (0 === $statusCode) {
232 | // successful with no conflicts
233 | return true;
234 | }
235 |
236 | if (str_contains($this->processExecutor->getErrorOutput(), 'with conflicts')) {
237 | // successful with conflicts
238 | return false;
239 | }
240 |
241 | throw new \LogicException('Error applying the patch: '.$this->processExecutor->getErrorOutput());
242 | } finally {
243 | unlink($patchPath);
244 | // clean up any temporary blobs
245 | foreach ($addedBlobs as $filename) {
246 | unlink($filename);
247 | }
248 | }
249 | }
250 |
251 | private function getIgnoredFiles(array $fileNames): array
252 | {
253 | $args = implode(' ', array_map([ProcessExecutor::class, 'escape'], $fileNames));
254 | $output = '';
255 | $this->processExecutor->execute(\sprintf('git check-ignore %s', $args), $output, $this->rootDir);
256 |
257 | return $this->processExecutor->splitLines($output);
258 | }
259 | }
260 |
--------------------------------------------------------------------------------
/src/Update/RecipeUpdate.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Flex\Update;
13 |
14 | use Symfony\Flex\Lock;
15 | use Symfony\Flex\Recipe;
16 |
17 | class RecipeUpdate
18 | {
19 | private $originalRecipe;
20 | private $newRecipe;
21 | private $lock;
22 | private $rootDir;
23 |
24 | /** @var string[] */
25 | private $originalRecipeFiles = [];
26 | /** @var string[] */
27 | private $newRecipeFiles = [];
28 | private $copyFromPackagePaths = [];
29 |
30 | public function __construct(Recipe $originalRecipe, Recipe $newRecipe, Lock $lock, string $rootDir)
31 | {
32 | $this->originalRecipe = $originalRecipe;
33 | $this->newRecipe = $newRecipe;
34 | $this->lock = $lock;
35 | $this->rootDir = $rootDir;
36 | }
37 |
38 | public function getOriginalRecipe(): Recipe
39 | {
40 | return $this->originalRecipe;
41 | }
42 |
43 | public function getNewRecipe(): Recipe
44 | {
45 | return $this->newRecipe;
46 | }
47 |
48 | public function getLock(): Lock
49 | {
50 | return $this->lock;
51 | }
52 |
53 | public function getRootDir(): string
54 | {
55 | return $this->rootDir;
56 | }
57 |
58 | public function getPackageName(): string
59 | {
60 | return $this->originalRecipe->getName();
61 | }
62 |
63 | public function setOriginalFile(string $filename, ?string $contents): void
64 | {
65 | $this->originalRecipeFiles[$filename] = $contents;
66 | }
67 |
68 | public function setNewFile(string $filename, ?string $contents): void
69 | {
70 | $this->newRecipeFiles[$filename] = $contents;
71 | }
72 |
73 | public function addOriginalFiles(array $files)
74 | {
75 | foreach ($files as $file => $contents) {
76 | if (null === $contents) {
77 | continue;
78 | }
79 |
80 | $this->setOriginalFile($file, $contents);
81 | }
82 | }
83 |
84 | public function addNewFiles(array $files)
85 | {
86 | foreach ($files as $file => $contents) {
87 | if (null === $contents) {
88 | continue;
89 | }
90 |
91 | $this->setNewFile($file, $contents);
92 | }
93 | }
94 |
95 | public function getOriginalFiles(): array
96 | {
97 | return $this->originalRecipeFiles;
98 | }
99 |
100 | public function getNewFiles(): array
101 | {
102 | return $this->newRecipeFiles;
103 | }
104 |
105 | public function getCopyFromPackagePaths(): array
106 | {
107 | return $this->copyFromPackagePaths;
108 | }
109 |
110 | public function addCopyFromPackagePath(string $source, string $target)
111 | {
112 | $this->copyFromPackagePaths[$source] = $target;
113 | }
114 | }
115 |
--------------------------------------------------------------------------------