Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,33 @@ 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
```

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.
10 changes: 10 additions & 0 deletions src/Command/CreateProjectCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/Command/UpdateProjectCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
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;
use SymPress\Cli\Repository\RepositoryManifestLoader;
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;
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions src/Completion/DirectoryCompletion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace SymPress\Cli\Completion;

/** Capability bridge for Console versions before and after native path completion. */
final class DirectoryCompletion
{
public static function suggest(object $suggestions): void
{
if (method_exists($suggestions, 'suggestDirectories')) {
$suggestions->suggestDirectories();
}
}
}
9 changes: 4 additions & 5 deletions src/Generator/SymfonyProcessCommandRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 10 additions & 6 deletions src/Repository/RepositoryManifestLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
26 changes: 15 additions & 11 deletions src/Repository/TemplateRevision.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
93 changes: 93 additions & 0 deletions tests/Command/DirectoryCompletionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace SymPress\Cli\Tests\Command;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use SymPress\Cli\Command\CreateProjectCommand;
use SymPress\Cli\Command\UpdateProjectCommand;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;

final class DirectoryCompletionTest extends TestCase
{
/** @return iterable<string, array{Command, list<string>, 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<string, array{string, string}> */
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());
}
}
Loading
Loading