d2d.emegrendeles.hu/app/Services/Deployment/GitRepository.php
E98Developer 6027511585 FIX Deployment csomagoló phase1 git ikon és a commit törzsére kiterjesztett keresés
- a topbar menüpont a most bemásolt git ikont használja; a fájl ico_gitpng néven
  érkezett, átnevezve ico_git.png-re, hogy illeszkedjen az ico_*.png konvencióhoz,
  amiből a nav az útvonalat építi
- a git log a törzset (%b) is elkéri, és a keresés a tárgysor mellett ebben is keres:
  a hash-re ritkán keres ember, az érdemi leírás viszont gyakran a törzsben van
- a --shortstat sora a törzs után érkezik, ezért a parse leválasztja róla, különben
  a "N files changed" szöveg a törzsbe és a keresésbe is beszivárogna

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:59:04 +02:00

285 lines
9.0 KiB
PHP

<?php
namespace App\Services\Deployment;
use RuntimeException;
use Symfony\Component\Process\Process;
/**
* Csak olvasó git wrapper a deployment csomagolóhoz.
*
* Minden hívás argumentum-tömbbel megy a Symfony Process-nek, soha nem shell-stringgel,
* így a felületről érkező érték nem tud parancsot injektálni. Ettől függetlenül minden
* bemenetet külön is validálunk: a git a kötőjellel kezdődő értéket kapcsolóként
* értelmezné, a `..` pedig commit-tartományt jelent, nem fájlnevet.
*/
class GitRepository
{
/** Mezőelválasztó a git log formátumban (ASCII unit separator). */
private const FIELD_SEPARATOR = "\x1f";
/** Rekordelválasztó a git log formátumban (ASCII record separator). */
private const RECORD_SEPARATOR = "\x1e";
private const COMMIT_PATTERN = '/^[0-9a-f]{7,40}$/';
private const REFERENCE_PATTERN = '#^[A-Za-z0-9][A-Za-z0-9._/-]*$#';
public function isAvailable(): bool
{
try {
$this->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<int, string>
*/
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<int, array{hash:string, short:string, author:string, date:string, subject:string, body:string, file_count:?int}>
*/
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<int, array{status:string, path:string, old_path:?string}>
*/
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<int, string> $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<int, string> $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();
}
}