run(['rev-parse', '--git-dir']); return true; } catch (RuntimeException) { return false; } } public function currentBranch(): ?string { try { $branch = trim($this->run(['rev-parse', '--abbrev-ref', 'HEAD'])); } catch (RuntimeException) { return null; } return ($branch === '' || $branch === 'HEAD') ? null : $branch; } /** * @return array */ public function branches(): array { $output = $this->run([ 'for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads', 'refs/remotes', ]); return collect(explode("\n", $output)) ->map(fn (string $line): string => trim($line)) ->filter() // Az "origin/HEAD" csak egy mutató az alapértelmezett branchre, nem önálló ág. ->reject(fn (string $branch): bool => str_ends_with($branch, '/HEAD')) ->unique() ->values() ->all(); } /** * Commitlista egy referenciáról, legfrissebbtől visszafelé. * * A törzset (%b) is elkérjük, mert a felületen erre is lehet keresni - a commit * üzenet érdemi része nálunk gyakran a törzsben van, nem a tárgysorban. * * A --shortstat miatt minden commit után megjelenik a "N files changed" sor is, * ezt az utolsó mezőből bányásszuk ki - így nem kell commitonként külön git hívás * a fájlszámhoz. * * @return array */ public function commits(string $reference, int $limit): array { $this->assertReference($reference); $format = self::RECORD_SEPARATOR.implode(self::FIELD_SEPARATOR, ['%H', '%h', '%an', '%aI', '%s', '%b']); $output = $this->run([ 'log', '--max-count='.max(1, min($limit, 500)), '--no-merges', '--shortstat', '--pretty=format:'.$format, $reference, '--', ]); $commits = []; foreach (explode(self::RECORD_SEPARATOR, $output) as $record) { if (trim($record) === '') { continue; } $fields = explode(self::FIELD_SEPARATOR, $record, 6); if (count($fields) < 6) { continue; } [$hash, $short, $author, $date, $subject, $tail] = $fields; // A --shortstat sora a törzs UTÁN érkezik, ezért előbb leválasztjuk róla: // enélkül a "3 files changed" szöveg a keresésbe és a törzsbe is beszivárogna. $fileCount = null; if (preg_match('/\n\s*(\d+)\s+files?\s+changed[^\n]*\n?$/', $tail, $matches) === 1) { $fileCount = (int) $matches[1]; $tail = substr($tail, 0, -strlen($matches[0])); } $commits[] = [ 'hash' => $hash, 'short' => $short, 'author' => $author, 'date' => $date, 'subject' => trim($subject), 'body' => trim($tail), 'file_count' => $fileCount, ]; } return $commits; } /** * Teljes hash, ha a megadott érték létező commitra mutat - különben null. */ public function resolveCommit(string $hash): ?string { if (preg_match(self::COMMIT_PATTERN, $hash) !== 1) { return null; } try { $resolved = trim($this->run(['rev-parse', '--verify', '--quiet', $hash.'^{commit}'])); } catch (RuntimeException) { return null; } return $resolved === '' ? null : $resolved; } /** * Őse-e az első commit a másodiknak? (Fordított tartomány felismeréséhez.) */ public function isAncestor(string $ancestor, string $descendant): bool { if (! $this->resolveCommit($ancestor) || ! $this->resolveCommit($descendant)) { return false; } // A merge-base 1-es kilépési kóddal jelzi a "nem őse" esetet, ez nem hiba, // ezért itt nem a run() dobó változatát használjuk. return $this->process(['merge-base', '--is-ancestor', $ancestor, $descendant])->run() === 0; } /** * A két commit között változott fájlok (from kizárva, to beleértve). * * @return array */ public function changedFiles(string $from, string $to): array { $fromHash = $this->resolveCommit($from); $toHash = $this->resolveCommit($to); if (! $fromHash || ! $toHash) { throw new RuntimeException('Ismeretlen commit azonosító.'); } // A core.quotePath=false nélkül a git a nem-ASCII fájlneveket idézőjelbe teszi // és oktálisan escape-eli ("app/\303\251kezet.php") - ékezetes útvonalaknál ez // használhatatlan lenne. $output = $this->run([ '-c', 'core.quotePath=false', 'diff', '--name-status', '--find-renames', $fromHash.'..'.$toHash, '--', ]); $files = []; foreach (explode("\n", $output) as $line) { $line = rtrim($line, "\r\n"); if ($line === '') { continue; } $parts = explode("\t", $line); $status = strtoupper(substr($parts[0], 0, 1)); // Átnevezés (R) és másolás (C) esetén két útvonal jön: régi és új. $isTwoPath = in_array($status, ['R', 'C'], true) && count($parts) >= 3; $path = $isTwoPath ? $parts[2] : ($parts[1] ?? ''); $oldPath = $isTwoPath ? $parts[1] : null; if (! $this->isSafePath($path) || ($oldPath !== null && ! $this->isSafePath($oldPath))) { continue; } $files[] = [ 'status' => $status, 'path' => $path, 'old_path' => $oldPath, ]; } usort($files, fn (array $a, array $b): int => strcmp($a['path'], $b['path'])); return $files; } /** * A megadott commit szerinti fájltartalmat írja ki a célmappába, relatív útvonalakat megőrizve. * * Szándékosan a git objektumtárból dolgozunk (git archive) és nem a working tree-ből: * így garantáltan a commitolt állapot kerül a csomagba (nem szivárog ki félkész * módosítás), és a .gitattributes `text=auto eol=lf` miatt LF sorvégekkel, ami a * Linux célgépeknek kell - a working tree másolása ezt egyik esetben sem garantálná. * * @param array $paths * @return array a ténylegesen kiírt útvonalak */ public function archiveTo(string $commit, array $paths, string $targetDirectory): array { // Pathspec nélkül a git archive a TELJES fát csomagolná - üres listánál ezért // nem hívhatjuk meg egyáltalán. if ($paths === []) { return []; } $resolved = $this->resolveCommit($commit); if (! $resolved) { throw new RuntimeException('Ismeretlen commit azonosító.'); } $written = []; // Az útvonalak argumentumként mennek: Windowson a CreateProcess parancssora // ~32 000 karakter, ezért darabolunk. foreach (array_chunk($paths, 100) as $chunk) { $archivePath = tempnam(sys_get_temp_dir(), 'deployment-archive-'); if ($archivePath === false) { throw new RuntimeException('Nem hozható létre ideiglenes fájl a csomagoláshoz.'); } try { $this->run(array_merge( ['archive', '--format=zip', '--output='.$archivePath, $resolved, '--'], $chunk, )); $written = array_merge($written, $this->extract($archivePath, $targetDirectory)); } finally { @unlink($archivePath); } } return $written; } /** * @return array */ private function extract(string $archivePath, string $targetDirectory): array { $zip = new ZipArchive; if ($zip->open($archivePath) !== true) { throw new RuntimeException('A git archive kimenete nem nyitható meg.'); } $entries = []; try { for ($index = 0; $index < $zip->numFiles; $index++) { $name = $zip->getNameIndex($index); if ($name === false || str_ends_with($name, '/')) { continue; } // A célmappából kimutató bejegyzés nem fordulhat elő valódi git archive-ban, // de a kiírás előtti ellenőrzés olcsó - és ez az egyetlen pont, ahol az // alkalmazás a repón kívülre írhatna. if (! $this->isSafePath($name)) { throw new RuntimeException(sprintf('Gyanús útvonal a csomagban: %s', $name)); } $entries[] = $name; } if ($entries !== [] && ! $zip->extractTo($targetDirectory, $entries)) { throw new RuntimeException('A fájlok kicsomagolása nem sikerült.'); } } finally { $zip->close(); } return $entries; } /** * A repo gyökeréből ki nem mutató, relatív útvonal-e. */ private function isSafePath(string $path): bool { if ($path === '' || str_starts_with($path, '/') || str_contains($path, "\0")) { return false; } // Windows meghajtó-előtag (C:/...) és szülőkönyvtár-hivatkozás sem fordulhat elő // valódi git útvonalban, viszont fájlkiírásnál kitörhetne a célmappából. return preg_match('#^[A-Za-z]:#', $path) !== 1 && preg_match('#(^|/)\.\.(/|$)#', $path) !== 1; } private function assertReference(string $reference): void { if (preg_match(self::REFERENCE_PATTERN, $reference) !== 1 || str_contains($reference, '..')) { throw new RuntimeException(sprintf('Érvénytelen git referencia: %s', $reference)); } } /** * @param array $arguments */ private function process(array $arguments): Process { return new Process( array_merge([(string) config('deployment.git_binary') ?: 'git'], $arguments), (string) config('deployment.repo_path') ?: base_path(), null, null, (float) config('deployment.timeout', 60), ); } /** * @param array $arguments */ private function run(array $arguments): string { $process = $this->process($arguments); $process->run(); if (! $process->isSuccessful()) { throw new RuntimeException(sprintf( 'A git parancs hibára futott (%s): %s', implode(' ', $arguments), trim($process->getErrorOutput()) ?: trim($process->getOutput()), )); } return $process->getOutput(); } }