diff --git a/.travis.yml b/.travis.yml index eb1c9a5de..a897f3cee 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,5 +9,7 @@ php: before_script: - echo '' > ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini - composer install --dev --prefer-source + - git config --global user.name travis-ci + - git config --global user.email travis@example.com script: ./vendor/bin/phpunit -c tests/complete.phpunit.xml diff --git a/doc/04-schema.md b/doc/04-schema.md index a222c06e0..f7de1a3df 100644 --- a/doc/04-schema.md +++ b/doc/04-schema.md @@ -656,4 +656,29 @@ See [Vendor Binaries](articles/vendor-binaries.md) for more details. Optional. +### archive + +A set of options for creating package archives. + +The following options are supported: + +* **exclude:** Allows configuring a list of patterns for excluded paths. The + pattern syntax matches .gitignore files. A leading exclamation mark (!) will + result in any matching files to be included even if a previous pattern + excluded them. A leading slash will only match at the beginning of the project + relative path. An asterisk will not expand to a directory separator. + +Example: + + { + "archive": { + "exclude": ["/foo/bar", "baz", "/*.test", "!/foo/bar/baz"] + } + } + +The example will include `/dir/foo/bar/file`, `/foo/bar/baz`, `/file.php`, +`/foo/my.test` but it will exclude `/foo/bar/any`, `/foo/baz`, and `/my.test`. + +Optional. + ← [Command-line interface](03-cli.md) | [Repositories](05-repositories.md) → diff --git a/res/composer-schema.json b/res/composer-schema.json index aefaa0463..5ae75c922 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -202,6 +202,16 @@ } } }, + "archive": { + "type": ["object"], + "description": "Options for creating package archives for distribution.", + "properties": { + "exclude": { + "type": "array", + "description": "A list of patterns for paths to exclude or include if prefixed with an exclamation mark." + } + } + }, "repositories": { "type": ["object", "array"], "description": "A set of additional repositories where packages can be found.", diff --git a/src/Composer/Command/ArchiveCommand.php b/src/Composer/Command/ArchiveCommand.php new file mode 100644 index 000000000..61a5d8e48 --- /dev/null +++ b/src/Composer/Command/ArchiveCommand.php @@ -0,0 +1,123 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Command; + +use Composer\Factory; +use Composer\IO\IOInterface; +use Composer\DependencyResolver\Pool; +use Composer\Package\LinkConstraint\VersionConstraint; +use Composer\Repository\CompositeRepository; + +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Creates an archive of a package for distribution. + * + * @author Nils Adermann + */ +class ArchiveCommand extends Command +{ + protected function configure() + { + $this + ->setName('archive') + ->setDescription('Create an archive of this composer package') + ->setDefinition(array( + new InputArgument('package', InputArgument::OPTIONAL, 'The package to archive instead of the current project'), + new InputArgument('version', InputArgument::OPTIONAL, 'The package version to archive'), + new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the resulting archive: tar or zip', 'tar'), + new InputOption('dir', false, InputOption::VALUE_REQUIRED, 'Write the archive to this directory', '.'), + )) + ->setHelp(<<archive command creates an archive of the specified format +containing the files and directories of the Composer project or the specified +package in the specified version and writes it to the specified directory. + +php composer.phar archive [--format=zip] [--dir=/foo] [package [version]] + +EOT + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + return $this->archive( + $this->getIO(), + $input->getArgument('package'), + $input->getArgument('version'), + $input->getOption('format'), + $input->getOption('dir') + ); + } + + protected function archive(IOInterface $io, $packageName = null, $version = null, $format = 'tar', $dest = '.') + { + $config = Factory::createConfig(); + $factory = new Factory; + $archiveManager = $factory->createArchiveManager($config); + + if ($packageName) { + $package = $this->selectPackage($io, $packageName, $version); + + if (!$package) { + return 1; + } + } else { + $package = $this->getComposer()->getPackage(); + } + + $io->write('Creating the archive.'); + $archiveManager->archive($package, $format, $dest); + + return 0; + } + + protected function selectPackage(IOInterface $io, $packageName, $version = null) + { + $io->write('Searching for the specified package.'); + + if ($composer = $this->getComposer(false)) { + $localRepo = $composer->getRepositoryManager()->getLocalRepository(); + $repos = new CompositeRepository(array_merge(array($localRepo), $composer->getRepositoryManager()->getRepositories())); + } else { + $defaultRepos = Factory::createDefaultRepositories($this->getIO()); + $output->writeln('No composer.json found in the current directory, searching packages from ' . implode(', ', array_keys($defaultRepos))); + $repos = new CompositeRepository($defaultRepos); + } + + $pool = new Pool(); + $pool->addRepository($repos); + + $constraint = ($version) ? new VersionConstraint('>=', $version) : null; + $packages = $pool->whatProvides($packageName, $constraint); + + if (count($packages) > 1) { + $package = $packages[0]; + $io->write('Found multiple matches, selected '.$package->getPrettyString().'.'); + $io->write('Alternatives were '.implode(', ', array_map(function ($p) { return $p->getPrettyString(); }, $packages)).'.'); + $io->write('Please use a more specific constraint to pick a different package.'); + } elseif ($packages) { + $package = $packages[0]; + $io->write('Found an exact match '.$package->getPrettyString().'.'); + } else { + $io->write('Could not find a package matching '.$packageName.'.'); + return false; + } + + return $package; + } +} diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index 55d078cee..40dfd9eb9 100755 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -194,6 +194,7 @@ class Application extends BaseApplication $commands[] = new Command\RequireCommand(); $commands[] = new Command\DumpAutoloadCommand(); $commands[] = new Command\StatusCommand(); + $commands[] = new Command\ArchiveCommand(); if ('phar:' === substr(__FILE__, 0, 5)) { $commands[] = new Command\SelfUpdateCommand(); diff --git a/src/Composer/Factory.php b/src/Composer/Factory.php index e9b7fbe55..9234f46f8 100644 --- a/src/Composer/Factory.php +++ b/src/Composer/Factory.php @@ -15,6 +15,7 @@ namespace Composer; use Composer\Config\JsonConfigSource; use Composer\Json\JsonFile; use Composer\IO\IOInterface; +use Composer\Package\Archiver; use Composer\Repository\ComposerRepository; use Composer\Repository\RepositoryManager; use Composer\Util\ProcessExecutor; @@ -317,6 +318,24 @@ class Factory return $dm; } + /** + * @param Config $config The configuration + * @param Downloader\DownloadManager $dm Manager use to download sources + * + * @return Archiver\ArchiveManager + */ + public function createArchiveManager(Config $config, Downloader\DownloadManager $dm = null) + { + if (null === $dm) { + $dm = $this->createDownloadManager(new IO\NullIO(), $config); + } + + $am = new Archiver\ArchiveManager($dm); + $am->addArchiver(new Archiver\PharArchiver); + + return $am; + } + /** * @return Installer\InstallationManager */ diff --git a/src/Composer/Package/AliasPackage.php b/src/Composer/Package/AliasPackage.php index e2f748092..7f16aaac9 100644 --- a/src/Composer/Package/AliasPackage.php +++ b/src/Composer/Package/AliasPackage.php @@ -311,6 +311,10 @@ class AliasPackage extends BasePackage implements CompletePackageInterface { return $this->aliasOf->getNotificationUrl(); } + public function getArchiveExcludes() + { + return $this->aliasOf->getArchiveExcludes(); + } public function __toString() { return parent::__toString().' (alias of '.$this->aliasOf->getVersion().')'; diff --git a/src/Composer/Package/Archiver/ArchivableFilesFinder.php b/src/Composer/Package/Archiver/ArchivableFilesFinder.php new file mode 100644 index 000000000..616b9540e --- /dev/null +++ b/src/Composer/Package/Archiver/ArchivableFilesFinder.php @@ -0,0 +1,77 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Archiver; + +use Composer\Package\BasePackage; +use Composer\Package\PackageInterface; + +use Symfony\Component\Finder; + +/** + * A Symfony Finder wrapper which locates files that should go into archives + * + * Handles .gitignore, .gitattributes and .hgignore files as well as composer's + * own exclude rules from composer.json + * + * @author Nils Adermann + */ +class ArchivableFilesFinder extends \FilterIterator +{ + /** + * @var Symfony\Component\Finder\Finder + */ + protected $finder; + + /** + * Initializes the internal Symfony Finder with appropriate filters + * + * @param string $sources Path to source files to be archived + * @param array $excludes Composer's own exclude rules from composer.json + */ + public function __construct($sources, array $excludes) + { + $sources = realpath($sources); + + $filters = array( + new HgExcludeFilter($sources), + new GitExcludeFilter($sources), + new ComposerExcludeFilter($sources, $excludes), + ); + + $this->finder = new Finder\Finder(); + $this->finder + ->in($sources) + ->filter(function (\SplFileInfo $file) use ($sources, $filters) { + $relativePath = preg_replace( + '#^'.preg_quote($sources, '#').'#', + '', + str_replace(DIRECTORY_SEPARATOR, '/', $file->getRealPath()) + ); + + $exclude = false; + foreach ($filters as $filter) { + $exclude = $filter->filter($relativePath, $exclude); + } + return !$exclude; + }) + ->ignoreVCS(true) + ->ignoreDotFiles(false); + + parent::__construct($this->finder->getIterator()); + } + + public function accept() + { + return !$this->getInnerIterator()->current()->isDir(); + } +} diff --git a/src/Composer/Package/Archiver/ArchiveManager.php b/src/Composer/Package/Archiver/ArchiveManager.php new file mode 100644 index 000000000..e896199ac --- /dev/null +++ b/src/Composer/Package/Archiver/ArchiveManager.php @@ -0,0 +1,147 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Archiver; + +use Composer\Downloader\DownloadManager; +use Composer\Factory; +use Composer\IO\NullIO; +use Composer\Package\PackageInterface; +use Composer\Package\RootPackage; +use Composer\Util\Filesystem; + +/** + * @author Matthieu Moquet + * @author Till Klampaeckel + */ +class ArchiveManager +{ + protected $downloadManager; + + protected $archivers = array(); + + /** + * @var bool + */ + protected $overwriteFiles = true; + + /** + * @param DownloadManager $downloadManager A manager used to download package sources + */ + public function __construct(DownloadManager $downloadManager) + { + $this->downloadManager = $downloadManager; + } + + /** + * @param ArchiverInterface $archiver + */ + public function addArchiver(ArchiverInterface $archiver) + { + $this->archivers[] = $archiver; + } + + /** + * Set whether existing archives should be overwritten + * + * @param bool $overwriteFiles New setting + * + * @return $this + */ + public function setOverwriteFiles($overwriteFiles) + { + $this->overwriteFiles = $overwriteFiles; + return $this; + } + + /** + * Generate a distinct filename for a particular version of a package. + * + * @param PackageInterface $package The package to get a name for + * + * @return string A filename without an extension + */ + public function getPackageFilename(PackageInterface $package) + { + $nameParts = array(preg_replace('#[^a-z0-9-_.]#i', '-', $package->getName())); + + if (preg_match('{^[a-f0-9]{40}$}', $package->getDistReference())) { + $nameParts = array_merge($nameParts, array($package->getDistReference(), $package->getDistType())); + } else { + $nameParts = array_merge($nameParts, array($package->getPrettyVersion(), $package->getDistReference())); + } + + if ($package->getSourceReference()) { + $nameParts[] = substr(sha1($package->getSourceReference()), 0, 6); + } + + return implode('-', array_filter($nameParts, function ($p) { + return !empty($p); + })); + } + + /** + * Create an archive of the specified package. + * + * @param PackageInterface $package The package to archive + * @param string $format The format of the archive (zip, tar, ...) + * @param string $targetDir The diretory where to build the archive + * + * @return string The path of the created archive + */ + public function archive(PackageInterface $package, $format, $targetDir) + { + if (empty($format)) { + throw new \InvalidArgumentException('Format must be specified'); + } + + // Search for the most appropriate archiver + $usableArchiver = null; + foreach ($this->archivers as $archiver) { + if ($archiver->supports($format, $package->getSourceType())) { + $usableArchiver = $archiver; + break; + } + } + + // Checks the format/source type are supported before downloading the package + if (null === $usableArchiver) { + throw new \RuntimeException(sprintf('No archiver found to support %s format', $format)); + } + + $filesystem = new Filesystem(); + $packageName = $this->getPackageFilename($package); + + // Archive filename + $filesystem->ensureDirectoryExists($targetDir); + $target = realpath($targetDir).'/'.$packageName.'.'.$format; + $filesystem->ensureDirectoryExists(dirname($target)); + + if (!$this->overwriteFiles && file_exists($target)) { + return $target; + } + + if ($package instanceof RootPackage) { + $sourcePath = realpath('.'); + } else { + // Directory used to download the sources + $sourcePath = sys_get_temp_dir().'/composer_archiver/'.$packageName; + $filesystem->ensureDirectoryExists($sourcePath); + + // Download sources + $this->downloadManager->download($package, $sourcePath, true); + } + + // Create the archive + return $usableArchiver->archive($sourcePath, $target, $format, $package->getArchiveExcludes()); + } +} diff --git a/src/Composer/Package/Archiver/ArchiverInterface.php b/src/Composer/Package/Archiver/ArchiverInterface.php new file mode 100644 index 000000000..5858c6892 --- /dev/null +++ b/src/Composer/Package/Archiver/ArchiverInterface.php @@ -0,0 +1,45 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Archiver; + +use Composer\Package\PackageInterface; + +/** + * @author Till Klampaeckel + * @author Matthieu Moquet + * @author Nils Adermann + */ +interface ArchiverInterface +{ + /** + * Create an archive from the sources. + * + * @param string $sources The sources directory + * @param string $target The target file + * @param string $format The format used for archive + * @param array $excludes A list of patterns for files to exclude + * + * @return string The path to the written archive file + */ + public function archive($sources, $target, $format, array $excludes = array()); + + /** + * Format supported by the archiver. + * + * @param string $format The archive format + * @param string $sourceType The source type (git, svn, hg, etc.) + * + * @return boolean true if the format is supported by the archiver + */ + public function supports($format, $sourceType); +} diff --git a/src/Composer/Package/Archiver/BaseExcludeFilter.php b/src/Composer/Package/Archiver/BaseExcludeFilter.php new file mode 100644 index 000000000..90ea53bf8 --- /dev/null +++ b/src/Composer/Package/Archiver/BaseExcludeFilter.php @@ -0,0 +1,141 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Archiver; + +use Symfony\Component\Finder; + +/** + * @author Nils Adermann + */ +abstract class BaseExcludeFilter +{ + /** + * @var string + */ + protected $sourcePath; + + /** + * @var array + */ + protected $excludePatterns; + + /** + * @param string $sourcePath Directory containing sources to be filtered + */ + public function __construct($sourcePath) + { + $this->sourcePath = $sourcePath; + $this->excludePatterns = array(); + } + + /** + * Checks the given path against all exclude patterns in this filter + * + * Negated patterns overwrite exclude decisions of previous filters. + * + * @param string $relativePath The file's path relative to the sourcePath + * @param bool $exclude Whether a previous filter wants to exclude this file + * + * @return bool Whether the file should be excluded + */ + public function filter($relativePath, $exclude) + { + foreach ($this->excludePatterns as $patternData) { + list($pattern, $negate, $stripLeadingSlash) = $patternData; + + if ($stripLeadingSlash) { + $path = substr($relativePath, 1); + } else { + $path = $relativePath; + } + + if (preg_match($pattern, $path)) { + $exclude = !$negate; + } + } + return $exclude; + } + + /** + * Processes a file containing exclude rules of different formats per line + * + * @param array $lines A set of lines to be parsed + * @param callback $lineParser The parser to be used on each line + * + * @return array Exclude patterns to be used in filter() + */ + protected function parseLines(array $lines, $lineParser) + { + return array_filter( + array_map( + function ($line) use ($lineParser) { + $line = trim($line); + + $commentHash = strpos($line, '#'); + if ($commentHash !== false) { + $line = substr($line, 0, $commentHash); + } + + if ($line) { + return call_user_func($lineParser, $line); + } + return null; + }, $lines), + function ($pattern) { + return $pattern !== null; + } + ); + } + + /** + * Generates a set of exclude patterns for filter() from gitignore rules + * + * @param array $rules A list of exclude rules in gitignore syntax + * + * @return array Exclude patterns + */ + protected function generatePatterns($rules) + { + $patterns = array(); + foreach ($rules as $rule) { + $patterns[] = $this->generatePattern($rule); + } + return $patterns; + } + + /** + * Generates an exclude pattern for filter() from a gitignore rule + * + * @param string An exclude rule in gitignore syntax + * + * @param array An exclude pattern + */ + protected function generatePattern($rule) + { + $negate = false; + $pattern = '#'; + + if (strlen($rule) && $rule[0] === '!') { + $negate = true; + $rule = substr($rule, 1); + } + + if (strlen($rule) && $rule[0] === '/') { + $pattern .= '^/'; + $rule = substr($rule, 1); + } + + $pattern .= substr(Finder\Glob::toRegex($rule), 2, -2); + return array($pattern . '#', $negate, false); + } +} diff --git a/src/Composer/Package/Archiver/ComposerExcludeFilter.php b/src/Composer/Package/Archiver/ComposerExcludeFilter.php new file mode 100644 index 000000000..c98a3ae66 --- /dev/null +++ b/src/Composer/Package/Archiver/ComposerExcludeFilter.php @@ -0,0 +1,31 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Archiver; + +/** + * An exclude filter which processes composer's own exclude rules + * + * @author Nils Adermann + */ +class ComposerExcludeFilter extends BaseExcludeFilter +{ + /** + * @param string $sourcePath Directory containing sources to be filtered + * @param array $excludeRules An array of exclude rules from composer.json + */ + public function __construct($sourcePath, array $excludeRules) + { + parent::__construct($sourcePath); + $this->excludePatterns = $this->generatePatterns($excludeRules); + } +} diff --git a/src/Composer/Package/Archiver/GitExcludeFilter.php b/src/Composer/Package/Archiver/GitExcludeFilter.php new file mode 100644 index 000000000..926bb4d69 --- /dev/null +++ b/src/Composer/Package/Archiver/GitExcludeFilter.php @@ -0,0 +1,80 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Archiver; + +/** + * An exclude filter that processes gitignore and gitattributes + * + * It respects export-ignore git attributes + * + * @author Nils Adermann + */ +class GitExcludeFilter extends BaseExcludeFilter +{ + /** + * Parses .gitignore and .gitattributes files if they exist + * + * @param string $sourcePath + */ + public function __construct($sourcePath) + { + parent::__construct($sourcePath); + + if (file_exists($sourcePath.'/.gitignore')) { + $this->excludePatterns = $this->parseLines( + file($sourcePath.'/.gitignore'), + array($this, 'parseGitIgnoreLine') + ); + } + if (file_exists($sourcePath.'/.gitattributes')) { + $this->excludePatterns = array_merge( + $this->excludePatterns, + $this->parseLines( + file($sourcePath.'/.gitattributes'), + array($this, 'parseGitAttributesLine') + )); + } + } + + /** + * Callback line parser which process gitignore lines + * + * @param string $line A line from .gitignore + * + * @return array An exclude pattern for filter() + */ + public function parseGitIgnoreLine($line) + { + return $this->generatePattern($line); + } + + /** + * Callback parser which finds export-ignore rules in git attribute lines + * + * @param string $line A line from .gitattributes + * + * @return array An exclude pattern for filter() + */ + public function parseGitAttributesLine($line) + { + $parts = preg_split('#\s+#', $line); + + if (count($parts) != 2) { + return null; + } + + if ($parts[1] === 'export-ignore') { + return $this->generatePattern($parts[0]); + } + } +} diff --git a/src/Composer/Package/Archiver/HgExcludeFilter.php b/src/Composer/Package/Archiver/HgExcludeFilter.php new file mode 100644 index 000000000..b2e843f75 --- /dev/null +++ b/src/Composer/Package/Archiver/HgExcludeFilter.php @@ -0,0 +1,104 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Archiver; + +use Symfony\Component\Finder; + +/** + * An exclude filter that processes hgignore files + * + * @author Nils Adermann + */ +class HgExcludeFilter extends BaseExcludeFilter +{ + const HG_IGNORE_REGEX = 1; + const HG_IGNORE_GLOB = 2; + + /** + * Either HG_IGNORE_REGEX or HG_IGNORE_GLOB + * @var integer + */ + protected $patternMode; + + /** + * Parses .hgignore file if it exist + * + * @param string $sourcePath + */ + public function __construct($sourcePath) + { + parent::__construct($sourcePath); + + $this->patternMode = self::HG_IGNORE_REGEX; + + if (file_exists($sourcePath.'/.hgignore')) { + $this->excludePatterns = $this->parseLines( + file($sourcePath.'/.hgignore'), + array($this, 'parseHgIgnoreLine') + ); + } + } + + /** + * Callback line parser which process hgignore lines + * + * @param string $line A line from .hgignore + * + * @return array An exclude pattern for filter() + */ + public function parseHgIgnoreLine($line) + { + if (preg_match('#^syntax\s*:\s*(glob|regexp)$#', $line, $matches)) { + if ($matches[1] === 'glob') { + $this->patternMode = self::HG_IGNORE_GLOB; + } else { + $this->patternMode = self::HG_IGNORE_REGEX; + } + return null; + } + + if ($this->patternMode == self::HG_IGNORE_GLOB) { + return $this->patternFromGlob($line); + } else { + return $this->patternFromRegex($line); + } + } + + /** + * Generates an exclude pattern for filter() from a hg glob expression + * + * @param string $line A line from .hgignore in glob mode + * + * @return array An exclude pattern for filter() + */ + protected function patternFromGlob($line) + { + $pattern = '#'.substr(Finder\Glob::toRegex($line), 2, -1).'#'; + $pattern = str_replace('[^/]*', '.*', $pattern); + return array($pattern, false, true); + } + + /** + * Generates an exclude pattern for filter() from a hg regexp expression + * + * @param string $line A line from .hgignore in regexp mode + * + * @return array An exclude pattern for filter() + */ + public function patternFromRegex($line) + { + // WTF need to escape the delimiter safely + $pattern = '#'.preg_replace('/((?:\\\\\\\\)*)(\\\\?)#/', '\1\2\2\\#', $line).'#'; + return array($pattern, false, true); + } +} diff --git a/src/Composer/Package/Archiver/PharArchiver.php b/src/Composer/Package/Archiver/PharArchiver.php new file mode 100644 index 000000000..bd8f5c292 --- /dev/null +++ b/src/Composer/Package/Archiver/PharArchiver.php @@ -0,0 +1,65 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Archiver; + +use Composer\Package\BasePackage; +use Composer\Package\PackageInterface; + +/** + * @author Till Klampaeckel + * @author Nils Adermann + * @author Matthieu Moquet + */ +class PharArchiver implements ArchiverInterface +{ + protected static $formats = array( + 'zip' => \Phar::ZIP, + 'tar' => \Phar::TAR, + ); + + /** + * {@inheritdoc} + */ + public function archive($sources, $target, $format, array $excludes = array()) + { + $sources = realpath($sources); + + // Phar would otherwise load the file which we don't want + if (file_exists($target)) { + unlink($target); + } + + try { + $phar = new \PharData($target, null, null, static::$formats[$format]); + $files = new ArchivableFilesFinder($sources, $excludes); + $phar->buildFromIterator($files, $sources); + return $target; + } catch (\UnexpectedValueException $e) { + $message = sprintf("Could not create archive '%s' from '%s': %s", + $target, + $sources, + $e->getMessage() + ); + + throw new \RuntimeException($message, $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function supports($format, $sourceType) + { + return isset(static::$formats[$format]); + } +} diff --git a/src/Composer/Package/Dumper/ArrayDumper.php b/src/Composer/Package/Dumper/ArrayDumper.php index 22d62381b..bca932d71 100644 --- a/src/Composer/Package/Dumper/ArrayDumper.php +++ b/src/Composer/Package/Dumper/ArrayDumper.php @@ -58,6 +58,10 @@ class ArrayDumper $data['dist']['shasum'] = $package->getDistSha1Checksum(); } + if ($package->getArchiveExcludes()) { + $data['archive']['exclude'] = $package->getArchiveExcludes(); + } + foreach (BasePackage::$supportedLinkTypes as $type => $opts) { if ($links = $package->{'get'.ucfirst($opts['method'])}()) { foreach ($links as $link) { diff --git a/src/Composer/Package/Loader/ArrayLoader.php b/src/Composer/Package/Loader/ArrayLoader.php index 559fd86fe..ffb0aa126 100644 --- a/src/Composer/Package/Loader/ArrayLoader.php +++ b/src/Composer/Package/Loader/ArrayLoader.php @@ -150,6 +150,10 @@ class ArrayLoader implements LoaderInterface $package->setNotificationUrl($config['notification-url']); } + if (!empty($config['archive']['exclude'])) { + $package->setArchiveExcludes($config['archive']['exclude']); + } + if ($package instanceof Package\CompletePackageInterface) { if (isset($config['scripts']) && is_array($config['scripts'])) { foreach ($config['scripts'] as $event => $listeners) { diff --git a/src/Composer/Package/Package.php b/src/Composer/Package/Package.php index ddd4b4ec5..802fa74aa 100644 --- a/src/Composer/Package/Package.php +++ b/src/Composer/Package/Package.php @@ -51,6 +51,7 @@ class Package extends BasePackage protected $suggests = array(); protected $autoload = array(); protected $includePaths = array(); + protected $archiveExcludes = array(); /** * Creates a new in memory package. @@ -525,4 +526,22 @@ class Package extends BasePackage { return $this->notificationUrl; } + + /** + * Sets a list of patterns to be excluded from archives + * + * @param array $excludes + */ + public function setArchiveExcludes(array $excludes) + { + $this->archiveExcludes = $excludes; + } + + /** + * {@inheritDoc} + */ + public function getArchiveExcludes() + { + return $this->archiveExcludes; + } } diff --git a/src/Composer/Package/PackageInterface.php b/src/Composer/Package/PackageInterface.php index 6c2b48b4f..227ce42c3 100644 --- a/src/Composer/Package/PackageInterface.php +++ b/src/Composer/Package/PackageInterface.php @@ -308,4 +308,11 @@ interface PackageInterface * @return string */ public function getPrettyString(); + + /** + * Returns a list of patterns to exclude from package archives + * + * @return array + */ + public function getArchiveExcludes(); } diff --git a/tests/Composer/Test/AllFunctionalTest.php b/tests/Composer/Test/AllFunctionalTest.php index 83f530f75..915804f33 100644 --- a/tests/Composer/Test/AllFunctionalTest.php +++ b/tests/Composer/Test/AllFunctionalTest.php @@ -55,9 +55,8 @@ class AllFunctionalTest extends \PHPUnit_Framework_TestCase $fs->ensureDirectoryExists(dirname(self::$pharPath)); chdir(dirname(self::$pharPath)); - $proc = new Process('php '.escapeshellarg(__DIR__.'/../../../bin/compile')); + $proc = new Process('php '.escapeshellarg(__DIR__.'/../../../bin/compile'), dirname(self::$pharPath)); $exitcode = $proc->run(); - if ($exitcode !== 0 || trim($proc->getOutput())) { $this->fail($proc->getOutput()); } @@ -76,7 +75,7 @@ class AllFunctionalTest extends \PHPUnit_Framework_TestCase putenv('COMPOSER_HOME='.$this->testDir.'home'); $cmd = 'php '.escapeshellarg(self::$pharPath).' --no-ansi '.$testData['RUN']; - $proc = new Process($cmd); + $proc = new Process($cmd, __DIR__.'/Fixtures/functional'); $exitcode = $proc->run(); if (isset($testData['EXPECT'])) { diff --git a/tests/Composer/Test/Package/Archiver/ArchivableFilesFinderTest.php b/tests/Composer/Test/Package/Archiver/ArchivableFilesFinderTest.php new file mode 100644 index 000000000..1e780414d --- /dev/null +++ b/tests/Composer/Test/Package/Archiver/ArchivableFilesFinderTest.php @@ -0,0 +1,206 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Package\Archiver; + +use Composer\Package\Archiver\ArchivableFilesFinder; +use Composer\Util\Filesystem; + +use Symfony\Component\Process\Process; + +/** + * @author Nils Adermann + */ +class ArchivableFilesFinderTest extends \PHPUnit_Framework_TestCase +{ + protected $sources; + protected $finder; + + protected function setup() + { + $fs = new Filesystem; + + $this->sources = sys_get_temp_dir(). + '/composer_archiver_test'.uniqid(mt_rand(), true); + + $fileTree = array( + 'A/prefixA.foo', + 'A/prefixB.foo', + 'A/prefixC.foo', + 'A/prefixD.foo', + 'A/prefixE.foo', + 'A/prefixF.foo', + 'B/sub/prefixA.foo', + 'B/sub/prefixB.foo', + 'B/sub/prefixC.foo', + 'B/sub/prefixD.foo', + 'B/sub/prefixE.foo', + 'B/sub/prefixF.foo', + 'toplevelA.foo', + 'toplevelB.foo', + 'prefixA.foo', + 'prefixB.foo', + 'prefixC.foo', + 'prefixD.foo', + 'prefixE.foo', + 'prefixF.foo', + ); + + foreach ($fileTree as $relativePath) { + $path = $this->sources.'/'.$relativePath; + $fs->ensureDirectoryExists(dirname($path)); + file_put_contents($path, ''); + } + } + + protected function tearDown() + { + $fs = new Filesystem; + $fs->removeDirectory($this->sources); + } + + public function testManualExcludes() + { + $excludes = array( + 'prefixB.foo', + '!/prefixB.foo', + '/prefixA.foo', + 'prefixC.*', + '!*/*/*/prefixC.foo' + ); + + $this->finder = new ArchivableFilesFinder($this->sources, $excludes); + + $this->assertArchivableFiles(array( + '/A/prefixA.foo', + '/A/prefixD.foo', + '/A/prefixE.foo', + '/A/prefixF.foo', + '/B/sub/prefixA.foo', + '/B/sub/prefixC.foo', + '/B/sub/prefixD.foo', + '/B/sub/prefixE.foo', + '/B/sub/prefixF.foo', + '/prefixB.foo', + '/prefixD.foo', + '/prefixE.foo', + '/prefixF.foo', + '/toplevelA.foo', + '/toplevelB.foo', + )); + } + + public function testGitExcludes() + { + file_put_contents($this->sources.'/.gitignore', implode("\n", array( + '# gitignore rules with comments and blank lines', + '', + 'prefixE.foo', + '# and more', + '# comments', + '', + '!/prefixE.foo', + '/prefixD.foo', + 'prefixF.*', + '!/*/*/prefixF.foo', + '', + ))); + + // git does not currently support negative git attributes + file_put_contents($this->sources.'/.gitattributes', implode("\n", array( + '', + '# gitattributes rules with comments and blank lines', + 'prefixB.foo export-ignore', + //'!/prefixB.foo export-ignore', + '/prefixA.foo export-ignore', + 'prefixC.* export-ignore', + //'!/*/*/prefixC.foo export-ignore' + ))); + + $this->finder = new ArchivableFilesFinder($this->sources, array()); + + $this->assertArchivableFiles($this->getArchivedFiles('git init && '. + 'git add .git* && '. + 'git commit -m "ignore rules" && '. + 'git add . && '. + 'git commit -m "init" && '. + 'git archive --format=zip --prefix=archive/ -o archive.zip HEAD' + )); + } + + public function testHgExcludes() + { + file_put_contents($this->sources.'/.hgignore', implode("\n", array( + '# hgignore rules with comments, blank lines and syntax changes', + '', + 'pre*A.foo', + 'prefixE.foo', + '# and more', + '# comments', + '', + '^prefixD.foo', + 'syntax: glob', + 'prefixF.*', + 'B/*', + ))); + + $this->finder = new ArchivableFilesFinder($this->sources, array()); + + $expectedFiles = $this->getArchivedFiles('hg init && '. + 'hg add && '. + 'hg commit -m "init" && '. + 'hg archive archive.zip' + ); + + array_shift($expectedFiles); // remove .hg_archival.txt + + $this->assertArchivableFiles($expectedFiles); + } + + protected function getArchivableFiles() + { + $files = array(); + foreach ($this->finder as $file) { + if (!$file->isDir()) { + $files[] = preg_replace('#^'.preg_quote($this->sources, '#').'#', '', $file->getRealPath()); + } + } + + sort($files); + + return $files; + } + + protected function getArchivedFiles($command) + { + $process = new Process($command, $this->sources); + $process->run(); + + $archive = new \PharData($this->sources.'/archive.zip'); + $iterator = new \RecursiveIteratorIterator($archive); + + $files = array(); + foreach ($iterator as $file) { + $files[] = preg_replace('#^phar://'.preg_quote($this->sources, '#').'/archive\.zip/archive#', '', $file); + } + + unlink($this->sources.'/archive.zip'); + return $files; + } + + protected function assertArchivableFiles($expectedFiles) + { + $actualFiles = $this->getArchivableFiles(); + + $this->assertEquals($expectedFiles, $actualFiles); + } +} diff --git a/tests/Composer/Test/Package/Archiver/ArchiveManagerTest.php b/tests/Composer/Test/Package/Archiver/ArchiveManagerTest.php new file mode 100644 index 000000000..2c51461ed --- /dev/null +++ b/tests/Composer/Test/Package/Archiver/ArchiveManagerTest.php @@ -0,0 +1,99 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Package\Archiver; + +use Composer\Factory; +use Composer\IO\NullIO; +use Composer\Package\Archiver; +use Composer\Package\Archiver\ArchiveManager; +use Composer\Package\PackageInterface; + +/** + * @author Till Klampaeckel + * @author Matthieu Moquet + */ +class ArchiveManagerTest extends ArchiverTest +{ + protected $manager; + protected $targetDir; + + public function setUp() + { + parent::setUp(); + + $factory = new Factory(); + $this->manager = $factory->createArchiveManager($factory->createConfig()); + $this->targetDir = $this->testDir.'/composer_archiver_tests'; + } + + public function testUnknownFormat() + { + $this->setExpectedException('RuntimeException'); + + $package = $this->setupPackage(); + + $this->manager->archive($package, '__unknown_format__', $this->targetDir); + } + + public function testArchiveTar() + { + $this->setupGitRepo(); + + $package = $this->setupPackage(); + + $this->manager->archive($package, 'tar', $this->targetDir); + + $target = $this->getTargetName($package, 'tar'); + $this->assertFileExists($target); + + unlink($target); + } + + protected function getTargetName(PackageInterface $package, $format) + { + $packageName = $this->manager->getPackageFilename($package); + $target = $this->targetDir.'/'.$packageName.'.'.$format; + + return $target; + } + + /** + * Create local git repository to run tests against! + */ + protected function setupGitRepo() + { + $currentWorkDir = getcwd(); + chdir($this->testDir); + + $output = null; + $result = $this->process->execute('git init -q', $output, $this->testDir); + if ($result > 0) { + chdir($currentWorkDir); + throw new \RuntimeException('Could not init: '.$this->process->getErrorOutput()); + } + + $result = file_put_contents('b', 'a'); + if (false === $result) { + chdir($currentWorkDir); + throw new \RuntimeException('Could not save file.'); + } + + $result = $this->process->execute('git add b && git commit -m "commit b" -q', $output, $this->testDir); + if ($result > 0) { + chdir($currentWorkDir); + throw new \RuntimeException('Could not commit: '.$this->process->getErrorOutput()); + } + + chdir($currentWorkDir); + } +} diff --git a/tests/Composer/Test/Package/Archiver/ArchiverTest.php b/tests/Composer/Test/Package/Archiver/ArchiverTest.php new file mode 100644 index 000000000..993244788 --- /dev/null +++ b/tests/Composer/Test/Package/Archiver/ArchiverTest.php @@ -0,0 +1,67 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Package\Archiver; + +use Composer\Util\Filesystem; +use Composer\Util\ProcessExecutor; +use Composer\Package\Package; + +/** + * @author Till Klampaeckel + * @author Matthieu Moquet + */ +abstract class ArchiverTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var \Composer\Util\Filesystem + */ + protected $filesystem; + + /** + * @var \Composer\Util\ProcessExecutor + */ + protected $process; + + /** + * @var string + */ + protected $testDir; + + public function setUp() + { + $this->filesystem = new Filesystem(); + $this->process = new ProcessExecutor(); + $this->testDir = sys_get_temp_dir().'/composer_archiver_test_'.mt_rand(); + $this->filesystem->ensureDirectoryExists($this->testDir); + } + + public function tearDown() + { + $this->filesystem->removeDirectory($this->testDir); + } + + /** + * Util method to quickly setup a package using the source path built. + * + * @return \Composer\Package\Package + */ + protected function setupPackage() + { + $package = new Package('archivertest/archivertest', 'master', 'master'); + $package->setSourceUrl(realpath($this->testDir)); + $package->setSourceReference('master'); + $package->setSourceType('git'); + + return $package; + } +} diff --git a/tests/Composer/Test/Package/Archiver/HgExcludeFilterTest.php b/tests/Composer/Test/Package/Archiver/HgExcludeFilterTest.php new file mode 100644 index 000000000..1a9d20089 --- /dev/null +++ b/tests/Composer/Test/Package/Archiver/HgExcludeFilterTest.php @@ -0,0 +1,42 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Package\Archiver; + +use Composer\Package\Archiver\HgExcludeFilter; + +/** + * @author Nils Adermann + */ +class HgExcludeFilterTest extends \PHPUnit_Framework_TestCase +{ + /** + * @dataProvider patterns + */ + public function testPatternEscape($ignore, $expected) + { + $filter = new HgExcludeFilter('/'); + + $this->assertEquals($expected, $filter->patternFromRegex($ignore)); + } + + public function patterns() + { + return array( + array('.#', array('#.\\##', false, true)), + array('.\\#', array('#.\\\\\\##', false, true)), + array('\\.#', array('#\\.\\##', false, true)), + array('\\\\.\\\\\\\\#', array('#\\\\.\\\\\\\\\\##', false, true)), + array('.\\\\\\\\\\#', array('#.\\\\\\\\\\\\\\##', false, true)), + ); + } +} diff --git a/tests/Composer/Test/Package/Archiver/PharArchiverTest.php b/tests/Composer/Test/Package/Archiver/PharArchiverTest.php new file mode 100644 index 000000000..721d34f92 --- /dev/null +++ b/tests/Composer/Test/Package/Archiver/PharArchiverTest.php @@ -0,0 +1,82 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Package\Archiver; + +use Composer\Package\Archiver\PharArchiver; + +/** + * @author Till Klampaeckel + * @author Matthieu Moquet + */ +class PharArchiverTest extends ArchiverTest +{ + public function testTarArchive() + { + // Set up repository + $this->setupDummyRepo(); + $package = $this->setupPackage(); + $target = sys_get_temp_dir().'/composer_archiver_test.tar'; + + // Test archive + $archiver = new PharArchiver(); + $archiver->archive($package->getSourceUrl(), $target, 'tar', array('foo/bar', 'baz', '!/foo/bar/baz')); + $this->assertFileExists($target); + + unlink($target); + } + + public function testZipArchive() + { + // Set up repository + $this->setupDummyRepo(); + $package = $this->setupPackage(); + $target = sys_get_temp_dir().'/composer_archiver_test.zip'; + + // Test archive + $archiver = new PharArchiver(); + $archiver->archive($package->getSourceUrl(), $target, 'zip'); + $this->assertFileExists($target); + + unlink($target); + } + + /** + * Create a local dummy repository to run tests against! + */ + protected function setupDummyRepo() + { + $currentWorkDir = getcwd(); + chdir($this->testDir); + + $this->writeFile('file.txt', 'content', $currentWorkDir); + $this->writeFile('foo/bar/baz', 'content', $currentWorkDir); + $this->writeFile('foo/bar/ignoreme', 'content', $currentWorkDir); + $this->writeFile('x/baz', 'content', $currentWorkDir); + $this->writeFile('x/includeme', 'content', $currentWorkDir); + + chdir($currentWorkDir); + } + + protected function writeFile($path, $content, $currentWorkDir) + { + if (!file_exists(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + + $result = file_put_contents($path, 'a'); + if (false === $result) { + chdir($currentWorkDir); + throw new \RuntimeException('Could not save file.'); + } + } +} diff --git a/tests/Composer/Test/Package/Dumper/ArrayDumperTest.php b/tests/Composer/Test/Package/Dumper/ArrayDumperTest.php index ec80984be..4b9877523 100644 --- a/tests/Composer/Test/Package/Dumper/ArrayDumperTest.php +++ b/tests/Composer/Test/Package/Dumper/ArrayDumperTest.php @@ -130,6 +130,14 @@ class ArrayDumperTest extends \PHPUnit_Framework_TestCase 'extra', array('class' => 'MyVendor\\Installer') ), + array( + 'archive', + array('/foo/bar', 'baz', '!/foo/bar/baz'), + 'archiveExcludes', + array( + 'exclude' => array('/foo/bar', 'baz', '!/foo/bar/baz'), + ), + ), array( 'require', array(new Link('foo', 'foo/bar', new VersionConstraint('=', '1.0.0.0'), 'requires', '1.0.0')), diff --git a/tests/Composer/Test/Package/Loader/ArrayLoaderTest.php b/tests/Composer/Test/Package/Loader/ArrayLoaderTest.php index ef1295ff8..68aed0a23 100644 --- a/tests/Composer/Test/Package/Loader/ArrayLoaderTest.php +++ b/tests/Composer/Test/Package/Loader/ArrayLoaderTest.php @@ -114,6 +114,9 @@ class ArrayLoaderTest extends \PHPUnit_Framework_TestCase 'target-dir' => 'some/prefix', 'extra' => array('random' => array('things' => 'of', 'any' => 'shape')), 'bin' => array('bin1', 'bin/foo'), + 'archive' => array( + 'exclude' => array('/foo/bar', 'baz', '!/foo/bar/baz'), + ), ); $package = $this->loader->load($config); diff --git a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php index e095f6e3d..23686d08f 100644 --- a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php +++ b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php @@ -123,6 +123,9 @@ class ValidatingArrayLoaderTest extends \PHPUnit_Framework_TestCase 'vendor-dir' => 'vendor', 'process-timeout' => 10000, ), + 'archive' => array( + 'exclude' => array('/foo/bar', 'baz', '!/foo/bar/baz'), + ), 'scripts' => array( 'post-update-cmd' => 'Foo\\Bar\\Baz::doSomething', 'post-install-cmd' => array(