Files
duckbrain-toolbox/packages/commands/src/Toolbox/CoreProvider.php
T

193 lines
5.7 KiB
PHP

<?php
namespace Toolbox;
/**
* Provee los archivos del core. La versión se resuelve por tags semver
* (`vX.Y.Z`); no hay archivo VERSION. Si no hay tags, la versión es desconocida
* y se trabaja sobre la rama por defecto, fijando el commit en el lock.
*/
final class CoreProvider
{
private function __construct(private readonly string $dir)
{
}
public static function fromDirectory(string $dir): self
{
if (!is_dir($dir)) {
throw new ToolboxException("El directorio del core no existe: {$dir}");
}
return new self(rtrim($dir, '/'));
}
public static function fromUrl(string $url, string $cacheDir, Console $console): self
{
$dir = rtrim($cacheDir, '/') . '/core';
if (!is_dir($dir . '/.git')) {
if (!is_dir(dirname($dir))) {
mkdir(dirname($dir), 0777, true);
}
$console->out(' clonando core desde ' . $url);
exec('git clone -q --tags ' . escapeshellarg($url) . ' ' . escapeshellarg($dir) . ' 2>&1', $out, $code);
if ($code !== 0) {
throw new ToolboxException('No se pudo clonar el core: ' . implode("\n", $out));
}
} else {
exec('git -C ' . escapeshellarg($dir) . ' remote set-url origin ' . escapeshellarg($url) . ' 2>&1');
$out = [];
exec('git -C ' . escapeshellarg($dir) . ' fetch -q --tags 2>&1', $out, $code);
if ($code !== 0) {
throw new ToolboxException('No se pudo actualizar el core: ' . implode("\n", $out));
}
}
$provider = new self($dir);
$version = $provider->version();
// Tags opcionales: si hay, se usa la release más alta; si no, la rama por defecto.
if ($version !== null) {
exec('git -C ' . escapeshellarg($dir) . ' checkout -q ' . escapeshellarg('v' . $version) . ' 2>&1');
} else {
exec('git -C ' . escapeshellarg($dir) . ' checkout -q 2>&1');
exec('git -C ' . escapeshellarg($dir) . ' pull -q --ff-only 2>/dev/null');
}
return $provider;
}
public function dir(): string
{
return $this->dir;
}
public function isGit(): bool
{
return is_dir($this->dir . '/.git');
}
/**
* Versión más alta entre los tags `vX.Y.Z`, o null si no hay tags.
*/
public function version(): ?string
{
if (!$this->isGit()) {
return null;
}
exec('git -C ' . escapeshellarg($this->dir) . ' tag -l ' . escapeshellarg('v*') . ' 2>/dev/null', $tags, $code);
if ($code !== 0) {
return null;
}
$best = null;
foreach ($tags as $tag) {
$candidate = ltrim(trim($tag), 'vV');
try {
Semver::parse($candidate);
} catch (ToolboxException) {
continue;
}
if ($best === null || Semver::compare($candidate, $best) > 0) {
$best = $candidate;
}
}
return $best;
}
public function commit(): ?string
{
if (!$this->isGit()) {
return null;
}
exec('git -C ' . escapeshellarg($this->dir) . ' rev-parse HEAD 2>/dev/null', $out, $code);
$commit = trim($out[0] ?? '');
return $code === 0 && $commit !== '' ? $commit : null;
}
/**
* Prefijos/exactos que no forman parte del core distribuible: herramientas
* del repositorio, control de versiones y documentación. `.htaccess` NO se
* excluye (sí es del core).
*/
private const EXCLUDED = [
'.git/', '.github/', '.opencode/', '.openspec/', '.idea/', '.vscode/',
'node_modules/', 'vendor/', 'dist/',
'.gitignore', '.gitattributes', '.editorconfig', '.DS_Store', 'Thumbs.db',
'readme.org', 'README.org', 'readme.md', 'README.md', 'readme.txt',
];
/**
* Archivos del core distribuible. Si la fuente es un repositorio git se
* parte de sus archivos RASTREADOS (`git ls-files`, los mismos del remoto);
* si no, se recorre el directorio. En ambos casos se filtra la lista negra
* (herramientas y documentación).
*
* @return list<string>
*/
public function files(): array
{
$files = $this->isGit() ? $this->trackedFiles() : $this->walkFiles();
$files = array_values(array_filter($files, fn (string $file) => !$this->isExcluded($file)));
sort($files);
return $files;
}
/**
* @return list<string>
*/
private function trackedFiles(): array
{
exec('git -C ' . escapeshellarg($this->dir) . ' ls-files 2>/dev/null', $out, $code);
if ($code !== 0) {
return $this->walkFiles();
}
return array_values(array_filter($out, fn (string $file) => $file !== ''));
}
/**
* @return list<string>
*/
private function walkFiles(): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$files[] = substr($item->getPathname(), strlen($this->dir) + 1);
}
return $files;
}
private function isExcluded(string $relative): bool
{
foreach (self::EXCLUDED as $excluded) {
if ($relative === $excluded || str_starts_with($relative, $excluded)) {
return true;
}
}
return false;
}
public function read(string $relative): string
{
$path = $this->dir . '/' . ltrim($relative, '/');
return (string) file_get_contents($path);
}
}