Skip to content

Commit d1f5a1b

Browse files
committed
Adds the validate-remote command
1 parent 4530fc5 commit d1f5a1b

11 files changed

Lines changed: 983 additions & 0 deletions

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
77

88
## [Unreleased]
99

10+
### Added
11+
- New `validate-remote` command to validate, create, or update the `.gitattributes` file of a cloned GitHub repository.
12+
1013
## [v6.1.1] - 2026-09-30
1114

1215
### Fixed

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ refresh Refresh a present .lpv file
4646
tree Display the source structure of a given project/micro-package repository or it's dist package
4747
update Update an existing .gitattributes file for a project/micro-package repository
4848
validate Validate the .gitattributes file of a given project/micro-package repository
49+
validate-remote Validate the .gitattributes file of a remote GitHub repository and create or update it in a local clone
4950

5051
## Project Structure
5152

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,24 @@ It is possible to influence the reformatting by providing the `--sort-alphabetic
243243
options. Via the `--group` option it is possible to group the export-ignores and non-export-ignores entries of the given
244244
`.gitattributes` file.
245245

246+
#### Validate remote command
247+
248+
The `validate-remote` command will clone the given GitHub repository and validate its `.gitattributes` file. If no
249+
`.gitattributes` file is present in the repository, one will be created in the clone. If an invalid `.gitattributes`
250+
file is present, it will be updated in the clone with the expected content.
251+
252+
``` bash
253+
lean-package-validator validate-remote https://github.com/<owner>/<repository>
254+
```
255+
256+
Per default the repository is shallow cloned into a unique temporary directory, which is removed again when no
257+
`.gitattributes` file has been written into it. Via the `--clone-directory` option it is possible to define the
258+
directory to clone into, it has to be either non-existent or empty. A `.lpv` file present in the cloned repository is
259+
used for the glob patterns, unless the `--glob-pattern` or `--glob-pattern-file` option is set.
260+
261+
Like the `create` and `update` commands it provides a `--dry-run` option to see what the `.gitattributes` content would
262+
look like without writing it into the clone.
263+
246264
#### Configuration init command
247265

248266
The `init` command will create an initial `.lpv` file with the default patterns used to match common repository artefacts.

‎bin/lean-package-validator‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ use Stolt\LeanPackage\Commands\RefreshCommand;
3333
use Stolt\LeanPackage\Commands\TreeCommand;
3434
use Stolt\LeanPackage\Commands\UpdateCommand;
3535
use Stolt\LeanPackage\Commands\ValidateCommand;
36+
use Stolt\LeanPackage\Commands\ValidateRemoteCommand;
3637
use Stolt\LeanPackage\Analyser;
3738
use Stolt\LeanPackage\Archive;
3839
use Stolt\LeanPackage\Archive\Validator;
3940
use Stolt\LeanPackage\Gitattributes\FileRepository as GitattributesFileRepository;
41+
use Stolt\LeanPackage\Git\GitRepositoryCloner;
4042
use Stolt\LeanPackage\Helpers\PhpInputReader;
4143
use Stolt\LeanPackage\Presets\Finder;
4244
use Stolt\LeanPackage\Presets\PhpPreset;
@@ -64,6 +66,7 @@ $validateCommand = new ValidateCommand(
6466
);
6567
$createCommand = new CreateCommand($analyser, $gitattributesFileRepository);
6668
$updateCommand = new UpdateCommand($analyser, $gitattributesFileRepository);
69+
$validateRemoteCommand = new ValidateRemoteCommand($analyser, $gitattributesFileRepository, new GitRepositoryCloner());
6770
$reformatCommand = new ReformatCommand($analyser, $gitattributesFileRepository);
6871
$treeCommand = new TreeCommand(new Tree(new Archive(WORKING_DIRECTORY,'tree-temp')));
6972
$listSkillsCommand = new ListSkillsCommand();
@@ -74,6 +77,7 @@ $application->addCommands([
7477
$initCommand,
7578
$refreshCommand,
7679
$validateCommand,
80+
$validateRemoteCommand,
7781
$createCommand,
7882
$updateCommand,
7983
$reformatCommand,
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Stolt\LeanPackage\Commands;
6+
7+
use Stolt\LeanPackage\Analyser;
8+
use Stolt\LeanPackage\Commands\Concerns\GeneratesGitattributesOptions;
9+
use Stolt\LeanPackage\Commands\Concerns\OutputOptions;
10+
use Stolt\LeanPackage\Exceptions\GitNotAvailable;
11+
use Stolt\LeanPackage\Exceptions\InvalidRepositoryUrl;
12+
use Stolt\LeanPackage\Exceptions\RepositoryCloneFailed;
13+
use Stolt\LeanPackage\Gitattributes\FileRepository as GitattributesFileRepository;
14+
use Stolt\LeanPackage\Git\RepositoryCloner;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Input\InputArgument;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Input\InputOption;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Throwable;
21+
22+
final class ValidateRemoteCommand extends Command
23+
{
24+
use GeneratesGitattributesOptions {
25+
getDefaultLpvFile as getWorkingDirectoryLpvFile;
26+
}
27+
use OutputOptions;
28+
29+
public const GITHUB_REPOSITORY_URL_PATTERN = '~^(?:https://github\.com/|git@github\.com:)(?<owner>[A-Za-z0-9_.-]+)/(?<repository>[A-Za-z0-9_.-]+?)(?:\.git)?/?$~';
30+
31+
public const ACTION_NONE = 'none';
32+
public const ACTION_CREATED = 'created';
33+
public const ACTION_UPDATED = 'updated';
34+
35+
protected static string $defaultName = 'validate-remote';
36+
37+
protected static string $defaultDescription = 'Validate the .gitattributes file of a remote GitHub repository and create or update it in a local clone';
38+
39+
/**
40+
* The directory the remote repository has been cloned into.
41+
*/
42+
private string $cloneDirectory = '';
43+
44+
private bool $hasWrittenGitattributesFile = false;
45+
46+
public function __construct(
47+
private readonly Analyser $analyser,
48+
private readonly GitattributesFileRepository $repository,
49+
private readonly RepositoryCloner $repositoryCloner
50+
) {
51+
parent::__construct();
52+
}
53+
54+
protected function configure(): void
55+
{
56+
$this
57+
->addArgument(
58+
'repository-url',
59+
InputArgument::REQUIRED,
60+
'The URL of the GitHub repository e.g. <comment>https://github.com/owner/repository</comment>'
61+
)->addOption(
62+
'clone-directory',
63+
null,
64+
InputOption::VALUE_REQUIRED,
65+
'The directory to clone the repository into. Defaults to a unique temporary directory'
66+
)->setName(self::$defaultName)->setDescription(self::$defaultDescription);
67+
68+
// Add common generation options
69+
$this->addGenerationOptions(function (...$args) {
70+
$this->getDefinition()->addOption(new InputOption(...$args));
71+
});
72+
73+
// Add dry-run option
74+
$this->addDryRunOutputOption(function (...$args) {
75+
$this->getDefinition()->addOption(new InputOption(...$args));
76+
}, 'Do not write any .gitattributes file into the clone. Output the expected .gitattributes content');
77+
}
78+
79+
/**
80+
* Resolve the default .lpv file from the cloned repository, as the one
81+
* of the current working directory is unrelated to the remote repository.
82+
*/
83+
protected function getDefaultLpvFile(): string
84+
{
85+
if ($this->cloneDirectory === '') {
86+
return $this->getWorkingDirectoryLpvFile();
87+
}
88+
89+
return $this->cloneDirectory . DIRECTORY_SEPARATOR . '.lpv';
90+
}
91+
92+
protected function execute(InputInterface $input, OutputInterface $output): int
93+
{
94+
$repositoryUrl = \trim((string) $input->getArgument('repository-url'));
95+
96+
try {
97+
$repositoryName = $this->getRepositoryName($repositoryUrl);
98+
} catch (InvalidRepositoryUrl $e) {
99+
return $this->fail($output, $e->getMessage());
100+
}
101+
102+
$cloneDirectory = (string) $input->getOption('clone-directory');
103+
$isTemporaryClone = $cloneDirectory === '';
104+
105+
if ($isTemporaryClone) {
106+
$cloneDirectory = \sys_get_temp_dir() . DIRECTORY_SEPARATOR
107+
. 'lpv-remote-' . $repositoryName . '-' . \bin2hex(\random_bytes(4));
108+
}
109+
110+
if (\file_exists($cloneDirectory) && !$this->isEmptyDirectory($cloneDirectory)) {
111+
return $this->fail($output, "The clone directory {$cloneDirectory} already exists and is not empty.");
112+
}
113+
114+
$output->writeln("+ Cloning {$repositoryUrl} into {$cloneDirectory}.", OutputInterface::VERBOSITY_VERBOSE);
115+
116+
try {
117+
$this->repositoryCloner->clone($repositoryUrl, $cloneDirectory);
118+
} catch (GitNotAvailable $e) {
119+
return $this->fail($output, 'The Git command is not available.');
120+
} catch (RepositoryCloneFailed $e) {
121+
$output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG);
122+
123+
return $this->fail($output, "Cloning the repository {$repositoryUrl} failed.");
124+
}
125+
126+
if (!\is_dir($cloneDirectory)) {
127+
return $this->fail($output, "Cloning the repository {$repositoryUrl} failed.");
128+
}
129+
130+
$this->cloneDirectory = (string) \realpath($cloneDirectory);
131+
132+
try {
133+
return $this->validateClone($input, $output, $repositoryUrl);
134+
} finally {
135+
// A temporary clone is only kept when a .gitattributes file has been written into it.
136+
if ($isTemporaryClone && $this->hasWrittenGitattributesFile === false) {
137+
$output->writeln("+ Removing the temporary clone {$this->cloneDirectory}.", OutputInterface::VERBOSITY_VERBOSE);
138+
$this->removeDirectory($this->cloneDirectory);
139+
}
140+
}
141+
}
142+
143+
private function validateClone(InputInterface $input, OutputInterface $output, string $repositoryUrl): int
144+
{
145+
$isAgenticRun = $this->isAgenticRun();
146+
147+
$this->analyser->getActualExportIgnoreAnalyser()->setDirectory($this->cloneDirectory);
148+
$this->repository->setWorkingDirectory($this->cloneDirectory);
149+
150+
// Only use a .lpv file of the clone when no explicit glob pattern file has been provided.
151+
if ((string) $input->getOption('glob-pattern-file') === $this->getWorkingDirectoryLpvFile()) {
152+
$input->setOption('glob-pattern-file', $this->getDefaultLpvFile());
153+
}
154+
155+
if (!$this->applyGenerationOptions($input, $output, $this->analyser->getActualExportIgnoreAnalyser(), $this->getName())) {
156+
return self::FAILURE;
157+
}
158+
159+
$exportIgnoreAnalyser = $this->analyser->getActualExportIgnoreAnalyser();
160+
$gitattributesPath = $exportIgnoreAnalyser->getGitattributesFilePath();
161+
$hasGitattributesFile = $exportIgnoreAnalyser->hasGitattributesFile();
162+
163+
$output->writeln('+ Analysing the .gitattributes content.', OutputInterface::VERBOSITY_VERBOSE);
164+
165+
if ($hasGitattributesFile && $this->hasValidGitattributesFile()) {
166+
$message = "The .gitattributes file of {$repositoryUrl} is considered valid.";
167+
if ($isAgenticRun) {
168+
$this->writeAgenticOutput($output, $this->getName(), true, $message, [
169+
'valid' => true,
170+
'action' => self::ACTION_NONE,
171+
]);
172+
} else {
173+
$output->writeln("The .gitattributes file of {$repositoryUrl} is considered <info>valid</info>.");
174+
}
175+
176+
return self::SUCCESS;
177+
}
178+
179+
$expected = $this->analyser->getExpectedGitattributesContent();
180+
181+
if ($expected === '') {
182+
return $this->fail($output, 'Unable to determine expected .gitattributes content for the cloned repository.');
183+
}
184+
185+
$finding = $hasGitattributesFile
186+
? "The .gitattributes file of {$repositoryUrl} is considered invalid."
187+
: "There is no .gitattributes file present in {$repositoryUrl}.";
188+
189+
if ($this->isDryRun($input)) {
190+
if ($isAgenticRun) {
191+
$this->writeAgenticOutput($output, $this->getName(), true, $finding, [
192+
'valid' => false,
193+
'action' => self::ACTION_NONE,
194+
'expected_gitattributes_content' => $expected,
195+
]);
196+
} else {
197+
$output->writeln($expected);
198+
}
199+
200+
return self::SUCCESS;
201+
}
202+
203+
try {
204+
if ($hasGitattributesFile) {
205+
$this->repository->overwriteGitattributesFile($expected);
206+
} else {
207+
$this->repository->createGitattributesFile($expected);
208+
}
209+
} catch (Throwable $e) {
210+
$output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG);
211+
212+
return $this->fail($output, 'Writing the .gitattributes file into the clone failed.');
213+
}
214+
215+
$this->hasWrittenGitattributesFile = true;
216+
217+
$action = $hasGitattributesFile ? self::ACTION_UPDATED : self::ACTION_CREATED;
218+
$result = $hasGitattributesFile
219+
? "Updated the .gitattributes file in {$this->cloneDirectory}."
220+
: "Created a .gitattributes file in {$this->cloneDirectory}.";
221+
222+
if ($isAgenticRun) {
223+
$this->writeAgenticOutput($output, $this->getName(), true, $finding . ' ' . $result, [
224+
'valid' => false,
225+
'action' => $action,
226+
'clone_directory' => $this->cloneDirectory,
227+
'gitattributes_file_path' => $gitattributesPath,
228+
'gitattributes_content' => $this->repository->getGitattributesContent(),
229+
]);
230+
231+
return self::SUCCESS;
232+
}
233+
234+
$output->writeln('<error>' . $finding . '</error>');
235+
$output->writeln($result . ' With the shown content:');
236+
$output->writeln('<info>' . $this->repository->getGitattributesContent() . '</info>');
237+
238+
return self::SUCCESS;
239+
}
240+
241+
/**
242+
* Extract a filesystem friendly owner-repository name from a GitHub repository URL.
243+
*
244+
* @throws InvalidRepositoryUrl
245+
*/
246+
private function getRepositoryName(string $repositoryUrl): string
247+
{
248+
if (\preg_match(self::GITHUB_REPOSITORY_URL_PATTERN, $repositoryUrl, $matches) !== 1) {
249+
throw new InvalidRepositoryUrl(
250+
"The provided repository URL '{$repositoryUrl}' is not a valid GitHub repository URL."
251+
);
252+
}
253+
254+
return $matches['owner'] . '-' . $matches['repository'];
255+
}
256+
257+
private function hasValidGitattributesFile(): bool
258+
{
259+
if ($this->analyser->hasCompleteExportIgnores()) {
260+
return true;
261+
}
262+
263+
if ($this->analyser->usesNegatedExportIgnoreStrategy()) {
264+
return false;
265+
}
266+
267+
return $this->analyser->getExpectedGitattributesContent() === $this->analyser->getActualGitattributesContent();
268+
}
269+
270+
private function isEmptyDirectory(string $directory): bool
271+
{
272+
return \is_dir($directory) && \array_diff((array) \scandir($directory), ['.', '..']) === [];
273+
}
274+
275+
private function removeDirectory(string $directory): void
276+
{
277+
if (!\is_dir($directory)) {
278+
return;
279+
}
280+
281+
$files = new \RecursiveIteratorIterator(
282+
new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS),
283+
\RecursiveIteratorIterator::CHILD_FIRST
284+
);
285+
286+
/** @var \SplFileInfo $fileInfo */
287+
foreach ($files as $fileInfo) {
288+
if ($fileInfo->isDir() && !$fileInfo->isLink()) {
289+
\rmdir($fileInfo->getPathname());
290+
continue;
291+
}
292+
// Git marks pack files read-only, which prevents their removal on Windows.
293+
\chmod($fileInfo->getPathname(), permissions: 0o666);
294+
\unlink($fileInfo->getPathname());
295+
}
296+
297+
\rmdir($directory);
298+
}
299+
300+
private function fail(OutputInterface $output, string $message): int
301+
{
302+
if ($this->isAgenticRun()) {
303+
$this->writeAgenticOutput($output, $this->getName(), false, $message);
304+
} else {
305+
$output->writeln('<error>' . $message . '</error>');
306+
}
307+
308+
return self::FAILURE;
309+
}
310+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Stolt\LeanPackage\Exceptions;
6+
7+
class InvalidRepositoryUrl extends \Exception
8+
{
9+
}

0 commit comments

Comments
 (0)