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 --shortstat miatt minden commit után megjelenik a "N files changed" sor is, * ezt az utolsó mezőből (a tárgybó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']); $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, 5); if (count($fields) < 5) { continue; } [$hash, $short, $author, $date, $tail] = $fields; $commits[] = [ 'hash' => $hash, 'short' => $short, 'author' => $author, 'date' => $date, 'subject' => trim(explode("\n", $tail, 2)[0]), 'file_count' => preg_match('/(\d+)\s+files?\s+changed/', $tail, $matches) === 1 ? (int) $matches[1] : null, ]; } 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 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(); } }