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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **A config published to `config/git-commit-checker.php`** is still honoured. Republish with
`--tag=laranail::git-commit-checker-config`, which now writes
`config/laranail/git-commit-checker.php`, and delete the old file.
- `git-commit-checker:install` and `git-commit-checker:pre-commit-hook`. Both stay registered as
aliases of the scoped commands and print a deprecation line naming the replacement when used,
so hooks installed before this release keep working. Re-run
`laranail::git-commit-checker.install` to rewrite a hook. The aliases may be removed in the next
minor after 0.1.

### Added

- A `Quick start` section in the README.

### Changed

- **The commands are `laranail::git-commit-checker.install` and
`laranail::git-commit-checker.pre-commit-hook`**, the family's `laranail::<slug>.<command>` shape.
The hook script `install` writes calls the scoped name. A local copy of `laranail/console`'s
`SupportsNamespacedNames` trait lets Symfony accept the `::`; the package still requires no
`laranail/*` package.
- The `repositories` block replaces Packagist with a copy that excludes `laranail/*`, so
`laranail/package-tools` can only resolve from its VCS repository. This is the family's standard
block.
- The Imani Manyara author entry carries `imani@simtabi.com`.

## v0.1.0

### Changed
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ composer require laranail/git-commit-checker

### Getting started

1. Run it from a Git checkout in a local environment: `git-commit-checker:install` refuses to write
a hook when `APP_ENV` is not `local` or there is no `.git` directory.
1. Run it from a Git checkout in a local environment: `laranail::git-commit-checker.install` refuses to
write a hook when `APP_ENV` is not `local` or there is no `.git` directory.
2. Optionally publish the config to change the hooks or the Pint presets. The pre-commit check is on
by default; `GIT_COMMIT_CHECKER_ENABLED=false` switches it off.

Expand All @@ -33,13 +33,18 @@ composer require laranail/git-commit-checker

```bash
# Write .git/hooks/pre-commit and, optionally, a pint.json preset
php artisan git-commit-checker:install
php artisan laranail::git-commit-checker.install

# Every commit now runs Pint in --test mode over the changed PHP files;
# run the same check by hand without committing
php artisan git-commit-checker:pre-commit-hook
php artisan laranail::git-commit-checker.pre-commit-hook
```

> `git-commit-checker:install` and `git-commit-checker:pre-commit-hook` are deprecated aliases of
> these two commands. They still run, print a deprecation line, and may be removed in the next minor
> after 0.1. A hook installed before the rename calls the old name and keeps working; run
> `laranail::git-commit-checker.install` again to rewrite it with the new one.

How the hook is wired is in [Architecture](docs/architecture.md); everything else is in the [documentation index](#documentation).

## <a name="documentation"></a>Documentation
Expand Down
11 changes: 11 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"authors": [
{
"name": "Imani Manyara",
"email": "imani@simtabi.com",
"role": "Developer",
"homepage": "https://simtabi.com"
},
Expand Down Expand Up @@ -80,6 +81,16 @@
{
"type": "vcs",
"url": "https://github.com/laranail/package-tools"
},
{
"type": "composer",
"url": "https://repo.packagist.org",
"exclude": [
"laranail/*"
]
},
{
"packagist.org": false
}
]
}
14 changes: 13 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ sibling package, a third-party one, or the consuming application's own.
| Config key | `laranail.git-commit-checker` |
| View namespace | `laranail/git-commit-checker` |
| Publish tags | `laranail::git-commit-checker-*` |
| Artisan commands | `laranail::git-commit-checker.install`, `laranail::git-commit-checker.pre-commit-hook` |

Views take the slash form because Laravel interpolates the namespace into the override path, so a
published override lands in `resources/views/vendor/laranail/git-commit-checker` — one directory per vendor
Expand All @@ -23,8 +24,19 @@ rather than thirty siblings flat in the `vendor` root.
Its publish tags were already vendor-scoped, which is what made the gap easy to miss by eye: two of
the four names were right.

**The two commands were `git-commit-checker:install` and `git-commit-checker:pre-commit-hook`.**
Both names stay registered as aliases of the scoped commands, so scripts and hooks written before the
rename still run, and print a deprecation line naming the replacement when used. They may be removed
in the next minor after 0.1. The hook script `install` writes calls the scoped name.

Symfony's command-name validator rejects the empty segment in `::`, so the commands use a local copy
of `laranail/console`'s `SupportsNamespacedNames` trait. A copy rather than a dependency: this
package requires no `laranail/*` package, and taking console on for one trait would make every
consumer declare a VCS repository for it. `tests/Feature/NamespacedNamesConformanceTest.php` holds
the copy to the canonical behaviour.

`tests/Feature/NamingConventionTest.php` asserts this against the **live registries** —
`View::getFinder()->getHints()` and the config repository — rather than by grepping the provider, so
`View::getFinder()->getHints()`, the config repository and the Artisan command map — rather than by grepping the provider, so
the guard survives a refactor of the registration code.

## Modernisation
Expand Down
104 changes: 104 additions & 0 deletions src/Commands/Concerns/SupportsNamespacedNames.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\GitCommitChecker\Commands\Concerns;

use ReflectionProperty;
use Symfony\Component\Console\Command\Command as SymfonyCommand;

/**
* Lets a command use the laranail naming shape `laranail::git-commit-checker.<command>`.
*
* Symfony's {@see SymfonyCommand::validateName()} regex (`^[^:]++(:[^:]++)*$`)
* rejects the empty segment in `::`, so this trait sets the name (and aliases)
* past that validator by writing the private property directly. Dispatch still
* works because Symfony resolves an exact command name (and its registered
* aliases) before its `:`-splitting namespace lookup runs, which is what lets
* `laranail::git-commit-checker.install` be found at all.
*
* The trait also applies an optional `$commandAliases` list during construction
* (Laravel invokes {@see setName()} while building the command from its
* signature).
*
* **The list is declared here, defaulting to empty**, and that is a fix rather
* than a style choice: it used to be read off the consuming command without
* being declared anywhere, so any command that used the trait *without*
* declaring the property died with `Undefined property: …::$commandAliases` the
* moment Laravel built it. A trait that requires an undeclared property of its
* user is a trap, and the failure lands at boot rather than at the call site.
* A command that wants aliases still declares its own list, which overrides
* this default.
*
* Whatever it declares must itself be vendor-scoped. The only bare names this
* package carries are the two legacy command names, kept as deprecated aliases
* through `#[AsCommand]` so existing scripts and installed git hooks still run.
*
* Self-contained: this is a local copy of the canonical laranail trait in
* `laranail/console`, because this package's `require` holds no `laranail/*`
* entry and taking console on for one trait would make every consumer declare
* a VCS repository for it. Its conformance test declares that reason so
* `package-tools/scripts/verify-trait-copies.py` can falsify it.
*
* @internal
*/
trait SupportsNamespacedNames
{
public function setName(string $name): static
{
$this->writeCommandName('name', $name);

$aliases = $this->declaredCommandAliases();

if ($aliases !== []) {
$this->setAliases($aliases);
}

return $this;
}

/**
* @param iterable<int, string> $aliases
*/
public function setAliases(iterable $aliases): static
{
$this->writeCommandName('aliases', is_array($aliases) ? $aliases : iterator_to_array($aliases));

return $this;
}

/**
* The consuming command's own `$commandAliases`, if it declares one.
*
* Deliberately NOT a property on this trait. A trait cannot declare a property that the using
* class also declares with a different default -- PHP rejects the composition outright with a
* fatal -- so declaring it here made the documented usage ("a command that wants aliases
* declares its own list") impossible to actually write.
*
* Declaring it was itself the fix for the opposite bug, where reading an undeclared property
* threw at boot. Reading it defensively fixes both at once.
*
* @return list<string>
*/
private function declaredCommandAliases(): array
{
if (! property_exists($this, 'commandAliases') || ! is_array($this->commandAliases)) {
return [];
}

// Filtered rather than cast: the property is the consuming command's, so its contents are
// not this trait's to assume. A stray null would reach Symfony's setAliases() as a type
// error at boot, which is the failure mode this whole method exists to avoid.
return array_values(array_filter(
$this->commandAliases,
static fn (mixed $alias): bool => is_string($alias) && $alias !== '',
));
}

private function writeCommandName(string $property, mixed $value): void
{
// The name/aliases are private on Symfony's base Command; writing them
// directly bypasses validateName()'s rejection of the `::` separator.
(new ReflectionProperty(SymfonyCommand::class, $property))->setValue($this, $value);
}
}
46 changes: 46 additions & 0 deletions src/Commands/Concerns/WarnsOnDeprecatedAlias.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\GitCommitChecker\Commands\Concerns;

use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
* Prints a one-line deprecation warning when a command is invoked by one of
* its bare legacy aliases (`git-commit-checker:*`) rather than by its
* vendor-scoped name (`laranail::git-commit-checker.*`).
*
* A local copy of `laranail/package-scaffolder`'s trait of the same name:
* `laranail/console` main has no deprecated-alias support yet. When it gains
* one, this delegates to it.
*
* The aliases stay registered, so every existing script keeps working; the
* warning names the replacement. Detection reads the name the caller actually
* typed: the input's first argument is the command token for `php artisan`,
* `Artisan::call()` and `$this->call()` alike, while `getName()` is always the
* canonical name.
*
* Symfony calls initialize() after binding the input and before interact(),
* so the warning prints before any prompt.
*/
trait WarnsOnDeprecatedAlias
{
protected function initialize(InputInterface $input, OutputInterface $output): void
{
parent::initialize($input, $output);

$invokedAs = $input->getFirstArgument();

if (! is_string($invokedAs) || $invokedAs === $this->getName() || ! in_array($invokedAs, $this->getAliases(), true)) {
return;
}

$output->writeln(sprintf(
'<comment>Deprecated:</comment> [%s] is a deprecated alias and will be removed in the next minor after 0.1. Use [%s] instead.',
$invokedAs,
(string) $this->getName(),
));
}
}
15 changes: 13 additions & 2 deletions src/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,23 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Attribute\AsCommand;
use Simtabi\Laranail\GitCommitChecker\Commands\Concerns\ReadsPackageConfig;

#[AsCommand('git-commit-checker:install', 'Install "pre_commit" hook into your git.')]
use Simtabi\Laranail\GitCommitChecker\Commands\Concerns\WarnsOnDeprecatedAlias;
use Simtabi\Laranail\GitCommitChecker\Commands\Concerns\SupportsNamespacedNames;

/**
* Registered as `laranail::git-commit-checker.install`.
*
* `git-commit-checker:install` stays registered as an alias of it and prints a
* deprecation line when used. That alias is deprecated and may be removed in the
* next minor after 0.1.
*/
#[AsCommand('laranail::git-commit-checker.install|git-commit-checker:install', 'Install "pre_commit" hook into your git.')]
class InstallCommand extends Command
{
use ConfirmableTrait;
use ReadsPackageConfig;
use SupportsNamespacedNames;
use WarnsOnDeprecatedAlias;

public function handle(): int
{
Expand Down
15 changes: 13 additions & 2 deletions src/Commands/PreCommitHookCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,22 @@
use Symfony\Component\Process\Process;
use Symfony\Component\Console\Attribute\AsCommand;
use Simtabi\Laranail\GitCommitChecker\Commands\Concerns\ReadsPackageConfig;

#[AsCommand('git-commit-checker:pre-commit-hook', 'Git hook before commit')]
use Simtabi\Laranail\GitCommitChecker\Commands\Concerns\WarnsOnDeprecatedAlias;
use Simtabi\Laranail\GitCommitChecker\Commands\Concerns\SupportsNamespacedNames;

/**
* Registered as `laranail::git-commit-checker.pre-commit-hook`.
*
* `git-commit-checker:pre-commit-hook` stays registered as an alias of it and prints a
* deprecation line when used. That alias is deprecated and may be removed in the
* next minor after 0.1.
*/
#[AsCommand('laranail::git-commit-checker.pre-commit-hook|git-commit-checker:pre-commit-hook', 'Git hook before commit')]
class PreCommitHookCommand extends Command
{
use ReadsPackageConfig;
use SupportsNamespacedNames;
use WarnsOnDeprecatedAlias;

public function handle(): int
{
Expand Down
6 changes: 3 additions & 3 deletions tests/Feature/ConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@
it('runs the hook when enabled at the registered key', function (): void {
Config::set('laranail.git-commit-checker.enabled', true);

$this->artisan('git-commit-checker:pre-commit-hook')
$this->artisan('laranail::git-commit-checker.pre-commit-hook')
->doesntExpectOutputToContain('is disabled');
});

it('skips the hook when disabled at the registered key', function (): void {
Config::set('laranail.git-commit-checker.enabled', false);

$this->artisan('git-commit-checker:pre-commit-hook')
$this->artisan('laranail::git-commit-checker.pre-commit-hook')
->expectsOutputToContain('is disabled')
->assertSuccessful();
});
Expand All @@ -47,6 +47,6 @@
Config::set('git-commit-checker', array_replace(Config::get('laranail.git-commit-checker'), ['enabled' => false]));
Config::set('laranail.git-commit-checker.enabled', true);

$this->artisan('git-commit-checker:pre-commit-hook')
$this->artisan('laranail::git-commit-checker.pre-commit-hook')
->expectsOutputToContain('is disabled');
});
Loading
Loading