Skip to content

Commit f7aa649

Browse files
committed
Adds a reformat command
1 parent f17ea1b commit f7aa649

8 files changed

Lines changed: 364 additions & 6 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [v5.8.0] - 2026-05-07
11+
12+
### Added
13+
- Added a `reformat` command to do opinionated reformatting of existing .gitattributes files.
14+
1015
## [v5.7.3] - 2026-05-07
1116

1217
### Changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,11 @@ The `update` command will update a present `.gitattributes` file in the given di
222222
option of the `validate` command. Please migrate to the dedicated commands. Like the above-mentioned `create` command it
223223
provides a `--dry-run` option to see what the `.gitattributes` content would look like.
224224

225+
#### Reformat command
226+
227+
The `reformat` command will reformat a present `.gitattributes` file in the given directory. This command provides a
228+
`--dry-run` option to see what the `.gitattributes` content would look like.
229+
225230
#### Init command
226231

227232
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: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ if (false === $autoloaded) {
2727
use Stolt\Console\Commands\ListSkillsCommand;
2828
use Stolt\LeanPackage\Commands\CreateCommand;
2929
use Stolt\LeanPackage\Commands\InitCommand;
30+
use Stolt\LeanPackage\Commands\ReformatCommand;
3031
use Stolt\LeanPackage\Commands\RefreshCommand;
3132
use Stolt\LeanPackage\Commands\TreeCommand;
3233
use Stolt\LeanPackage\Commands\UpdateCommand;
@@ -44,6 +45,7 @@ use Symfony\Component\Console\Application;
4445
$finder = new Finder(new PhpPreset());
4546
$archive = new Archive(WORKING_DIRECTORY);
4647
$analyser = new Analyser($finder);
48+
$gitattributesFileRepository = new GitattributesFileRepository($analyser);
4749

4850
$initCommand = new InitCommand(
4951
$analyser
@@ -58,13 +60,23 @@ $validateCommand = new ValidateCommand(
5860
new Validator($archive),
5961
new PhpInputReader()
6062
);
61-
$createCommand = new CreateCommand($analyser, new GItattributesFileRepository($analyser));
62-
$updateCommand = new UpdateCommand($analyser, new GItattributesFileRepository($analyser));
63+
$createCommand = new CreateCommand($analyser, $gitattributesFileRepository);
64+
$updateCommand = new UpdateCommand($analyser, $gitattributesFileRepository);
65+
$reformatCommand = new ReformatCommand($analyser, $gitattributesFileRepository);
6366
$treeCommand = new TreeCommand(new Tree(new Archive(WORKING_DIRECTORY,'tree-temp')));
6467
$listSkillsCommand = new ListSkillsCommand();
6568

6669
$application = new Application('Lean package validator', VERSION);
67-
$application->addCommands(
68-
[$initCommand, $refreshCommand, $validateCommand, $createCommand, $updateCommand, $treeCommand, $listSkillsCommand]
69-
);
70+
71+
$application->addCommands([
72+
$initCommand,
73+
$refreshCommand,
74+
$validateCommand,
75+
$createCommand,
76+
$updateCommand,
77+
$reformatCommand,
78+
$treeCommand,
79+
$listSkillsCommand
80+
]);
81+
7082
$application->run();

src/Commands/ReformatCommand.php

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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\OutputOptions;
9+
use Stolt\LeanPackage\GitattributesFileRepository;
10+
use Symfony\Component\Console\Command\Command;
11+
use Symfony\Component\Console\Input\InputArgument;
12+
use Symfony\Component\Console\Input\InputInterface;
13+
use Symfony\Component\Console\Input\InputOption;
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
16+
final class ReformatCommand extends Command
17+
{
18+
use OutputOptions;
19+
20+
/**
21+
* @param Analyser $analyser
22+
* @param GitattributesFileRepository $repository
23+
*/
24+
public function __construct(private readonly Analyser $analyser,
25+
private readonly GitattributesFileRepository $repository)
26+
{
27+
parent::__construct();
28+
}
29+
30+
/**
31+
* Command configuration.
32+
*
33+
* @return void
34+
*/
35+
protected function configure(): void
36+
{
37+
$this->analyser->setDirectory(WORKING_DIRECTORY);
38+
39+
$this
40+
->setName('reformat')
41+
->setDescription('Reformat a present .gitattributes file');
42+
43+
$directoryDescription = 'The directory of a project/micro-package repository';
44+
45+
$this->addArgument(
46+
'directory',
47+
InputArgument::OPTIONAL,
48+
$directoryDescription,
49+
$this->analyser->getDirectory()
50+
);
51+
52+
$this->addDryRunOutputOption(function (...$args) {
53+
$this->getDefinition()->addOption(new InputOption(...$args));
54+
}, 'Do not write any files. Output the content that would be written');
55+
$this->addAgenticOutputOption(function (...$args) {
56+
$this->getDefinition()->addOption(new InputOption(...$args));
57+
});
58+
}
59+
60+
/**
61+
* Execute command.
62+
*
63+
* @param InputInterface $input
64+
* @param OutputInterface $output
65+
*
66+
* @return integer
67+
*/
68+
protected function execute(InputInterface $input, OutputInterface $output): int
69+
{
70+
$directory = (string)$input->getArgument('directory') ?: WORKING_DIRECTORY;
71+
$this->analyser->setDirectory($directory);
72+
$isAgenticRun = $this->isAgenticRun($input);
73+
74+
return $this->reformatPresentExportIgnores($input, $output, $directory, $isAgenticRun);
75+
}
76+
77+
78+
79+
80+
private function reformatPresentExportIgnores(
81+
InputInterface $input,
82+
OutputInterface $output,
83+
string $directory,
84+
bool $isAgenticRun
85+
): int
86+
{
87+
$gitattributesPath = $this->analyser->getGitattributesFilePath();
88+
89+
if (!\file_exists($gitattributesPath) && $this->isDryRun($input) !== true) {
90+
if ($isAgenticRun) {
91+
$this->writeAgenticOutput($output, $this->getName(), false, 'No .gitattributes file found. Use the create command to create one first.');
92+
} else {
93+
$output->writeln('No .gitattributes file found. Use the <info>create</info> command to create one first.');
94+
}
95+
return self::FAILURE;
96+
}
97+
98+
$aligned = $this->getPresentGitattributesContentWithAlignedExportIgnores();
99+
100+
if ($aligned === '') {
101+
$message = 'Unable to determine present .gitattributes content for the given directory.';
102+
if ($isAgenticRun) {
103+
$this->writeAgenticOutput($output, $this->getName(), false, $message);
104+
} else {
105+
$output->writeln($message);
106+
}
107+
return self::FAILURE;
108+
}
109+
110+
if ($this->isDryRun($input)) {
111+
$output->writeln($aligned);
112+
113+
return self::SUCCESS;
114+
}
115+
116+
try {
117+
$this->repository->overwriteGitattributesFileFormatted($aligned);
118+
} catch (\Throwable $e) {
119+
$message = 'Update of .gitattributes file failed.';
120+
if ($isAgenticRun) {
121+
$this->writeAgenticOutput($output, $this->getName(), false, $message);
122+
} else {
123+
$output->writeln($message);
124+
}
125+
return self::FAILURE;
126+
}
127+
128+
$directory = \realpath($directory);
129+
$message = "The export-ignore directives in {$directory} have been reformatted.";
130+
if ($isAgenticRun) {
131+
$this->writeAgenticOutput($output, $this->getName(), true, $message, ['gitattributes_file_path' => $gitattributesPath]);
132+
} else {
133+
$output->writeln($message);
134+
}
135+
136+
return self::SUCCESS;
137+
}
138+
139+
private function getPresentGitattributesContentWithAlignedExportIgnores(): string
140+
{
141+
$gitattributesContent = $this->analyser->getPresentGitAttributesContent();
142+
143+
if ($gitattributesContent === '') {
144+
return '';
145+
}
146+
147+
$eol = $this->detectEol($gitattributesContent);
148+
$gitattributesLines = \preg_split('/\\r\\n|\\r|\\n/', $gitattributesContent);
149+
150+
if ($gitattributesLines === false) {
151+
return $gitattributesContent;
152+
}
153+
154+
$exportIgnorePatterns = [];
155+
156+
foreach ($gitattributesLines as $line) {
157+
if ($this->isAlignableExportIgnoreLine($line) === false) {
158+
continue;
159+
}
160+
161+
[$pattern] = \explode('export-ignore', $line, 2);
162+
$exportIgnorePatterns[] = \rtrim($pattern);
163+
}
164+
165+
if ($exportIgnorePatterns === []) {
166+
return $gitattributesContent;
167+
}
168+
169+
$longestPattern = \max(\array_map('strlen', $exportIgnorePatterns));
170+
171+
$alignedLines = \array_map(function (string $line) use ($longestPattern): string {
172+
if ($this->isAlignableExportIgnoreLine($line) === false) {
173+
return $line;
174+
}
175+
176+
[$pattern, $suffix] = \explode('export-ignore', $line, 2);
177+
$pattern = \rtrim($pattern);
178+
179+
if (str_starts_with($pattern, '/') && str_ends_with($pattern, '/') === false) {
180+
$pattern = str_replace('/', '', $pattern);
181+
}
182+
183+
return $pattern . \str_repeat(' ', $longestPattern - \strlen($pattern) + 1) . 'export-ignore' . $suffix;
184+
}, $gitattributesLines);
185+
186+
return \implode($eol, $alignedLines);
187+
}
188+
189+
private function isAlignableExportIgnoreLine(string $line): bool
190+
{
191+
return \str_contains($line, 'export-ignore')
192+
&& \str_starts_with(\ltrim($line), '#') === false;
193+
}
194+
195+
private function detectEol(string $content): string
196+
{
197+
if (\str_contains($content, "\r\n")) {
198+
return "\r\n";
199+
}
200+
201+
if (\str_contains($content, "\r")) {
202+
return "\r";
203+
}
204+
205+
return "\n";
206+
}
207+
}

src/Commands/UpdateCommand.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ protected function configure(): void
4343
InputArgument::OPTIONAL,
4444
'The package directory whose .gitattributes file should be updated',
4545
\defined('WORKING_DIRECTORY') ? WORKING_DIRECTORY : \getcwd()
46+
)->addOption(
47+
'reformat-export-ignores',
48+
null,
49+
InputOption::VALUE_NONE,
50+
'Only reformat the export-ignores directives in the .gitattributes file'
4651
)->setName(self::$defaultName)->setDescription(self::$defaultDescription);
4752

4853
// Add common generation options

src/GitattributesFileRepository.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ public function overwriteGitattributesFile(string $content): string
7979
throw new GitattributesCreationFailed($message);
8080
}
8181

82+
public function overwriteGitattributesFileFormatted(string $content): string
83+
{
84+
$bytesWritten = file_put_contents(
85+
$this->analyser->getGitattributesFilePath(),
86+
$content
87+
);
88+
89+
if ($bytesWritten !== false) {
90+
return '';
91+
}
92+
93+
$message = 'Overwrite of .gitattributes file failed.';
94+
throw new GitattributesCreationFailed($message);
95+
}
96+
8297
/**
8398
* Prepare .gitattributes content for overwriting by adjusting the header.
8499
* If the present file contains the "generated by" header, replace it with

0 commit comments

Comments
 (0)