| Author | Dave Jarvis <email> |
|---|---|
| Date | 2026-02-11 23:50:14 GMT-0800 |
| Commit | fff610d11b54cf8e370b99c30c84a5f3480552bb |
| Parent | 8ed6561 |
| class File { | ||
| + private ?Git $git; | ||
| private string $name; | ||
| private string $sha; | ||
| private string $mode; | ||
| private int $timestamp; | ||
| private int $size; | ||
| private bool $isDir; | ||
| - public function __construct(string $name, string $sha, string $mode, int $timestamp = 0, int $size = 0) { | ||
| - $this->name = $name; | ||
| - $this->sha = $sha; | ||
| - $this->mode = $mode; | ||
| + public function __construct( ?Git $git, string $name, string $sha, string $mode, int $timestamp = 0, int $size = 0 ) { | ||
| + $this->git = $git; | ||
| + $this->name = $name; | ||
| + $this->sha = $sha; | ||
| + $this->mode = $mode; | ||
| $this->timestamp = $timestamp; | ||
| - $this->size = $size; | ||
| - $this->isDir = ($mode === '40000' || $mode === '040000'); | ||
| + $this->size = $size; | ||
| + $this->isDir = ( $mode === '40000' || $mode === '040000' ); | ||
| } | ||
| - public function compare(File $other): int { | ||
| - if ($this->isDir !== $other->isDir) { | ||
| + public function compare( File $other ): int { | ||
| + if( $this->isDir !== $other->isDir ) { | ||
| return $this->isDir ? -1 : 1; | ||
| } | ||
| - return strcasecmp($this->name, $other->name); | ||
| + return strcasecmp( $this->name, $other->name ); | ||
| } | ||
| - public function render(FileRenderer $renderer): void { | ||
| + public function render( FileRenderer $renderer ): void { | ||
| $renderer->renderFileItem( | ||
| $this->name, | ||
| private function getIconClass(): string { | ||
| - if ($this->isDir) return 'fa-folder'; | ||
| + if( $this->isDir ) return 'fa-folder'; | ||
| - return match (true) { | ||
| - $this->isType('application/pdf') => 'fa-file-pdf', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_ARCHIVE) => 'fa-file-archive', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_IMAGE) => 'fa-file-image', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_AUDIO) => 'fa-file-audio', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_VIDEO) => 'fa-file-video', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_TEXT) => 'fa-file-code', | ||
| - default => 'fa-file', | ||
| + return match ( true ) { | ||
| + $this->isType( 'application/pdf' ) => 'fa-file-pdf', | ||
| + $this->isCategory( MediaTypeSniffer::CAT_ARCHIVE ) => 'fa-file-archive', | ||
| + $this->isCategory( MediaTypeSniffer::CAT_IMAGE ) => 'fa-file-image', | ||
| + $this->isCategory( MediaTypeSniffer::CAT_AUDIO ) => 'fa-file-audio', | ||
| + $this->isCategory( MediaTypeSniffer::CAT_VIDEO ) => 'fa-file-video', | ||
| + $this->isCategory( MediaTypeSniffer::CAT_TEXT ) => 'fa-file-code', | ||
| + default => 'fa-file', | ||
| }; | ||
| } | ||
| private function getFormattedSize(): string { | ||
| - if ($this->size <= 0) return '0 B'; | ||
| + if( $this->size <= 0 ) return '0 B'; | ||
| $units = ['B', 'KB', 'MB', 'GB']; | ||
| - $i = (int)floor(log($this->size, 1024)); | ||
| - return round($this->size / pow(1024, $i), 1) . ' ' . $units[$i]; | ||
| + $i = (int)floor( log( $this->size, 1024 ) ); | ||
| + return round( $this->size / pow( 1024, $i ), 1 ) . ' ' . $units[$i]; | ||
| } | ||
| - public function isType(string $type): bool { | ||
| - return str_contains(MediaTypeSniffer::isMediaType($this->getSniffBuffer(), $this->name), $type); | ||
| + public function isType( string $type ): bool { | ||
| + return str_contains( MediaTypeSniffer::isMediaType( $this->getSniffBuffer(), $this->name ), $type ); | ||
| } | ||
| - public function isCategory(string $category): bool { | ||
| - return MediaTypeSniffer::isCategory($this->getSniffBuffer(), $this->name) === $category; | ||
| + public function isCategory( string $category ): bool { | ||
| + return MediaTypeSniffer::isCategory( $this->getSniffBuffer(), $this->name ) === $category; | ||
| } | ||
| public function isBinary(): bool { | ||
| - return MediaTypeSniffer::isBinary($this->getSniffBuffer(), $this->name); | ||
| + return MediaTypeSniffer::isBinary( $this->getSniffBuffer(), $this->name ); | ||
| } | ||
| private function getSniffBuffer(): string { | ||
| - if ($this->isDir || !file_exists($this->name)) return ''; | ||
| - $handle = @fopen($this->name, 'rb'); | ||
| - if (!$handle) return ''; | ||
| - $read = fread($handle, 12); | ||
| - fclose($handle); | ||
| - return ($read !== false) ? $read : ''; | ||
| + if( $this->isDir ) return ''; | ||
| + | ||
| + if( $this->git ) { | ||
| + return $this->git->peek( $this->sha ); | ||
| + } | ||
| + | ||
| + if( !file_exists( $this->name ) ) return ''; | ||
| + $handle = @fopen( $this->name, 'rb' ); | ||
| + if( !$handle ) return ''; | ||
| + $read = fread( $handle, 12 ); | ||
| + fclose( $handle ); | ||
| + return ( $read !== false ) ? $read : ''; | ||
| } | ||
| } | ||
| class Git { | ||
| - private const CHUNK_SIZE = 128; | ||
| + private const CHUNK_SIZE = 128; | ||
| private const MAX_READ_SIZE = 1048576; | ||
| public function eachTag( callable $callback ): void { | ||
| - $this->refs->scanRefs( 'refs/tags', function($name, $sha) use ($callback) { | ||
| - $data = $this->read($sha); | ||
| + $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( $callback ) { | ||
| + $data = $this->read( $sha ); | ||
| $targetSha = $sha; | ||
| $timestamp = 0; | ||
| $message = ''; | ||
| $author = ''; | ||
| - // Determine if Annotated Tag or Lightweight Tag | ||
| - if (strncmp($data, 'object ', 7) === 0) { | ||
| - // Annotated Tag | ||
| - if (preg_match('/^object ([0-9a-f]{40})$/m', $data, $m)) { | ||
| - $targetSha = $m[1]; | ||
| + if( strncmp( $data, 'object ', 7 ) === 0 ) { | ||
| + if( preg_match( '/^object ([0-9a-f]{40})$/m', $data, $m ) ) { | ||
| + $targetSha = $m[1]; | ||
| } | ||
| - if (preg_match('/^tagger (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m)) { | ||
| - $author = trim($m[1]); | ||
| - $timestamp = (int)$m[2]; | ||
| + if( preg_match( '/^tagger (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m ) ) { | ||
| + $author = trim( $m[1] ); | ||
| + $timestamp = (int)$m[2]; | ||
| } | ||
| - $pos = strpos($data, "\n\n"); | ||
| - if ($pos !== false) { | ||
| - $message = trim(substr($data, $pos + 2)); | ||
| + $pos = strpos( $data, "\n\n" ); | ||
| + if( $pos !== false ) { | ||
| + $message = trim( substr( $data, $pos + 2 ) ); | ||
| } | ||
| } else { | ||
| - // Lightweight Tag (points directly to commit) | ||
| - // We parse the commit data to get date/author | ||
| - if (preg_match('/^author (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m)) { | ||
| - $author = trim($m[1]); | ||
| - $timestamp = (int)$m[2]; | ||
| + if( preg_match( '/^author (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m ) ) { | ||
| + $author = trim( $m[1] ); | ||
| + $timestamp = (int)$m[2]; | ||
| } | ||
| - $pos = strpos($data, "\n\n"); | ||
| - if ($pos !== false) { | ||
| - $message = trim(substr($data, $pos + 2)); | ||
| + $pos = strpos( $data, "\n\n" ); | ||
| + if( $pos !== false ) { | ||
| + $message = trim( substr( $data, $pos + 2 ) ); | ||
| } | ||
| } | ||
| - $callback(new Tag( | ||
| + $callback( new Tag( | ||
| $name, | ||
| $sha, | ||
| $targetSha, | ||
| $timestamp, | ||
| $message, | ||
| $author | ||
| - )); | ||
| - }); | ||
| + ) ); | ||
| + } ); | ||
| } | ||
| return $this->getLooseObjectSize( $sha ); | ||
| + } | ||
| + | ||
| + public function peek( string $sha, int $length = 12 ): string { | ||
| + $size = $this->packs->getSize( $sha ); | ||
| + | ||
| + if( $size === null ) { | ||
| + return $this->peekLooseObject( $sha, $length ); | ||
| + } | ||
| + | ||
| + return $this->packs->peek( $sha, $length ) ?? ''; | ||
| } | ||
| } | ||
| - // Try streaming from pack file first (supports large files) | ||
| if( method_exists( $this->packs, 'stream' ) ) { | ||
| $streamed = $this->packs->stream( $sha, $callback ); | ||
| if( $streamed ) { | ||
| return; | ||
| } | ||
| } | ||
| - // Fallback to read method (limited size) | ||
| $data = $this->packs->read( $sha ); | ||
| if( $data !== null && $data !== '' ) { | ||
| $callback( $data ); | ||
| + } | ||
| + } | ||
| + | ||
| + private function peekLooseObject( string $sha, int $length ): string { | ||
| + $path = $this->getLoosePath( $sha ); | ||
| + | ||
| + if( !is_file( $path ) ) { | ||
| + return ''; | ||
| + } | ||
| + | ||
| + $fileHandle = @fopen( $path, 'rb' ); | ||
| + | ||
| + if( !$fileHandle ) { | ||
| + return ''; | ||
| + } | ||
| + | ||
| + $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | ||
| + $headerFound = false; | ||
| + $buffer = ''; | ||
| + | ||
| + while( !feof( $fileHandle ) && strlen( $buffer ) < $length ) { | ||
| + $chunk = fread( $fileHandle, 128 ); | ||
| + $inflated = @inflate_add( $inflator, $chunk ); | ||
| + | ||
| + if( !$headerFound ) { | ||
| + $raw = $inflated; | ||
| + $nullPos = strpos( $raw, "\0" ); | ||
| + | ||
| + if( $nullPos !== false ) { | ||
| + $headerFound = true; | ||
| + $buffer .= substr( $raw, $nullPos + 1 ); | ||
| + } | ||
| + } else { | ||
| + $buffer .= $inflated; | ||
| + } | ||
| } | ||
| + | ||
| + fclose( $fileHandle ); | ||
| + | ||
| + return substr( $buffer, 0, $length ); | ||
| } | ||
| $size = $isDirectory ? 0 : $this->getObjectSize( $sha ); | ||
| - $callback( new File( $name, $sha, $mode, 0, $size ) ); | ||
| + $callback( new File( $this, $name, $sha, $mode, 0, $size ) ); | ||
| $position = $nullPos + 21; | ||
| $nullPos = strpos( $data, "\0" ); | ||
| - return $nullPos !== false && ($nullPos + 21 <= strlen( $data )); | ||
| + return $nullPos !== false && ( $nullPos + 21 <= strlen( $data ) ); | ||
| } | ||
| private const MAX_DIFF_SIZE = 1048576; | ||
| - public function __construct(Git $git) { | ||
| + public function __construct( Git $git ) { | ||
| $this->git = $git; | ||
| } | ||
| - public function compare(string $commitHash) { | ||
| - $commitData = $this->git->read($commitHash); | ||
| + public function compare( string $commitHash ) { | ||
| + $commitData = $this->git->read( $commitHash ); | ||
| $parentHash = ''; | ||
| - if (preg_match('/^parent ([0-9a-f]{40})/m', $commitData, $matches)) { | ||
| + if( preg_match( '/^parent ([0-9a-f]{40})/m', $commitData, $matches ) ) { | ||
| $parentHash = $matches[1]; | ||
| } | ||
| - $newTree = $this->getTreeHash($commitHash); | ||
| - $oldTree = $parentHash ? $this->getTreeHash($parentHash) : null; | ||
| + $newTree = $this->getTreeHash( $commitHash ); | ||
| + $oldTree = $parentHash ? $this->getTreeHash( $parentHash ) : null; | ||
| - return $this->diffTrees($oldTree, $newTree); | ||
| + return $this->diffTrees( $oldTree, $newTree ); | ||
| } | ||
| - private function getTreeHash($commitSha) { | ||
| - $data = $this->git->read($commitSha); | ||
| - if (preg_match('/^tree ([0-9a-f]{40})/m', $data, $matches)) { | ||
| + private function getTreeHash( $commitSha ) { | ||
| + $data = $this->git->read( $commitSha ); | ||
| + if( preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) { | ||
| return $matches[1]; | ||
| } | ||
| return null; | ||
| } | ||
| - private function diffTrees($oldTreeSha, $newTreeSha, $path = '') { | ||
| + private function diffTrees( $oldTreeSha, $newTreeSha, $path = '' ) { | ||
| $changes = []; | ||
| - if ($oldTreeSha === $newTreeSha) return []; | ||
| + if( $oldTreeSha === $newTreeSha ) return []; | ||
| - $oldEntries = $oldTreeSha ? $this->parseTree($oldTreeSha) : []; | ||
| - $newEntries = $newTreeSha ? $this->parseTree($newTreeSha) : []; | ||
| + $oldEntries = $oldTreeSha ? $this->parseTree( $oldTreeSha ) : []; | ||
| + $newEntries = $newTreeSha ? $this->parseTree( $newTreeSha ) : []; | ||
| - $allNames = array_unique(array_merge(array_keys($oldEntries), array_keys($newEntries))); | ||
| - sort($allNames); | ||
| + $allNames = array_unique( array_merge( array_keys( $oldEntries ), array_keys( $newEntries ) ) ); | ||
| + sort( $allNames ); | ||
| - foreach ($allNames as $name) { | ||
| - $old = $oldEntries[$name] ?? null; | ||
| - $new = $newEntries[$name] ?? null; | ||
| + foreach( $allNames as $name ) { | ||
| + $old = $oldEntries[$name] ?? null; | ||
| + $new = $newEntries[$name] ?? null; | ||
| $currentPath = $path ? "$path/$name" : $name; | ||
| - if (!$old) { | ||
| - if ($new['is_dir']) { | ||
| - $changes = array_merge($changes, $this->diffTrees(null, $new['sha'], $currentPath)); | ||
| + if( !$old ) { | ||
| + if( $new['is_dir'] ) { | ||
| + $changes = array_merge( $changes, $this->diffTrees( null, $new['sha'], $currentPath ) ); | ||
| } else { | ||
| - $changes[] = $this->createChange('A', $currentPath, null, $new['sha']); | ||
| + $changes[] = $this->createChange( 'A', $currentPath, null, $new['sha'] ); | ||
| } | ||
| - } elseif (!$new) { | ||
| - if ($old['is_dir']) { | ||
| - $changes = array_merge($changes, $this->diffTrees($old['sha'], null, $currentPath)); | ||
| + } elseif( !$new ) { | ||
| + if( $old['is_dir'] ) { | ||
| + $changes = array_merge( $changes, $this->diffTrees( $old['sha'], null, $currentPath ) ); | ||
| } else { | ||
| - $changes[] = $this->createChange('D', $currentPath, $old['sha'], null); | ||
| + $changes[] = $this->createChange( 'D', $currentPath, $old['sha'], null ); | ||
| } | ||
| - } elseif ($old['sha'] !== $new['sha']) { | ||
| - if ($old['is_dir'] && $new['is_dir']) { | ||
| - $changes = array_merge($changes, $this->diffTrees($old['sha'], $new['sha'], $currentPath)); | ||
| - } elseif (!$old['is_dir'] && !$new['is_dir']) { | ||
| - $changes[] = $this->createChange('M', $currentPath, $old['sha'], $new['sha']); | ||
| + } elseif( $old['sha'] !== $new['sha'] ) { | ||
| + if( $old['is_dir'] && $new['is_dir'] ) { | ||
| + $changes = array_merge( $changes, $this->diffTrees( $old['sha'], $new['sha'], $currentPath ) ); | ||
| + } elseif( !$old['is_dir'] && !$new['is_dir'] ) { | ||
| + $changes[] = $this->createChange( 'M', $currentPath, $old['sha'], $new['sha'] ); | ||
| } | ||
| } | ||
| } | ||
| return $changes; | ||
| } | ||
| - private function parseTree($sha) { | ||
| - $data = $this->git->read($sha); | ||
| + private function parseTree( $sha ) { | ||
| + $data = $this->git->read( $sha ); | ||
| $entries = []; | ||
| - $len = strlen($data); | ||
| - $pos = 0; | ||
| + $len = strlen( $data ); | ||
| + $pos = 0; | ||
| - while ($pos < $len) { | ||
| - $space = strpos($data, ' ', $pos); | ||
| - $null = strpos($data, "\0", $space); | ||
| + while( $pos < $len ) { | ||
| + $space = strpos( $data, ' ', $pos ); | ||
| + $null = strpos( $data, "\0", $space ); | ||
| - if ($space === false || $null === false) break; | ||
| + if( $space === false || $null === false ) break; | ||
| - $mode = substr($data, $pos, $space - $pos); | ||
| - $name = substr($data, $space + 1, $null - $space - 1); | ||
| - $hash = bin2hex(substr($data, $null + 1, 20)); | ||
| + $mode = substr( $data, $pos, $space - $pos ); | ||
| + $name = substr( $data, $space + 1, $null - $space - 1 ); | ||
| + $hash = bin2hex( substr( $data, $null + 1, 20 ) ); | ||
| $entries[$name] = [ | ||
| - 'mode' => $mode, | ||
| - 'sha' => $hash, | ||
| - 'is_dir' => ($mode === '40000' || $mode === '040000') | ||
| + 'mode' => $mode, | ||
| + 'sha' => $hash, | ||
| + 'is_dir' => ( $mode === '40000' || $mode === '040000' ) | ||
| ]; | ||
| $pos = $null + 21; | ||
| } | ||
| return $entries; | ||
| } | ||
| - private function createChange($type, $path, $oldSha, $newSha) { | ||
| - // Check file sizes before reading content to prevent OOM | ||
| - $oldSize = $oldSha ? $this->git->getObjectSize($oldSha) : 0; | ||
| - $newSize = $newSha ? $this->git->getObjectSize($newSha) : 0; | ||
| + private function createChange( $type, $path, $oldSha, $newSha ) { | ||
| + $oldSize = $oldSha ? $this->git->getObjectSize( $oldSha ) : 0; | ||
| + $newSize = $newSha ? $this->git->getObjectSize( $newSha ) : 0; | ||
| - // If file is too large, skip diffing and treat as binary | ||
| - if ($oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE) { | ||
| + if( $oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE ) { | ||
| return [ | ||
| - 'type' => $type, | ||
| - 'path' => $path, | ||
| + 'type' => $type, | ||
| + 'path' => $path, | ||
| 'is_binary' => true, | ||
| - 'hunks' => [] | ||
| + 'hunks' => [] | ||
| ]; | ||
| } | ||
| - $oldContent = $oldSha ? $this->git->read($oldSha) : ''; | ||
| - $newContent = $newSha ? $this->git->read($newSha) : ''; | ||
| + $oldContent = $oldSha ? $this->git->read( $oldSha ) : ''; | ||
| + $newContent = $newSha ? $this->git->read( $newSha ) : ''; | ||
| $isBinary = false; | ||
| - if ($newSha) { | ||
| - $f = new VirtualDiffFile($path, $newContent); | ||
| - if ($f->isBinary()) $isBinary = true; | ||
| + if( $newSha ) { | ||
| + $f = new VirtualDiffFile( $path, $newContent ); | ||
| + if( $f->isBinary() ) $isBinary = true; | ||
| } | ||
| - if (!$isBinary && $oldSha) { | ||
| - $f = new VirtualDiffFile($path, $oldContent); | ||
| - if ($f->isBinary()) $isBinary = true; | ||
| + if( !$isBinary && $oldSha ) { | ||
| + $f = new VirtualDiffFile( $path, $oldContent ); | ||
| + if( $f->isBinary() ) $isBinary = true; | ||
| } | ||
| $diff = null; | ||
| - if (!$isBinary) { | ||
| - $diff = $this->calculateDiff($oldContent, $newContent); | ||
| + if( !$isBinary ) { | ||
| + $diff = $this->calculateDiff( $oldContent, $newContent ); | ||
| } | ||
| return [ | ||
| - 'type' => $type, | ||
| - 'path' => $path, | ||
| + 'type' => $type, | ||
| + 'path' => $path, | ||
| 'is_binary' => $isBinary, | ||
| - 'hunks' => $diff | ||
| + 'hunks' => $diff | ||
| ]; | ||
| } | ||
| - private function calculateDiff($old, $new) { | ||
| - // Normalize line endings | ||
| - $old = str_replace("\r\n", "\n", $old); | ||
| - $new = str_replace("\r\n", "\n", $new); | ||
| + private function calculateDiff( $old, $new ) { | ||
| + $old = str_replace( "\r\n", "\n", $old ); | ||
| + $new = str_replace( "\r\n", "\n", $new ); | ||
| - $oldLines = explode("\n", $old); | ||
| - $newLines = explode("\n", $new); | ||
| + $oldLines = explode( "\n", $old ); | ||
| + $newLines = explode( "\n", $new ); | ||
| - $m = count($oldLines); | ||
| - $n = count($newLines); | ||
| + $m = count( $oldLines ); | ||
| + $n = count( $newLines ); | ||
| - // LCS Algorithm Optimization: Trim matching start/end | ||
| $start = 0; | ||
| - while ($start < $m && $start < $n && $oldLines[$start] === $newLines[$start]) { | ||
| + while( $start < $m && $start < $n && $oldLines[$start] === $newLines[$start] ) { | ||
| $start++; | ||
| } | ||
| $end = 0; | ||
| - while ($m - $end > $start && $n - $end > $start && $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end]) { | ||
| + while( $m - $end > $start && $n - $end > $start && $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end] ) { | ||
| $end++; | ||
| } | ||
| - $oldSlice = array_slice($oldLines, $start, $m - $start - $end); | ||
| - $newSlice = array_slice($newLines, $start, $n - $start - $end); | ||
| + $oldSlice = array_slice( $oldLines, $start, $m - $start - $end ); | ||
| + $newSlice = array_slice( $newLines, $start, $n - $start - $end ); | ||
| - $cntOld = count($oldSlice); | ||
| - $cntNew = count($newSlice); | ||
| + $cntOld = count( $oldSlice ); | ||
| + $cntNew = count( $newSlice ); | ||
| - if (($cntOld * $cntNew) > 500000) { | ||
| - return [['t' => 'gap']]; | ||
| + if( ( $cntOld * $cntNew ) > 500000 ) { | ||
| + return [['t' => 'gap']]; | ||
| } | ||
| - $ops = $this->computeLCS($oldSlice, $newSlice); | ||
| + $ops = $this->computeLCS( $oldSlice, $newSlice ); | ||
| $groupedOps = []; | ||
| - $bufferDel = []; | ||
| - $bufferAdd = []; | ||
| + $bufferDel = []; | ||
| + $bufferAdd = []; | ||
| - foreach ($ops as $op) { | ||
| - if ($op['t'] === ' ') { | ||
| - foreach ($bufferDel as $o) $groupedOps[] = $o; | ||
| - foreach ($bufferAdd as $o) $groupedOps[] = $o; | ||
| - $bufferDel = []; | ||
| - $bufferAdd = []; | ||
| - $groupedOps[] = $op; | ||
| - } elseif ($op['t'] === '-') { | ||
| - $bufferDel[] = $op; | ||
| - } elseif ($op['t'] === '+') { | ||
| - $bufferAdd[] = $op; | ||
| - } | ||
| + foreach( $ops as $op ) { | ||
| + if( $op['t'] === ' ' ) { | ||
| + foreach( $bufferDel as $o ) $groupedOps[] = $o; | ||
| + foreach( $bufferAdd as $o ) $groupedOps[] = $o; | ||
| + $bufferDel = []; | ||
| + $bufferAdd = []; | ||
| + $groupedOps[] = $op; | ||
| + } elseif( $op['t'] === '-' ) { | ||
| + $bufferDel[] = $op; | ||
| + } elseif( $op['t'] === '+' ) { | ||
| + $bufferAdd[] = $op; | ||
| + } | ||
| } | ||
| - foreach ($bufferDel as $o) $groupedOps[] = $o; | ||
| - foreach ($bufferAdd as $o) $groupedOps[] = $o; | ||
| + foreach( $bufferDel as $o ) $groupedOps[] = $o; | ||
| + foreach( $bufferAdd as $o ) $groupedOps[] = $o; | ||
| $ops = $groupedOps; | ||
| - // Generate Stream with Context | ||
| $stream = []; | ||
| - // Prefix context | ||
| - for ($i = 0; $i < $start; $i++) { | ||
| - $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $i + 1, 'nn' => $i + 1]; | ||
| + for( $i = 0; $i < $start; $i++ ) { | ||
| + $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $i + 1, 'nn' => $i + 1]; | ||
| } | ||
| $currO = $start + 1; | ||
| $currN = $start + 1; | ||
| - foreach ($ops as $op) { | ||
| - if ($op['t'] === ' ') { | ||
| - $stream[] = ['t' => ' ', 'l' => $op['l'], 'no' => $currO++, 'nn' => $currN++]; | ||
| - } elseif ($op['t'] === '-') { | ||
| - $stream[] = ['t' => '-', 'l' => $op['l'], 'no' => $currO++, 'nn' => null]; | ||
| - } elseif ($op['t'] === '+') { | ||
| - $stream[] = ['t' => '+', 'l' => $op['l'], 'no' => null, 'nn' => $currN++]; | ||
| - } | ||
| + foreach( $ops as $op ) { | ||
| + if( $op['t'] === ' ' ) { | ||
| + $stream[] = ['t' => ' ', 'l' => $op['l'], 'no' => $currO++, 'nn' => $currN++]; | ||
| + } elseif( $op['t'] === '-' ) { | ||
| + $stream[] = ['t' => '-', 'l' => $op['l'], 'no' => $currO++, 'nn' => null]; | ||
| + } elseif( $op['t'] === '+' ) { | ||
| + $stream[] = ['t' => '+', 'l' => $op['l'], 'no' => null, 'nn' => $currN++]; | ||
| + } | ||
| } | ||
| - // Suffix context | ||
| - for ($i = $m - $end; $i < $m; $i++) { | ||
| - $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $currO++, 'nn' => $currN++]; | ||
| + for( $i = $m - $end; $i < $m; $i++ ) { | ||
| + $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $currO++, 'nn' => $currN++]; | ||
| } | ||
| - // Filter to Hunks | ||
| - $finalLines = []; | ||
| + $finalLines = []; | ||
| $lastVisibleIndex = -1; | ||
| - $streamLen = count($stream); | ||
| - $contextLines = 3; | ||
| + $streamLen = count( $stream ); | ||
| + $contextLines = 3; | ||
| - for ($i = 0; $i < $streamLen; $i++) { | ||
| - $show = false; | ||
| + for( $i = 0; $i < $streamLen; $i++ ) { | ||
| + $show = false; | ||
| - if ($stream[$i]['t'] !== ' ') { | ||
| + if( $stream[$i]['t'] !== ' ' ) { | ||
| + $show = true; | ||
| + } else { | ||
| + for( $j = 1; $j <= $contextLines; $j++ ) { | ||
| + if( ( $i + $j ) < $streamLen && $stream[$i + $j]['t'] !== ' ' ) { | ||
| $show = true; | ||
| - } else { | ||
| - // Check ahead | ||
| - for ($j = 1; $j <= $contextLines; $j++) { | ||
| - if (($i + $j) < $streamLen && $stream[$i + $j]['t'] !== ' ') { | ||
| - $show = true; | ||
| - break; | ||
| - } | ||
| - } | ||
| - // Check behind | ||
| - if (!$show) { | ||
| - for ($j = 1; $j <= $contextLines; $j++) { | ||
| - if (($i - $j) >= 0 && $stream[$i - $j]['t'] !== ' ') { | ||
| - $show = true; | ||
| - break; | ||
| - } | ||
| - } | ||
| + break; | ||
| + } | ||
| + } | ||
| + if( !$show ) { | ||
| + for( $j = 1; $j <= $contextLines; $j++ ) { | ||
| + if( ( $i - $j ) >= 0 && $stream[$i - $j]['t'] !== ' ' ) { | ||
| + $show = true; | ||
| + break; | ||
| } | ||
| + } | ||
| } | ||
| + } | ||
| - if ($show) { | ||
| - if ($lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1) { | ||
| - $finalLines[] = ['t' => 'gap']; | ||
| - } | ||
| - $finalLines[] = $stream[$i]; | ||
| - $lastVisibleIndex = $i; | ||
| + if( $show ) { | ||
| + if( $lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1 ) { | ||
| + $finalLines[] = ['t' => 'gap']; | ||
| } | ||
| + $finalLines[] = $stream[$i]; | ||
| + $lastVisibleIndex = $i; | ||
| + } | ||
| } | ||
| return $finalLines; | ||
| } | ||
| - private function computeLCS($old, $new) { | ||
| - $m = count($old); | ||
| - $n = count($new); | ||
| - $c = array_fill(0, $m + 1, array_fill(0, $n + 1, 0)); | ||
| + private function computeLCS( $old, $new ) { | ||
| + $m = count( $old ); | ||
| + $n = count( $new ); | ||
| + $c = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) ); | ||
| - for ($i = 1; $i <= $m; $i++) { | ||
| - for ($j = 1; $j <= $n; $j++) { | ||
| - if ($old[$i-1] === $new[$j-1]) { | ||
| - $c[$i][$j] = $c[$i-1][$j-1] + 1; | ||
| + for( $i = 1; $i <= $m; $i++ ) { | ||
| + for( $j = 1; $j <= $n; $j++ ) { | ||
| + if( $old[$i - 1] === $new[$j - 1] ) { | ||
| + $c[$i][$j] = $c[$i - 1][$j - 1] + 1; | ||
| } else { | ||
| - $c[$i][$j] = max($c[$i][$j-1], $c[$i-1][$j]); | ||
| + $c[$i][$j] = max( $c[$i][$j - 1], $c[$i - 1][$j] ); | ||
| } | ||
| } | ||
| } | ||
| $diff = []; | ||
| - $i = $m; $j = $n; | ||
| - while ($i > 0 || $j > 0) { | ||
| - if ($i > 0 && $j > 0 && $old[$i-1] === $new[$j-1]) { | ||
| - array_unshift($diff, ['t' => ' ', 'l' => $old[$i-1]]); | ||
| - $i--; $j--; | ||
| - } elseif ($j > 0 && ($i === 0 || $c[$i][$j-1] >= $c[$i-1][$j])) { | ||
| - array_unshift($diff, ['t' => '+', 'l' => $new[$j-1]]); | ||
| + $i = $m; | ||
| + $j = $n; | ||
| + while( $i > 0 || $j > 0 ) { | ||
| + if( $i > 0 && $j > 0 && $old[$i - 1] === $new[$j - 1] ) { | ||
| + array_unshift( $diff, ['t' => ' ', 'l' => $old[$i - 1]] ); | ||
| + $i--; | ||
| $j--; | ||
| - } elseif ($i > 0 && ($j === 0 || $c[$i][$j-1] < $c[$i-1][$j])) { | ||
| - array_unshift($diff, ['t' => '-', 'l' => $old[$i-1]]); | ||
| + } elseif( $j > 0 && ( $i === 0 || $c[$i][$j - 1] >= $c[$i - 1][$j] ) ) { | ||
| + array_unshift( $diff, ['t' => '+', 'l' => $new[$j - 1]] ); | ||
| + $j--; | ||
| + } elseif( $i > 0 && ( $j === 0 || $c[$i][$j - 1] < $c[$i - 1][$j] ) ) { | ||
| + array_unshift( $diff, ['t' => '-', 'l' => $old[$i - 1]] ); | ||
| $i--; | ||
| } | ||
| private $vName; | ||
| - public function __construct($name, $content) { | ||
| - parent::__construct($name, '', '100644', 0, strlen($content)); | ||
| - $this->vName = $name; | ||
| + public function __construct( $name, $content ) { | ||
| + parent::__construct( null, $name, '', '100644', 0, strlen( $content ) ); | ||
| + $this->vName = $name; | ||
| $this->content = $content; | ||
| } | ||
| public function isBinary(): bool { | ||
| - $buffer = substr($this->content, 0, 12); | ||
| - return MediaTypeSniffer::isBinary($buffer, $this->vName); | ||
| + $buffer = substr( $this->content, 0, 12 ); | ||
| + return MediaTypeSniffer::isBinary( $buffer, $this->vName ); | ||
| } | ||
| } | ||
| } | ||
| - public function read( string $sha ): ?string { | ||
| - $info = $this->findPackInfo( $sha ); | ||
| - | ||
| - if( $info['offset'] === -1 ) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - $size = $this->extractPackedSize( $info['file'], $info['offset'] ); | ||
| - | ||
| - if( $size > self::MAX_RAM ) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - $handle = $this->getHandle( $info['file'] ); | ||
| - | ||
| - return $handle | ||
| - ? $this->readPackEntry( $handle, $info['offset'], $size ) | ||
| - : null; | ||
| - } | ||
| - | ||
| - public function stream( string $sha, callable $callback ): bool { | ||
| - $info = $this->findPackInfo( $sha ); | ||
| - | ||
| - if( $info['offset'] === -1 ) { | ||
| - return false; | ||
| - } | ||
| - | ||
| - $size = $this->extractPackedSize( $info['file'], $info['offset'] ); | ||
| - $handle = $this->getHandle( $info['file'] ); | ||
| - | ||
| - if( !$handle ) { | ||
| - return false; | ||
| - } | ||
| - | ||
| - return $this->streamPackEntry( $handle, $info['offset'], $size, $callback ); | ||
| - } | ||
| - | ||
| - public function getSize( string $sha ): ?int { | ||
| - $info = $this->findPackInfo( $sha ); | ||
| - | ||
| - if( $info['offset'] === -1 ) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - return $this->extractPackedSize( $info['file'], $info['offset'] ); | ||
| - } | ||
| - | ||
| - private function findPackInfo( string $sha ): array { | ||
| - if( !ctype_xdigit( $sha ) || strlen( $sha ) !== 40 ) { | ||
| - return ['offset' => -1]; | ||
| - } | ||
| - | ||
| - $binarySha = hex2bin( $sha ); | ||
| - | ||
| - if( $this->lastPack ) { | ||
| - $offset = $this->findInIdx( $this->lastPack, $binarySha ); | ||
| - | ||
| - if( $offset !== -1 ) { | ||
| - return $this->makeResult( $this->lastPack, $offset ); | ||
| - } | ||
| - } | ||
| - | ||
| - foreach( $this->packFiles as $indexFile ) { | ||
| - if( $indexFile === $this->lastPack ) { | ||
| - continue; | ||
| - } | ||
| - | ||
| - $offset = $this->findInIdx( $indexFile, $binarySha ); | ||
| - | ||
| - if( $offset !== -1 ) { | ||
| - $this->lastPack = $indexFile; | ||
| - | ||
| - return $this->makeResult( $indexFile, $offset ); | ||
| - } | ||
| - } | ||
| - | ||
| - return ['offset' => -1]; | ||
| - } | ||
| - | ||
| - private function makeResult( string $indexPath, int $offset ): array { | ||
| - return [ | ||
| - 'file' => str_replace( '.idx', '.pack', $indexPath ), | ||
| - 'offset' => $offset | ||
| - ]; | ||
| - } | ||
| - | ||
| - private function findInIdx( string $indexFile, string $binarySha ): int { | ||
| - $fileHandle = $this->getHandle( $indexFile ); | ||
| - | ||
| - if( !$fileHandle ) { | ||
| - return -1; | ||
| - } | ||
| - | ||
| - if( !isset( $this->fanoutCache[$indexFile] ) ) { | ||
| - fseek( $fileHandle, 0 ); | ||
| - | ||
| - if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) { | ||
| - $this->fanoutCache[$indexFile] = array_values( | ||
| - unpack( 'N*', fread( $fileHandle, 1024 ) ) | ||
| - ); | ||
| - } else { | ||
| - return -1; | ||
| - } | ||
| - } | ||
| - | ||
| - $fanout = $this->fanoutCache[$indexFile]; | ||
| - | ||
| - $firstByte = ord( $binarySha[0] ); | ||
| - $start = $firstByte === 0 ? 0 : $fanout[$firstByte - 1]; | ||
| - $end = $fanout[$firstByte]; | ||
| - | ||
| - if( $end <= $start ) { | ||
| - return -1; | ||
| - } | ||
| - | ||
| - $cacheKey = "$indexFile:$firstByte"; | ||
| - | ||
| - if( !isset( $this->shaBucketCache[$cacheKey] ) ) { | ||
| - $count = $end - $start; | ||
| - fseek( $fileHandle, 1032 + ($start * 20) ); | ||
| - $this->shaBucketCache[$cacheKey] = fread( $fileHandle, $count * 20 ); | ||
| - | ||
| - fseek( | ||
| - $fileHandle, | ||
| - 1032 + ($fanout[255] * 24) + ($start * 4) | ||
| - ); | ||
| - $this->offsetBucketCache[$cacheKey] = fread( $fileHandle, $count * 4 ); | ||
| - } | ||
| - | ||
| - $shaBlock = $this->shaBucketCache[$cacheKey]; | ||
| - $count = strlen( $shaBlock ) / 20; | ||
| - $low = 0; | ||
| - $high = $count - 1; | ||
| - $foundIdx = -1; | ||
| - | ||
| - while( $low <= $high ) { | ||
| - $mid = ($low + $high) >> 1; | ||
| - $compare = substr( $shaBlock, $mid * 20, 20 ); | ||
| - | ||
| - if( $compare < $binarySha ) { | ||
| - $low = $mid + 1; | ||
| - } elseif( $compare > $binarySha ) { | ||
| - $high = $mid - 1; | ||
| - } else { | ||
| - $foundIdx = $mid; | ||
| - break; | ||
| - } | ||
| - } | ||
| - | ||
| - if( $foundIdx === -1 ) { | ||
| - return -1; | ||
| - } | ||
| - | ||
| - $offsetData = substr( | ||
| - $this->offsetBucketCache[$cacheKey], | ||
| - $foundIdx * 4, | ||
| - 4 | ||
| - ); | ||
| - $offset = unpack( 'N', $offsetData )[1]; | ||
| - | ||
| - if( $offset & 0x80000000 ) { | ||
| - $packTotal = $fanout[255]; | ||
| - $pos64 = 1032 + ($packTotal * 28) + | ||
| - (($offset & 0x7FFFFFFF) * 8); | ||
| - fseek( $fileHandle, $pos64 ); | ||
| - $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1]; | ||
| - } | ||
| - | ||
| - return (int)$offset; | ||
| - } | ||
| - | ||
| - private function readPackEntry( $fileHandle, int $offset, int $expectedSize ): string { | ||
| - fseek( $fileHandle, $offset ); | ||
| - | ||
| - $header = $this->readVarInt( $fileHandle ); | ||
| - $type = ($header['byte'] >> 4) & 7; | ||
| - | ||
| - if( $type === 6 ) { | ||
| - return $this->handleOfsDelta( $fileHandle, $offset, $expectedSize ); | ||
| - } | ||
| - | ||
| - if( $type === 7 ) { | ||
| - return $this->handleRefDelta( $fileHandle, $expectedSize ); | ||
| - } | ||
| - | ||
| - return $this->decompressToString( $fileHandle, $expectedSize ); | ||
| - } | ||
| - | ||
| - private function streamPackEntry( $fileHandle, int $offset, int $expectedSize, callable $callback ): bool { | ||
| - fseek( $fileHandle, $offset ); | ||
| - | ||
| - $header = $this->readVarInt( $fileHandle ); | ||
| - $type = ($header['byte'] >> 4) & 7; | ||
| - | ||
| - if( $type === 6 || $type === 7 ) { | ||
| - return $this->streamDeltaObject( $fileHandle, $offset, $type, $expectedSize, $callback ); | ||
| - } | ||
| - | ||
| - return $this->streamDecompression( $fileHandle, $callback ); | ||
| - } | ||
| - | ||
| - private function streamDeltaObject( $fileHandle, int $offset, int $type, int $expectedSize, callable $callback ): bool { | ||
| - fseek( $fileHandle, $offset ); | ||
| - $this->readVarInt( $fileHandle ); | ||
| - | ||
| - if( $type === 6 ) { | ||
| - $byte = ord( fread( $fileHandle, 1 ) ); | ||
| - $negative = $byte & 127; | ||
| - | ||
| - while( $byte & 128 ) { | ||
| - $byte = ord( fread( $fileHandle, 1 ) ); | ||
| - $negative = (($negative + 1) << 7) | ($byte & 127); | ||
| - } | ||
| - | ||
| - $deltaPos = ftell( $fileHandle ); | ||
| - $baseOffset = $offset - $negative; | ||
| - | ||
| - $base = ''; | ||
| - $this->streamPackEntry( $fileHandle, $baseOffset, 0, function( $chunk ) use ( &$base ) { | ||
| - $base .= $chunk; | ||
| - } ); | ||
| - | ||
| - fseek( $fileHandle, $deltaPos ); | ||
| - } else { | ||
| - $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | ||
| - | ||
| - $base = ''; | ||
| - $streamed = $this->stream( $baseSha, function( $chunk ) use ( &$base ) { | ||
| - $base .= $chunk; | ||
| - } ); | ||
| - | ||
| - if( !$streamed ) { | ||
| - return false; | ||
| - } | ||
| - } | ||
| - | ||
| - $compressed = fread( $fileHandle, self::MAX_READ ); | ||
| - $delta = @gzuncompress( $compressed ) ?: ''; | ||
| - | ||
| - $result = $this->applyDelta( $base, $delta ); | ||
| - | ||
| - $chunkSize = 8192; | ||
| - $length = strlen( $result ); | ||
| - for( $i = 0; $i < $length; $i += $chunkSize ) { | ||
| - $callback( substr( $result, $i, $chunkSize ) ); | ||
| - } | ||
| - | ||
| - return true; | ||
| - } | ||
| - | ||
| - private function streamDecompression( $fileHandle, callable $callback ): bool { | ||
| - $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | ||
| - if( $inflator === false ) { | ||
| - return false; | ||
| - } | ||
| - | ||
| - while( !feof( $fileHandle ) ) { | ||
| - $chunk = fread( $fileHandle, 8192 ); | ||
| - | ||
| - if( $chunk === false || $chunk === '' ) { | ||
| - break; | ||
| - } | ||
| - | ||
| - $data = @inflate_add( $inflator, $chunk ); | ||
| - | ||
| - if( $data !== false && $data !== '' ) { | ||
| - $callback( $data ); | ||
| - } | ||
| - | ||
| - if( $data === false || | ||
| - inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | ||
| - break; | ||
| - } | ||
| - } | ||
| - | ||
| - return true; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Decompress to string (for small objects < 1MB) | ||
| - */ | ||
| - private function decompressToString( $fileHandle, int $maxSize ): string { | ||
| - $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | ||
| - if( $inflator === false ) { | ||
| - return ''; | ||
| - } | ||
| - | ||
| - $result = ''; | ||
| - | ||
| - while( !feof( $fileHandle ) ) { | ||
| - $chunk = fread( $fileHandle, 8192 ); | ||
| - | ||
| - if( $chunk === false || $chunk === '' ) { | ||
| - break; | ||
| - } | ||
| - | ||
| - $data = @inflate_add( $inflator, $chunk ); | ||
| - | ||
| - if( $data !== false ) { | ||
| - $result .= $data; | ||
| - } | ||
| - | ||
| - if( $data === false || | ||
| - inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | ||
| - break; | ||
| - } | ||
| - } | ||
| - | ||
| - return $result; | ||
| - } | ||
| - | ||
| - private function extractPackedSize( string $packPath, int $offset ): int { | ||
| - $fileHandle = $this->getHandle( $packPath ); | ||
| - | ||
| - if( !$fileHandle ) { | ||
| - return 0; | ||
| - } | ||
| - | ||
| - fseek( $fileHandle, $offset ); | ||
| - | ||
| - $header = $this->readVarInt( $fileHandle ); | ||
| - $size = $header['value']; | ||
| - $type = ($header['byte'] >> 4) & 7; | ||
| - | ||
| - if( $type === 6 || $type === 7 ) { | ||
| - return $this->readDeltaTargetSize( $fileHandle, $type ); | ||
| - } | ||
| - | ||
| - return $size; | ||
| - } | ||
| - | ||
| - private function handleOfsDelta( $fileHandle, int $offset, int $expectedSize ): string { | ||
| - $byte = ord( fread( $fileHandle, 1 ) ); | ||
| - $negative = $byte & 127; | ||
| - | ||
| - while( $byte & 128 ) { | ||
| - $byte = ord( fread( $fileHandle, 1 ) ); | ||
| - $negative = (($negative + 1) << 7) | ($byte & 127); | ||
| - } | ||
| - | ||
| - $currentPos = ftell( $fileHandle ); | ||
| - $baseOffset = $offset - $negative; | ||
| - | ||
| - fseek( $fileHandle, $baseOffset ); | ||
| - $baseHeader = $this->readVarInt( $fileHandle ); | ||
| - $baseSize = $baseHeader['value']; | ||
| - | ||
| - fseek( $fileHandle, $baseOffset ); | ||
| - $base = $this->readPackEntry( $fileHandle, $baseOffset, $baseSize ); | ||
| - | ||
| - fseek( $fileHandle, $currentPos ); | ||
| - | ||
| - $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | ||
| - $compressed = fread( $fileHandle, $remainingBytes ); | ||
| - $delta = @gzuncompress( $compressed ) ?: ''; | ||
| - | ||
| - return $this->applyDelta( $base, $delta ); | ||
| - } | ||
| - | ||
| - private function handleRefDelta( $fileHandle, int $expectedSize ): string { | ||
| - $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | ||
| - $base = $this->read( $baseSha ) ?? ''; | ||
| - | ||
| - $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | ||
| - $compressed = fread( $fileHandle, $remainingBytes ); | ||
| - $delta = @gzuncompress( $compressed ) ?: ''; | ||
| - | ||
| - return $this->applyDelta( $base, $delta ); | ||
| - } | ||
| - | ||
| - private function applyDelta( string $base, string $delta ): string { | ||
| - $position = 0; | ||
| - $this->skipSize( $delta, $position ); | ||
| - $this->skipSize( $delta, $position ); | ||
| - | ||
| - $output = ''; | ||
| - $deltaLength = strlen( $delta ); | ||
| - | ||
| - while( $position < $deltaLength ) { | ||
| - $opcode = ord( $delta[$position++] ); | ||
| - | ||
| - if( $opcode & 128 ) { | ||
| - $offset = 0; | ||
| - $length = 0; | ||
| - | ||
| - if( $opcode & 0x01 ) { | ||
| - $offset |= ord( $delta[$position++] ); | ||
| - } | ||
| - if( $opcode & 0x02 ) { | ||
| - $offset |= ord( $delta[$position++] ) << 8; | ||
| - } | ||
| - if( $opcode & 0x04 ) { | ||
| - $offset |= ord( $delta[$position++] ) << 16; | ||
| - } | ||
| - if( $opcode & 0x08 ) { | ||
| - $offset |= ord( $delta[$position++] ) << 24; | ||
| - } | ||
| - | ||
| - if( $opcode & 0x10 ) { | ||
| - $length |= ord( $delta[$position++] ); | ||
| - } | ||
| - if( $opcode & 0x20 ) { | ||
| - $length |= ord( $delta[$position++] ) << 8; | ||
| - } | ||
| - if( $opcode & 0x40 ) { | ||
| - $length |= ord( $delta[$position++] ) << 16; | ||
| - } | ||
| - | ||
| - if( $length === 0 ) { | ||
| - $length = 0x10000; | ||
| - } | ||
| - | ||
| - $output .= substr( $base, $offset, $length ); | ||
| - } else { | ||
| - $length = $opcode & 127; | ||
| - $output .= substr( $delta, $position, $length ); | ||
| - $position += $length; | ||
| - } | ||
| - } | ||
| - | ||
| - return $output; | ||
| - } | ||
| - | ||
| - private function readVarInt( $fileHandle ): array { | ||
| - $byte = ord( fread( $fileHandle, 1 ) ); | ||
| - $value = $byte & 15; | ||
| - $shift = 4; | ||
| - $first = $byte; | ||
| - | ||
| - while( $byte & 128 ) { | ||
| - $byte = ord( fread( $fileHandle, 1 ) ); | ||
| - $value |= (($byte & 127) << $shift); | ||
| - $shift += 7; | ||
| - } | ||
| - | ||
| - return ['value' => $value, 'byte' => $first]; | ||
| - } | ||
| - | ||
| - private function readDeltaTargetSize( $fileHandle, int $type ): int { | ||
| - if( $type === 6 ) { | ||
| - $byte = ord( fread( $fileHandle, 1 ) ); | ||
| - | ||
| - while( $byte & 128 ) { | ||
| - $byte = ord( fread( $fileHandle, 1 ) ); | ||
| - } | ||
| - } else { | ||
| - fseek( $fileHandle, 20, SEEK_CUR ); | ||
| - } | ||
| - | ||
| - $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | ||
| - if( $inflator === false ) { | ||
| - return 0; | ||
| - } | ||
| - | ||
| - $header = ''; | ||
| - $attempts = 0; | ||
| - $maxAttempts = 64; | ||
| - | ||
| - while( !feof( $fileHandle ) && strlen( $header ) < 32 && $attempts < $maxAttempts ) { | ||
| - $chunk = fread( $fileHandle, 512 ); | ||
| - | ||
| - if( $chunk === false || $chunk === '' ) { | ||
| - break; | ||
| - } | ||
| - | ||
| - $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH ); | ||
| - | ||
| - if( $output !== false ) { | ||
| - $header .= $output; | ||
| - } | ||
| - | ||
| - if( inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | ||
| - break; | ||
| - } | ||
| - | ||
| - $attempts++; | ||
| - } | ||
| - | ||
| - $position = 0; | ||
| - | ||
| - if( strlen( $header ) > 0 ) { | ||
| - $this->skipSize( $header, $position ); | ||
| - | ||
| - return $this->readSize( $header, $position ); | ||
| - } | ||
| - | ||
| - return 0; | ||
| - } | ||
| - | ||
| - private function skipSize( string $data, int &$position ): void { | ||
| - $length = strlen( $data ); | ||
| - | ||
| - while( $position < $length && (ord( $data[$position++] ) & 128) ) { | ||
| - // Loop continues while MSB is 1 | ||
| - } | ||
| - } | ||
| - | ||
| - private function readSize( string $data, int &$position ): int { | ||
| - $byte = ord( $data[$position++] ); | ||
| - $value = $byte & 127; | ||
| - $shift = 7; | ||
| - | ||
| - while( $byte & 128 ) { | ||
| - $byte = ord( $data[$position++] ); | ||
| + public function peek( string $sha, int $len = 12 ): ?string { | ||
| + $info = $this->findPackInfo( $sha ); | ||
| + | ||
| + if( $info['offset'] === -1 ) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + $handle = $this->getHandle( $info['file'] ); | ||
| + | ||
| + if( !$handle ) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + return $this->readPackEntry( $handle, $info['offset'], $len, $len ); | ||
| + } | ||
| + | ||
| + public function read( string $sha ): ?string { | ||
| + $info = $this->findPackInfo( $sha ); | ||
| + | ||
| + if( $info['offset'] === -1 ) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + $size = $this->extractPackedSize( $info['file'], $info['offset'] ); | ||
| + | ||
| + if( $size > self::MAX_RAM ) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + $handle = $this->getHandle( $info['file'] ); | ||
| + | ||
| + return $handle | ||
| + ? $this->readPackEntry( $handle, $info['offset'], $size ) | ||
| + : null; | ||
| + } | ||
| + | ||
| + public function stream( string $sha, callable $callback ): bool { | ||
| + $info = $this->findPackInfo( $sha ); | ||
| + | ||
| + if( $info['offset'] === -1 ) { | ||
| + return false; | ||
| + } | ||
| + | ||
| + $size = $this->extractPackedSize( $info['file'], $info['offset'] ); | ||
| + $handle = $this->getHandle( $info['file'] ); | ||
| + | ||
| + if( !$handle ) { | ||
| + return false; | ||
| + } | ||
| + | ||
| + return $this->streamPackEntry( $handle, $info['offset'], $size, $callback ); | ||
| + } | ||
| + | ||
| + public function getSize( string $sha ): ?int { | ||
| + $info = $this->findPackInfo( $sha ); | ||
| + | ||
| + if( $info['offset'] === -1 ) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + return $this->extractPackedSize( $info['file'], $info['offset'] ); | ||
| + } | ||
| + | ||
| + private function findPackInfo( string $sha ): array { | ||
| + if( !ctype_xdigit( $sha ) || strlen( $sha ) !== 40 ) { | ||
| + return ['offset' => -1]; | ||
| + } | ||
| + | ||
| + $binarySha = hex2bin( $sha ); | ||
| + | ||
| + if( $this->lastPack ) { | ||
| + $offset = $this->findInIdx( $this->lastPack, $binarySha ); | ||
| + | ||
| + if( $offset !== -1 ) { | ||
| + return $this->makeResult( $this->lastPack, $offset ); | ||
| + } | ||
| + } | ||
| + | ||
| + foreach( $this->packFiles as $indexFile ) { | ||
| + if( $indexFile === $this->lastPack ) { | ||
| + continue; | ||
| + } | ||
| + | ||
| + $offset = $this->findInIdx( $indexFile, $binarySha ); | ||
| + | ||
| + if( $offset !== -1 ) { | ||
| + $this->lastPack = $indexFile; | ||
| + | ||
| + return $this->makeResult( $indexFile, $offset ); | ||
| + } | ||
| + } | ||
| + | ||
| + return ['offset' => -1]; | ||
| + } | ||
| + | ||
| + private function makeResult( string $indexPath, int $offset ): array { | ||
| + return [ | ||
| + 'file' => str_replace( '.idx', '.pack', $indexPath ), | ||
| + 'offset' => $offset | ||
| + ]; | ||
| + } | ||
| + | ||
| + private function findInIdx( string $indexFile, string $binarySha ): int { | ||
| + $fileHandle = $this->getHandle( $indexFile ); | ||
| + | ||
| + if( !$fileHandle ) { | ||
| + return -1; | ||
| + } | ||
| + | ||
| + if( !isset( $this->fanoutCache[$indexFile] ) ) { | ||
| + fseek( $fileHandle, 0 ); | ||
| + | ||
| + if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) { | ||
| + $this->fanoutCache[$indexFile] = array_values( | ||
| + unpack( 'N*', fread( $fileHandle, 1024 ) ) | ||
| + ); | ||
| + } else { | ||
| + return -1; | ||
| + } | ||
| + } | ||
| + | ||
| + $fanout = $this->fanoutCache[$indexFile]; | ||
| + | ||
| + $firstByte = ord( $binarySha[0] ); | ||
| + $start = $firstByte === 0 ? 0 : $fanout[$firstByte - 1]; | ||
| + $end = $fanout[$firstByte]; | ||
| + | ||
| + if( $end <= $start ) { | ||
| + return -1; | ||
| + } | ||
| + | ||
| + $cacheKey = "$indexFile:$firstByte"; | ||
| + | ||
| + if( !isset( $this->shaBucketCache[$cacheKey] ) ) { | ||
| + $count = $end - $start; | ||
| + fseek( $fileHandle, 1032 + ($start * 20) ); | ||
| + $this->shaBucketCache[$cacheKey] = fread( $fileHandle, $count * 20 ); | ||
| + | ||
| + fseek( | ||
| + $fileHandle, | ||
| + 1032 + ($fanout[255] * 24) + ($start * 4) | ||
| + ); | ||
| + $this->offsetBucketCache[$cacheKey] = fread( $fileHandle, $count * 4 ); | ||
| + } | ||
| + | ||
| + $shaBlock = $this->shaBucketCache[$cacheKey]; | ||
| + $count = strlen( $shaBlock ) / 20; | ||
| + $low = 0; | ||
| + $high = $count - 1; | ||
| + $foundIdx = -1; | ||
| + | ||
| + while( $low <= $high ) { | ||
| + $mid = ($low + $high) >> 1; | ||
| + $compare = substr( $shaBlock, $mid * 20, 20 ); | ||
| + | ||
| + if( $compare < $binarySha ) { | ||
| + $low = $mid + 1; | ||
| + } elseif( $compare > $binarySha ) { | ||
| + $high = $mid - 1; | ||
| + } else { | ||
| + $foundIdx = $mid; | ||
| + break; | ||
| + } | ||
| + } | ||
| + | ||
| + if( $foundIdx === -1 ) { | ||
| + return -1; | ||
| + } | ||
| + | ||
| + $offsetData = substr( | ||
| + $this->offsetBucketCache[$cacheKey], | ||
| + $foundIdx * 4, | ||
| + 4 | ||
| + ); | ||
| + $offset = unpack( 'N', $offsetData )[1]; | ||
| + | ||
| + if( $offset & 0x80000000 ) { | ||
| + $packTotal = $fanout[255]; | ||
| + $pos64 = 1032 + ($packTotal * 28) + | ||
| + (($offset & 0x7FFFFFFF) * 8); | ||
| + fseek( $fileHandle, $pos64 ); | ||
| + $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1]; | ||
| + } | ||
| + | ||
| + return (int)$offset; | ||
| + } | ||
| + | ||
| + private function readPackEntry( $fileHandle, int $offset, int $expectedSize, int $cap = 0 ): string { | ||
| + fseek( $fileHandle, $offset ); | ||
| + | ||
| + $header = $this->readVarInt( $fileHandle ); | ||
| + $type = ($header['byte'] >> 4) & 7; | ||
| + | ||
| + if( $type === 6 ) { | ||
| + return $this->handleOfsDelta( $fileHandle, $offset, $expectedSize, $cap ); | ||
| + } | ||
| + | ||
| + if( $type === 7 ) { | ||
| + return $this->handleRefDelta( $fileHandle, $expectedSize, $cap ); | ||
| + } | ||
| + | ||
| + return $this->decompressToString( $fileHandle, $expectedSize, $cap ); | ||
| + } | ||
| + | ||
| + private function streamPackEntry( $fileHandle, int $offset, int $expectedSize, callable $callback ): bool { | ||
| + fseek( $fileHandle, $offset ); | ||
| + | ||
| + $header = $this->readVarInt( $fileHandle ); | ||
| + $type = ($header['byte'] >> 4) & 7; | ||
| + | ||
| + if( $type === 6 || $type === 7 ) { | ||
| + return $this->streamDeltaObject( $fileHandle, $offset, $type, $expectedSize, $callback ); | ||
| + } | ||
| + | ||
| + return $this->streamDecompression( $fileHandle, $callback ); | ||
| + } | ||
| + | ||
| + private function streamDeltaObject( $fileHandle, int $offset, int $type, int $expectedSize, callable $callback ): bool { | ||
| + fseek( $fileHandle, $offset ); | ||
| + $this->readVarInt( $fileHandle ); | ||
| + | ||
| + if( $type === 6 ) { | ||
| + $byte = ord( fread( $fileHandle, 1 ) ); | ||
| + $negative = $byte & 127; | ||
| + | ||
| + while( $byte & 128 ) { | ||
| + $byte = ord( fread( $fileHandle, 1 ) ); | ||
| + $negative = (($negative + 1) << 7) | ($byte & 127); | ||
| + } | ||
| + | ||
| + $deltaPos = ftell( $fileHandle ); | ||
| + $baseOffset = $offset - $negative; | ||
| + | ||
| + $base = ''; | ||
| + $this->streamPackEntry( $fileHandle, $baseOffset, 0, function( $chunk ) use ( &$base ) { | ||
| + $base .= $chunk; | ||
| + } ); | ||
| + | ||
| + fseek( $fileHandle, $deltaPos ); | ||
| + } else { | ||
| + $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | ||
| + | ||
| + $base = ''; | ||
| + $streamed = $this->stream( $baseSha, function( $chunk ) use ( &$base ) { | ||
| + $base .= $chunk; | ||
| + } ); | ||
| + | ||
| + if( !$streamed ) { | ||
| + return false; | ||
| + } | ||
| + } | ||
| + | ||
| + $compressed = fread( $fileHandle, self::MAX_READ ); | ||
| + $delta = @gzuncompress( $compressed ) ?: ''; | ||
| + | ||
| + $result = $this->applyDelta( $base, $delta ); | ||
| + | ||
| + $chunkSize = 8192; | ||
| + $length = strlen( $result ); | ||
| + for( $i = 0; $i < $length; $i += $chunkSize ) { | ||
| + $callback( substr( $result, $i, $chunkSize ) ); | ||
| + } | ||
| + | ||
| + return true; | ||
| + } | ||
| + | ||
| + private function streamDecompression( $fileHandle, callable $callback ): bool { | ||
| + $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | ||
| + if( $inflator === false ) { | ||
| + return false; | ||
| + } | ||
| + | ||
| + while( !feof( $fileHandle ) ) { | ||
| + $chunk = fread( $fileHandle, 8192 ); | ||
| + | ||
| + if( $chunk === false || $chunk === '' ) { | ||
| + break; | ||
| + } | ||
| + | ||
| + $data = @inflate_add( $inflator, $chunk ); | ||
| + | ||
| + if( $data !== false && $data !== '' ) { | ||
| + $callback( $data ); | ||
| + } | ||
| + | ||
| + if( $data === false || | ||
| + inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | ||
| + break; | ||
| + } | ||
| + } | ||
| + | ||
| + return true; | ||
| + } | ||
| + | ||
| + private function decompressToString( $fileHandle, int $maxSize, int $cap = 0 ): string { | ||
| + $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | ||
| + if( $inflator === false ) { | ||
| + return ''; | ||
| + } | ||
| + | ||
| + $result = ''; | ||
| + | ||
| + while( !feof( $fileHandle ) ) { | ||
| + $chunk = fread( $fileHandle, 8192 ); | ||
| + | ||
| + if( $chunk === false || $chunk === '' ) { | ||
| + break; | ||
| + } | ||
| + | ||
| + $data = @inflate_add( $inflator, $chunk ); | ||
| + | ||
| + if( $data !== false ) { | ||
| + $result .= $data; | ||
| + } | ||
| + | ||
| + if( $cap > 0 && strlen( $result ) >= $cap ) { | ||
| + return substr( $result, 0, $cap ); | ||
| + } | ||
| + | ||
| + if( $data === false || | ||
| + inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | ||
| + break; | ||
| + } | ||
| + } | ||
| + | ||
| + return $result; | ||
| + } | ||
| + | ||
| + private function extractPackedSize( string $packPath, int $offset ): int { | ||
| + $fileHandle = $this->getHandle( $packPath ); | ||
| + | ||
| + if( !$fileHandle ) { | ||
| + return 0; | ||
| + } | ||
| + | ||
| + fseek( $fileHandle, $offset ); | ||
| + | ||
| + $header = $this->readVarInt( $fileHandle ); | ||
| + $size = $header['value']; | ||
| + $type = ($header['byte'] >> 4) & 7; | ||
| + | ||
| + if( $type === 6 || $type === 7 ) { | ||
| + return $this->readDeltaTargetSize( $fileHandle, $type ); | ||
| + } | ||
| + | ||
| + return $size; | ||
| + } | ||
| + | ||
| + private function handleOfsDelta( $fileHandle, int $offset, int $expectedSize, int $cap = 0 ): string { | ||
| + $byte = ord( fread( $fileHandle, 1 ) ); | ||
| + $negative = $byte & 127; | ||
| + | ||
| + while( $byte & 128 ) { | ||
| + $byte = ord( fread( $fileHandle, 1 ) ); | ||
| + $negative = (($negative + 1) << 7) | ($byte & 127); | ||
| + } | ||
| + | ||
| + $currentPos = ftell( $fileHandle ); | ||
| + $baseOffset = $offset - $negative; | ||
| + | ||
| + fseek( $fileHandle, $baseOffset ); | ||
| + $baseHeader = $this->readVarInt( $fileHandle ); | ||
| + $baseSize = $baseHeader['value']; | ||
| + | ||
| + fseek( $fileHandle, $baseOffset ); | ||
| + $base = $this->readPackEntry( $fileHandle, $baseOffset, $baseSize ); | ||
| + | ||
| + fseek( $fileHandle, $currentPos ); | ||
| + | ||
| + $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | ||
| + $compressed = fread( $fileHandle, $remainingBytes ); | ||
| + $delta = @gzuncompress( $compressed ) ?: ''; | ||
| + | ||
| + return $this->applyDelta( $base, $delta, $cap ); | ||
| + } | ||
| + | ||
| + private function handleRefDelta( $fileHandle, int $expectedSize, int $cap = 0 ): string { | ||
| + $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | ||
| + $base = $this->read( $baseSha ) ?? ''; | ||
| + | ||
| + $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | ||
| + $compressed = fread( $fileHandle, $remainingBytes ); | ||
| + $delta = @gzuncompress( $compressed ) ?: ''; | ||
| + | ||
| + return $this->applyDelta( $base, $delta, $cap ); | ||
| + } | ||
| + | ||
| + private function applyDelta( string $base, string $delta, int $cap = 0 ): string { | ||
| + $position = 0; | ||
| + $this->skipSize( $delta, $position ); | ||
| + $this->skipSize( $delta, $position ); | ||
| + | ||
| + $output = ''; | ||
| + $deltaLength = strlen( $delta ); | ||
| + | ||
| + while( $position < $deltaLength ) { | ||
| + if( $cap > 0 && strlen( $output ) >= $cap ) { | ||
| + break; | ||
| + } | ||
| + | ||
| + $opcode = ord( $delta[$position++] ); | ||
| + | ||
| + if( $opcode & 128 ) { | ||
| + $offset = 0; | ||
| + $length = 0; | ||
| + | ||
| + if( $opcode & 0x01 ) { | ||
| + $offset |= ord( $delta[$position++] ); | ||
| + } | ||
| + if( $opcode & 0x02 ) { | ||
| + $offset |= ord( $delta[$position++] ) << 8; | ||
| + } | ||
| + if( $opcode & 0x04 ) { | ||
| + $offset |= ord( $delta[$position++] ) << 16; | ||
| + } | ||
| + if( $opcode & 0x08 ) { | ||
| + $offset |= ord( $delta[$position++] ) << 24; | ||
| + } | ||
| + | ||
| + if( $opcode & 0x10 ) { | ||
| + $length |= ord( $delta[$position++] ); | ||
| + } | ||
| + if( $opcode & 0x20 ) { | ||
| + $length |= ord( $delta[$position++] ) << 8; | ||
| + } | ||
| + if( $opcode & 0x40 ) { | ||
| + $length |= ord( $delta[$position++] ) << 16; | ||
| + } | ||
| + | ||
| + if( $length === 0 ) { | ||
| + $length = 0x10000; | ||
| + } | ||
| + | ||
| + $output .= substr( $base, $offset, $length ); | ||
| + } else { | ||
| + $length = $opcode & 127; | ||
| + $output .= substr( $delta, $position, $length ); | ||
| + $position += $length; | ||
| + } | ||
| + } | ||
| + | ||
| + return $output; | ||
| + } | ||
| + | ||
| + private function readVarInt( $fileHandle ): array { | ||
| + $byte = ord( fread( $fileHandle, 1 ) ); | ||
| + $value = $byte & 15; | ||
| + $shift = 4; | ||
| + $first = $byte; | ||
| + | ||
| + while( $byte & 128 ) { | ||
| + $byte = ord( fread( $fileHandle, 1 ) ); | ||
| + $value |= (($byte & 127) << $shift); | ||
| + $shift += 7; | ||
| + } | ||
| + | ||
| + return ['value' => $value, 'byte' => $first]; | ||
| + } | ||
| + | ||
| + private function readDeltaTargetSize( $fileHandle, int $type ): int { | ||
| + if( $type === 6 ) { | ||
| + $byte = ord( fread( $fileHandle, 1 ) ); | ||
| + | ||
| + while( $byte & 128 ) { | ||
| + $byte = ord( fread( $fileHandle, 1 ) ); | ||
| + } | ||
| + } else { | ||
| + fseek( $fileHandle, 20, SEEK_CUR ); | ||
| + } | ||
| + | ||
| + $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | ||
| + if( $inflator === false ) { | ||
| + return 0; | ||
| + } | ||
| + | ||
| + $header = ''; | ||
| + $attempts = 0; | ||
| + $maxAttempts = 64; | ||
| + | ||
| + while( !feof( $fileHandle ) && strlen( $header ) < 32 && $attempts < $maxAttempts ) { | ||
| + $chunk = fread( $fileHandle, 512 ); | ||
| + | ||
| + if( $chunk === false || $chunk === '' ) { | ||
| + break; | ||
| + } | ||
| + | ||
| + $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH ); | ||
| + | ||
| + if( $output !== false ) { | ||
| + $header .= $output; | ||
| + } | ||
| + | ||
| + if( inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | ||
| + break; | ||
| + } | ||
| + | ||
| + $attempts++; | ||
| + } | ||
| + | ||
| + $position = 0; | ||
| + | ||
| + if( strlen( $header ) > 0 ) { | ||
| + $this->skipSize( $header, $position ); | ||
| + | ||
| + return $this->readSize( $header, $position ); | ||
| + } | ||
| + | ||
| + return 0; | ||
| + } | ||
| + | ||
| + private function skipSize( string $data, int &$position ): void { | ||
| + $length = strlen( $data ); | ||
| + | ||
| + while( $position < $length && (ord( $data[$position++] ) & 128) ) { | ||
| + } | ||
| + } | ||
| + | ||
| + private function readSize( string $data, int &$position ): int { | ||
| + $byte = ord( $data[$position++] ); | ||
| + $value = $byte & 127; | ||
| + $shift = 7; | ||
| + | ||
| + while( $byte & 128 ) { | ||
| + $byte = ord( $data[$position++] ); | ||
| $value |= (($byte & 127) << $shift); | ||
| $shift += 7; |
| Delta | 798 lines added, 735 lines removed, 63-line increase |
|---|