alexskrypnyk / snapshot
Directory snapshot, diff, and patch system useful for test fixtures.
Requires
- php: >=8.3
- alexskrypnyk/file: ^1.2.0
- sebastian/diff: ^6.0 || ^7.0
Requires (Dev)
- composer: >=2.10
- alexskrypnyk/phpunit-helpers: ^1.0.0
- dealerdirect/phpcodesniffer-composer-installer: ^1.2.1
- drevops/phpcs-standard: ^0.7
- drupal/coder: ^9.0.1
- ergebnis/composer-normalize: ^2.52.0
- phpbench/phpbench: ^1.7.0
- phpstan/phpstan: ^2.2.13
- phpunit/phpunit: ^12.5.34
- rector/rector: ^2.6.6
- symfony/process: ^7.4.18
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Directory snapshot, diff, and patch system useful for test fixtures
✨ Features
- Directory comparison - Compare two directories for identical structure and content
- Baseline + diff architecture - Store a baseline once, then only diffs per test scenario
- Unified diff format - Human-readable patch files that can be reviewed in PRs
- Auto-update snapshots - Automatically update snapshots when tests fail
- Batch update CLI - Regenerate many snapshots at once, in parallel, with timeouts and retries
- Version normalization - Replace volatile versions and hashes before snapshots are written
- Flexible ignore rules - Skip files, directories, or ignore content differences
- PHPUnit integration - Simple trait with intuitive assertions
🎯 Use Cases
This library is designed for testing systems that generate file output:
- Template repositories - Test scaffolds, skeletons, and boilerplate generators to ensure customization options produce the expected file structure
- Code generators - Verify that generated code matches expected output across different configuration scenarios
- Build tools - Assert that compilation or transformation processes produce correct artifacts
- Migration scripts - Validate that file transformations work correctly
For example, if you maintain a project template with customizable options (like choosing a database driver or enabling optional features), you can use this library to test each combination of options produces the correct files.
🧩 Concepts
Baseline
A baseline is a reference directory containing the expected file structure and content. It represents the "golden master" that your test output is compared against.
fixtures/
└── _baseline/ # The baseline directory
├── composer.json
├── src/
│ └── App.php
└── README.md
Snapshot (Scenario)
A snapshot (or scenario) represents differences from the baseline for a specific test case. Instead of duplicating the entire expected output, you only store the files that differ.
fixtures/
├── _baseline/ # Shared baseline
│ └── ...
├── scenario_mysql/ # Only files that differ for MySQL option
│ └── config/
│ └── database.php
└── scenario_postgres/ # Only files that differ for PostgreSQL option
└── config/
└── database.php
Diff Files
Snapshot directories contain diff files in unified diff format. These describe how a file should differ from its baseline version:
@@ -1,8 +1,8 @@ <?php return [ - 'driver' => 'sqlite', - 'database' => ':memory:', + 'driver' => 'mysql', + 'host' => 'localhost', + 'database' => 'app', ];
Snapshot directories can also contain:
- New files - Full file content for files not in baseline (copied as-is)
- Deletion markers - Files prefixed with
-(e.g.,-README.md) indicate the file should not exist in this scenario
📦 Installation
composer require --dev alexskrypnyk/snapshot
🚀 Usage
Basic Directory Comparison
Use assertDirectoriesIdentical() to compare two directories:
use AlexSkrypnyk\Snapshot\Testing\SnapshotTrait; use PHPUnit\Framework\TestCase; class MyTest extends TestCase { use SnapshotTrait; public function testGeneratorOutput(): void { // Run your code generator $generator->generate($output_dir); // Compare against expected output $this->assertDirectoriesIdentical($expected_dir, $output_dir); } }
Baseline + Diff Testing
For multiple test scenarios sharing common files, use a baseline directory with scenario-specific diffs:
public function testScenarioA(): void { $generator->generate($output_dir, ['option' => 'A']); $this->assertSnapshotMatchesBaseline( $baseline_dir, // Common baseline $scenario_a_diffs_dir, // Diffs specific to scenario A $output_dir // Actual output ); }
This approach:
- Reduces duplication across test fixtures
- Makes differences between scenarios explicit
- Produces reviewable diff files in pull requests
Auto-Update Snapshots
Enable automatic snapshot updates when tests fail:
protected function tearDown(): void { // Updates snapshots when UPDATE_SNAPSHOTS=1 is set $this->snapshotUpdateOnFailure($snapshots_dir, $actual_dir); parent::tearDown(); }
Run tests with the environment variable:
UPDATE_SNAPSHOTS=1 ./vendor/bin/phpunit
Batch Snapshot Updates
For tests with many datasets, use the update-snapshots CLI tool to update snapshots with timeout handling, automatic retries, and parallel execution:
# Update all datasets for a test vendor/bin/update-snapshots testMySnapshot tests/snapshots # Update specific datasets vendor/bin/update-snapshots testMySnapshot tests/snapshots baseline scenario1 # Run with 8 parallel jobs vendor/bin/update-snapshots --jobs=8 testMySnapshot tests/snapshots # Specify project root (useful when running from subdirectory) vendor/bin/update-snapshots --root=../.. testMySnapshot tests/snapshots
The tool:
- Discovers all datasets from PHPUnit test list
- Runs baseline dataset first (sequentially), then remaining scenarios in parallel
- Handles timeouts with configurable retries
- Auto-commits baseline and snapshot changes
- Shows a live TUI progress display with scrolling when running in a terminal
- Terminates every spawned dataset process and exits
130(SIGINT) or143(SIGTERM) when signalled; requires thepcntlextension
Options:
--root=<path>- Project root directory (default: current directory)--test-dir=<path>- Directory containing tests (default:tests)--timeout=<seconds>- Timeout per test run (default: 30)--retries=<count>- Max retries for timed out tests (default: 12)--jobs=<count>- Number of parallel jobs for scenarios (default: 4)--debug- Show PHPUnit output for failed tests--help- Show the usage summary and exit
Exit Codes
Updating a snapshot is the expected outcome, so the tool exits 0 when it updates one. It exits non-zero only when a dataset genuinely cannot be updated - a failure that is not a snapshot mismatch, or a run that keeps timing out. A single PHPUnit run still exits non-zero after updating a snapshot, because the assertion fails before tearDown() rewrites the files; the tool reclassifies those runs as updated.
Parallel Execution
When updating all datasets, the baseline is always run first (since other scenarios may depend on it). Once the baseline completes, all remaining scenarios run in parallel using the number of jobs specified by --jobs.
In a TTY terminal, a live progress display shows the status of all tasks with keyboard scrolling (arrow keys and Page Up/Down). In non-TTY environments (e.g., CI), results are printed after all tasks complete.
Ignore Rules
Create a .ignorecontent file in your baseline directory to control which files are compared and how.
# Skip by file name, anywhere in the tree
*.log
.DS_Store
# Skip by path, relative to this directory
node_modules/
build/cache/
# Include a file that a path rule would otherwise skip
!build/cache/manifest.json
# Ignore content differences - verify the file exists, but allow any content
^composer.lock
^package-lock.json
A pattern is matched in one of two ways, depending on whether it contains a /:
- Without a
/the pattern is matched against the file name alone, so*.logskips every.logfile at any depth. - With a
/the pattern is matched against the path relative to the directory being indexed, sobuild/cache/only skips that one directory.
A ! rule overrides either kind: it is matched against both the file name and the relative path, so !important.log keeps that file even though *.log would otherwise skip it.
A !^ rule overrides content ignoring instead: !^composer.lock keeps comparing that file's content even though ^composer.lock would otherwise leave it unchecked. Unlike !, a !^ rule is matched only against the relative path, the same way a ^ rule is.
The .ignorecontent file itself and the .git/ directory are always skipped and cannot be re-included by any ! rule.
Patterns are always written with a forward slash, on every platform. The file is portable text, so a Windows host reads build/cache/ as the same path pattern a Linux host does.
Why Ignore Content?
Some files should exist but have unpredictable or environment-specific content:
composer.lock- You want to verify it was generated, but the exact content depends on dependency resolution timing and isn't meaningful to testpackage-lock.json- Same as above for npm dependencies- Generated timestamps - Files containing build dates or version hashes
- Environment configs - Files that vary between CI and local environments
Using ^filename ensures the file exists without failing on content differences. Comparison is the only operation the rule changes: sync() and patch() still copy the file verbatim, so an ignored file reaches its destination intact.
Pattern Reference
| Pattern | Effect |
|---|---|
*.log |
Skip every file whose name matches the glob, at any depth |
cache/ |
Skip the directory and everything under it |
cache/* |
Skip the files directly in the directory, but not its subdirectories |
!cache/keep.txt |
Include this file even though another rule would otherwise skip it |
^composer.lock |
Check that the file exists, but do not compare its content |
^cache/ |
Check that files under the directory exist, but do not compare their content |
!^composer.lock |
Compare this file's content even though a ^ rule would otherwise ignore it |
Programmatic API
Use the Snapshot class directly for custom workflows:
use AlexSkrypnyk\Snapshot\Snapshot; // Scan a directory $index = Snapshot::scan($directory); // Compare directories $comparer = Snapshot::compare($baseline, $actual); echo $comparer->render(); // Create diff files Snapshot::diff($baseline, $actual, $output_dir); // Apply diffs to the baseline Snapshot::patch($baseline, $diffs, $destination); // Sync directories Snapshot::sync($source, $destination);
Fluent Builder API
For configured operations with rules, a file filter or a content processor, use SnapshotBuilder:
use AlexSkrypnyk\Snapshot\Index\IndexedFile; use AlexSkrypnyk\Snapshot\Rules\Rules; use AlexSkrypnyk\Snapshot\SnapshotBuilder; // Create a reusable builder with configuration $builder = SnapshotBuilder::create() ->withRules(Rules::phpProject()) ->addSkip('custom/') ->addIgnoreContent('custom.lock') ->addInclude('custom/keep.txt') ->addIncludeContent('custom/keep.log') // Drop every generated file from the index ->withFileFilter(fn(IndexedFile $file) => !str_contains( $file->getPathnameFromBasepath(), 'generated/' )) // Normalise the content of every file written by patch() ->withContentProcessor(fn(string $content) => trim($content)); // Use the builder for multiple operations $index = $builder->scan($directory); $comparer = $builder->compare($dir1, $dir2); $builder->sync($source, $destination); $builder->diff($baseline, $actual, $output); $builder->patch($baseline, $diffs, $destination);
The two callbacks serve different operations and receive different values:
| Callback | Used by | Receives | Effect |
|---|---|---|---|
withFileFilter() |
scan(), compare(), diff(), sync() |
An IndexedFile |
Returning FALSE excludes the file from the index |
withContentProcessor() |
patch() |
The patched file content as a string | The returned string is written back to the file |
patch() takes no file filter: its baseline sync and its scan of the diffs directory are driven by the configured rules alone.
Programmatic Rules
Configure comparison rules programmatically using the Rules class:
use AlexSkrypnyk\Snapshot\Rules\Rules; use AlexSkrypnyk\Snapshot\Snapshot; // Use preset rules for common project types $rules = Rules::phpProject(); // Skips vendor/, ignores composer.lock $rules = Rules::nodeProject(); // Skips node_modules/, ignores lock files // Or create custom rules with fluent API $rules = Rules::create() ->skip('vendor/', 'node_modules/', '.git/') ->ignoreContent('composer.lock', 'package-lock.json', 'reports/') // Keeps this one file even though vendor/ is skipped ->include('vendor/autoload.php') // Compares this one file's content even though reports/ is content-ignored ->includeContent('reports/summary.json'); // Or load them from an existing .ignorecontent file $rules = Rules::fromFile($baseline . '/.ignorecontent'); // Use rules with Snapshot operations $comparer = Snapshot::compare($baseline, $actual, $rules);
The presets are built from rule sets. Extend AbstractRuleSet to define your own reusable set and turn it into rules with Rules::fromRuleSet():
use AlexSkrypnyk\Snapshot\Rules\AbstractRuleSet; use AlexSkrypnyk\Snapshot\Rules\Rules; class MyProjectRuleSet extends AbstractRuleSet { protected const SKIP_PATTERNS = ['dist/', '.cache/']; protected const IGNORE_CONTENT_PATTERNS = ['reports/']; protected const GLOBAL_PATTERNS = ['*.log']; protected const INCLUDE_PATTERNS = ['dist/keep.txt']; protected const INCLUDE_CONTENT_PATTERNS = ['reports/summary.json']; } $rules = Rules::fromRuleSet(new MyProjectRuleSet());
Each constant maps to the matching Rules pattern list, and a set can define only the constants it needs - the rest default to empty.
Version Normalization
When updating snapshots, volatile content like version numbers and commit hashes can cause unnecessary churn. The Replacer class automatically normalizes this content during snapshot updates.
Default Behavior
The snapshotUpdateBefore() hook automatically applies version normalization using File::getReplacer()->addVersionReplacements():
// This happens automatically in snapshotUpdateOnFailure() File::getReplacer()->addVersionReplacements()->replaceInDir($actual);
The default patterns replace:
- Semver versions (
1.2.3,v1.2.3-beta.1) →__VERSION__ - Git hashes prefixed with
@or#(@abc123...) →@__HASH__ - SRI integrity hashes (
sha512-...) →__INTEGRITY__ - Docker image tags, including digests and
canary(nginx:1.21.0) →nginx:__VERSION__ - GitHub Actions versions and digests (
actions/checkout@v4) →actions/checkout@__VERSION__ - Node versions in workflows (
node-version: 20.1.0) →node-version: __VERSION__ - Package versions in JSON (
"^1.2.3") →"__VERSION__"
Customizing Version Replacement
Override snapshotUpdateBefore() to customize the replacement behavior:
protected function snapshotUpdateBefore(string $actual): void { // Use default patterns but add custom ones $build = Replacement::create('build', '/BUILD-\d+/', '__BUILD__'); File::getReplacer() ->addVersionReplacements() ->setMaxReplacements(0) ->addReplacement($build) ->replaceInDir($actual); }
Or disable version replacement entirely:
protected function snapshotUpdateBefore(string $actual): void { // Do nothing - keep versions as-is }
Standalone Usage
Use Replacer independently for custom workflows:
use AlexSkrypnyk\File\File; use AlexSkrypnyk\File\Replacer\Replacement; // Use preset version patterns $replacer = File::getReplacer()->addVersionReplacements(); $replacer->replaceInDir($directory); // Or create custom replacer $version = Replacement::create('version', '/v\d+\.\d+\.\d+/', '__VERSION__'); $date = Replacement::create('date', '/\d{4}-\d{2}-\d{2}/', '__DATE__'); $replacer = File::getReplacer() ->addReplacement($version) ->addReplacement($date); // Apply to string content $content = 'Version: v1.2.3'; $replacer->replace($content); // $content is now 'Version: __VERSION__' // Apply to directory $replacer->replaceInDir($directory);
⬆️ Upgrading
This release settles the public API and contains breaking changes.
- Read this first - one break is silent.
assertSnapshotMatchesBaseline()reordered its three leading string parameters from($actual, $baseline, $diffs)to($baseline, $diffs, $actual). Every parameter is a string path, so an un-updated call still type-checks and runs against the wrong directories instead of failing at the call site. Update every call site before upgrading. - The companion break is loud.
assertDirectoriesIdentical()moved$messageto last and promoted$rulesto third, so an un-updated call that passed a message third now throws aTypeErrorrather than misbehaving quietly. sync()no longer rewrites content-ignored files.Snapshot::sync()andSnapshotBuilder::sync()used to write the fixed markercontent_ignoredin place of the content of every file matched by a^rule, so syncing a tree that carried a.ignorecontentproduced a destination with placeholder text instead of the real files. They now copy every file verbatim. Passplaceholder_ignored_content: TRUEto get the old behaviour, which is whatSnapshotTraituses to keep volatile files stable in a committed baseline.
Every renamed or reshaped public symbol:
| Old | New |
|---|---|
assertSnapshotMatchesBaseline($actual, $baseline, $diffs, $expected, $message) |
assertSnapshotMatchesBaseline($baseline, $diffs, $actual, $expected, $message) |
assertDirectoriesIdentical($dir1, $dir2, $message, $match_content, $show_diff, $rules) |
assertDirectoriesIdentical($expected, $actual, $rules, $file_filter, $show_diff, $message) |
Snapshot::scan/compare/diff/sync(..., $content_processor) |
Snapshot::scan/compare/diff/sync(..., $file_filter) |
Snapshot::patch($baseline, $patches, ...) |
Snapshot::patch($baseline, $diffs, ...) |
SnapshotBuilder::patch($baseline, $patches, ...) |
SnapshotBuilder::patch($baseline, $diffs, ...) |
SnapshotBuilder::withContentProcessor() fed every operation |
withContentProcessor() feeds patch() only; new withFileFilter() feeds scan(), compare(), diff(), sync() |
SnapshotBuilder::withContentProcessor(callable $processor) |
SnapshotBuilder::withContentProcessor(callable $content_processor) |
Index::__construct(..., $beforeMatchContent) |
Index::__construct(..., $fileFilter) |
Index::getFiles($cb) |
Index::getFiles($transformer) |
Comparer::getAbsentLeftDiffs/getAbsentRightDiffs/getContentDiffs($cb) |
the same methods taking $transformer |
Comparer::addLeftFile/addRightFile(): void |
the same methods returning static, on ComparerInterface too |
IndexedFile::setBasepath/setContent/setIgnoreContent(): void |
the same methods returning static, on IndexedFileInterface too |
RuleSetInterface::getSkipPatterns() |
RuleSetInterface::getSkip() |
RuleSetInterface::getIgnoreContentPatterns() |
RuleSetInterface::getIgnoreContent() |
RuleSetInterface::toRules() |
removed; use Rules::fromRuleSet($set) or $set->applyTo() |
RuleSetInterface::applyTo(?Rules $rules): Rules |
applyTo(?RulesInterface $rules): RulesInterface |
Rules::create/fromRuleSet/phpProject/nodeProject/fromFile(): self |
the same factories returning static |
?Rules parameters and returns across the facade, builder, trait and Index |
?RulesInterface |
PatchException::$file_path/$line_number/$line_content |
$filePath/$lineNumber/$lineContent; the getters are unchanged |
RulesInterface::addSkip/addIgnoreContent/addGlobal/addInclude/addIncludeContent(string $pattern) |
the same methods taking string ...$patterns |
SyncerInterface::sync($dst, $permissions, $copy_empty_dirs) |
sync($dst, $permissions, $copy_empty_dirs, $placeholder_ignored_content) |
Additive for callers, breaking for direct implementers: Rules::global() (also on RulesInterface) and RuleSetInterface::getGlobal()/getInclude()/getIncludeContent() with their GLOBAL_PATTERNS, INCLUDE_PATTERNS and INCLUDE_CONTENT_PATTERNS constants, the variadic RulesInterface::add*() signatures, and the fourth SyncerInterface::sync() parameter. Classes extending Rules, AbstractRuleSet or Syncer inherit them and need no change; classes implementing RulesInterface, RuleSetInterface or SyncerInterface directly must match the new signatures. Purely additive: SnapshotBuilder::addGlobal(), SnapshotBuilder::withFileFilter()/getFileFilter() and the Rules::PATTERN_SEPARATOR constant.
🤝 Contributing
See CONTRIBUTING.md for local development setup, the linting and testing commands, and how to run the performance benchmarks.
🔄 Updating
To pull the latest infrastructure from the template into this project, ask Claude Code to "update scaffold" - see AGENTS.md for details.
This repository was created using the Scaffold project template
