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
9 changes: 8 additions & 1 deletion src/Console/Commands/CheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Difflock\Console\Commands;

use Difflock\Checkup;
use Difflock\CheckupResult;
use Difflock\Console\Commands\Concerns\InteractsWithDifflock;
use Difflock\Console\Formatters\JsonReport;
use Difflock\Console\Renderers\Banner;
Expand Down Expand Up @@ -74,7 +75,13 @@ public function handle(Checkup $checkup, Repository $config, CheckupRenderer $re
$connection = $this->option('connection');

try {
$result = $checkup->run($threshold, is_string($connection) && $connection !== '' ? $connection : null);
$result = $this->whileWorking(
'Reading the schema and analysing migrations',
fn (): CheckupResult => $checkup->run(
$threshold,
is_string($connection) && $connection !== '' ? $connection : null,
),
);
} catch (Throwable $exception) {
$this->components->error('Difflock could not complete the check: '.$exception->getMessage());

Expand Down
33 changes: 33 additions & 0 deletions src/Console/Commands/Concerns/InteractsWithDifflock.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,39 @@ protected function writeJson(array $document): void
$this->output->writeln(JsonReport::encode($document), OutputInterface::OUTPUT_RAW);
}

/**
* Run something slow, saying so while it runs.
*
* Reading a large schema is a few hundred queries and several seconds of silence,
* which is long enough for somebody to wonder whether the command has hung. The
* notice is written and then erased, so it exists only while the work does.
*
* Only on a decorated terminal: a CI log has no cursor to move, a JSON document
* must not gain a line, and a test buffer should assert on the report rather than
* on the reassurance.
*
* @template TResult
*
* @param callable(): TResult $work
* @return TResult
*/
protected function whileWorking(string $message, callable $work): mixed
{
if (! $this->output->isDecorated() || $this->wantsJson()) {
return $work();
}

$this->output->write(' <fg=gray>'.$message.'…</>');

try {
return $work();
} finally {
// Back to the start of the line, blank it, and back again — so whatever
// prints next starts on a clean line rather than after the notice.
$this->output->write("\r".str_repeat(' ', mb_strlen($message) + 4)."\r");
}
}

/**
* @return list<string>
*/
Expand Down
38 changes: 38 additions & 0 deletions src/Console/Commands/DiffCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
use Difflock\Exceptions\InvalidSnapshot;
use Difflock\Exceptions\MissingBaseline;
use Difflock\Schema\Baseline;
use Difflock\Schema\DatabaseSchema;
use Difflock\Support\SecretHeuristics;
use Illuminate\Console\Command;
use Illuminate\Contracts\Config\Repository;
use Throwable;
Expand Down Expand Up @@ -145,6 +147,8 @@ private function save(SchemaInspector $inspector, Baseline $baseline, ?string $c
$this->output->writeln(' <fg=green>✓</> Baseline recorded: '.$tables.' table'
.($tables === 1 ? '' : 's').'.');

$this->warnAboutSecrets($schema);

foreach (Text::wrap('Written to '.$baseline->path().'. Commit it, and future runs of '
.'difflock:diff will report anything that no longer matches.', ' ') as $line) {
$this->output->writeln('<fg=gray>'.$line.'</>');
Expand All @@ -155,6 +159,40 @@ private function save(SchemaInspector $inspector, Baseline $baseline, ?string $c
return self::SUCCESS;
}

/**
* Say so before a column default that looks like a credential goes into git.
*
* Printed after the file is written rather than before, and it does not refuse:
* these are shapes, not certainties, and a tool that blocked on a heuristic
* would be wrong often enough to be turned off. Deleting the file and setting
* `snapshot.defaults` is a ten-second fix — noticing a year later is not.
*/
private function warnAboutSecrets(DatabaseSchema $schema): void
{
$suspects = SecretHeuristics::suspects($schema);

if ($suspects === []) {
return;
}

$this->output->writeln('');
$this->output->writeln(' <fg=yellow>⚠</> '.count($suspects).' column default'
.(count($suspects) === 1 ? '' : 's').' in this file look like they may hold a credential:');

foreach (SecretHeuristics::describe($suspects) as $described) {
$this->output->writeln(' <fg=yellow>·</> '.$described);
}

foreach (Text::wrap(
'Difflock recognises shapes, not secrets, so check before you commit. To keep defaults out '
.'of the baseline entirely, set `snapshot.defaults` to false in config/difflock.php and '
.'record it again.',
' ',
) as $line) {
$this->output->writeln('<fg=gray>'.$line.'</>');
}
}

private function connection(string $option): ?string
{
$value = $this->option($option);
Expand Down
5 changes: 4 additions & 1 deletion src/Console/Commands/LintCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ public function handle(MigrationAnalyzer $analyzer, Repository $config, ReportRe
$scope = $this->option('all') === true ? MigrationScope::All : MigrationScope::Pending;
$paths = $this->paths();

$report = $analyzer->analyze($scope, $paths);
$report = $this->whileWorking(
'Analysing migrations',
fn (): MigrationReport => $analyzer->analyze($scope, $paths),
);

// Nothing pending is the ordinary state of a machine that is up to date, and
// printing nothing there is how a useful tool gets mistaken for a broken one.
Expand Down
145 changes: 145 additions & 0 deletions src/Console/Commands/ReportCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

declare(strict_types=1);

namespace Difflock\Console\Commands;

use Difflock\Checkup;
use Difflock\CheckupResult;
use Difflock\Console\Commands\Concerns\InteractsWithDifflock;
use Difflock\Console\Formatters\HtmlReport;
use Difflock\Console\Formatters\JsonReport;
use Difflock\Console\Renderers\Banner;
use Difflock\Console\Renderers\Text;
use Difflock\Risk\RiskLevel;
use Illuminate\Console\Command;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Filesystem\Filesystem;
use Throwable;

/**
* Writes the whole run — drift and findings — to a file somebody can open.
*
* The console output is for the person who ran the command. This is for everybody
* else: attach it to a pull request, keep it as a CI artifact, send it to whoever
* has to approve the deploy. The HTML is entirely self-contained, because an
* artifact opened from a `file://` URL has no network to fetch anything with.
*
* It never changes the verdict — the exit code is the one `difflock:check` would
* have given, so a report and a gate can never disagree.
*/
final class ReportCommand extends Command
{
use InteractsWithDifflock;

protected $signature = 'difflock:report
{--output= : Where to write the report, default storage/difflock/report.html}
{--fail-on= : The lowest risk level that should fail the command}
{--connection= : The connection to inspect, overriding the configured one}
{--format=html : html for a file somebody opens, json for a machine}';

protected $description = 'Write the schema drift and migration analysis to a shareable file';

protected function configure(): void
{
parent::configure();

$this->setHelp(<<<'HELP'
Runs exactly what <info>difflock:check</info> runs and writes it to a file instead of
the terminal, exiting with the same codes.

php artisan difflock:report
php artisan difflock:report --output=build/difflock.html

The HTML has no external stylesheet, font or script, so it renders correctly
as a CI artifact opened straight from disk. Everything in it — table names,
column names, rule messages — is escaped, because all of it comes from a
database or from migration source rather than from this package.

<info>--format=json</info> writes the same document as difflock:check, for anything
that would rather parse than read.
HELP);
}

public function handle(Checkup $checkup, Repository $config, Filesystem $files, HtmlReport $html): int
{
if (! $this->enabled($config)) {
return self::INVALID;
}

$threshold = $this->threshold($config);

if (! $threshold instanceof RiskLevel) {
return $this->unknownThreshold();
}

$connection = $this->option('connection');

try {
$result = $this->whileWorking(
'Building the report',
fn (): CheckupResult => $checkup->run(
$threshold,
is_string($connection) && $connection !== '' ? $connection : null,
),
);
} catch (Throwable $exception) {
$this->components->error('Difflock could not complete the report: '.$exception->getMessage());

return self::INVALID;
}

$json = $this->option('format') === 'json';
$path = $this->path($json);

$files->ensureDirectoryExists(dirname($path));
$files->put($path, $json
? JsonReport::encode(JsonReport::check($result->drift, $result->report, $threshold, $result->failed()))."\n"
: $html->render($result, $this->generatedAt(), $this->application($config)));

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

$this->output->writeln(' <fg=green>✓</> Written to '.$path);

foreach (Text::wrap(
$result->failed()
? 'The run failed — the report says why, and this command exits with the same code '
.'difflock:check would have.'
: 'Nothing at or above the threshold.',
' ',
) as $line) {
$this->output->writeln('<fg=gray>'.$line.'</>');
}

$this->output->writeln('');

return $result->failed() ? self::FAILURE : self::SUCCESS;
}

private function path(bool $json): string
{
$output = $this->option('output');

if (is_string($output) && $output !== '') {
return $this->absolute($output);
}

return $this->laravel->storagePath('difflock/report.'.($json ? 'json' : 'html'));
}

/**
* A fixed-format timestamp rather than a localised one, so two reports of the
* same run diff cleanly.
*/
private function generatedAt(): string
{
return gmdate('Y-m-d H:i').' UTC';
}

private function application(Repository $config): ?string
{
$name = $config->get('app.name');

return is_string($name) && $name !== '' ? $name : null;
}
}
Loading
Loading