/ tests / GitPackReaderTest.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\tests;

use seekquarry\yioop\library as L;
use seekquarry\yioop\library\UnitTest;

/**
 * Unit tests for the GitPackReader library class, which reads objects out
 * of a Git packfile. Each test builds a small packfile and its index in PHP
 * (no git program needed) and then checks the reader finds objects through
 * the index and rebuilds an object that was stored as a difference against
 * another (a delta). Reading is done through GitRepository, which is how the
 * pack reader is reached in normal use.
 *
 * @author Chris Pollett
 */
class GitPackReaderTest extends UnitTest
{
    /**
     * Folder, relative to this test file, holding the repository built for
     * a test run
     */
    const TEST_DIR = '/test_files/git_pack_reader_test';
    /**
     * Absolute path of the test repository for the current test
     * @var string
     */
    public $repo_path;
    /**
     * Starts each test from an empty, freshly created bare repository.
     */
    public function setUp()
    {
        $this->repo_path = __DIR__ . self::TEST_DIR;
        $this->deleteTree($this->repo_path);
        $repository = new L\GitRepository($this->repo_path);
        $repository->initBare("main");
    }
    /**
     * Removes the test repository created during a test.
     */
    public function tearDown()
    {
        $this->deleteTree($this->repo_path);
    }
    /**
     * Deletes a folder and everything under it.
     *
     * @param string $path folder to remove
     */
    public function deleteTree($path)
    {
        if (!is_dir($path)) {
            return;
        }
        foreach (scandir($path) as $name) {
            if ($name == "." || $name == "..") {
                continue;
            }
            $full = $path . "/" . $name;
            if (is_dir($full)) {
                $this->deleteTree($full);
            } else {
                unlink($full);
            }
        }
        rmdir($path);
    }
    /**
     * Encodes a packfile object header giving the object's type and size in
     * the variable-length form a packfile uses.
     *
     * @param int $type packfile type number
     * @param int $size uncompressed size of the object data
     * @return string the encoded header bytes
     */
    public function objectHeader($type, $size)
    {
        $current = ($type << 4) | ($size & 0x0f);
        $size >>= 4;
        $header = "";
        while ($size > 0) {
            $header .= chr($current | 0x80);
            $current = $size & 0x7f;
            $size >>= 7;
        }
        return $header . chr($current);
    }
    /**
     * Encodes the distance back to a delta's base in the form a packfile
     * uses for a base named by position.
     *
     * @param int $distance how far back the base object sits
     * @return string the encoded distance bytes
     */
    public function offsetDistance($distance)
    {
        $bytes = chr($distance & 0x7f);
        $distance >>= 7;
        while ($distance > 0) {
            $distance -= 1;
            $bytes = chr(0x80 | ($distance & 0x7f)) . $bytes;
            $distance >>= 7;
        }
        return $bytes;
    }
    /**
     * Encodes a number in the little-endian base-128 form used for the two
     * sizes at the start of a delta.
     *
     * @param int $number value to encode
     * @return string the encoded bytes
     */
    public function deltaSize($number)
    {
        $bytes = "";
        do {
            $seven = $number & 0x7f;
            $number >>= 7;
            $bytes .= chr($number > 0 ? ($seven | 0x80) : $seven);
        } while ($number > 0);
        return $bytes;
    }
    /**
     * Returns the object name (hex SHA-1) a body of a given kind would have.
     *
     * @param string $type_name object kind name
     * @param string $body raw object contents
     * @return string the object name
     */
    public function objectName($type_name, $body)
    {
        return sha1($type_name . " " . strlen($body) . "\0" . $body);
    }
    /**
     * Writes a packfile and its index into the test repository given a list
     * of already-assembled entries. Each entry supplies the object name, the
     * bytes to place in the packfile, and the pack data used for the index.
     *
     * @param array $entries list of ["sha" => name, "pack" => pack bytes]
     */
    public function writePack($entries)
    {
        $pack = "PACK" . pack("N", 2) . pack("N", count($entries));
        $offsets = [];
        foreach ($entries as $entry) {
            $offsets[$entry["sha"]] = strlen($pack);
            $pack .= $entry["pack"];
        }
        $pack .= hex2bin(sha1($pack));
        $names = array_keys($offsets);
        sort($names);
        $counts = array_fill(0, 256, 0);
        foreach ($names as $sha) {
            $counts[hexdec(substr($sha, 0, 2))]++;
        }
        $idx = "\377tOc" . pack("N", 2);
        $running = 0;
        for ($slot = 0; $slot < 256; $slot++) {
            $running += $counts[$slot];
            $idx .= pack("N", $running);
        }
        foreach ($names as $sha) {
            $idx .= hex2bin($sha);
        }
        foreach ($names as $sha) {
            $idx .= pack("N", 0);
        }
        foreach ($names as $sha) {
            $idx .= pack("N", $offsets[$sha]);
        }
        $idx .= hex2bin(sha1($pack)) . hex2bin(sha1($idx));
        $base = $this->repo_path . "/objects/pack/pack-" . sha1($pack);
        file_put_contents($base . ".pack", $pack);
        file_put_contents($base . ".idx", $idx);
    }
    /**
     * Objects stored whole in a packfile are found through the index and
     * read back with their correct kind and contents.
     */
    public function readWholeObjectsTestCase()
    {
        $blob = "a blob stored in a pack\n";
        $tree_body = "40000 dir\0" . str_repeat("z", 20);
        $entries = [
            ["sha" => $this->objectName("blob", $blob),
             "pack" => $this->objectHeader(3, strlen($blob)) .
                 gzcompress($blob)],
            ["sha" => $this->objectName("tree", $tree_body),
             "pack" => $this->objectHeader(2, strlen($tree_body)) .
                 gzcompress($tree_body)]];
        $this->writePack($entries);
        $repository = new L\GitRepository($this->repo_path);
        $blob_object = $repository->readObject($entries[0]["sha"]);
        $this->assertEqual("blob", $blob_object["type"],
            "whole blob has right kind");
        $this->assertEqual($blob, $blob_object["body"],
            "whole blob has right contents");
        $tree_object = $repository->readObject($entries[1]["sha"]);
        $this->assertEqual("tree", $tree_object["type"],
            "whole tree has right kind");
        $this->assertEqual($tree_body, $tree_object["body"],
            "whole tree has right contents");
    }
    /**
     * An object stored as a delta against an earlier object in the same
     * packfile is rebuilt into the full object when read.
     */
    public function resolveOffsetDeltaTestCase()
    {
        $base = "0123456789";
        $added = "ABCDE";
        $result = $base . $added;
        $base_pack = $this->objectHeader(3, strlen($base)) .
            gzcompress($base);
        $base_offset = strlen("PACK" . pack("N", 2) . pack("N", 2));
        $delta_stream = $this->deltaSize(strlen($base)) .
            $this->deltaSize(strlen($result)) .
            chr(0x90) . chr(strlen($base)) .
            chr(strlen($added)) . $added;
        $delta_offset = $base_offset + strlen($base_pack);
        $delta_pack = $this->objectHeader(6, strlen($delta_stream)) .
            $this->offsetDistance($delta_offset - $base_offset) .
            gzcompress($delta_stream);
        $entries = [
            ["sha" => $this->objectName("blob", $base), "pack" => $base_pack],
            ["sha" => $this->objectName("blob", $result),
             "pack" => $delta_pack]];
        $this->writePack($entries);
        $repository = new L\GitRepository($this->repo_path);
        $rebuilt = $repository->readObject($this->objectName("blob",
            $result));
        $this->assertEqual("blob", $rebuilt["type"],
            "rebuilt delta has the base object's kind");
        $this->assertEqual($result, $rebuilt["body"],
            "delta rebuilt into the full object");
    }
}
X