193 lines
5.7 KiB
PHP
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);
|
|
}
|
|
}
|