From 3ee6c83dee9b1cffa5a544156967f83cc7162a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 23 Oct 2026 23:21:12 +0200 Subject: [PATCH 1/3] feat: complete project directories with native Symfony API --- README.md | 31 ++++++++ src/Command/CreateProjectCommand.php | 10 +++ src/Command/UpdateProjectCommand.php | 10 +++ src/Completion/DirectoryCompletion.php | 16 ++++ tests/Command/DirectoryCompletionTest.php | 93 +++++++++++++++++++++++ 5 files changed, 160 insertions(+) create mode 100644 src/Completion/DirectoryCompletion.php create mode 100644 tests/Command/DirectoryCompletionTest.php diff --git a/README.md b/README.md index 211f9ff..f4dffb0 100644 --- a/README.md +++ b/README.md @@ -305,3 +305,34 @@ guidelines are provided by the SymPress `.github` repository. ## License GPL-2.0-or-later. See [LICENSE](LICENSE). + +## Directory completion + +With Symfony Console 8.2, `project:create` and `project:update` (including their +aliases) ask the shell to complete the `directory` argument using the native +`suggestDirectories()` API. Files are not suggested for this argument. Options +such as `--template` and `--manifest` retain their existing completion behavior. +Console 8.0 and 8.1 remain supported and do not request native path suggestions. +SymPress does not enumerate filesystem entries or provide a replacement completer. + +Regenerate your completion script after upgrading Console to 8.2. Its completion +protocol API 2 adds `:directories`/`:paths`; an installed API 1 script ignores +these suggestions. The supported shells are Bash, Fish and Zsh: + +```sh +# Bash (requires the bash-completion package to be loaded) +sympress completion bash > ~/.sympress-completion.bash +source ~/.sympress-completion.bash + +# Fish +mkdir -p ~/.config/fish/completions +sympress completion fish > ~/.config/fish/completions/sympress.fish + +# Zsh: add ~/.zfunc to fpath before running compinit in ~/.zshrc +mkdir -p ~/.zfunc +sympress completion zsh > ~/.zfunc/_sympress +``` + +Symfony 8.2 is still under development as of 2026-10-23. This integration is +prepared for qualification; publication is held until the stable component and +its final shell scripts have been rechecked. diff --git a/src/Command/CreateProjectCommand.php b/src/Command/CreateProjectCommand.php index 7d3f066..b1c1344 100644 --- a/src/Command/CreateProjectCommand.php +++ b/src/Command/CreateProjectCommand.php @@ -9,11 +9,14 @@ use SymPress\Cli\Catalog\DefaultPackageCatalog; use SymPress\Cli\Catalog\DefaultProfileCatalog; use SymPress\Cli\Catalog\DefaultTemplateCatalog; +use SymPress\Cli\Completion\DirectoryCompletion; use SymPress\Cli\Generator\ProjectGenerator; use SymPress\Cli\IO\ProjectQuestionnaire; use SymPress\Cli\Util\ProjectNameNormalizer; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -133,6 +136,13 @@ protected function configure(): void HELP); } + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('directory')) { + DirectoryCompletion::suggest($suggestions); + } + } + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); diff --git a/src/Command/UpdateProjectCommand.php b/src/Command/UpdateProjectCommand.php index 07896e2..4bb5566 100644 --- a/src/Command/UpdateProjectCommand.php +++ b/src/Command/UpdateProjectCommand.php @@ -9,6 +9,7 @@ use SymPress\Cli\Catalog\DefaultPackageCatalog; use SymPress\Cli\Catalog\DefaultProfileCatalog; use SymPress\Cli\Catalog\DefaultTemplateCatalog; +use SymPress\Cli\Completion\DirectoryCompletion; use SymPress\Cli\Project\ProjectMetadata; use SymPress\Cli\Project\ProjectMetadataStore; use SymPress\Cli\Repository\RepositoryManifest; @@ -16,6 +17,8 @@ use SymPress\Cli\Update\ProjectUpdater; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -75,6 +78,13 @@ protected function configure(): void HELP); } + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('directory')) { + DirectoryCompletion::suggest($suggestions); + } + } + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); diff --git a/src/Completion/DirectoryCompletion.php b/src/Completion/DirectoryCompletion.php new file mode 100644 index 0000000..f1e3a31 --- /dev/null +++ b/src/Completion/DirectoryCompletion.php @@ -0,0 +1,16 @@ +suggestDirectories(); + } + } +} diff --git a/tests/Command/DirectoryCompletionTest.php b/tests/Command/DirectoryCompletionTest.php new file mode 100644 index 0000000..bfcf951 --- /dev/null +++ b/tests/Command/DirectoryCompletionTest.php @@ -0,0 +1,93 @@ +, int, bool}> */ + public static function completions(): iterable + { + foreach ([new CreateProjectCommand(), new UpdateProjectCommand()] as $command) { + foreach ( + [ + 'empty' => [[], 0, true], + 'relative' => [['./project'], 0, true], + 'spaces' => [['/tmp/my project'], 0, true], + 'after option' => [['--dry-run', './project'], 1, true], + 'option name' => [['--te'], 0, false], + 'option value' => [['--template', 'starter'], 1, false], + 'inline option value' => [['--template=starter'], 0, false], + ] as $case => [$tokens, $index, $directory] + ) { + yield $command->getName() . ' ' . $case => [$command, $tokens, $index, $directory]; + } + } + } + + #[DataProvider('completions')] + public function testOnlyDirectoryArgumentRequestsNativePaths( + Command $command, + array $tokens, + int $index, + bool $directory, + ): void { + $input = CompletionInput::fromTokens([$command->getName(), ...$tokens], $index + 1); + $input->bind($command->getDefinition()); + $suggestions = new CompletionSuggestions(); + + $command->complete($input, $suggestions); + + self::assertSame([], $suggestions->getValueSuggestions()); + self::assertSame([], $suggestions->getOptionSuggestions()); + if (method_exists($suggestions, 'getPathSuggestion')) { + self::assertSame($directory ? 'directories' : null, $suggestions->getPathSuggestion()); + } else { + self::assertFalse(method_exists($suggestions, 'suggestDirectories')); + } + } + /** @return iterable */ + public static function shells(): iterable + { + foreach (['bash', 'fish', 'zsh'] as $shell) { + foreach (['1', '2'] as $version) { + yield $shell . ' API ' . $version => [$shell, $version]; + } + } + } + + #[DataProvider('shells')] + public function testShellProtocolAndInstalledScriptCompatibility(string $shell, string $version): void + { + $application = new Application(); + $application->addCommand(new CreateProjectCommand()); + $application->addCommand(new UpdateProjectCommand()); + $native = method_exists(CompletionSuggestions::class, 'suggestDirectories') && $version === '2'; + foreach (['project:create', 'new', 'project:update', 'upgrade'] as $name) { + foreach ([['./my project', true], ['--template=starter', false]] as [$token, $directory]) { + $tester = new CommandTester($application->find('_complete')); + self::assertSame(0, $tester->execute([ + '--shell' => $shell, + '--api-version' => $version, + '--input' => ['sympress', $name, $token], + '--current' => '2', + ]), $tester->getDisplay()); + self::assertSame($native && $directory, str_contains($tester->getDisplay(), ':directories')); + } + } + $tester = new CommandTester($application->find('completion')); + self::assertSame(0, $tester->execute(['shell' => $shell]), $tester->getDisplay()); + self::assertStringNotContainsString('{{', $tester->getDisplay()); + } +} From 65bd8f5e164cd147d90009263f8ff31d20e0cbf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 23 Oct 2026 23:35:48 +0200 Subject: [PATCH 2/3] fix: support Git environment isolation with Symfony Process 8.0 --- src/Generator/SymfonyProcessCommandRunner.php | 9 +- src/Repository/RepositoryManifestLoader.php | 16 +- src/Repository/TemplateRevision.php | 26 +-- .../SymfonyProcessCommandRunnerTest.php | 167 ++++++++++++++++++ 4 files changed, 196 insertions(+), 22 deletions(-) create mode 100644 tests/Generator/SymfonyProcessCommandRunnerTest.php diff --git a/src/Generator/SymfonyProcessCommandRunner.php b/src/Generator/SymfonyProcessCommandRunner.php index 6f29ae9..5b99846 100644 --- a/src/Generator/SymfonyProcessCommandRunner.php +++ b/src/Generator/SymfonyProcessCommandRunner.php @@ -11,13 +11,12 @@ final class SymfonyProcessCommandRunner implements CommandRunner { public function run(array $command, ?string $cwd, OutputInterface $output): int { - $process = new Process($command, $cwd); + $environment = null; if (($command[0] ?? null) === 'git') { - if ($cwd === null) { - $process->setWorkingDirectory(sys_get_temp_dir()); - } - $process->setEnv(['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false]); + $cwd ??= sys_get_temp_dir(); + $environment = ['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false]; } + $process = new Process($command, $cwd, $environment); $process->setTimeout(null); $process->run(static function (string $type, string $buffer) use ($output): void { unset($type); diff --git a/src/Repository/RepositoryManifestLoader.php b/src/Repository/RepositoryManifestLoader.php index 7a0a449..a88d8d0 100644 --- a/src/Repository/RepositoryManifestLoader.php +++ b/src/Repository/RepositoryManifestLoader.php @@ -44,12 +44,16 @@ public function load(string $source, string $ref = ''): ?RepositoryManifest if (is_dir($source)) { if (preg_match('/^[a-f0-9]{40}$/D', $ref) === 1) { foreach (self::MANIFEST_PATHS as $path) { - $process = new \Symfony\Component\Process\Process(['git', - '-C', - $source, - 'show', - $ref . ':' . $path]); - $process->setEnv(['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false]); + $process = new \Symfony\Component\Process\Process( + [ + 'git', + '-C', + $source, + 'show', + $ref . ':' . $path, + ], + env: ['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false], + ); if ($process->run() === 0) { return $this->parser->parse($process->getOutput(), $source . '@' . $ref . ':' . $path); } diff --git a/src/Repository/TemplateRevision.php b/src/Repository/TemplateRevision.php index 42bd8a6..bc69c48 100644 --- a/src/Repository/TemplateRevision.php +++ b/src/Repository/TemplateRevision.php @@ -18,17 +18,21 @@ public function resolve(string $repository, string $version): string if (!is_string($ref) || $ref === '' || str_starts_with($ref, '-') || str_contains($ref, "\0")) { throw new RuntimeException('Template version must identify an explicit Git ref.'); } - $process = new Process(['git', - 'ls-remote', - '--', - $repository, - 'refs/heads/' . $ref, - 'refs/tags/' . $ref, - 'refs/tags/' . $ref . '^{}', - 'refs/tags/v' . $ref, - 'refs/tags/v' . $ref . '^{}']); - $process->setWorkingDirectory(sys_get_temp_dir()); - $process->setEnv(['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false]); + $process = new Process( + [ + 'git', + 'ls-remote', + '--', + $repository, + 'refs/heads/' . $ref, + 'refs/tags/' . $ref, + 'refs/tags/' . $ref . '^{}', + 'refs/tags/v' . $ref, + 'refs/tags/v' . $ref . '^{}', + ], + cwd: sys_get_temp_dir(), + env: ['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false], + ); $process->mustRun(); $matches = []; preg_match_all('/^([a-f0-9]{40})\s+([^\r\n]+)$/m', $process->getOutput(), $matches, PREG_SET_ORDER); diff --git a/tests/Generator/SymfonyProcessCommandRunnerTest.php b/tests/Generator/SymfonyProcessCommandRunnerTest.php new file mode 100644 index 0000000..b0a63cf --- /dev/null +++ b/tests/Generator/SymfonyProcessCommandRunnerTest.php @@ -0,0 +1,167 @@ +root = sys_get_temp_dir() . '/cli-git-environment-' . bin2hex(random_bytes(6)); + $this->repository = $this->root . '/template repository'; + mkdir($this->repository . '/.sympress', 0700, true); + $this->git(['init', '-b', 'main']); + $this->git(['config', 'user.name', 'Git environment fixture']); + $this->git(['config', 'user.email', 'fixture@example.test']); + file_put_contents($this->repository . '/.sympress/cli.json', json_encode([ + 'schemaVersion' => 1, + 'profiles' => [[ + 'id' => 'website', 'label' => 'Pinned release', + 'description' => 'Committed manifest.', 'exampleName' => 'fixture-site', + ]], + ], JSON_THROW_ON_ERROR)); + $this->git(['add', '.']); + $this->git(['commit', '-m', 'Committed template manifest']); + $this->revision = $this->git(['rev-parse', 'HEAD']); + $this->git(['tag', 'v1.0.0']); + file_put_contents($this->repository . '/.sympress/cli.json', '{"schemaVersion":999}'); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->root); + } + + /** @return iterable */ + public static function workingDirectories(): iterable + { + yield 'explicit working directory' => [true]; + yield 'temporary working directory with git -C' => [false]; + } + + #[DataProvider('workingDirectories')] + public function testGitUsesOnlyItsOwnDirectoryWorktreeAndIndex(bool $explicitCwd): void + { + $this->withForeignGitEnvironment(function () use ($explicitCwd): void { + $output = new BufferedOutput(); + $command = ['git', ...($explicitCwd ? [] : ['-C', $this->repository]), + 'rev-parse', '--path-format=absolute', '--absolute-git-dir', '--show-toplevel', '--git-path', 'index']; + self::assertSame(0, (new SymfonyProcessCommandRunner())->run( + $command, + $explicitCwd ? $this->repository : null, + $output, + )); + $repository = str_replace('\\', '/', (string) realpath($this->repository)); + self::assertSame( + [$repository . '/.git', $repository, $repository . '/.git/index'], + preg_split('/\r?\n/', trim(str_replace('\\', '/', $output->fetch()))), + ); + }); + } + + public function testRevisionAndPinnedManifestIgnoreForeignGitEnvironment(): void + { + $this->withForeignGitEnvironment(function (): void { + self::assertSame($this->revision, (new TemplateRevision())->resolve($this->repository, '1.0.0')); + $manifest = (new RepositoryManifestLoader())->loadFromRepository($this->repository, $this->revision); + self::assertNotNull($manifest); + self::assertSame('Pinned release', $manifest->profiles[0]->label); + self::assertSame('{"schemaVersion":999}', file_get_contents($this->repository . '/.sympress/cli.json')); + }); + } + + public function testNonGitCommandRetainsItsEnvironmentWorkingDirectoryAndExitCode(): void + { + $this->withForeignGitEnvironment(function (): void { + $output = new BufferedOutput(); + $script = 'echo json_encode([getenv("GIT_DIR"), getenv("GIT_WORK_TREE"), ' + . 'getenv("GIT_INDEX_FILE"), getcwd()]); exit(7);'; + self::assertSame(7, (new SymfonyProcessCommandRunner())->run( + [PHP_BINARY, '-r', $script], + $this->root, + $output, + )); + self::assertSame([ + $this->root . '/foreign.git', $this->root . '/foreign-worktree', $this->root . '/foreign.index', + realpath($this->root), + ], json_decode($output->fetch(), true, flags: JSON_THROW_ON_ERROR)); + }); + } + + public function testGitFailurePreservesExitCodeAndForwardedError(): void + { + $this->withForeignGitEnvironment(function (): void { + $output = new BufferedOutput(); + self::assertSame(128, (new SymfonyProcessCommandRunner())->run( + ['git', '-C', $this->repository, 'rev-parse', '--verify', 'refs/heads/missing'], + null, + $output, + )); + self::assertNotSame('', $output->fetch()); + }); + } + + /** @param callable(): void $operation */ + private function withForeignGitEnvironment(callable $operation): void + { + $values = [ + 'GIT_DIR' => $this->root . '/foreign.git', + 'GIT_WORK_TREE' => $this->root . '/foreign-worktree', + 'GIT_INDEX_FILE' => $this->root . '/foreign.index', + ]; + $previous = []; + foreach ($values as $key => $value) { + $previous[$key] = [getenv($key), array_key_exists($key, $_ENV), $_ENV[$key] ?? null, + array_key_exists($key, $_SERVER), $_SERVER[$key] ?? null]; + putenv($key . '=' . $value); + $_ENV[$key] = $_SERVER[$key] = $value; + } + try { + $operation(); + foreach ($values as $key => $value) { + self::assertSame($value, getenv($key)); + self::assertSame($value, $_ENV[$key]); + self::assertSame($value, $_SERVER[$key]); + } + self::assertFileDoesNotExist($this->root . '/foreign.index'); + } finally { + foreach ($previous as $key => [$environment, $hadEnv, $env, $hadServer, $server]) { + putenv($environment === false ? $key : $key . '=' . $environment); + if ($hadEnv) { + $_ENV[$key] = $env; + } else { + unset($_ENV[$key]); + } + if ($hadServer) { + $_SERVER[$key] = $server; + } else { + unset($_SERVER[$key]); + } + } + } + } + + /** @param list $arguments */ + private function git(array $arguments): string + { + $process = new Process(['git', '-C', $this->repository, ...$arguments], env: [ + 'GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false, + ]); + $process->mustRun(); + return trim($process->getOutput()); + } +} From 35825c13a9ef55ea4326cc8843d4f9a5027f6be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sun, 25 Oct 2026 14:09:45 +0100 Subject: [PATCH 3/3] docs: describe runtime detection and prepare cli v0.1.3 --- CHANGELOG.md | 5 +++++ README.md | 5 ++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 017f667..ce31c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.1.3 — 2026-10-25 + +- Complete the `directory` argument of `project:create` and `project:update` (and their aliases) with the native Symfony Console 8.2 directory suggestions. Files are not suggested, and Console 8.0 and 8.1 behave as before. +- Pass the Git environment isolation of the Process-based helpers through the constructor, so static analysis passes with Symfony Process 8.0 as well. + ## 0.1.2 — 2026-10-09 - Require Symfony Process 8.0.5 or newer to exclude CVE-2026-24739 from consumer dependency resolution. diff --git a/README.md b/README.md index f4dffb0..77353d2 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,5 @@ mkdir -p ~/.zfunc sympress completion zsh > ~/.zfunc/_sympress ``` -Symfony 8.2 is still under development as of 2026-10-23. This integration is -prepared for qualification; publication is held until the stable component and -its final shell scripts have been rechecked. +The feature is detected at runtime. With Console 8.0 or 8.1 the commands behave +exactly as before; it becomes active once Console 8.2 is installed.