Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ jobs:
composer update --${{ matrix.stability }} --prefer-dist --no-interaction

- name: Run tests
run: vendor/bin/phpunit
run: vendor/bin/pest
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ composer.lock
.DS_Store
.phpunit.result.cache
.php-cs-fixer.cache
.phpunit.cache
.claude
54 changes: 54 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
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": "^9.0 || ^10.0",
"phpunit/phpunit": "^10.0 || ^11.0"
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-laravel": "^3.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
7 changes: 5 additions & 2 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" backupGlobals="false" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd" cacheDirectory=".phpunit.cache" backupStaticProperties="false">
<coverage/>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Test Suite">
<directory>./tests</directory>
Expand Down
90 changes: 77 additions & 13 deletions src/Commands/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
';
Expand All @@ -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()) {
Expand All @@ -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
Expand All @@ -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(),
Expand All @@ -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
Expand Down
16 changes: 12 additions & 4 deletions src/Commands/Sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,26 @@ public function handle(): void
});
});

$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 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 ');
}
}
14 changes: 0 additions & 14 deletions tests/ExampleTest.php

This file was deleted.

62 changes: 62 additions & 0 deletions tests/Feature/RsyncOptionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;

beforeEach(function () {
Once::flush();
Http::fake(['api.ipify.org/*' => 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}");
});
Loading