SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / library / version_control / GitUploadPack.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\library\version_control;

/**
 * GitUploadPack serves a Git repository to a client that clones or fetches
 * it over the "smart" side of Git's HTTP protocol, in pure PHP with no git
 * program involved. The dumb side of that protocol just hands back the
 * repository's stored files, so a client has to download the whole
 * history. The smart side lets the client ask for only what it wants, which
 * is what makes a shallow clone (git's --depth option) possible.
 * GitUploadPack produces the two smart replies. The first is the
 * advertisement a client reads to learn the repository's branches and what
 * the server can do. The second answers the request in which the client
 * says which commits it wants, which it has, and how much history to take.
 *
 * GitUploadPack reads objects through a GitRepository, which pulls commits,
 * trees (folders) and blobs (file contents) out of both loose files and
 * packfiles. A packfile is the one file git keeps or sends a set of
 * objects in, each compressed, some stored as their differences from
 * another object. GitUploadPack adds the protocol on top. It frames bytes
 * into Git's length-prefixed pkt-line records, reads the client's request,
 * walks history from the wanted commits, and writes the chosen objects into
 * a fresh packfile. An object is written as its difference from another
 * where that is smaller, and an entry already stored in the repository's
 * packfile is copied as stored. Like the rest of the Git library it only
 * ever reads the repository.
 *
 * The framing follows Git's pack protocol: a pkt-line is a four-hex-digit
 * length (counting the four digits) then that many bytes, and the special
 * length "0000" is a flush marker that separates sections.
 *
 * @author Chris Pollett
 */
class GitUploadPack
{
    /**
     * $repository is the GitRepository being served, which GitUploadPack
     * reads branches, tags and objects through
     * @var object
     */
    private $repository;
    /**
     * $held_objects holds, as keys, the object names the client already
     * has, gathered from the commits it says it has and the folders and
     * files those record. Anything in here is left out of the packfile (the
     * one file the reply's objects travel in), since sending a file the
     * client already has turns a small update into a copy of the whole
     * working tree.
     * @var array
     */
    private $held_objects = [];
    /**
     * $object_groups gives, for each object being packed, the kind of thing
     * it is and, for a folder or file, the name it goes by. Successive
     * versions of one file are nearly alike, so packing them next to each
     * other lets each be sent as its small difference from the one before.
     * @var array
     */
    private $object_groups = [];
    /**
     * $held_by_name gives, for each folder and file the client already
     * holds, the object name of the version it has, kept under the name
     * that thing goes by. A new version can then be sent as its difference
     * from the client's own copy instead of whole, which the thin-pack
     * capability (the client's word that it will finish such a pack from
     * its own copies) allows.
     * @var array
     */
    private $held_by_name = [];
    /**
     * PKT_LENGTH_DIGITS is how many hexadecimal (base sixteen) digits begin
     * every pkt-line (one record of git's protocol) and give its length
     * @var int
     */
    const PKT_LENGTH_DIGITS = 4;
    /**
     * FLUSH_PKT is the flush marker, a pkt-line (one record of git's
     * protocol) whose length field is all zeros, which separates one
     * section of the protocol from the next
     * @var string
     */
    const FLUSH_PKT = "0000";
    /**
     * MAX_PKT_LENGTH is the largest number of bytes one pkt-line (one
     * record of git's protocol) can carry, including its own four-digit
     * length, as the pack protocol fixes it
     * @var int
     */
    const MAX_PKT_LENGTH = 65520;
    /**
     * SHA_HEX_LENGTH is how many hexadecimal (base sixteen) characters an
     * object name
     * has; git names an object by the SHA-1 checksum (a 20-byte summary)
     * of its contents
     * @var int
     */
    const SHA_HEX_LENGTH = 40;
    /**
     * OBJECT_TYPE_COMMIT is the number a packfile (the one file a set of
     * git objects travels in) puts in an object's header to say it is a
     * commit
     * @var int
     */
    const OBJECT_TYPE_COMMIT = 1;
    /**
     * OBJECT_TYPE_TREE is the number a packfile (the one file a set of git
     * objects travels in) puts in an object's header to say it is a tree,
     * which is git's word for a folder
     * @var int
     */
    const OBJECT_TYPE_TREE = 2;
    /**
     * OBJECT_TYPE_BLOB is the number a packfile (the one file a set of git
     * objects travels in) puts in an object's header to say it is a blob,
     * which is git's word for a file's contents
     * @var int
     */
    const OBJECT_TYPE_BLOB = 3;
    /**
     * OBJECT_TYPE_TAG is the number a packfile (the one file a set of git
     * objects travels in) puts in an object's header to say it is an
     * annotated tag, a named label on a commit with a message of its own
     * @var int
     */
    const OBJECT_TYPE_TAG = 4;
    /**
     * TYPE_CODES maps git's words for the kinds of object to the numbers
     * a packfile (the one file a set of git objects travels in) puts in
     * each object's header
     * @var array
     */
    const TYPE_CODES = ["commit" => self::OBJECT_TYPE_COMMIT,
        "tree" => self::OBJECT_TYPE_TREE, "blob" => self::OBJECT_TYPE_BLOB,
        "tag" => self::OBJECT_TYPE_TAG];
    /**
     * FIRST_SIZE_BITS is how many bits of an object's size fit in the
     * first byte of its header in a packfile (the one file a set of git
     * objects travels in): the low four, after the three that give its
     * kind
     * @var int
     */
    const FIRST_SIZE_BITS = 4;
    /**
     * CONTINUATION_SIZE_BITS is how many bits of an object's size each
     * further byte of its header in a packfile (the one file a set of git
     * objects travels in) carries: the low seven
     * @var int
     */
    const CONTINUATION_SIZE_BITS = 7;
    /**
     * CONTINUATION_FLAG is the high bit of a byte of an object's header in
     * a packfile (the one file a set of git objects travels in), set to
     * say that another byte of the size follows
     * @var int
     */
    const CONTINUATION_FLAG = 0x80;
    /**
     * MAX_PACK_OBJECTS is the most objects one reply may pack, so a request
     * cannot make the server walk an unbounded amount of history at once
     * @var int
     */
    const MAX_PACK_OBJECTS = 250000;
    /**
     * MAX_HELD_HISTORY is the most commits of the client's own history
     * markHeldHistory walks back through while working out where the two
     * sides' histories meet. The walk reads commits and nothing else, which
     * costs a fraction of a millisecond each, so this is generous enough
     * for a repository of any age while still refusing an unbounded amount
     * of work.
     * @var int
     */
    const MAX_HELD_HISTORY = 100000;
    /**
     * TYPE_REF_DELTA is the number a packfile (the one file a set of git
     * objects travels in) puts in an object's header to say the object is
     * written as its difference from another object, named outright
     * @var int
     */
    const TYPE_REF_DELTA = 7;
    /**
     * MAX_DELTA_CHAIN is the most objects in a row that packChunks writes
     * as a difference from the one before. Rebuilding the last of such a
     * run means rebuilding all of them, so the run is broken every so often
     * by an object written in full, keeping that work bounded for the
     * client.
     * @var int
     */
    const MAX_DELTA_CHAIN = 50;
    /**
     * MAX_DELTA_BODY is the largest object, in bytes, that packChunks sends
     * as a difference from another (16 megabytes). Working out a difference
     * compares two whole objects and holds both along with the answer, so
     * on objects of a hundred megabytes and more it costs minutes and
     * gigabytes for a saving that a large file, being mostly
     * incompressible, rarely gives. Anything larger is sent whole.
     * @var int
     */
    const MAX_DELTA_BODY = 16777216;
    /**
     * UPLOAD_PACK_SERVICE is the service name a client that clones or
     * fetches, and so only reads, asks for
     * @var string
     */
    const UPLOAD_PACK_SERVICE = "git-upload-pack";
    /**
     * THIN_PACK_CAPABILITY is the word a client uses to say it will accept
     * a pack whose differences may be built against content the client
     * already holds rather than against content sent alongside. Such a
     * pack is smaller, and the client completes it from its own store on
     * arrival.
     * @var string
     */
    const THIN_PACK_CAPABILITY = "thin-pack";
    /**
     * CAPABILITIES lists what the server tells the client it supports.
     * "shallow"
     * is the one that allows a depth-limited clone; "no-progress" says the
     * server will not send human-readable progress lines;
     * "multi_ack_detailed" lets a round of negotiation name each commit the
     * two sides are found to hold in common and say when the server is ready
     * to send, which is what lets a client with a long history settle
     * quickly instead of giving up and asking for all of it; "thin-pack"
     * lets a file's new version be sent as its difference from the version
     * the client already has, rather than whole.
     * "allow-tip-sha1-in-want" and "allow-reachable-sha1-in-want" let a
     * client ask for an object by its name rather than by a branch or tag
     * name, so someone who knows a commit name can fetch just that commit
     * and its history; without them a client refuses to send the request at
     * all and says the server does not allow an unadvertised object.
     * "deepen-relative" lets a client with a shallow history ask for a
     * number of commits more beyond where its history stops, which is what
     * git fetch --deepen sends; without it git refuses with "Server does
     * not support --deepen".
     * Side-band multiplexing is deliberately not offered, so the packfile
     * comes back as plain bytes after the acknowledgment rather than chopped
     * into band-tagged records.
     * @var string
     */
    const CAPABILITIES =
        "shallow no-progress multi_ack_detailed thin-pack " .
        "allow-tip-sha1-in-want allow-reachable-sha1-in-want " .
        self::DEEPEN_RELATIVE;
    /**
     * DEEPEN_RELATIVE is the name of the capability a client adds to its
     * first want line when the depth it asks for counts from its own
     * shallow commits rather than from the commits it wants
     * @var string
     */
    const DEEPEN_RELATIVE = "deepen-relative";

    /**
     * __construct makes a GitUploadPack that serves one repository.
     * GitComponent makes one for each clone or fetch request it answers.
     *
     * @param object $repository the GitRepository whose branches, tags and
     *      objects are to be served
     */
    public function __construct($repository)
    {
        $this->repository = $repository;
    }
    /**
     * pktLine wraps a piece of data as one pkt-line, the record git's
     * protocol is made of: the length, counting the four length digits
     * themselves, in four hexadecimal digits, followed by the data. Every
     * line of a reply GitUploadPack builds, and of a request a test sends
     * it, is framed this way.
     *
     * @param string $data the bytes to frame
     * @return string the framed pkt-line
     */
    public static function pktLine($data)
    {
        $length = strlen($data) + self::PKT_LENGTH_DIGITS;
        return sprintf("%04x", $length) . $data;
    }
    /**
     * decodePktLines splits a run of pkt-lines, the length-prefixed records
     * git's protocol is made of, into the pieces of data they carry.
     * parseUploadRequest reads a client's request with it, and GitComponent
     * reads a request's lines for its log. A flush marker is reported as a
     * boolean false entry so a reader can see where one section ends, since
     * the protocol uses flushes to separate the list of wants from the list
     * of the client's commits.
     *
     * @param string $stream the bytes to read, a sequence of pkt-lines
     * @return array in-order contents of each line, with false marking a
     *      flush; the trailing newline Git puts on a line is trimmed.
     *      Reading stops at the first thing that is not a line, so
     *      whatever follows the lines is left alone
     */
    public static function decodePktLines($stream)
    {
        $lines = [];
        $offset = 0;
        $total = strlen($stream);
        while ($offset + self::PKT_LENGTH_DIGITS <= $total) {
            $digits = substr($stream, $offset, self::PKT_LENGTH_DIGITS);
            /* A line begins with its length in hexadecimal. Anything else
               means the reading has walked past the lines into whatever
               follows them -- a pack, in a push -- and stopping is the
               only correct answer. Handing such bytes to a hexadecimal
               reading gives a number made up from whichever characters
               happened to be valid, which quietly puts the rest of the
               parse out of step. */
            if (!ctype_xdigit($digits)) {
                break;
            }
            $length = hexdec($digits);
            if ($length == 0) {
                $lines[] = false;
                $offset += self::PKT_LENGTH_DIGITS;
                continue;
            }
            if ($length < self::PKT_LENGTH_DIGITS ||
                $offset + $length > $total) {
                break;
            }
            $content = substr($stream, $offset + self::PKT_LENGTH_DIGITS,
                $length - self::PKT_LENGTH_DIGITS);
            $lines[] = rtrim($content, "\n");
            $offset += $length;
        }
        return $lines;
    }
    /**
     * advertiseRefs builds the advertisement a client reads first, with an
     * HTTP GET request, to learn about the repository. It names the
     * service, then lists every ref (a branch or tag name) with the object
     * it points at, attaching the server's capabilities to the first line.
     * It points HEAD at the default branch so the client knows which branch
     * to check out. An annotated tag also gets a peeled line, which gives
     * the commit the tag finally refers to. GitComponent sends it as the
     * body of the info/refs reply.
     *
     * @return string the advertisement as pkt-lines (git's length-prefixed
     *      records), ready to send as the body of the info/refs reply
     */
    public function advertiseRefs()
    {
        $output = self::pktLine("# service=" . self::UPLOAD_PACK_SERVICE .
            "\n") . self::FLUSH_PKT;
        $refs = $this->repository->allRefs();
        $head_branch = $this->repository->headBranch();
        $head_line = "";
        if ($head_branch != "" &&
            isset($refs["refs/heads/" . $head_branch])) {
            $head_line = $refs["refs/heads/" . $head_branch] . " HEAD";
        }
        $capabilities = self::CAPABILITIES;
        if ($head_line != "") {
            $capabilities .= " symref=HEAD:refs/heads/" . $head_branch;
        }
        $first = true;
        if ($head_line != "") {
            $output .= self::pktLine($head_line . "\0" . $capabilities .
                "\n");
            $first = false;
        }
        foreach ($refs as $name => $object_name) {
            $line = $object_name . " " . $name;
            if ($first) {
                $output .= self::pktLine($line . "\0" . $capabilities .
                    "\n");
                $first = false;
            } else {
                $output .= self::pktLine($line . "\n");
            }
            $peeled = $this->repository->peeledTarget($object_name);
            if ($peeled != "") {
                $output .= self::pktLine($peeled . " " . $name . "^{}\n");
            }
        }
        return $output . self::FLUSH_PKT;
    }
    /**
     * parseUploadRequest turns the pkt-lines (git's length-prefixed
     * records) a client posted into the lists and settings uploadPackChunks
     * works from. The client lists the object names it wants and the
     * object names it already has, so the server can leave their history
     * out. A client with a shallow history names the commits that history
     * stops at, and may say how deep a history to send. The last round
     * ends with "done". Of the capabilities the client adds to its first
     * want line, thin-pack and deepen-relative are read; the rest change
     * nothing here.
     *
     * @param string $request_body the raw posted body
     * @return array ["wants" => list of wanted object names,
     *      "haves" => list of object names the client already has,
     *      "depth" => requested history depth, 0 meaning the whole history,
     *      "done" => whether the client said it is done negotiating,
     *      "thin" => whether the client takes a thin pack,
     *      "shallow" => the commits the client's shallow history stops at,
     *      which it holds without their parents,
     *      "relative" => whether the depth counts from those shallow
     *      commits rather than from the wanted commits, as git fetch
     *      --deepen asks]
     */
    public function parseUploadRequest($request_body)
    {
        $wants = [];
        $haves = [];
        $client_shallow = [];
        $capabilities = [];
        $depth = 0;
        $done = false;
        foreach (self::decodePktLines($request_body) as $line) {
            if ($line === false || $line === "") {
                continue;
            }
            $parts = explode(" ", $line);
            $names_object = isset($parts[1]) &&
                strlen($parts[1]) == self::SHA_HEX_LENGTH &&
                ctype_xdigit($parts[1]);
            if ($parts[0] == "want" && $names_object) {
                $wants[$parts[1]] = true;
                foreach (array_slice($parts, 2) as $capability) {
                    $capabilities[trim($capability)] = true;
                }
            } else if ($parts[0] == "have" && $names_object) {
                $haves[$parts[1]] = true;
            } else if ($parts[0] == "shallow" && $names_object) {
                $client_shallow[$parts[1]] = true;
            } else if ($parts[0] == "deepen" && isset($parts[1])) {
                $depth = max(0, intval($parts[1]));
            } else if ($parts[0] == "done") {
                $done = true;
            }
        }
        return ["wants" => array_keys($wants),
            "haves" => array_keys($haves), "depth" => $depth,
            "done" => $done,
            "thin" => isset($capabilities[self::THIN_PACK_CAPABILITY]),
            "shallow" => array_keys($client_shallow),
            "relative" => isset($capabilities[self::DEEPEN_RELATIVE])];
    }
    /**
     * uploadPackChunks is the generator GitComponent draws a clone's or
     * fetch's whole reply from. It yields each piece of the reply as soon
     * as that piece is made, rather than building the whole reply first.
     *
     * Yielding keeps a large clone from holding the server's memory:
     * GitComponent::serveUploadPack writes each piece to a file, and the
     * web server sends that file while the rest is made. The packfile (the
     * one file the reply's objects travel in) is never held in memory, and
     * the client starts receiving before it is finished.
     *
     * A request with a depth, or from a client with a shallow history,
     * first gets the shallow lines, which say where the new history ends,
     * and the unshallow lines, which say where the client's old history no
     * longer ends.
     *
     * A fetch happens in rounds. The client names what it wants and lists
     * commits it already has; while nothing it lists is recognized the reply
     * is a bare "NAK" and the client offers older commits next. As soon as
     * one is recognized the reply names it as held in common and says the
     * server is ready, which stops the client walking further back. Once the
     * client says it is done, the reply is a final acknowledgment followed
     * by the packfile of just the objects it is missing. Sending the pack
     * before that leaves the client reading pack bytes where it expects the
     * next pkt-line.
     *
     * @param string $request_body the client's posted upload request
     * @return \Generator yields the reply's bytes in order
     */
    public function uploadPackChunks($request_body)
    {
        $request = $this->parseUploadRequest($request_body);
        $commons = $this->commonCommits($request["haves"]);
        if (!$request["done"] && $request["depth"] <= 0) {
            yield from $this->negotiationRoundChunks($commons);
            return;
        }
        $walk = $this->walkWanted($request["wants"], $request["depth"],
            $request["haves"], $request["shallow"], $request["relative"],
            $request["done"]);
        if ($request["depth"] > 0) {
            foreach ($walk["shallow"] as $shallow_sha) {
                yield self::pktLine("shallow " . $shallow_sha . "\n");
            }
            foreach ($walk["unshallow"] as $unshallow_sha) {
                yield self::pktLine("unshallow " . $unshallow_sha . "\n");
            }
            yield self::FLUSH_PKT;
            if (!$request["done"]) {
                /* A round that offers held commits waits for its
                   acknowledgments after where the history ends; one that
                   offers none, as the first does, waits for nothing more. */
                if (!empty($request["haves"])) {
                    yield from $this->negotiationRoundChunks($commons);
                }
                return;
            }
        }
        if (empty($commons)) {
            yield self::pktLine("NAK\n");
        } else {
            yield self::pktLine("ACK " . $commons[0] . "\n");
        }
        yield from $this->packChunks($walk["objects"],
            $request["thin"]);
    }
    /**
     * negotiationRoundChunks gives the acknowledgment lines that close a
     * round in which the client offered commits it holds and has not yet
     * said it is done. uploadPackChunks sends them. Naming each recognized
     * commit as held in common, and then
     * saying the server is ready, is what lets the client stop offering ever
     * older commits and ask for its update; without it a client with a long
     * history gives up after a couple of hundred offers, concludes the two
     * sides share nothing, and asks for the whole history. The closing "NAK"
     * ends the round: a flush in its place is read as a missing
     * acknowledgment, and no closing line at all leaves the client waiting.
     *
     * @param array $commons object names from this round that the repository
     *      also holds, newest first
     * @return \Generator yields the round's pkt-lines in order
     */
    private function negotiationRoundChunks($commons)
    {
        foreach ($commons as $common) {
            yield self::pktLine("ACK " . $common . " common\n");
        }
        if (!empty($commons)) {
            yield self::pktLine("ACK " . $commons[0] . " ready\n");
        }
        yield self::pktLine("NAK\n");
    }
    /**
     * commonCommits keeps, of the commits a client offers as held, those
     * this repository has too; uploadPackChunks acknowledges them. A client
     * lists its commits newest first, so the result keeps that order and
     * its first entry is the newest commit the two sides share.
     *
     * @param array $haves object names the client says it has
     * @return array the object names of those this repository also holds
     */
    private function commonCommits($haves)
    {
        $commons = [];
        foreach ($haves as $sha) {
            if ($this->repository->readObject($sha) !== null) {
                $commons[] = $sha;
            }
        }
        return $commons;
    }
    /**
     * walkWanted decides which objects go in the reply's pack, for
     * uploadPackChunks. Starting at the wanted commits, it follows history
     * back and takes each commit the client lacks together with the whole
     * tree of folders and files that commit records. When a depth is given
     * the walk keeps only that many commits deep along each parent line,
     * and the commits at the cutoff are marked as shallow. A wanted object
     * that is a tag is followed to the commit it names first.
     *
     * A client whose own history is shallow names the commits it stops at.
     * Such a commit is held but its parents are not. Where the new history
     * ends is worked out first by shallowCutoff, from the wanted commits
     * or, for git fetch --deepen, from the client's shallow commits. The
     * walk that gathers objects then goes on through commits the client
     * holds, packing none of them, and stops at that new cutoff.
     *
     * @param array $wants object names the client asked for
     * @param int $depth most commits to include along each history line, or
     *      0 for no limit
     * @param array $haves object names the client already has; the walk
     *      does not pack these or their history
     * @param array $client_shallow object names of the commits the
     *      client's shallow history stops at
     * @param bool $relative whether $depth counts from the client's shallow
     *      commits rather than from the wanted commits
     * @param bool $pack whether to gather the objects to pack; false for a
     *      round of a depth request that only asks where history ends
     * @return array ["objects" => ordered list of object names to pack,
     *      "shallow" => object names of commits at the depth cutoff,
     *      "unshallow" => the client's shallow commits whose parents are
     *      now sent]
     */
    private function walkWanted($wants, $depth, $haves = [],
        $client_shallow = [], $relative = false, $pack = true)
    {
        $objects = [];
        $held_commits = [];
        $client_shallow = array_flip($client_shallow);
        $this->held_objects = [];
        $this->held_by_name = [];
        $queue = [];
        foreach ($wants as $want) {
            $commit_sha = $this->resolveToCommit($want, $objects);
            if ($commit_sha != "") {
                $queue[] = $commit_sha;
            }
        }
        $cutoff = ["shallow" => [], "unshallow" => []];
        if ($depth > 0) {
            $cutoff = $this->shallowCutoff($relative ?
                array_keys($client_shallow) : $queue, $depth +
                ($relative ? 1 : 0), $client_shallow);
        }
        if (!$pack) {
            return ["objects" => [], "shallow" => $cutoff["shallow"],
                "unshallow" => $cutoff["unshallow"]];
        }
        foreach ($haves as $have_sha) {
            $this->markHeldHistory($have_sha, $held_commits,
                $client_shallow);
            $this->collectHeld($have_sha);
        }
        $stop_at = array_flip($cutoff["shallow"]);
        $visited = [];
        while (!empty($queue)) {
            $commit_sha = array_shift($queue);
            if (isset($visited[$commit_sha])) {
                continue;
            }
            $visited[$commit_sha] = true;
            $held = isset($held_commits[$commit_sha]);
            /* Without a depth the client holds all history under a commit
               it holds. With one, a commit it holds may lie above its old
               shallow commits, under which the new commits are. */
            if ($held && $depth <= 0) {
                continue;
            }
            $commit = $this->repository->commit($commit_sha);
            if (!$held) {
                $this->addObject($commit_sha, $objects);
                $this->collectTree($commit["tree"], $objects, "");
            }
            /* Walking a history has no waiting in it, so the loop is
               handed back after each commit. A full clone of a long
               history spent seconds here before the first object was
               packed, and without this the whole site waited with it. */
            if (\Fiber::getCurrent() !== null) {
                \Fiber::suspend();
            }
            if (isset($stop_at[$commit_sha])) {
                continue;
            }
            foreach ($commit["parents"] as $parent_sha) {
                $queue[] = $parent_sha;
            }
        }
        return ["objects" => array_keys($objects),
            "shallow" => $cutoff["shallow"],
            "unshallow" => $cutoff["unshallow"]];
    }
    /**
     * shallowCutoff works out where a depth-limited history ends: the
     * commits that many levels down from the starting commits, counting a
     * starting commit as level one, whose parents are not sent. walkWanted
     * calls it with the wanted commits for git fetch --depth, and with the
     * client's shallow commits and one level more for git fetch --deepen,
     * so that the history goes that many commits past where the client's
     * stops. Only commits are read, not their folders, since where history
     * ends is all that is being worked out.
     *
     * @param array $starts object names of the commits to count from
     * @param int $levels how many levels of commits the history keeps
     * @param array $client_shallow the client's shallow commits, as keys
     * @return array ["shallow" => commits at the cutoff that have parents,
     *      "unshallow" => the client's shallow commits the history now goes
     *      past, whose parents are sent]
     */
    private function shallowCutoff($starts, $levels, $client_shallow)
    {
        $queue = [];
        foreach ($starts as $start_sha) {
            $queue[] = [$start_sha, 1];
        }
        $visited = [];
        $shallow = [];
        $expanded = [];
        while (!empty($queue)) {
            [$commit_sha, $level] = array_shift($queue);
            if (isset($visited[$commit_sha]) ||
                $this->repository->readObject($commit_sha) === null) {
                continue;
            }
            $visited[$commit_sha] = true;
            $parents = $this->repository->commit($commit_sha)["parents"];
            if ($level >= $levels && !empty($parents)) {
                $shallow[$commit_sha] = true;
                continue;
            }
            $expanded[$commit_sha] = true;
            foreach ($parents as $parent_sha) {
                $queue[] = [$parent_sha, $level + 1];
            }
        }
        return ["shallow" => array_keys($shallow),
            "unshallow" => array_keys(array_intersect_key($client_shallow,
            $expanded))];
    }
    /**
     * resolveToCommit follows a wanted object name to the commit it stands
     * for, so walkWanted can start its walk there. A commit is returned as
     * itself. An annotated tag is unwrapped, and the tag object is added to
     * the pack on the way, until a commit is reached. Anything that is not
     * a commit or tag gives the empty string.
     *
     * @param string $sha the wanted object name
     * @param array &$objects running set of objects to pack, added to when
     *      a tag is unwrapped
     * @return string the commit's object name, or "" if none is reached
     */
    private function resolveToCommit($sha, &$objects)
    {
        $steps = 0;
        while ($steps < self::MAX_PACK_OBJECTS) {
            $steps++;
            $object = $this->repository->readObject($sha);
            if ($object === null) {
                return "";
            }
            if ($object["type"] == "commit") {
                return $sha;
            }
            if ($object["type"] != "tag") {
                return "";
            }
            $this->addObject($sha, $objects);
            $target = "";
            foreach (explode("\n", $object["body"]) as $line) {
                if (strncmp($line, "object ", 7) === 0) {
                    $target = trim(substr($line, 7));
                    break;
                }
            }
            if (strlen($target) != self::SHA_HEX_LENGTH ||
                !ctype_xdigit($target)) {
                return "";
            }
            $sha = $target;
        }
        return "";
    }
    /**
     * collectTree adds a tree (git's word for a folder) and everything
     * under it to the set of objects to pack, for each commit walkWanted
     * sends: the tree object itself, each blob (file contents) it lists,
     * and, by walking into them, all its sub-folders. A tree already added,
     * or one the client holds, is skipped, so shared folders are packed
     * once.
     *
     * @param string $tree_sha object name of the tree to collect
     * @param array &$objects running set of objects to pack
     * @param string $name path this folder goes by, used to set successive
     *      versions of one file next to each other when packing
     */
    private function collectTree($tree_sha, &$objects,
        $name = "")
    {
        if (isset($objects[$tree_sha]) ||
            isset($this->held_objects[$tree_sha])) {
            return;
        }
        $this->addObject($tree_sha, $objects, "tree:" . $name);
        foreach ($this->repository->tree($tree_sha) as $entry) {
            $entry_name = $name . "/" . $entry["name"];
            if ($entry["is_dir"]) {
                $this->collectTree($entry["sha"], $objects, $entry_name);
            } else {
                $this->addObject($entry["sha"], $objects,
                    "blob:" . $entry_name);
            }
        }
    }
    /**
     * addObject is how walkWanted and the methods it calls put an object
     * into the set to pack, along with the group orderForPack later sorts
     * it by. The set keeps the order names were added in, leaves out
     * objects the client holds, and stops growing at MAX_PACK_OBJECTS.
     *
     * @param string $sha object name to add
     * @param array &$objects running set of objects to pack
     * @param string $group what the object is and the name it goes by, used
     *      to set successive versions of one file next to each other
     */
    private function addObject($sha, &$objects, $group = "")
    {
        if (!isset($objects[$sha]) && !isset($this->held_objects[$sha]) &&
            count($objects) < self::MAX_PACK_OBJECTS) {
            $objects[$sha] = true;
            $this->object_groups[$sha] = $group;
        }
    }
    /**
     * markHeldHistory fills the set of commits the client is known to
     * have, starting from one commit it offered and going back through all
     * that commit's ancestors. walkWanted's walk over what to send then
     * stops where the two sides' histories meet, rather than running on to
     * the first commit ever made.
     *
     * Marking the named commit alone is what made one pull cost a whole
     * clone. The send walk stops when it reaches a visited commit, so a
     * commit the client names that does not happen to lie on the path being
     * walked stops nothing at all, and every commit in the repository is
     * gathered along with every folder and file any of them records. On a
     * repository with years of history that is minutes of work and hundreds
     * of megabytes, for a pull that should have sent a handful of objects.
     * Walking the client's history first costs one read of each commit and
     * nothing else, since where to stop is all that is being worked out.
     *
     * A client with a shallow history holds a shallow commit but not its
     * parents, so the marking stops at each commit the client named as
     * shallow. Marking past it took the parents a deepening fetch asks for
     * as already held, and the fetch brought nothing.
     *
     * @param string $have_sha object name of a commit the client says it
     *      has
     * @param array &$seen_commits running set of commits the send walk
     *      treats as already visited
     * @param array $client_shallow the client's shallow commits, as keys,
     *      whose parents the client does not hold
     */
    private function markHeldHistory($have_sha, &$seen_commits,
        $client_shallow = [])
    {
        $queue = [$have_sha];
        $walked = 0;
        while (!empty($queue) && $walked < self::MAX_HELD_HISTORY) {
            $sha = array_shift($queue);
            if (isset($seen_commits[$sha])) {
                continue;
            }
            $seen_commits[$sha] = true;
            $walked++;
            if (\Fiber::getCurrent() !== null) {
                \Fiber::suspend();
            }
            $object = $this->repository->readObject($sha);
            if ($object === null || $object["type"] != "commit" ||
                isset($client_shallow[$sha])) {
                continue;
            }
            foreach ($this->repository->commit($sha)["parents"]
                as $parent_sha) {
                $queue[] = $parent_sha;
            }
        }
    }
    /**
     * collectHeld gathers into $held_objects everything one commit the
     * client says it has records: the commit, the folders it names, and the
     * files in them. walkWanted calls it for each such commit, so nothing
     * the client holds is packed. A folder already
     * gathered is skipped, so walking several of the client's commits costs
     * little more than walking one, neighboring commits sharing most of
     * their folders. A name the repository does not hold, or that is not a
     * commit, is passed over.
     *
     * @param string $commit_sha object name of a commit the client has
     */
    private function collectHeld($commit_sha)
    {
        $object = $this->repository->readObject($commit_sha);
        if ($object === null || $object["type"] != "commit") {
            return;
        }
        $this->held_objects[$commit_sha] = true;
        $commit = $this->repository->commit($commit_sha);
        $this->collectHeldTree($commit["tree"], "");
    }
    /**
     * collectHeldTree gathers into $held_objects a folder the client already
     * has and everything under it: the folder itself, each file it lists,
     * and, by walking into them, all its sub-folders. It also records each
     * under the name it goes by in $held_by_name, so that a new version of
     * a file can be sent as its difference from the client's copy.
     * collectHeld calls it on a commit's top folder.
     *
     * @param string $tree_sha object name of the folder to gather
     * @param string $name path this folder goes by, so the version of each
     *      thing the client holds can be found again by name
     */
    private function collectHeldTree($tree_sha, $name = "")
    {
        if (isset($this->held_objects[$tree_sha])) {
            return;
        }
        $this->held_objects[$tree_sha] = true;
        $this->held_by_name["tree:" . $name] = $tree_sha;
        foreach ($this->repository->tree($tree_sha) as $entry) {
            $entry_name = $name . "/" . $entry["name"];
            if ($entry["is_dir"]) {
                $this->collectHeldTree($entry["sha"], $entry_name);
            } else {
                $this->held_objects[$entry["sha"]] = true;
                $this->held_by_name["blob:" . $entry_name] = $entry["sha"];
            }
        }
    }
    /**
     * packChunks produces a packfile (the one file a set of git objects
     * travels in) of the given objects a piece at a time, for the end of
     * uploadPackChunks's reply. The packfile holds the "PACK" signature,
     * the count of objects, then each object compressed, and finally a
     * checksum of everything before it. The checksum is kept running as the
     * pieces go out, so the file never has to be assembled first in order to
     * be summed, and the caller can send each piece as it arrives.
     *
     * Objects are put in an order that sets successive versions of the same
     * file next to each other, and each is then written as its difference
     * from the one before it where that comes out smaller. Since two
     * versions of a file usually share nearly all their bytes, this is what
     * keeps a clone from carrying every version at full size. A run of such
     * differences is broken every so often by a version written in full, so
     * that rebuilding any one of them never means following a long chain.
     *
     * @param array $object_names object names to write
     * @param bool $thin whether the client will accept differences built
     *      against content it already holds and completes the pack itself
     * @return \Generator yields the packfile's bytes in order
     */
    private function packChunks($object_names, $thin = false)
    {
        $ordered = $this->orderForPack($object_names);
        $checksum = hash_init("sha1");
        $header = "PACK" . pack("N", 2) . pack("N", count($ordered));
        hash_update($checksum, $header);
        yield $header;
        $previous = null;
        $chain = 0;
        $sending = array_flip($ordered);
        foreach ($ordered as $sha) {
            /* An object already stored in a packfile is copied as it is
               stored, still compressed, when the client will be able to
               read it: whole, or as a delta on an object this pack also
               sends, or, in a thin pack, on one the client holds. A delta
               the packfile gives by position is sent by its base's name,
               since positions in the new pack differ. */
            $entry = $this->repository->storedEntry($sha);
            if ($entry !== null && ($entry["base"] === "" ||
                isset($sending[$entry["base"]]) ||
                ($thin && isset($this->held_objects[$entry["base"]])))) {
                $piece = ($entry["base"] === "") ?
                    $this->packObjectHeader($entry["kind"],
                    $entry["size"]) . $entry["data"] :
                    $this->packObjectHeader(self::TYPE_REF_DELTA,
                    $entry["size"]) . hex2bin($entry["base"]) .
                    $entry["data"];
                $entry = null;
                hash_update($checksum, $piece);
                yield $piece;
                $previous = null;
                $chain = 0;
                if (\Fiber::getCurrent() !== null) {
                    \Fiber::suspend();
                }
                continue;
            }
            $object = $this->repository->readObject($sha);
            if ($object === null) {
                throw new \Exception("git_object_unreadable " . $sha);
            }
            $group = $this->object_groups[$sha] ?? "";
            $delta = null;
            $base_sha = "";
            $small_enough = (strlen($object["body"]) <=
                self::MAX_DELTA_BODY);
            /* A delta's object takes the type of the object it is built
               on, so tags and commits, which share the group "", are only
               built on their own kind. */
            if ($previous !== null && $previous["group"] === $group &&
                $previous["type"] === $object["type"] &&
                $chain < self::MAX_DELTA_CHAIN && $small_enough &&
                strlen($previous["body"]) <= self::MAX_DELTA_BODY) {
                $delta = GitDelta::create($previous["body"],
                    $object["body"]);
                $base_sha = $previous["sha"];
            }
            if ($delta === null && $thin && $small_enough) {
                $held = $this->heldVersion($group);
                if ($held !== null &&
                    strlen($held["body"]) <= self::MAX_DELTA_BODY) {
                    $delta = GitDelta::create($held["body"],
                        $object["body"]);
                    $base_sha = $held["sha"];
                }
            }
            if ($delta === null) {
                $chain = 0;
                $type_code = self::TYPE_CODES[$object["type"]] ?? 0;
                $piece = $this->packObjectHeader($type_code,
                    strlen($object["body"])) . gzcompress($object["body"]);
            } else {
                $chain++;
                $piece = $this->packObjectHeader(self::TYPE_REF_DELTA,
                    strlen($delta)) . hex2bin($base_sha) .
                    gzcompress($delta);
            }
            hash_update($checksum, $piece);
            yield $piece;
            $previous = ["sha" => $sha, "body" => $object["body"],
                "group" => $group, "type" => $object["type"]];
            /* Building a pack walks the history and works out differences,
               a long stretch with no waiting in it, so the loop is handed
               back between objects. Without this one pull held the whole
               site for minutes. */
            if (\Fiber::getCurrent() !== null) {
                \Fiber::suspend();
            }
        }
        yield hash_final($checksum, true);
    }
    /**
     * heldVersion gives packChunks the client's own copy of a folder or
     * file, looked up by the name it goes by, so a new version can be sent
     * as its difference from that copy rather than whole. It is only used
     * when the client has said it accepts such a pack, as the content built
     * against is not sent alongside.
     *
     * @param string $group what the object is and the name it goes by
     * @return array|null ["sha" => object name of the client's version,
     *      "body" => its content], or null if the client holds no version
     */
    private function heldVersion($group)
    {
        if (!isset($this->held_by_name[$group])) {
            return null;
        }
        $sha = $this->held_by_name[$group];
        $object = $this->repository->readObject($sha);
        if ($object === null) {
            return null;
        }
        return ["sha" => $sha, "body" => $object["body"]];
    }
    /**
     * orderForPack puts the objects packChunks is to pack in an order that
     * sets successive versions of the same file, or the same folder, next
     * to each other.
     * Commits keep the order history was walked in and come first; folders
     * and files follow, gathered by the name they go by. Only neighbors are
     * compared when packing, so this ordering is what decides which
     * differences can be found.
     *
     * @param array $object_names object names to put in order
     * @return array the same object names, ordered for packing
     */
    private function orderForPack($object_names)
    {
        $commits = [];
        $by_group = [];
        foreach ($object_names as $sha) {
            $group = $this->object_groups[$sha] ?? "";
            if ($group === "") {
                $commits[] = $sha;
                continue;
            }
            if (!isset($by_group[$group])) {
                $by_group[$group] = [];
            }
            $by_group[$group][] = $sha;
        }
        ksort($by_group);
        $ordered = $commits;
        foreach ($by_group as $group_names) {
            foreach ($group_names as $sha) {
                $ordered[] = $sha;
            }
        }
        return $ordered;
    }
    /**
     * packObjectHeader builds the header of varying length that packChunks
     * writes before each object it packs. The first byte holds the object's
     * kind and the lowest four bits of its size. Each further byte holds
     * seven more bits of the size, low bits first, with the high bit set on
     * every byte but the last to say more follow.
     *
     * @param int $type_code the number the pack uses for the object's kind,
     *      such as OBJECT_TYPE_COMMIT
     * @param int $size the object's uncompressed size in bytes
     * @return string the encoded header
     */
    private function packObjectHeader($type_code, $size)
    {
        $first = ($type_code << self::FIRST_SIZE_BITS) |
            ($size & ((1 << self::FIRST_SIZE_BITS) - 1));
        $size = $size >> self::FIRST_SIZE_BITS;
        $header = "";
        while ($size > 0) {
            $header .= chr($first | self::CONTINUATION_FLAG);
            $first = $size & ((1 << self::CONTINUATION_SIZE_BITS) - 1);
            $size = $size >> self::CONTINUATION_SIZE_BITS;
        }
        return $header . chr($first);
    }
}