/ tests / GitRepositoryTest.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 GitRepository library class, which reads a bare Git
 * repository straight from disk. Each test builds a small repository by
 * writing Git objects and refs by hand in PHP (so the tests need no git
 * program) and then checks the reader returns what was written. The tests
 * also cover the safety rule that creating a repository never overwrites
 * one that is already there.
 *
 * @author Chris Pollett
 */
class GitRepositoryTest extends UnitTest
{
    /**
     * Folder, relative to this test file, holding the repository built for
     * a test run
     */
    const TEST_DIR = '/test_files/git_repository_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, used to clear the test
     * repository between tests.
     *
     * @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);
    }
    /**
     * Checks that the push size caps let writes within the limits through
     * and turn away writes that break the blob, pack, or repository size
     * caps, while treating a cap of zero as no limit at all.
     */
    public function withinPushLimitsTestCase()
    {
        $this->assertTrue(L\GitRepository::withinPushLimits(
            5000, 90000, false, 0, 0, 0),
            "no caps set lets any write through");
        $this->assertTrue(L\GitRepository::withinPushLimits(
            100, 0, false, 200, 0, 0),
            "object under the blob cap is allowed");
        $this->assertFalse(L\GitRepository::withinPushLimits(
            300, 0, false, 200, 0, 0),
            "object over the blob cap is refused");
        $this->assertFalse(L\GitRepository::withinPushLimits(
            300, 0, true, 0, 200, 0),
            "pack over the push cap is refused");
        $this->assertTrue(L\GitRepository::withinPushLimits(
            300, 0, false, 0, 200, 0),
            "loose object is not bound by the pack push cap");
        $this->assertTrue(L\GitRepository::withinPushLimits(
            100, 800, false, 0, 0, 1000),
            "write that keeps the repository under its cap is allowed");
        $this->assertFalse(L\GitRepository::withinPushLimits(
            300, 800, false, 0, 0, 1000),
            "write that would push the repository over its cap is refused");
    }
    /**
     * A ref file naming another ref rather than an object is passed over,
     * so that a repository carrying one can still be listed for a client.
     */
    public function allRefsSymbolicTestCase()
    {
        $repository = new L\GitRepository($this->repo_path);
        $object_name = str_repeat("a", 40);
        mkdir($this->repo_path . "/refs/heads", 0777, true);
        file_put_contents($this->repo_path . "/refs/heads/main",
            $object_name . "\n");
        mkdir($this->repo_path . "/refs/remotes/origin", 0777, true);
        file_put_contents($this->repo_path . "/refs/remotes/origin/HEAD",
            "ref: refs/heads/main\n");
        $refs = $repository->allRefs();
        $this->assertEqual($object_name, $refs["refs/heads/main"] ?? "",
            "a ref naming an object is listed with that object's name");
        $this->assertTrue(!isset($refs["refs/remotes/origin/HEAD"]),
            "a ref naming another ref is left out of the listing");
    }
    /**
     * Checks the folder classifier: an existing bare repository reports as a
     * repository, a folder holding nothing but dot files reports as empty,
     * and a folder holding other files reports as occupied.
     */
    public function folderStateTestCase()
    {
        $repository = new L\GitRepository($this->repo_path);
        $this->assertEqual("repository", $repository->folderState(),
            "an existing bare repository reports as a repository");
        $empty_path = $this->repo_path . "_empty";
        $this->deleteTree($empty_path);
        mkdir($empty_path, 0777, true);
        file_put_contents($empty_path . "/.DS_Store", "");
        $empty = new L\GitRepository($empty_path);
        $this->assertEqual("empty", $empty->folderState(),
            "a folder holding only dot files reports as empty");
        $this->deleteTree($empty_path);
        $occupied_path = $this->repo_path . "_occupied";
        $this->deleteTree($occupied_path);
        mkdir($occupied_path, 0777, true);
        file_put_contents($occupied_path . "/notes.txt", "hello");
        $occupied = new L\GitRepository($occupied_path);
        $this->assertEqual("occupied", $occupied->folderState(),
            "a folder holding other files reports as occupied");
        $this->deleteTree($occupied_path);
    }
    /**
     * Writes one Git object into the test repository as a loose object,
     * the way a freshly pushed object is stored, and returns its object
     * name. The object name is the hash of the type, size, and contents,
     * and the file is stored compressed exactly as Git stores it.
     *
     * @param string $type object kind, one of blob, tree, commit, tag
     * @param string $body raw object contents
     * @return string the object name (hex SHA-1)
     */
    public function writeLooseObject($type, $body)
    {
        $store = $type . " " . strlen($body) . "\0" . $body;
        $sha = sha1($store);
        $dir = $this->repo_path . "/objects/" . substr($sha, 0, 2);
        if (!is_dir($dir)) {
            mkdir($dir, 0777, true);
        }
        file_put_contents($dir . "/" . substr($sha, 2), gzcompress($store));
        return $sha;
    }
    /**
     * Writes a ref file (a branch or tag) pointing at an object name.
     *
     * @param string $ref_name ref path, for example refs/heads/main
     * @param string $sha object name the ref points at
     */
    public function writeRef($ref_name, $sha)
    {
        $full = $this->repo_path . "/" . $ref_name;
        $dir = dirname($full);
        if (!is_dir($dir)) {
            mkdir($dir, 0777, true);
        }
        file_put_contents($full, $sha . "\n");
    }
    /**
     * Builds a tree object's contents from a list of entries.
     *
     * @param array $entries list of [mode, name, hex object name]
     * @return string the raw tree object contents
     */
    public function buildTreeBody($entries)
    {
        $body = "";
        foreach ($entries as $entry) {
            $body .= $entry[0] . " " . $entry[1] . "\0" .
                hex2bin($entry[2]);
        }
        return $body;
    }
    /**
     * A blob written into the repository is read back with its exact bytes,
     * including embedded null bytes.
     */
    public function readBlobTestCase()
    {
        $body = "first line\n\x00\x01second\n";
        $sha = $this->writeLooseObject("blob", $body);
        $repository = new L\GitRepository($this->repo_path);
        $this->assertEqual($body, $repository->blob($sha),
            "blob reads back byte for byte");
    }
    /**
     * A commit written into the repository is parsed into its tree, parent,
     * author, and message.
     */
    public function readCommitTestCase()
    {
        $tree_sha = str_repeat("a", 40);
        $parent_sha = str_repeat("b", 40);
        $body = "tree $tree_sha\nparent $parent_sha\n" .
            "author A U Thor <a@example.com> 100 +0000\n" .
            "committer A U Thor <a@example.com> 100 +0000\n\n" .
            "the commit message\n";
        $sha = $this->writeLooseObject("commit", $body);
        $repository = new L\GitRepository($this->repo_path);
        $commit = $repository->commit($sha);
        $this->assertEqual($tree_sha, $commit["tree"],
            "commit tree parsed");
        $this->assertEqual($parent_sha, $commit["parents"][0],
            "commit parent parsed");
        $this->assertEqual("the commit message\n", $commit["message"],
            "commit message parsed");
    }
    /**
     * A tree written into the repository is parsed into its entries, with
     * folders marked as such.
     */
    public function readTreeTestCase()
    {
        $blob_sha = $this->writeLooseObject("blob", "x");
        $sub_tree = $this->writeLooseObject("tree", "");
        $tree_body = $this->buildTreeBody([
            ["100644", "readme.txt", $blob_sha],
            ["40000", "src", $sub_tree]]);
        $tree_sha = $this->writeLooseObject("tree", $tree_body);
        $repository = new L\GitRepository($this->repo_path);
        $entries = $repository->tree($tree_sha);
        $this->assertEqual("readme.txt", $entries[0]["name"],
            "first tree entry name");
        $this->assertFalse($entries[0]["is_dir"],
            "blob entry is not a folder");
        $this->assertEqual("src", $entries[1]["name"],
            "second tree entry name");
        $this->assertTrue($entries[1]["is_dir"],
            "tree entry is a folder");
        $this->assertEqual($blob_sha, $entries[0]["sha"],
            "tree entry object name");
    }
    /**
     * An object read with the wrong name is reported as corrupt rather than
     * returned, since the name must be the hash of the contents.
     */
    public function corruptObjectTestCase()
    {
        $store = "blob 5\0hello";
        $wrong_sha = str_repeat("c", 40);
        $dir = $this->repo_path . "/objects/" . substr($wrong_sha, 0, 2);
        mkdir($dir, 0777, true);
        file_put_contents($dir . "/" . substr($wrong_sha, 2),
            gzcompress($store));
        $repository = new L\GitRepository($this->repo_path);
        $threw = false;
        try {
            $repository->readObject($wrong_sha);
        } catch (\Exception $error) {
            $threw = true;
        }
        $this->assertTrue($threw, "object with mismatched name is rejected");
    }
    /**
     * Branches and tags, whether stored as their own files, are all listed,
     * and a missing object read returns nothing.
     */
    public function allRefsTestCase()
    {
        $commit_sha = $this->writeLooseObject("commit",
            "tree " . str_repeat("a", 40) . "\n\nm\n");
        $this->writeRef("refs/heads/main", $commit_sha);
        $this->writeRef("refs/heads/dev", $commit_sha);
        $this->writeRef("refs/tags/v1", $commit_sha);
        $repository = new L\GitRepository($this->repo_path);
        $refs = $repository->allRefs();
        $this->assertEqual($commit_sha, $refs["refs/heads/main"],
            "main branch listed");
        $this->assertEqual($commit_sha, $refs["refs/heads/dev"],
            "dev branch listed");
        $this->assertEqual($commit_sha, $refs["refs/tags/v1"],
            "tag listed among refs");
    }
    /**
     * An annotated tag is peeled to the commit it names, while a plain
     * object peels to nothing.
     */
    public function peeledTagTestCase()
    {
        $commit_sha = $this->writeLooseObject("commit",
            "tree " . str_repeat("a", 40) . "\n\nm\n");
        $tag_body = "object $commit_sha\ntype commit\ntag v2\n" .
            "tagger A U Thor <a@example.com> 100 +0000\n\nrelease\n";
        $tag_sha = $this->writeLooseObject("tag", $tag_body);
        $repository = new L\GitRepository($this->repo_path);
        $this->assertEqual($commit_sha, $repository->peeledTarget($tag_sha),
            "annotated tag peels to its commit");
        $this->assertEqual("", $repository->peeledTarget($commit_sha),
            "a commit does not peel");
    }
    /**
     * Creating a bare repository in a folder that already holds one does
     * nothing and leaves the existing repository untouched.
     */
    public function initBareNeverClobbersTestCase()
    {
        $sha = $this->writeLooseObject("blob", "precious contents\n");
        $this->writeRef("refs/heads/main", $sha);
        $before = file_get_contents($this->repo_path . "/HEAD");
        $repository = new L\GitRepository($this->repo_path);
        $this->assertFalse($repository->initBare("other"),
            "init declines when a repository is already present");
        $after = file_get_contents($this->repo_path . "/HEAD");
        $this->assertEqual($before, $after,
            "existing HEAD was not overwritten");
        $this->assertEqual("precious contents\n", $repository->blob($sha),
            "existing object still readable after init attempt");
    }
    /**
     * The statistics walk counts commits, groups them by author and by
     * month, and counts the newest commit's files by their ending,
     * descending into folders. A small two-commit history with a handful of
     * files of known kinds is built so every count can be checked exactly.
     */
    public function statisticsTestCase()
    {
        $blob = $this->writeLooseObject("blob", "x");
        $sub_body = $this->buildTreeBody([
            ["100644", "deep.php", $blob]]);
        $sub_tree = $this->writeLooseObject("tree", $sub_body);
        $tree_body = $this->buildTreeBody([
            ["100644", "one.php", $blob],
            ["100644", "two.php", $blob],
            ["100644", "app.js", $blob],
            ["100644", "README", $blob],
            ["40000", "src", $sub_tree]]);
        $tree = $this->writeLooseObject("tree", $tree_body);
        $parent_body = "tree $tree\n" .
            "author Ann <ann@example.com> 1592179200 +0000\n" .
            "committer Ann <ann@example.com> 1592179200 +0000\n\nfirst\n";
        $parent = $this->writeLooseObject("commit", $parent_body);
        $child_body = "tree $tree\nparent $parent\n" .
            "author Bob <bob@example.com> 1600128000 +0000\n" .
            "committer Bob <bob@example.com> 1600128000 +0000\n\nsecond\n";
        $child = $this->writeLooseObject("commit", $child_body);
        $repository = new L\GitRepository($this->repo_path);
        $stats = $repository->statistics($child);
        $this->assertEqual(2, $stats["commits"],
            "both commits are counted");
        $this->assertEqual(1, $stats["authors"]["Ann"],
            "Ann is credited with one commit");
        $this->assertEqual(1, $stats["authors"]["Bob"],
            "Bob is credited with one commit");
        $this->assertEqual(1, $stats["months"]["2020-06"],
            "one commit falls in June 2020");
        $this->assertEqual(1, $stats["months"]["2020-09"],
            "one commit falls in September 2020");
        $this->assertEqual(5, $stats["files"],
            "all five files, including the one in a folder, are counted");
        $this->assertEqual(3, $stats["extensions"]["php"],
            "the three php files are counted together");
        $this->assertEqual(1, $stats["extensions"]["js"],
            "the one js file is counted");
        $this->assertEqual(1, $stats["extensions"][""],
            "the file with no ending is counted under the empty key");
    }
}
X