From 08ce6ba42c63c8e84a78eeae6450f4763b9a5e0c Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 14 Mar 2026 07:52:18 +0100 Subject: [PATCH 01/11] Migrate test suite from PHPUnit to Pest and update related configuration and dependencies. --- .github/workflows/run-tests.yaml | 2 +- .gitignore | 2 ++ CLAUDE.md | 54 ++++++++++++++++++++++++++++++++ composer.json | 12 ++++--- phpunit.xml | 7 +++-- tests/ExampleTest.php | 15 ++------- tests/Pest.php | 5 +++ 7 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md create mode 100644 tests/Pest.php diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 87d66ba..0c69e45 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -37,4 +37,4 @@ jobs: composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: Run tests - run: vendor/bin/phpunit + run: vendor/bin/pest diff --git a/.gitignore b/.gitignore index f999f25..540e263 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ composer.lock .DS_Store .phpunit.result.cache .php-cs-fixer.cache +.phpunit.cache +.claude diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..14a14b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +composer install # Install dependencies +composer test # Run tests (alias for vendor/bin/pest) +vendor/bin/pest # Run tests directly +vendor/bin/pint # Fix code style (Laravel Pint) +``` + +Run a single test: +```bash +vendor/bin/pest --filter "test description" +``` + +## Architecture + +This is a **Laravel package** (`aerni/sync`) that provides artisan commands to sync files/folders between environments using `rsync` over SSH. + +### Core Concepts + +- **Remotes**: Named SSH targets (user, host, root, port, read_only) defined in `config/sync.php` +- **Recipes**: Named groups of file paths to sync, also defined in `config/sync.php` +- **Operations**: `push` (local → remote) or `pull` (remote → local) + +### Key Files + +- `src/SyncServiceProvider.php` — Registers the three artisan commands and publishes `config/sync.php` +- `src/SyncCommand.php` — Value object representing a single rsync invocation; implements `Arrayable` and `Stringable` to produce the shell command string +- `src/PathGenerator.php` — Builds local and remote paths; detects if remote host equals local host by calling `api.ipify.org` +- `src/Commands/BaseCommand.php` — Shared base for all three commands; handles argument parsing, interactive prompts (via Laravel Prompts), validation, and building the collection of `SyncCommand` objects +- `src/Commands/Sync.php` — Executes rsync via `Illuminate\Support\Facades\Process` +- `src/Commands/SyncList.php` — Displays a table of origin/target/options/port +- `src/Commands/SyncCommands.php` — Prints the raw rsync shell commands + +### Command Signature Pattern + +`BaseCommand` appends shared arguments/options to the child command's `$signature`: +``` +{operation} {remote} {recipe} {--O|option=*} {--D|dry} +``` +All three commands (`sync`, `sync:list`, `sync:commands`) share this signature via inheritance. + +### Testing + +Tests use [Pest 3](https://pestphp.com) with [Orchestra Testbench](https://github.com/orchestral/testbench). `tests/Pest.php` configures all tests to extend `Orchestra\Testbench\TestCase` automatically, so test files just use `it()` / `expect()` directly without any class boilerplate. + +### CI + +- **run-tests.yaml**: Runs PHPUnit across PHP 8.2/8.3/8.4 × Laravel 11/12 × prefer-lowest/prefer-stable +- **pint.yaml**: Auto-commits style fixes via Laravel Pint on every push diff --git a/composer.json b/composer.json index 45f439a..5de7669 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,10 @@ "laravel/prompts": "^0.1.17 || ^0.2.0 || ^0.3.0" }, "require-dev": { - "nunomaduro/collision": "^8.1", + "nunomaduro/collision": "^8.8", "orchestra/testbench": "^9.0 || ^10.0", - "phpunit/phpunit": "^10.0 || ^11.0" + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0" }, "autoload": { "psr-4": { @@ -32,7 +33,7 @@ } }, "scripts": { - "test": "vendor/bin/phpunit" + "test": "vendor/bin/pest" }, "extra": { "laravel": { @@ -42,7 +43,10 @@ } }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } }, "prefer-stable": true, "minimum-stability": "dev" diff --git a/phpunit.xml b/phpunit.xml index 78049a5..0a1c0a0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,6 +1,9 @@ - - + ./tests diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php index baa38e0..b78810a 100644 --- a/tests/ExampleTest.php +++ b/tests/ExampleTest.php @@ -1,14 +1,5 @@ assertTrue(true); - } -} +it('is true', function () { + expect(true)->toBeTrue(); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..cbeee61 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +extend(TestCase::class)->in(__DIR__); From 75c31ebf83fd8f8c93350e400d8922039cc83915 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 14 Mar 2026 08:34:58 +0100 Subject: [PATCH 02/11] Add unit and feature tests for all package components --- tests/ExampleTest.php | 5 -- tests/Feature/RsyncOptionsTest.php | 62 ++++++++++++++++ tests/Feature/SyncCommandsTest.php | 53 +++++++++++++ tests/Feature/SyncListTest.php | 41 ++++++++++ tests/Feature/SyncTest.php | 52 +++++++++++++ tests/Feature/ValidationTest.php | 111 ++++++++++++++++++++++++++++ tests/Pest.php | 9 ++- tests/Unit/PathGeneratorTest.php | 53 +++++++++++++ tests/Unit/SyncCommandTest.php | 115 +++++++++++++++++++++++++++++ 9 files changed, 495 insertions(+), 6 deletions(-) delete mode 100644 tests/ExampleTest.php create mode 100644 tests/Feature/RsyncOptionsTest.php create mode 100644 tests/Feature/SyncCommandsTest.php create mode 100644 tests/Feature/SyncListTest.php create mode 100644 tests/Feature/SyncTest.php create mode 100644 tests/Feature/ValidationTest.php create mode 100644 tests/Unit/PathGeneratorTest.php create mode 100644 tests/Unit/SyncCommandTest.php diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index b78810a..0000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Feature/RsyncOptionsTest.php b/tests/Feature/RsyncOptionsTest.php new file mode 100644 index 0000000..8e00963 --- /dev/null +++ b/tests/Feature/RsyncOptionsTest.php @@ -0,0 +1,62 @@ + Http::response(['ip' => '1.2.3.4'])]); + + config()->set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + ]); +}); + +it('uses default config options when no CLI options are provided', function () { + config()->set('sync.options', ['--archive']); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + ])->expectsOutputToContain('--archive'); +}); + +it('uses CLI options when provided', function () { + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + '--option' => ['--compress', '--verbose'], + ])->expectsOutputToContain('--compress --verbose'); +}); + +it('adds dry-run flags when the dry option is set', function () { + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + '--dry' => true, + ])->expectsOutputToContain('--dry-run'); +}); + +it('deduplicates options', function () { + $localPath = base_path('storage/app/assets/'); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + '--option' => ['--archive', '--archive'], + ])->expectsOutputToContain("--archive {$localPath}"); +}); diff --git a/tests/Feature/SyncCommandsTest.php b/tests/Feature/SyncCommandsTest.php new file mode 100644 index 0000000..cab0ca7 --- /dev/null +++ b/tests/Feature/SyncCommandsTest.php @@ -0,0 +1,53 @@ + Http::response(['ip' => '1.2.3.4'])]); + + config()->set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/', 'storage/app/img/'], + ]); +}); + +it('outputs one rsync command per recipe path', function () { + $localAssets = base_path('storage/app/assets/'); + $localImg = base_path('storage/app/img/'); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + ]) + ->expectsOutputToContain("rsync -e 'ssh -p 1431' --archive {$localAssets} forge@104.26.3.113:/home/forge/site.com/storage/app/assets/") + ->expectsOutputToContain("rsync -e 'ssh -p 1431' --archive {$localImg} forge@104.26.3.113:/home/forge/site.com/storage/app/img/") + ->assertSuccessful(); +}); + +it('outputs the correct rsync command format for pull', function () { + $localAssets = base_path('storage/app/assets/'); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + ]); + + $this->artisan('sync:commands', [ + 'operation' => 'pull', + 'remote' => 'production', + 'recipe' => 'assets', + ]) + ->expectsOutputToContain("rsync -e 'ssh -p 1431' --archive forge@104.26.3.113:/home/forge/site.com/storage/app/assets/ {$localAssets}") + ->assertSuccessful(); +}); diff --git a/tests/Feature/SyncListTest.php b/tests/Feature/SyncListTest.php new file mode 100644 index 0000000..2f3c637 --- /dev/null +++ b/tests/Feature/SyncListTest.php @@ -0,0 +1,41 @@ + Http::response(['ip' => '1.2.3.4'])]); + Prompt::fallbackWhen(true); + + config()->set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + ]); +}); + +it('executes successfully for a push operation', function () { + $this->artisan('sync:list', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + ])->assertSuccessful(); +}); + +it('executes successfully for a pull operation', function () { + $this->artisan('sync:list', [ + 'operation' => 'pull', + 'remote' => 'production', + 'recipe' => 'assets', + ])->assertSuccessful(); +}); diff --git a/tests/Feature/SyncTest.php b/tests/Feature/SyncTest.php new file mode 100644 index 0000000..d927a06 --- /dev/null +++ b/tests/Feature/SyncTest.php @@ -0,0 +1,52 @@ + Http::response(['ip' => '1.2.3.4'])]); + Process::fake(); + + config()->set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/', 'storage/app/img/'], + ]); +}); + +it('runs rsync for each path in the recipe during dry run', function () { + $this->withoutMockingConsoleOutput() + ->artisan('sync push production assets --dry'); + + $localAssets = base_path('storage/app/assets/'); + $localImg = base_path('storage/app/img/'); + + Process::assertRan(fn ($process) => str_contains($process->command, $localAssets)); + Process::assertRan(fn ($process) => str_contains($process->command, $localImg)); +}); + +it('skips confirmation during dry run', function () { + $this->withoutMockingConsoleOutput() + ->artisan('sync push production assets --dry'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'rsync'), 2); +}); + +it('displays dry run messages', function () { + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + '--dry' => true, + ])->expectsOutputToContain('Starting a dry run'); +}); diff --git a/tests/Feature/ValidationTest.php b/tests/Feature/ValidationTest.php new file mode 100644 index 0000000..b99b8e5 --- /dev/null +++ b/tests/Feature/ValidationTest.php @@ -0,0 +1,111 @@ +set('sync.remotes', [ + 'production' => [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + 'read_only' => false, + ], + 'staging' => [ + 'user' => 'forge', + 'host' => '104.26.3.114', + 'root' => '/home/forge/staging.com', + 'read_only' => true, + ], + ]); + + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/', 'storage/app/img/'], + ]); +}); + +it('validates that operation must be push or pull', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'invalid', + 'remote' => 'production', + 'recipe' => 'assets', + ]); +})->throws(ValidationException::class); + +it('validates that remote must exist in config', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'nonexistent', + 'recipe' => 'assets', + ]); +})->throws(ValidationException::class); + +it('validates that recipe must exist in config', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'nonexistent', + ]); +})->throws(ValidationException::class); + +it('throws when pushing to a read-only remote', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'staging', + 'recipe' => 'assets', + ]); +})->throws(RuntimeException::class, "You can't push to the selected target as it is configured to be read-only."); + +it('allows pulling from a read-only remote', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'pull', + 'remote' => 'staging', + 'recipe' => 'assets', + ])->assertSuccessful(); +}); + +it('throws when local path equals remote path', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '104.26.3.113'])]); + + config()->set('sync.remotes.production.root', base_path()); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + ]); +})->throws(RuntimeException::class, "The origin and target path are one and the same."); + +it('throws when no remotes are configured', function () { + config()->set('sync.remotes', []); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + ]); +})->throws(RuntimeException::class, 'You need to define at least one remote in your config file.'); + +it('throws when no recipes are configured', function () { + config()->set('sync.recipes', []); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + ]); +})->throws(RuntimeException::class, 'You need to define at least one recipe in your config file.'); diff --git a/tests/Pest.php b/tests/Pest.php index cbeee61..79d6220 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,12 @@ extend(TestCase::class)->in(__DIR__); +pest()->extend(TestCase::class) + ->beforeEach(function () { + $this->app->register(SyncServiceProvider::class); + Once::flush(); + }) + ->in(__DIR__); diff --git a/tests/Unit/PathGeneratorTest.php b/tests/Unit/PathGeneratorTest.php new file mode 100644 index 0000000..a4a71a9 --- /dev/null +++ b/tests/Unit/PathGeneratorTest.php @@ -0,0 +1,53 @@ +toBe(base_path('storage/app/assets/')); +}); + +it('generates the remote path with user and host prefix', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $remote = [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'root' => '/home/forge/site.com', + ]; + + expect(PathGenerator::remotePath($remote, 'storage/app/assets/')) + ->toBe('forge@104.26.3.113:/home/forge/site.com/storage/app/assets/'); +}); + +it('generates the remote path without prefix when remote host equals local host', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '104.26.3.113'])]); + + $remote = [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'root' => '/home/forge/site.com', + ]; + + expect(PathGenerator::remotePath($remote, 'storage/app/assets/')) + ->toBe('/home/forge/site.com/storage/app/assets/'); +}); + +it('normalizes multiple slashes in the remote path', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $remote = [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'root' => '/home/forge/site.com/', + ]; + + expect(PathGenerator::remotePath($remote, '/storage/app/assets/')) + ->toBe('forge@104.26.3.113:/home/forge/site.com/storage/app/assets/'); +}); diff --git a/tests/Unit/SyncCommandTest.php b/tests/Unit/SyncCommandTest.php new file mode 100644 index 0000000..4d81603 --- /dev/null +++ b/tests/Unit/SyncCommandTest.php @@ -0,0 +1,115 @@ + Http::response(['ip' => '1.2.3.4'])]); + + $this->remote = [ + 'user' => 'forge', + 'host' => '104.26.3.113', + 'port' => 1431, + 'root' => '/home/forge/site.com', + ]; +}); + +it('generates the correct array for a push operation', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $this->remote, + options: '--archive', + ); + + expect($command->toArray())->toBe([ + 'origin' => base_path('storage/app/assets/'), + 'target' => 'forge@104.26.3.113:/home/forge/site.com/storage/app/assets/', + 'options' => '--archive', + 'port' => '1431', + ]); +}); + +it('generates the correct array for a pull operation', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'pull', + remote: $this->remote, + options: '--archive', + ); + + expect($command->toArray())->toBe([ + 'origin' => 'forge@104.26.3.113:/home/forge/site.com/storage/app/assets/', + 'target' => base_path('storage/app/assets/'), + 'options' => '--archive', + 'port' => '1431', + ]); +}); + +it('generates the correct rsync string for push', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $this->remote, + options: '--archive', + ); + + $localPath = base_path('storage/app/assets/'); + + expect((string) $command) + ->toBe("rsync -e 'ssh -p 1431' --archive {$localPath} forge@104.26.3.113:/home/forge/site.com/storage/app/assets/"); +}); + +it('generates the correct rsync string for pull', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'pull', + remote: $this->remote, + options: '--archive', + ); + + $localPath = base_path('storage/app/assets/'); + + expect((string) $command) + ->toBe("rsync -e 'ssh -p 1431' --archive forge@104.26.3.113:/home/forge/site.com/storage/app/assets/ {$localPath}"); +}); + +it('uses the default port 22 when port is not set', function () { + $remote = collect($this->remote)->except('port')->all(); + + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $remote, + options: '--archive', + ); + + expect($command->toArray()['port'])->toBe('22'); +}); + +it('uses the custom port from the remote', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $this->remote, + options: '--archive', + ); + + expect($command->toArray()['port'])->toBe('1431'); +}); + +it('implements Arrayable and Stringable', function () { + $command = new SyncCommand( + path: 'storage/app/assets/', + operation: 'push', + remote: $this->remote, + options: '--archive', + ); + + expect($command) + ->toBeInstanceOf(Arrayable::class) + ->toBeInstanceOf(Stringable::class); +}); From c4cea4ef8ab93ca34acdc7a491e1a29287d454ba Mon Sep 17 00:00:00 2001 From: marcorieser Date: Sat, 14 Mar 2026 07:35:49 +0000 Subject: [PATCH 03/11] Fix styling --- tests/Feature/ValidationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/ValidationTest.php b/tests/Feature/ValidationTest.php index b99b8e5..9e97702 100644 --- a/tests/Feature/ValidationTest.php +++ b/tests/Feature/ValidationTest.php @@ -88,7 +88,7 @@ 'remote' => 'production', 'recipe' => 'assets', ]); -})->throws(RuntimeException::class, "The origin and target path are one and the same."); +})->throws(RuntimeException::class, 'The origin and target path are one and the same.'); it('throws when no remotes are configured', function () { config()->set('sync.remotes', []); From 2ad09ff240a14cd62a1418a60cda467c75d161c3 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sun, 15 Mar 2026 07:40:26 +0100 Subject: [PATCH 04/11] Remove PHP 8.2 from GitHub Actions test matrix --- .github/workflows/run-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 0c69e45..f1306a4 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -9,7 +9,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - php: [8.4, 8.3, 8.2] + php: [8.4, 8.3] laravel: [11.*, 12.*] stability: [prefer-lowest, prefer-stable] include: From b1a01c5c85ba0ffd905ba38de140aba3eb4f4687 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sun, 15 Mar 2026 07:48:42 +0100 Subject: [PATCH 05/11] Add PHP 8.2 to test matrix and downgrade Pest dependencies to ^3.0 --- .github/workflows/run-tests.yaml | 2 +- composer.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index f1306a4..0c69e45 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -9,7 +9,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - php: [8.4, 8.3] + php: [8.4, 8.3, 8.2] laravel: [11.*, 12.*] stability: [prefer-lowest, prefer-stable] include: diff --git a/composer.json b/composer.json index 5de7669..4b601d1 100644 --- a/composer.json +++ b/composer.json @@ -19,8 +19,8 @@ "require-dev": { "nunomaduro/collision": "^8.8", "orchestra/testbench": "^9.0 || ^10.0", - "pestphp/pest": "^4.0", - "pestphp/pest-plugin-laravel": "^4.0" + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0" }, "autoload": { "psr-4": { From 27eb0bb74d7dd6cf855ca695fcc0923681c576cf Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sun, 15 Mar 2026 08:20:19 +0100 Subject: [PATCH 06/11] Add more tests for code coverage --- tests/Feature/SyncTest.php | 83 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/Feature/SyncTest.php b/tests/Feature/SyncTest.php index d927a06..385c69d 100644 --- a/tests/Feature/SyncTest.php +++ b/tests/Feature/SyncTest.php @@ -50,3 +50,86 @@ '--dry' => true, ])->expectsOutputToContain('Starting a dry run'); }); + +it('displays dry run success message', function () { + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + '--dry' => true, + ])->expectsOutputToContain('The dry run of the'); +}); + +it('aborts when user declines confirmation', function () { + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + ])->expectsConfirmation( + 'You are about to push the assets to production. Are you sure?', + 'no', + )->assertSuccessful(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'rsync')); +}); + +it('runs sync when user confirms', function () { + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + ])->expectsConfirmation( + 'You are about to push the assets to production. Are you sure?', + 'yes', + )->expectsOutputToContain('Syncing files') + ->expectsOutputToContain('The sync of the'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'rsync')); +}); + +it('uses the correct confirmation text for pull', function () { + $this->artisan('sync', [ + 'operation' => 'pull', + 'remote' => 'production', + 'recipe' => 'assets', + ])->expectsConfirmation( + 'You are about to pull the assets from production. Are you sure?', + 'yes', + )->assertSuccessful(); +}); + +it('shows process output in verbose mode without dry run', function () { + $fakeResult = new \Illuminate\Process\FakeProcessResult(exitCode: 0, output: 'rsync output'); + $fakePending = Mockery::mock(\Illuminate\Process\PendingProcess::class); + $fakePending->shouldReceive('forever')->andReturnSelf(); + $fakePending->shouldReceive('run')->andReturnUsing(function ($command, $callback) use ($fakeResult) { + if ($callback) { + $callback('out', 'rsync output'); + } + + return $fakeResult; + }); + + Process::swap($fakePending); + + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => 'assets', + '-v' => true, + ])->expectsConfirmation( + 'You are about to push the assets to production. Are you sure?', + 'yes', + )->expectsOutputToContain('Syncing files') + ->assertSuccessful(); +}); + +it('prompts for missing arguments interactively', function () { + $this->artisan('sync', ['--dry' => true]) + ->expectsChoice('Choose if you want to push or pull', 'push', ['push', 'pull']) + ->expectsChoice('Choose the remote you want to sync with', 'production', ['production']) + ->expectsChoice('Choose the recipe defining the paths to sync', 'assets', ['assets']) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'rsync')); +}); From ec4776fc07852f83277dac95f0d7d7956a1c8ff0 Mon Sep 17 00:00:00 2001 From: marcorieser Date: Sun, 15 Mar 2026 07:20:43 +0000 Subject: [PATCH 07/11] Fix styling --- tests/Feature/SyncTest.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Feature/SyncTest.php b/tests/Feature/SyncTest.php index 385c69d..72e13ed 100644 --- a/tests/Feature/SyncTest.php +++ b/tests/Feature/SyncTest.php @@ -1,5 +1,7 @@ expectsOutputToContain('Syncing files') - ->expectsOutputToContain('The sync of the'); + ->expectsOutputToContain('The sync of the'); Process::assertRan(fn ($process) => str_contains($process->command, 'rsync')); }); @@ -99,8 +101,8 @@ }); it('shows process output in verbose mode without dry run', function () { - $fakeResult = new \Illuminate\Process\FakeProcessResult(exitCode: 0, output: 'rsync output'); - $fakePending = Mockery::mock(\Illuminate\Process\PendingProcess::class); + $fakeResult = new FakeProcessResult(exitCode: 0, output: 'rsync output'); + $fakePending = Mockery::mock(PendingProcess::class); $fakePending->shouldReceive('forever')->andReturnSelf(); $fakePending->shouldReceive('run')->andReturnUsing(function ($command, $callback) use ($fakeResult) { if ($callback) { @@ -121,7 +123,7 @@ 'You are about to push the assets to production. Are you sure?', 'yes', )->expectsOutputToContain('Syncing files') - ->assertSuccessful(); + ->assertSuccessful(); }); it('prompts for missing arguments interactively', function () { From b34ac1a29e56d2e88c0c44b1f3d4fd3bbd4827a0 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sun, 15 Mar 2026 08:56:19 +0100 Subject: [PATCH 08/11] Add Laravel Pint as a development dependency --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 4b601d1..dd9bcea 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "laravel/prompts": "^0.1.17 || ^0.2.0 || ^0.3.0" }, "require-dev": { + "laravel/pint": "^1.29", "nunomaduro/collision": "^8.8", "orchestra/testbench": "^9.0 || ^10.0", "pestphp/pest": "^3.0", From 802cb4085cd3c6a39cbd0761be1759622a94cd8e Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sun, 15 Mar 2026 08:56:34 +0100 Subject: [PATCH 09/11] Support multiple recipes and improve interaction for sync command --- src/Commands/BaseCommand.php | 90 ++++++++++++++++++++++++++++++------ src/Commands/Sync.php | 15 ++++-- 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php index 6a72118..f4778ee 100644 --- a/src/Commands/BaseCommand.php +++ b/src/Commands/BaseCommand.php @@ -10,18 +10,40 @@ 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\multiselect; use function Laravel\Prompts\select; class BaseCommand extends Command implements PromptsForMissingInput { + public const RSYNC_OPTIONS = [ + '--archive' => 'Archive mode (equals -rlptgoD)', + '--compress' => 'Compress file data during transfer', + '--verbose' => 'Increase verbosity', + '--progress' => 'Show progress during transfer', + '--human-readable' => 'Output numbers in a human-readable format', + '--delete' => 'Delete extraneous files from destination', + '--delete-after' => 'Delete after transfer, not during', + '--dry-run' => 'Perform a trial run with no changes made', + '--stats' => 'Give some file-transfer stats', + '--partial' => 'Keep partially transferred files', + '--checksum' => 'Skip based on checksum, not mod-time & size', + '--update' => 'Skip files newer on the receiver', + '--copy-links' => 'Transform symlinks into referent files', + '--no-perms' => 'Do not preserve permissions', + '--no-owner' => 'Do not preserve owner', + '--no-group' => 'Do not preserve group', + '--itemize-changes' => 'Output a change-summary for all updates', + ]; + 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} '; @@ -42,23 +64,53 @@ 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')); + + if ($isInteractive) { + $input->setArgument('recipe', multiselect( + label: 'Choose the recipes defining the paths to sync', + options: array_keys($this->recipes()), + required: 'You must select at least one recipe.', + )); + + if (empty($input->getOption('option'))) { + $configOptions = config('sync.options', []); + $default = array_values(array_intersect($configOptions, array_keys(self::RSYNC_OPTIONS))); + + $input->setOption('option', multiselect( + label: 'Choose rsync options', + options: self::RSYNC_OPTIONS, + default: $default, + hint: 'Config defaults are pre-selected. Use space to toggle.', + )); + } + } + } + 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()) { @@ -72,8 +124,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 @@ -83,7 +136,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(), @@ -102,9 +155,20 @@ 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')); + 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 diff --git a/src/Commands/Sync.php b/src/Commands/Sync.php index 45f9b66..d7e0361 100644 --- a/src/Commands/Sync.php +++ b/src/Commands/Sync.php @@ -48,18 +48,25 @@ public function handle(): void }); }); + $recipes = $this->recipeNames(); + $this->option('dry') - ? $this->info("The dry run of the {$this->argument('recipe')} recipe was successfull.") - : $this->info("The sync of the {$this->argument('recipe')} recipe was successfull."); + ? $this->info("The dry run of the {$recipes} recipe was successfull.") + : $this->info("The sync of the {$recipes} recipe was successfull."); } 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 '); } } From 35e6fa5702cf6eb7ad8b2bca6ed2245175f892a4 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sun, 15 Mar 2026 08:56:42 +0100 Subject: [PATCH 10/11] Refactor tests to support multiple recipes in sync commands --- tests/Feature/RsyncOptionsTest.php | 8 ++--- tests/Feature/SyncCommandsTest.php | 4 +-- tests/Feature/SyncListTest.php | 4 +-- tests/Feature/SyncTest.php | 48 +++++++++++++++++++++++++----- tests/Feature/ValidationTest.php | 26 +++++++++++----- 5 files changed, 67 insertions(+), 23 deletions(-) diff --git a/tests/Feature/RsyncOptionsTest.php b/tests/Feature/RsyncOptionsTest.php index 8e00963..5ba2124 100644 --- a/tests/Feature/RsyncOptionsTest.php +++ b/tests/Feature/RsyncOptionsTest.php @@ -28,7 +28,7 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ])->expectsOutputToContain('--archive'); }); @@ -36,7 +36,7 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], '--option' => ['--compress', '--verbose'], ])->expectsOutputToContain('--compress --verbose'); }); @@ -45,7 +45,7 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], '--dry' => true, ])->expectsOutputToContain('--dry-run'); }); @@ -56,7 +56,7 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], '--option' => ['--archive', '--archive'], ])->expectsOutputToContain("--archive {$localPath}"); }); diff --git a/tests/Feature/SyncCommandsTest.php b/tests/Feature/SyncCommandsTest.php index cab0ca7..c413659 100644 --- a/tests/Feature/SyncCommandsTest.php +++ b/tests/Feature/SyncCommandsTest.php @@ -29,7 +29,7 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ]) ->expectsOutputToContain("rsync -e 'ssh -p 1431' --archive {$localAssets} forge@104.26.3.113:/home/forge/site.com/storage/app/assets/") ->expectsOutputToContain("rsync -e 'ssh -p 1431' --archive {$localImg} forge@104.26.3.113:/home/forge/site.com/storage/app/img/") @@ -46,7 +46,7 @@ $this->artisan('sync:commands', [ 'operation' => 'pull', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ]) ->expectsOutputToContain("rsync -e 'ssh -p 1431' --archive forge@104.26.3.113:/home/forge/site.com/storage/app/assets/ {$localAssets}") ->assertSuccessful(); diff --git a/tests/Feature/SyncListTest.php b/tests/Feature/SyncListTest.php index 2f3c637..63d4fdc 100644 --- a/tests/Feature/SyncListTest.php +++ b/tests/Feature/SyncListTest.php @@ -28,7 +28,7 @@ $this->artisan('sync:list', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ])->assertSuccessful(); }); @@ -36,6 +36,6 @@ $this->artisan('sync:list', [ 'operation' => 'pull', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ])->assertSuccessful(); }); diff --git a/tests/Feature/SyncTest.php b/tests/Feature/SyncTest.php index 72e13ed..133f189 100644 --- a/tests/Feature/SyncTest.php +++ b/tests/Feature/SyncTest.php @@ -1,5 +1,6 @@ artisan('sync', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], '--dry' => true, ])->expectsOutputToContain('Starting a dry run'); }); @@ -57,7 +58,7 @@ $this->artisan('sync', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], '--dry' => true, ])->expectsOutputToContain('The dry run of the'); }); @@ -66,7 +67,7 @@ $this->artisan('sync', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ])->expectsConfirmation( 'You are about to push the assets to production. Are you sure?', 'no', @@ -79,7 +80,7 @@ $this->artisan('sync', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ])->expectsConfirmation( 'You are about to push the assets to production. Are you sure?', 'yes', @@ -93,7 +94,7 @@ $this->artisan('sync', [ 'operation' => 'pull', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ])->expectsConfirmation( 'You are about to pull the assets from production. Are you sure?', 'yes', @@ -117,7 +118,7 @@ $this->artisan('sync', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], '-v' => true, ])->expectsConfirmation( 'You are about to push the assets to production. Are you sure?', @@ -130,8 +131,41 @@ $this->artisan('sync', ['--dry' => true]) ->expectsChoice('Choose if you want to push or pull', 'push', ['push', 'pull']) ->expectsChoice('Choose the remote you want to sync with', 'production', ['production']) - ->expectsChoice('Choose the recipe defining the paths to sync', 'assets', ['assets']) + ->expectsChoice('Choose the recipes defining the paths to sync', ['assets'], ['assets']) + ->expectsChoice('Choose rsync options', ['--archive'], array_merge(array_keys(BaseCommand::RSYNC_OPTIONS), array_values(BaseCommand::RSYNC_OPTIONS))) ->assertSuccessful(); Process::assertRan(fn ($process) => str_contains($process->command, 'rsync')); }); + +it('runs rsync for paths from multiple recipes', function () { + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + 'uploads' => ['storage/app/uploads/'], + ]); + + $this->withoutMockingConsoleOutput() + ->artisan('sync push production assets uploads --dry'); + + $localAssets = base_path('storage/app/assets/'); + $localUploads = base_path('storage/app/uploads/'); + + Process::assertRan(fn ($process) => str_contains($process->command, $localAssets)); + Process::assertRan(fn ($process) => str_contains($process->command, $localUploads)); +}); + +it('uses the correct confirmation text for multiple recipes', function () { + config()->set('sync.recipes', [ + 'assets' => ['storage/app/assets/'], + 'uploads' => ['storage/app/uploads/'], + ]); + + $this->artisan('sync', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets', 'uploads'], + ])->expectsConfirmation( + 'You are about to push the assets and uploads to production. Are you sure?', + 'yes', + )->assertSuccessful(); +}); diff --git a/tests/Feature/ValidationTest.php b/tests/Feature/ValidationTest.php index 9e97702..4eb5aa2 100644 --- a/tests/Feature/ValidationTest.php +++ b/tests/Feature/ValidationTest.php @@ -34,7 +34,7 @@ $this->artisan('sync:commands', [ 'operation' => 'invalid', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ]); })->throws(ValidationException::class); @@ -44,7 +44,7 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'nonexistent', - 'recipe' => 'assets', + 'recipe' => ['assets'], ]); })->throws(ValidationException::class); @@ -54,7 +54,17 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'nonexistent', + 'recipe' => ['nonexistent'], + ]); +})->throws(ValidationException::class); + +it('validates that all recipes must exist in config', function () { + Http::fake(['api.ipify.org/*' => Http::response(['ip' => '1.2.3.4'])]); + + $this->artisan('sync:commands', [ + 'operation' => 'push', + 'remote' => 'production', + 'recipe' => ['assets', 'nonexistent'], ]); })->throws(ValidationException::class); @@ -64,7 +74,7 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'staging', - 'recipe' => 'assets', + 'recipe' => ['assets'], ]); })->throws(RuntimeException::class, "You can't push to the selected target as it is configured to be read-only."); @@ -74,7 +84,7 @@ $this->artisan('sync:commands', [ 'operation' => 'pull', 'remote' => 'staging', - 'recipe' => 'assets', + 'recipe' => ['assets'], ])->assertSuccessful(); }); @@ -86,7 +96,7 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ]); })->throws(RuntimeException::class, 'The origin and target path are one and the same.'); @@ -96,7 +106,7 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ]); })->throws(RuntimeException::class, 'You need to define at least one remote in your config file.'); @@ -106,6 +116,6 @@ $this->artisan('sync:commands', [ 'operation' => 'push', 'remote' => 'production', - 'recipe' => 'assets', + 'recipe' => ['assets'], ]); })->throws(RuntimeException::class, 'You need to define at least one recipe in your config file.'); From 52fef0c0940d94ca408712c91105b2f0bd6e83ac Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sun, 15 Mar 2026 09:19:37 +0100 Subject: [PATCH 11/11] Fix grammar for singular/plural labels in sync command output --- src/Commands/Sync.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Commands/Sync.php b/src/Commands/Sync.php index d7e0361..555ef25 100644 --- a/src/Commands/Sync.php +++ b/src/Commands/Sync.php @@ -49,10 +49,11 @@ public function handle(): void }); $recipes = $this->recipeNames(); + $label = count($this->selectedRecipes()) === 1 ? 'recipe' : 'recipes'; $this->option('dry') - ? $this->info("The dry run of the {$recipes} recipe was successfull.") - : $this->info("The sync of the {$recipes} recipe was successfull."); + ? $this->info("The dry run of the {$recipes} {$label} was successful.") + : $this->info("The sync of the {$recipes} {$label} was successful."); } protected function confirmText(): string