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 */ 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 */ 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 */ 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); } }