Két commit közötti fájlok összegyűjtését előkészítő felület első fázisa: célkörnyezet választás (e2e/d2d), commitlista, diff-előnézet figyelmeztetésekkel. Ez a fázis még semmit nem ír a fájlrendszerre, a csomagolás a phase2-ben érkezik. - DeploymentGuard: .env kapcsoló + hardkódolt stage whitelist + developer szerep + feature flag; a flag szándékosan nem biztonsági réteg, csak láthatóság-vezérlés - GitRepository: csak olvasó wrapper, argumentum-tömbös Symfony Process (nincs shell), hash- és referencia-validáció, core.quotePath=false az ékezetes útvonalakhoz - ChangeSetAnalyzer: kizárási lista, törlendő/másolandó szétválasztás (átnevezésnél mindkettő), figyelmeztetések migrációra, composer.lock-ra és a nem verziókövetett fordított assetekre Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
8.5 KiB
PHP
274 lines
8.5 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 --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<int, array{hash:string, short:string, author:string, date:string, subject: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']);
|
|
|
|
$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<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();
|
|
}
|
|
}
|