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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
* text=auto eol=lf

/.github export-ignore
/art export-ignore
/docs export-ignore
/tests export-ignore
/.editorconfig export-ignore
/.gitattributes export-ignore
Expand Down
53 changes: 10 additions & 43 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<p align="center">
<img src="art/social.svg" alt="Difflock — diff, analyze and protect your Laravel database schema" width="100%">
</p>

# Difflock

[![Latest Version](https://img.shields.io/packagist/v/heyosseus/difflock.svg)](https://packagist.org/packages/heyosseus/difflock)
Expand All @@ -9,40 +13,13 @@

Difflock reads your migrations and your database, tells you what is about to change, how badly it could go, and stops the changes that should not run unattended.

```
Diff what changed. Analyze the risk. Lock dangerous changes.
```
<img src="art/pillars.svg" alt="Difflock's three jobs: diff what changed, analyze the risk, lock dangerous changes" width="100%">

```bash
composer require heyosseus/difflock --dev
php artisan difflock:lint
```

```text
Difflock · Migration Analysis
────────────────────────────────────────

2026_08_10_120000_remove_legacy_token

✗ CRITICAL DROP COLUMN users.legacy_token
drop-column:14 · destructive, not reversible
Dropping a column destroys the values in it. A `down()` that adds the
column back gives you the column and not one row of what was in it.
The table holds 4,921,000 rows.
→ Stop reading and writing the column in application code first,
deploy that, and drop it in a later migration once you are sure
nothing needs it.

Risk
✓ Safe: 0
⚠ Low: 1
⚠ Medium: 0
✗ High: 0
✗ Critical: 1

3 migrations analysed.
```

---

## Contents
Expand Down Expand Up @@ -70,21 +47,7 @@ php artisan difflock:lint

## Overview

Difflock has three jobs, and it keeps them separate.

```text
DIFFLOCK
┌────────────┼────────────┐
│ │ │
DIFF ANALYZE LOCK
│ │ │
What changed? Is it risky? Should it run?
│ │ │
└────────────┼────────────┘
DATABASE
```
Difflock has three jobs, and it keeps them separate. The diff engine does not know how findings are rendered; the rules do not know Artisan exists; the guard consumes analysis rather than repeating it. [Architecture tests](tests/ArchTest.php) enforce each of those boundaries.

| Command | Answers |
| --- | --- |
Expand Down Expand Up @@ -242,6 +205,10 @@ php artisan difflock:lint --accept # record what it found as accepted
php artisan difflock:lint --path=database/migrations/legacy
```

<img src="art/terminal.svg" alt="difflock:lint output — 14 critical and 125 high findings across 170 migrations, summarised on one screen" width="100%">

*Real output from a 170-migration production application: 251 findings on one screen. The summary's length does not grow with the number of findings — `-v` expands every one, `--rule=` takes them a rule at a time.*

Only pending migrations are analysed by default. A migration that has already run cannot be made safer by a finding, and a build that fails over a drop committed two years ago is a build nobody keeps green.

When nothing is pending — which is the normal state of a machine that is up to date — it audits every migration instead of printing nothing, and says that is what it did.
Expand Down
40 changes: 40 additions & 0 deletions art/pillars.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions art/social.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 46 additions & 0 deletions art/terminal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 39 additions & 1 deletion src/Console/Commands/MigrateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public function handle(MigrationGuard $guard, Repository $config, ReportRenderer

Banner::render($this->output, 'Difflock · Migration Guard');

$renderer->render($this->output, $decision->report);
$this->report($renderer, $decision);

if ($this->option('dry-run') === true) {
$this->note('Dry run. Nothing was written to the database.');
Expand All @@ -117,6 +117,44 @@ public function handle(MigrationGuard $guard, Repository $config, ReportRenderer
return $this->migrate($decision);
}

/**
* Show what the guard found, in the detail the situation deserves.
*
* Everywhere else in Difflock a summary is right, because the reader is browsing.
* Here they are not: the guard has stopped them writing to a database, and the
* findings that caused it are the whole reason to read the output. So a blocked
* run prints every blocking finding in full and collapses only the rest.
*
* When nothing is in scope the renderer is skipped entirely — it would say "no
* migrations were found to analyse" and suggest fixing `--path`, which on an
* application whose migrations have all simply been applied is untrue.
*/
private function report(ReportRenderer $renderer, GuardDecision $decision): void
{
if ($decision->report->migrations === []) {
$this->note('No migrations are pending, so there was nothing to analyse.');

return;
}

if (! $decision->blocked) {
$renderer->render($this->output, $decision->report, 'difflock:migrate');

return;
}

$blocking = $decision->report->only(atLeast: $decision->threshold);

$renderer->detail($this->output, $blocking);

$remaining = $decision->report->summary()->total - $blocking->summary()->total;

if ($remaining > 0) {
$this->note($remaining.' further finding'.($remaining === 1 ? '' : 's').' below '
.$decision->threshold->label().' are not shown — php artisan difflock:lint');
}
}

/**
* Hand over to Laravel's own migrator.
*
Expand Down
11 changes: 8 additions & 3 deletions src/Console/Renderers/CheckupRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,13 @@ public function render(OutputInterface $output, CheckupResult $result, bool $ter
$this->diffs->render($output, $result->drift);
}

if (! $terse || $result->report->fails($result->threshold)) {
$this->reports->render($output, $result->report);
// Nothing in scope is already stated by the migration line above. Letting the
// report renderer speak here would have it say "no migrations were found to
// analyse" and suggest fixing `--path` — which on an application whose 170
// migrations have simply all been applied is false, and sends the reader
// debugging something that is not wrong.
if ($result->report->migrations !== [] && (! $terse || $result->report->fails($result->threshold))) {
$this->reports->render($output, $result->report, 'difflock:check');
}

$output->writeln($result->failed()
Expand Down Expand Up @@ -75,7 +80,7 @@ public function overview(OutputInterface $output, CheckupResult $result): void
if ($summary->total > 0) {
$worst = $summary->highest;

$this->reports->render($output, $result->report->only(atLeast: $worst));
$this->reports->render($output, $result->report->only(atLeast: $worst), 'difflock:lint');

$remaining = $summary->total - $summary->count($worst);

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

declare(strict_types=1);

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

beforeEach(function (): void {
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('legacy_token')->nullable();
});

Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('customer_id');
});

config()->set('difflock.migrations.paths', [fixtures()]);
});

/**
* Everywhere else a summary is right, because the reader is browsing. Here they are
* not: the guard has just stopped them writing to a database, and the findings that
* caused it are the entire reason to read the output.
*/
it('prints every blocking finding in full when it blocks', function (): void {
[$exit, $output] = runCommand('difflock:migrate');

expect($exit)->toBe(1)
->and($output)->toContain('Migration blocked')
->toContain('DROP COLUMN users.legacy_token')
// The explanation, not just the headline — this is the detail view.
->toContain('Dropping a column destroys the values in it')
->and(Schema::hasColumn('users', 'legacy_token'))->toBeTrue();
});

it('collapses the findings that are not blocking', function (): void {
[, $output] = runCommand('difflock:migrate');

expect($output)->toContain('below CRITICAL are not shown')
->toContain('difflock:lint');
});

it('summarises rather than expanding when nothing blocks', function (): void {
[$exit, $output] = runCommand('difflock:migrate', [
'--dry-run' => true,
'--path' => [fixtures('safe')],
'--realpath' => true,
]);

expect($exit)->toBe(0)
->and($output)->toContain('Dry run')
->and($output)->not->toContain('Migration blocked');
});

/**
* An application whose migrations have all been applied has an empty pending scope.
* Saying "no migrations were found to analyse" there is false, and the advice that
* followed it — check your `--path` — sends the reader debugging nothing.
*/
it('says nothing is pending rather than claiming it found no migrations', function (): void {
runCommand('migrate', ['--path' => [fixtures()], '--realpath' => true, '--force' => true]);

[, $output] = runCommand('difflock:migrate', ['--dry-run' => true]);

expect($output)->toContain('No migrations are pending')
->and($output)->not->toContain('No migrations were found to analyse')
->and($output)->not->toContain('--path');
});

it('does not tell difflock:check to fix its --path either', function (): void {
runCommand('migrate', ['--path' => [fixtures()], '--realpath' => true, '--force' => true]);
runCommand('difflock:diff', ['--save' => true]);

[$exit, $output] = runCommand('difflock:check');

expect($exit)->toBe(0)
->and($output)->toContain('No pending migrations')
->and($output)->not->toContain('No migrations were found to analyse');
});
4 changes: 3 additions & 1 deletion tests/Feature/ProtectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@
[$exit, $output] = runCommand('difflock:migrate', ['--path' => ['/definitely/not/a/directory'], '--realpath' => true]);

expect($exit)->toBe(0)
->and($output)->toContain('No migrations were found to analyse');
// The guard says nothing is pending — not that it could not find any
// migration files, which would send the reader debugging their --path.
->and($output)->toContain('No migrations are pending');
});
});
Loading