diff --git a/rector.php b/rector.php index 27de71c..d18c2c2 100644 --- a/rector.php +++ b/rector.php @@ -21,6 +21,7 @@ use Rector\DeadCode\Rector\If_\RemoveAlwaysTrueIfConditionRector; use Rector\Set\ValueObject\SetList; use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector; +use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector; return static function (RectorConfig $rectorConfig): void { $rectorConfig->paths([ @@ -28,8 +29,7 @@ ]); $rectorConfig->sets([ - SetList::PHP_80, - SetList::PHP_81, + SetList::PHP_82, SetList::CODE_QUALITY, SetList::CODING_STYLE, SetList::DEAD_CODE, @@ -37,6 +37,8 @@ SetList::TYPE_DECLARATION, ]); + $rectorConfig->rule(DeclareStrictTypesRector::class); + $rectorConfig->skip([ // Rules added by Rector's rule sets. CountArrayToEmptyArrayComparisonRector::class, diff --git a/src/Commands/ArtifactCommand.php b/src/Commands/ArtifactCommand.php index 0b79b54..7f19ecd 100644 --- a/src/Commands/ArtifactCommand.php +++ b/src/Commands/ArtifactCommand.php @@ -143,7 +143,7 @@ public function __construct( ?string $name = NULL, ) { parent::__construct($name); - $this->fsFileSystem = is_null($fsFileSystem) ? new Filesystem() : $fsFileSystem; + $this->fs = is_null($fsFileSystem) ? new Filesystem() : $fsFileSystem; $this->git = is_null($gitWrapper) ? new ArtifactGit() : $gitWrapper; } @@ -160,46 +160,46 @@ protected function configure(): void { $this ->addOption('branch', NULL, InputOption::VALUE_REQUIRED, 'Destination branch with optional tokens.', '[branch]') ->addOption( - 'gitignore', - NULL, - InputOption::VALUE_REQUIRED, - 'Path to gitignore file to replace current .gitignore.' + 'gitignore', + NULL, + InputOption::VALUE_REQUIRED, + 'Path to gitignore file to replace current .gitignore.' ) ->addOption( - 'message', - NULL, - InputOption::VALUE_REQUIRED, - 'Commit message with optional tokens.', - 'Deployment commit' + 'message', + NULL, + InputOption::VALUE_REQUIRED, + 'Commit message with optional tokens.', + 'Deployment commit' ) ->addOption( - 'mode', - NULL, - InputOption::VALUE_REQUIRED, - 'Mode of artifact build: branch, force-push or diff. Defaults to force-push.', - 'force-push' + 'mode', + NULL, + InputOption::VALUE_REQUIRED, + 'Mode of artifact build: branch, force-push or diff. Defaults to force-push.', + 'force-push' ) ->addOption('no-cleanup', NULL, InputOption::VALUE_NONE, 'Do not cleanup after run.') ->addOption('now', NULL, InputOption::VALUE_REQUIRED, 'Internal value used to set internal time.') ->addOption('dry-run', NULL, InputOption::VALUE_NONE, 'Run without pushing to the remote repository.') ->addOption('log', NULL, InputOption::VALUE_REQUIRED, 'Path to the log file.') ->addOption( - 'root', - NULL, - InputOption::VALUE_REQUIRED, - 'Path to the root for file path resolution. If not specified, current directory is used.' + 'root', + NULL, + InputOption::VALUE_REQUIRED, + 'Path to the root for file path resolution. If not specified, current directory is used.' ) ->addOption( - 'show-changes', - NULL, - InputOption::VALUE_NONE, - 'Show changes made to the repo by the build in the output.' + 'show-changes', + NULL, + InputOption::VALUE_NONE, + 'Show changes made to the repo by the build in the output.' ) ->addOption( - 'src', - NULL, - InputOption::VALUE_REQUIRED, - 'Directory where source repository is located. If not specified, root directory is used.' + 'src', + NULL, + InputOption::VALUE_REQUIRED, + 'Directory where source repository is located. If not specified, root directory is used.' ); } @@ -217,27 +217,34 @@ protected function configure(): void { * @throws \Exception */ protected function execute(InputInterface $input, OutputInterface $output): int { - // If log option was set, we set verbosity is debug. if ($input->getOption('log')) { $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); } + $this->output = $output; - $tmpLogFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . time() . '-artifact-log.log'; - $this->logger = self::createLogger((string) $this->getName(), $output, $tmpLogFile); + + $logfile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . time() . '-artifact-log.log'; + $this->logger = self::loggerCreate((string) $this->getName(), $output, $logfile); + $remote = $input->getArgument('remote'); + if (!is_string($remote)) { + throw new \RuntimeException('Remote argument must be a string'); + } + try { // Now we have all what we need. // Let process artifact function. $this->checkRequirements(); - // @phpstan-ignore-next-line + $this->processArtifact($remote, $input->getOptions()); // Dump log file and clean tmp log file. - if ($this->fsFileSystem->exists($tmpLogFile)) { + if ($this->fs->exists($logfile)) { if (!empty($this->logFile)) { - $this->fsFileSystem->copy($tmpLogFile, $this->logFile); + $this->fs->copy($logfile, $this->logFile); } - $this->fsFileSystem->remove($tmpLogFile); + + $this->fs->remove($logfile); } } catch (\Exception $exception) { @@ -245,6 +252,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'Deployment failed.', '' . $exception->getMessage() . '', ]); + return Command::FAILURE; } @@ -463,46 +471,43 @@ protected function doPush(): void { /** * Resolve and validate CLI options values into internal values. * - * @param string $remote + * @param string $url * Remote URL. - * @param array $options + * @param array $options * Array of CLI options. - * - * @throws \CzProject\GitPhp\GitException - * @throws \Exception - * - * @phpstan-ignore-next-line */ - protected function resolveOptions(string $remote, array $options): void { - // First handle root for filesystem. - $this->fsSetRootDir($options['root']); + protected function resolveOptions(string $url, array $options): void { + if (!empty($options['root']) && is_scalar($options['root'])) { + $this->fsSetRootDir(strval($options['root'])); + } - // Resolve some basic options into properties. + $this->remoteUrl = $url; + $this->remoteName = self::GIT_REMOTE_NAME; + $this->now = empty($options['now']) ? time() : (int) $options['now']; $this->showChanges = !empty($options['show-changes']); $this->needCleanup = empty($options['no-cleanup']); $this->isDryRun = !empty($options['dry-run']); $this->logFile = empty($options['log']) ? '' : $this->fsGetAbsolutePath($options['log']); - $this->now = empty($options['now']) ? time() : (int) $options['now']; - $this->remoteName = self::GIT_REMOTE_NAME; - $this->remoteUrl = $remote; + $this->setMode($options['mode'], $options); - // Handle some complex options. $srcPath = empty($options['src']) ? $this->fsGetRootDir() : $this->fsGetAbsolutePath($options['src']); $this->sourcePathGitRepository = $srcPath; + // Setup Git repository from source path. $this->initGitRepository($srcPath); + // Set original, destination, artifact branch name. $this->originalBranch = $this->resolveOriginalBranch(); $this->setDstBranch($options['branch']); $this->artifactBranch = $this->destinationBranch . '-artifact'; + // Set commit message. $this->setMessage($options['message']); - // Set git ignore file path. + if (!empty($options['gitignore'])) { $this->setGitignoreFile($options['gitignore']); } - } /** @@ -572,10 +577,8 @@ protected function logReport(): void { * * @param string $mode * Mode to set. - * @param array $options + * @param array $options * Array of CLI options. - * - * @phpstan-ignore-next-line */ protected function setMode(string $mode, array $options): void { switch ($mode) { @@ -584,9 +587,9 @@ protected function setMode(string $mode, array $options): void { break; case self::modeBranch(): - if (!$this->hasToken($options['branch'])) { + if (is_scalar($options['branch'] ?? NULL) && !self::tokenExists(strval($options['branch']))) { $this->output->writeln('WARNING! Provided branch name does not have a token. - Pushing of the artifact into this branch will fail on second and follow up pushes to remote. + Pushing of the artifact into this branch will fail on second and follow-up pushes to remote. Consider adding tokens with unique values to the branch name.'); } break; @@ -701,8 +704,8 @@ protected function checkRequirements(): void { protected function replaceGitignoreInGitRepository(string $filename): void { $path = $this->getSourcePathGitRepository(); $this->logDebug(sprintf('Replacing .gitignore: %s with %s', $path . DIRECTORY_SEPARATOR . '.gitignore', $filename)); - $this->fsFileSystem->copy($filename, $path . DIRECTORY_SEPARATOR . '.gitignore', TRUE); - $this->fsFileSystem->remove($filename); + $this->fs->copy($filename, $path . DIRECTORY_SEPARATOR . '.gitignore', TRUE); + $this->fs->remove($filename); } /** @@ -728,7 +731,7 @@ protected function getLocalExcludeFileName(string $path): string { * True if exists, false otherwise. */ protected function localExcludeExists(string $path): bool { - return $this->fsFileSystem->exists($this->getLocalExcludeFileName($path)); + return $this->fs->exists($this->getLocalExcludeFileName($path)); } /** @@ -761,10 +764,10 @@ protected function localExcludeEmpty(string $path, bool $strict = FALSE): bool { $lines = file($filename); if ($lines) { $lines = array_map(trim(...), $lines); - $lines = array_filter($lines, static function ($line) : bool { + $lines = array_filter($lines, static function ($line): bool { return strlen($line) > 0; }); - $lines = array_filter($lines, static function ($line) : bool { + $lines = array_filter($lines, static function ($line): bool { return !str_starts_with(trim($line), '#'); }); } @@ -781,9 +784,9 @@ protected function localExcludeEmpty(string $path, bool $strict = FALSE): bool { protected function disableLocalExclude(string $path): void { $filename = $this->getLocalExcludeFileName($path); $filenameDisabled = $filename . '.bak'; - if ($this->fsFileSystem->exists($filename)) { + if ($this->fs->exists($filename)) { $this->logDebug('Disabling local exclude'); - $this->fsFileSystem->rename($filename, $filenameDisabled); + $this->fs->rename($filename, $filenameDisabled); } } @@ -796,9 +799,9 @@ protected function disableLocalExclude(string $path): void { protected function restoreLocalExclude(string $path): void { $filename = $this->getLocalExcludeFileName($path); $filenameDisabled = $filename . '.bak'; - if ($this->fsFileSystem->exists($filenameDisabled)) { + if ($this->fs->exists($filenameDisabled)) { $this->logDebug('Restoring local exclude'); - $this->fsFileSystem->rename($filenameDisabled, $filename); + $this->fs->rename($filenameDisabled, $filename); } } @@ -836,8 +839,8 @@ protected function removeIgnoredFiles(string $location, ?string $gitignorePath = foreach ($files as $file) { $fileName = $location . DIRECTORY_SEPARATOR . $file; $this->logDebug(sprintf('Removing excluded file %s', $fileName)); - if ($this->fsFileSystem->exists($fileName)) { - $this->fsFileSystem->remove($fileName); + if ($this->fs->exists($fileName)) { + $this->fs->remove($fileName); } } } @@ -858,7 +861,7 @@ protected function removeOtherFilesInGitRepository(): void { foreach ($files as $file) { $fileName = $this->getSourcePathGitRepository() . DIRECTORY_SEPARATOR . $file; $this->logDebug(sprintf('Removing other file %s', $fileName)); - $this->fsFileSystem->remove($fileName); + $this->fs->remove($fileName); } } } @@ -882,7 +885,7 @@ protected function removeSubReposInGitRepository(): void { if ($dir instanceof \SplFileInfo) { $dir = $dir->getPathname(); } - $this->fsFileSystem->remove($dir); + $this->fs->remove($dir); $this->logDebug(sprintf('Removing sub-repository "%s"', (string) $dir)); } } diff --git a/src/Git/ArtifactGitRepository.php b/src/Git/ArtifactGitRepository.php index 787c8d7..01cb7b4 100644 --- a/src/Git/ArtifactGitRepository.php +++ b/src/Git/ArtifactGitRepository.php @@ -19,7 +19,7 @@ class ArtifactGitRepository extends GitRepository { /** * Filesystem. */ - protected Filesystem $fileSystem; + protected Filesystem $fs; /** * Logger. @@ -56,6 +56,7 @@ public function pushForce(string $remote, string $refSpec): ArtifactGitRepositor */ public function listIgnoredFilesFromGitIgnoreFile(string $gitIgnoreFilePath): array { $files = $this->extractFromCommand(['ls-files', '-i', '-c', '--exclude-from=' . $gitIgnoreFilePath]); + if (!$files) { return []; } diff --git a/src/Traits/FilesystemTrait.php b/src/Traits/FilesystemTrait.php index a1d4f3d..6d1c534 100644 --- a/src/Traits/FilesystemTrait.php +++ b/src/Traits/FilesystemTrait.php @@ -20,7 +20,7 @@ trait FilesystemTrait { /** * File system for custom commands. */ - protected Filesystem $fsFileSystem; + protected Filesystem $fs; /** * Stack of original current working directories. @@ -39,12 +39,15 @@ trait FilesystemTrait { * @param string|null $path * The path of the root directory. * - * @throws \Exception + * @return static + * The called object. */ - protected function fsSetRootDir(?string $path = NULL): void { + protected function fsSetRootDir(?string $path = NULL): static { $path = empty($path) ? $this->fsGetRootDir() : $this->fsGetAbsolutePath($path); $this->fsPathsExist($path); $this->fsRootDir = $path; + + return $this; } /** @@ -74,10 +77,14 @@ protected function fsGetRootDir(): string { * * @param string $dir * Path to the current directory. + * + * @return static + * The called object. */ - protected function fsCwdSet(string $dir): void { + protected function fsSetCwd(string $dir): static { chdir($dir); $this->fsOriginalCwdStack[] = $dir; + return $this; } /** @@ -130,14 +137,15 @@ protected function fsIsCommandAvailable(string $command): bool { * Absolute path for provided file. */ protected function fsGetAbsolutePath(string $file, ?string $root = NULL): string { - if ($this->fsFileSystem->isAbsolutePath($file)) { - return $this->realpath($file); + if ($this->fs->isAbsolutePath($file)) { + return $this->fsRealpath($file); } + $root = $root ? $root : $this->fsGetRootDir(); - $root = $this->realpath($root); + $root = $this->fsRealpath($root); $file = $root . DIRECTORY_SEPARATOR . $file; - return $this->realpath($file); + return $this->fsRealpath($file); } /** @@ -157,7 +165,7 @@ protected function fsGetAbsolutePath(string $file, ?string $root = NULL): string */ protected function fsPathsExist($paths, bool $strict = TRUE): bool { $paths = is_array($paths) ? $paths : [$paths]; - if (!$this->fsFileSystem->exists($paths)) { + if (!$this->fs->exists($paths)) { if ($strict) { throw new \Exception(sprintf('One of the files or directories does not exist: %s', implode(', ', $paths))); } @@ -169,7 +177,7 @@ protected function fsPathsExist($paths, bool $strict = TRUE): bool { } /** - * Replacement for PHP's `realpath` resolves non-existing paths. + * Replacement for PHP's `fsRealpath` resolves non-existing paths. * * The main deference is that it does not return FALSE on non-existing * paths. @@ -185,10 +193,11 @@ protected function fsPathsExist($paths, bool $strict = TRUE): bool { * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) */ - protected function realpath(string $path): string { + protected function fsRealpath(string $path): string { // Whether $path is unix or not. $unipath = $path === '' || $path[0] !== '/'; $unc = str_starts_with($path, '\\\\'); + // Attempt to detect if path is relative in which case, add cwd. if (!str_contains($path, ':') && $unipath && !$unc) { $path = getcwd() . DIRECTORY_SEPARATOR . $path; @@ -215,15 +224,21 @@ protected function realpath(string $path): string { $absolutes[] = $part; } } + $path = implode(DIRECTORY_SEPARATOR, $absolutes); + // Resolve any symlinks. if (function_exists('readlink') && file_exists($path) && linkinfo($path) > 0) { $path = readlink($path); + + if (!$path) { + throw new \Exception(sprintf('Could not resolve symlink for path: %s', $path)); + } } + // Put initial separator that could have been lost. $path = $unipath ? $path : '/' . $path; - /* @phpstan-ignore-next-line */ return $unc ? '\\\\' . $path : $path; } diff --git a/src/Traits/LogTrait.php b/src/Traits/LogTrait.php index 477551e..43497b4 100644 --- a/src/Traits/LogTrait.php +++ b/src/Traits/LogTrait.php @@ -28,30 +28,29 @@ trait LogTrait { * Name. * @param \Symfony\Component\Console\Output\OutputInterface $output * Output. - * @param string $logFile - * Log file. + * @param string $filepath + * Filepath to log file. * * @return \Psr\Log\LoggerInterface * Logger. */ - public static function createLogger(string $name, OutputInterface $output, string $logFile): LoggerInterface { + public static function loggerCreate(string $name, OutputInterface $output, string $filepath): LoggerInterface { $logger = new Logger($name); - // Console handler. + $handler = new ConsoleHandler($output); $logger->pushHandler($handler); - // Stream handler if needed. - if (!empty($logFile)) { - $verbosityMapping = [ + + if (!empty($filepath)) { + $map = [ OutputInterface::VERBOSITY_QUIET => Level::Error, OutputInterface::VERBOSITY_NORMAL => Level::Warning, OutputInterface::VERBOSITY_VERBOSE => Level::Notice, OutputInterface::VERBOSITY_VERY_VERBOSE => Level::Info, OutputInterface::VERBOSITY_DEBUG => Level::Debug, ]; - $verbosity = $output->getVerbosity(); - // @phpstan-ignore-next-line - $level = $verbosityMapping[$verbosity] ?? Level::Debug; - $handler = new StreamHandler($logFile, $level); + + $handler = new StreamHandler($filepath, $map[$output->getVerbosity()] ?? Level::Debug); + $logger->pushHandler($handler); } diff --git a/src/Traits/TokenTrait.php b/src/Traits/TokenTrait.php index 64a6245..68a6325 100644 --- a/src/Traits/TokenTrait.php +++ b/src/Traits/TokenTrait.php @@ -20,22 +20,23 @@ trait TokenTrait { * original string. */ protected function tokenProcess(string $string): ?string { - /* @phpstan-ignore-next-line */ - return preg_replace_callback('/(?:\[([^\]]+)\])/', function (array $match) { + return preg_replace_callback('/(?:\[([^\]]+)\])/', function (array $match): string { if (!empty($match[1])) { $parts = explode(':', $match[1], 2); + $token = $parts[0] ?? NULL; $argument = $parts[1] ?? NULL; + if ($token) { $method = 'getToken' . ucfirst($token); - if (method_exists($this, $method)) { - /* @phpstan-ignore-next-line */ - $match[0] = call_user_func([$this, $method], $argument); + + if (method_exists($this, $method) && is_callable([$this, $method])) { + $match[0] = (string) $this->$method($argument); } } } - return $match[0]; + return strval($match[0]); }, $string); } @@ -46,9 +47,9 @@ protected function tokenProcess(string $string): ?string { * String to check. * * @return bool - * True if there is at least one token present, false otherwise. + * TRUE if there is at least one token present, FALSE otherwise. */ - protected function hasToken(string $string): bool { + protected static function tokenExists(string $string): bool { return (bool) preg_match('/\[[^]]+]/', $string); } diff --git a/src/app.php b/src/app.php index 899ea18..a597f7e 100644 --- a/src/app.php +++ b/src/app.php @@ -5,6 +5,8 @@ * Main entry point for the application. */ +declare(strict_types=1); + use DrevOps\GitArtifact\Commands\ArtifactCommand; use Symfony\Component\Console\Application; diff --git a/tests/phpunit/Functional/TokenTest.php b/tests/phpunit/Functional/TokenTest.php index 173cf19..08b78be 100644 --- a/tests/phpunit/Functional/TokenTest.php +++ b/tests/phpunit/Functional/TokenTest.php @@ -20,8 +20,8 @@ class TokenTest extends AbstractFunctionalTestCase { */ public function testTokenProcess(string $string, string $name, string $replacement, string $expectedString): void { $mock = $this->prepareMock(TokenTrait::class, [ - 'getToken' . ucfirst($name) => static function (?string $prop) use ($replacement) : string { - return empty($prop) ? $replacement : $replacement . ' with property ' . $prop; + 'getToken' . ucfirst($name) => static function (?string $prop) use ($replacement): string { + return empty($prop) ? $replacement : $replacement . ' with property ' . $prop; }, ]); @@ -35,93 +35,68 @@ public function testTokenProcess(string $string, string $name, string $replaceme */ public static function dataProviderTokenProcess(): array { return [ - [ - '', - '', - '', - '', - ], - [ - '', - 'sometoken', - 'somevalue', - '', - ], - [ - 'string without a token', - 'sometoken', - 'somevalue', - 'string without a token', - ], - [ - 'string with sometoken without delimiters', - 'sometoken', - 'somevalue', - 'string with sometoken without delimiters', - ], - [ - 'string with [sometoken broken delimiters', - 'sometoken', - 'somevalue', - 'string with [sometoken broken delimiters', - ], - [ - 'string with sometoken] broken delimiters', - 'sometoken', - 'somevalue', - 'string with sometoken] broken delimiters', - ], - // Proper token. - [ - '[sometoken]', - 'sometoken', - 'somevalue', - 'somevalue', - ], - [ - 'string with [sometoken] present', - 'sometoken', - 'somevalue', - 'string with somevalue present', - ], - // Token with properties. - [ - 'string with [sometoken:prop] present', - 'sometoken', - 'somevalue', - 'string with somevalue with property prop present', - ], - [ - 'string with [sometoken:prop:otherprop] present', - 'sometoken', - 'somevalue', - 'string with somevalue with property prop:otherprop present', - ], - ]; - } - - /** - * @dataProvider dataProviderHasToken - */ - public function testHasToken(string $string, bool $hasToken): void { - $mock = $this->prepareMock(TokenTrait::class); - - $actual = $this->callProtectedMethod($mock, 'hasToken', [$string]); - $this->assertEquals($hasToken, $actual); - } - - /** - * @return array - * Data provider. - */ - public static function dataProviderHasToken(): array { - return [ - ['notoken', FALSE], - ['[broken token', FALSE], - ['broken token]', FALSE], - ['[token]', TRUE], - ['string with [token] and other string', TRUE], - ['[token] and [otherttoken]', TRUE], + [ + '', + '', + '', + '', + ], + [ + '', + 'sometoken', + 'somevalue', + '', + ], + [ + 'string without a token', + 'sometoken', + 'somevalue', + 'string without a token', + ], + [ + 'string with sometoken without delimiters', + 'sometoken', + 'somevalue', + 'string with sometoken without delimiters', + ], + [ + 'string with [sometoken broken delimiters', + 'sometoken', + 'somevalue', + 'string with [sometoken broken delimiters', + ], + [ + 'string with sometoken] broken delimiters', + 'sometoken', + 'somevalue', + 'string with sometoken] broken delimiters', + ], + // Proper token. + [ + '[sometoken]', + 'sometoken', + 'somevalue', + 'somevalue', + ], + [ + 'string with [sometoken] present', + 'sometoken', + 'somevalue', + 'string with somevalue present', + ], + // Token with properties. + [ + 'string with [sometoken:prop] present', + 'sometoken', + 'somevalue', + 'string with somevalue with property prop present', + ], + [ + 'string with [sometoken:prop:otherprop] present', + 'sometoken', + 'somevalue', + 'string with somevalue with property prop:otherprop present', + ], ]; } diff --git a/tests/phpunit/Traits/MockTrait.php b/tests/phpunit/Traits/MockTrait.php index b5782e6..0dddfe4 100644 --- a/tests/phpunit/Traits/MockTrait.php +++ b/tests/phpunit/Traits/MockTrait.php @@ -1,5 +1,7 @@ prepareMock(TokenTrait::class); + + $actual = $this->callProtectedMethod($mock, 'tokenExists', [$string]); + $this->assertEquals($expected, $actual); + } + + /** + * @return array + * Data provider. + */ + public static function dataProviderTokenExists(): array { + return [ + ['notoken', FALSE], + ['[broken token', FALSE], + ['broken token]', FALSE], + ['[token]', TRUE], + ['string with [token] and other string', TRUE], + ['[token] and [otherttoken]', TRUE], + ]; + } + +}