Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
08ce6ba
Migrate test suite from PHPUnit to Pest and update related configurat…
marcorieser Mar 14, 2026
75c31eb
Add unit and feature tests for all package components
marcorieser Mar 14, 2026
c4cea4e
Fix styling
marcorieser Mar 14, 2026
2ad09ff
Remove PHP 8.2 from GitHub Actions test matrix
marcorieser Mar 15, 2026
b1a01c5
Add PHP 8.2 to test matrix and downgrade Pest dependencies to ^3.0
marcorieser Mar 15, 2026
27eb0bb
Add more tests for code coverage
marcorieser Mar 15, 2026
ec4776f
Fix styling
marcorieser Mar 15, 2026
b34ac1a
Add Laravel Pint as a development dependency
marcorieser Mar 15, 2026
802cb40
Support multiple recipes and improve interaction for sync command
marcorieser Mar 15, 2026
35e6fa5
Refactor tests to support multiple recipes in sync commands
marcorieser Mar 15, 2026
52fef0c
Fix grammar for singular/plural labels in sync command output
marcorieser Mar 15, 2026
d488811
Merge branch 'master' into feature/multi-command-support
marcorieser May 20, 2026
e47874c
Update Pest
marcorieser May 20, 2026
ce7d194
Show flag name in rsync option labels
marcorieser May 20, 2026
7fe5a57
Reorder rsync options by usage frequency in interactive prompt
marcorieser May 20, 2026
2772caf
Add short flags to rsync option labels
marcorieser May 20, 2026
2450ffc
Sort config default options to top of interactive rsync options list
marcorieser May 20, 2026
971bbf1
Stream rsync output when output-producing options selected
marcorieser May 26, 2026
2ae652e
Add tests for streaming rsync output based on selected options
marcorieser May 26, 2026
40462dd
Add support for selecting all recipes interactively in sync command
marcorieser May 26, 2026
2def523
Add backup support: new options for `--backup`, `--backup-dir`, `--su…
marcorieser May 27, 2026
537a850
Fix styling
marcorieser May 27, 2026
f538194
Revert "Add backup support: new options for `--backup`, `--backup-dir…
marcorieser May 27, 2026
96e0f96
Remove Claude.md
marcorieser May 27, 2026
efbf249
Use constants for `__all__` recipe option in SyncTest
marcorieser May 27, 2026
9f6595e
Track if rsync options are set interactively and update default behavior
marcorieser May 27, 2026
cdcc928
Fix styling
marcorieser May 27, 2026
0b5ce31
Add .gitattributes to manage export-ignore patterns
marcorieser May 27, 2026
a8ad4f9
Merge remote-tracking branch 'origin/feature/multi-command-support' i…
marcorieser May 27, 2026
9d2b7b2
Move "sync all recipes" to a dedicated confirm step
marcorieser Jul 13, 2026
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
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
* text=auto
/.github export-ignore
/tests export-ignore
.gitattributes export-ignore
.gitignore export-ignore
phpunit.xml export-ignore
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ jobs:
composer update --${{ matrix.stability }} --prefer-dist --no-interaction

- name: Run tests
run: vendor/bin/phpunit
run: vendor/bin/pest
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ composer.lock
.DS_Store
.phpunit.result.cache
.php-cs-fixer.cache
.phpunit.cache
.claude
CLAUDE.md
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ You may use the following options:
| `-O*` or `--option=*` | Override the default rsync options |
| `-D` or `--dry` | Perform a dry run of the sync with real-time output |
| `-v` or `--verbose` | Displays the real-time output of the sync in the terminal |
| `-A` or `--all` | Sync all recipes |

## Usage Examples

Expand All @@ -191,6 +192,11 @@ Echo the real-time output of the sync in your terminal:
php artisan sync pull staging assets --verbose
```

Sync all recipes:
```bash
php artisan sync push production --all
```

List the origin, target, options, and port in a nice table:
```bash
php artisan sync:list pull staging assets
Expand Down
13 changes: 9 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
"laravel/prompts": "^0.1.17 || ^0.2.0 || ^0.3.0"
},
"require-dev": {
"nunomaduro/collision": "^8.1",
"laravel/pint": "^1.29",
"nunomaduro/collision": "^8.8",
"orchestra/testbench": "^10.0 || ^11.0",
"phpunit/phpunit": "^11.0|^12.0|^13.0"
"pestphp/pest": "^3.0 || ^4.0",
"pestphp/pest-plugin-laravel": "^3.0 || ^4.0"
},
"autoload": {
"psr-4": {
Expand All @@ -32,7 +34,7 @@
}
},
"scripts": {
"test": "vendor/bin/phpunit"
"test": "vendor/bin/pest"
},
"extra": {
"laravel": {
Expand All @@ -42,7 +44,10 @@
}
},
"config": {
"sort-packages": true
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"prefer-stable": true,
"minimum-stability": "dev"
Expand Down
31 changes: 11 additions & 20 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
backupGlobals="false"
colors="true"
processIsolation="false"
stopOnFailure="false"
failOnRisky="false"
>
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:ybcI9MKuhLnESRSuWDfnJQuohOXMBaynfbTC5Y5i1FE="/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
<testsuites>
<testsuite name="Test Suite">
<directory>./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>
119 changes: 105 additions & 14 deletions src/Commands/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,46 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

use function Laravel\Prompts\confirm;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\select;

class BaseCommand extends Command implements PromptsForMissingInput
{
private bool $optionsSetInteractively = false;

public const RSYNC_OPTIONS = [
'--archive' => 'Archive mode (-a, --archive)',
'--compress' => 'Compress file data during transfer (-z, --compress)',
'--verbose' => 'Increase verbosity (-v, --verbose)',
'--progress' => 'Show progress during transfer (--progress)',
'--delete' => 'Delete extraneous files from destination (--delete)',
'--dry-run' => 'Perform a trial run with no changes made (-n, --dry-run)',
'--stats' => 'Give some file-transfer stats (--stats)',
'--human-readable' => 'Output numbers in a human-readable format (-h, --human-readable)',
'--delete-after' => 'Delete after transfer, not during (--delete-after)',
'--partial' => 'Keep partially transferred files (--partial)',
'--update' => 'Skip files newer on the receiver (-u, --update)',
'--checksum' => 'Skip based on checksum, not mod-time & size (-c, --checksum)',
'--copy-links' => 'Transform symlinks into referent files (-L, --copy-links)',
'--itemize-changes' => 'Output a change-summary for all updates (-i, --itemize-changes)',
'--no-perms' => 'Do not preserve permissions (--no-perms)',
'--no-owner' => 'Do not preserve owner (--no-owner)',
'--no-group' => 'Do not preserve group (--no-group)',
];

public function __construct()
{
$baseSignature = '
{operation : Choose if you want to push or pull}
{remote : The remote you want to sync with}
{recipe : The recipe defining the paths to sync}
{recipe?* : The recipes defining the paths to sync}
{--O|option=* : An rsync option to use}
{--D|dry : Perform a dry run of the sync}
{--A|all : Sync all recipes}
';

$this->signature .= $baseSignature;
Expand All @@ -42,23 +68,72 @@ protected function promptForMissingArgumentsUsing(): array
label: 'Choose the remote you want to sync with',
options: array_keys($this->remotes()),
),
'recipe' => fn () => select(
label: 'Choose the recipe defining the paths to sync',
options: array_keys($this->recipes()),
),
];
}

protected function interact(InputInterface $input, OutputInterface $output): void
{
parent::interact($input, $output);

$isInteractive = empty($input->getArgument('recipe')) && ! $input->getOption('all');

if ($isInteractive) {
$syncAll = confirm(
label: 'Do you want to sync all recipes?',
default: false,
hint: 'Skips recipe selection and syncs every recipe.',
);

if ($syncAll) {
$input->setOption('all', true);
} else {
$recipeKeys = array_keys($this->recipes());

$selected = multiselect(
label: 'Choose the recipes defining the paths to sync',
options: array_combine($recipeKeys, $recipeKeys),
required: 'You must select at least one recipe.',
);

$input->setArgument('recipe', $selected);
}

if (empty($input->getOption('option'))) {
$configOptions = config('sync.options', []);
$default = array_values(array_intersect($configOptions, array_keys(self::RSYNC_OPTIONS)));

$defaultOptions = array_intersect_key(self::RSYNC_OPTIONS, array_flip($default));
$options = array_merge($defaultOptions, array_diff_key(self::RSYNC_OPTIONS, $defaultOptions));

$input->setOption('option', multiselect(
label: 'Choose rsync options',
options: $options,
default: $default,
hint: 'Config defaults are pre-selected. Use space to toggle.',
));

$this->optionsSetInteractively = true;
}
}
}

protected function validate(): void
{
Validator::validate($this->arguments(), [
Validator::validate([
'operation' => $this->operation(),
'remote' => $this->argument('remote'),
'recipe' => $this->selectedRecipes(),
], [
'operation' => 'required|in:push,pull',
'remote' => ['required', Rule::in(array_keys($this->remotes()))],
'recipe' => ['required', Rule::in(array_keys($this->recipes()))],
'recipe' => 'required|array|min:1',
'recipe.*' => [Rule::in(array_keys($this->recipes()))],
], [
'operation.in' => 'The :attribute [:input] does not exists. Valid values are [push] or [pull].',
'remote.in' => 'The :attribute [:input] does not exists. Please choose a valid remote.',
'recipe.in' => 'The :attribute [:input] does not exists. Please choose a valid recipe.',
'recipe.required' => 'You must provide at least one recipe.',
'recipe.min' => 'You must provide at least one recipe.',
'recipe.*.in' => 'The recipe [:input] does not exist. Please choose a valid recipe.',
]);

if ($this->localPathEqualsRemotePath()) {
Expand All @@ -72,8 +147,9 @@ protected function validate(): void

protected function localPathEqualsRemotePath(): bool
{
return PathGenerator::localPath($this->recipe()[0])
=== PathGenerator::remotePath($this->remote(), $this->recipe()[0]);
return collect($this->recipePaths())->contains(
fn ($path) => PathGenerator::localPath($path) === PathGenerator::remotePath($this->remote(), $path)
);
}

protected function remoteIsReadOnly(): bool
Expand All @@ -83,7 +159,7 @@ protected function remoteIsReadOnly(): bool

protected function commands(): Collection
{
return collect($this->recipe())
return collect($this->recipePaths())
->map(fn ($path) => new SyncCommand(
path: $path,
operation: $this->operation(),
Expand All @@ -102,9 +178,24 @@ protected function remote(): array
return Arr::get($this->remotes(), $this->argument('remote'));
}

protected function recipe(): array
protected function selectedRecipes(): array
{
return Arr::get($this->recipes(), $this->argument('recipe'));
if ($this->option('all')) {
return array_keys($this->recipes());
}

return (array) $this->argument('recipe');
}

protected function recipePaths(): array
{
$recipes = $this->recipes();

return collect($this->selectedRecipes())
->flatMap(fn ($name) => Arr::get($recipes, $name, []))
->unique()
->values()
->all();
}

protected function remotes(): array
Expand Down Expand Up @@ -133,7 +224,7 @@ protected function rsyncOptions(): string
{
$options = $this->option('option');

if (empty($options)) {
if (! $this->optionsSetInteractively && empty($options)) {
$options = config('sync.options');
}

Expand Down
53 changes: 47 additions & 6 deletions src/Commands/Sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,66 @@ public function handle(): void

$this->commands()->each(function ($command) {
Process::forever()->run($command, function (string $type, string $output) {
/* Only show the output if we're performing a dry run or the verbosity is set to verbose */
if ($this->option('dry') || $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
if ($this->shouldStreamOutput()) {
echo $output;
}
});
});

$recipes = $this->recipeNames();
$label = count($this->selectedRecipes()) === 1 ? 'recipe' : 'recipes';

$this->option('dry')
? $this->info("The dry run of the <comment>{$this->argument('recipe')}</comment> recipe was successfull.")
: $this->info("The sync of the <comment>{$this->argument('recipe')}</comment> recipe was successfull.");
? $this->info("The dry run of the <comment>{$recipes}</comment> {$label} was successful.")
: $this->info("The sync of the <comment>{$recipes}</comment> {$label} was successful.");
}

protected function shouldStreamOutput(): bool
{
if ($this->option('dry')) {
return true;
}

if ($this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
return true;
}

return $this->hasOutputProducingOption();
}

protected function hasOutputProducingOption(): bool
{
$options = $this->rsyncOptions();

$producers = [
'--verbose', '-v',
'--progress', '-P',
'--stats',
'--itemize-changes', '-i',
'--human-readable',
];

foreach ($producers as $flag) {
if (preg_match('/(^|\s)'.preg_quote($flag, '/').'(\s|$)/', $options)) {
return true;
}
}

return false;
}

protected function confirmText(): string
{
$operation = $this->argument('operation');
$recipe = $this->argument('recipe');
$recipes = $this->recipeNames();
$remote = $this->argument('remote');
$preposition = $operation === 'pull' ? 'from' : 'to';

return "You are about to $operation the $recipe $preposition $remote. Are you sure?";
return "You are about to $operation the $recipes $preposition $remote. Are you sure?";
}

protected function recipeNames(): string
{
return collect($this->selectedRecipes())->join(', ', ' and ');
}
}
15 changes: 0 additions & 15 deletions tests/ExampleTest.php

This file was deleted.

Loading