Skip to content

Clone, Fetch, Push

Pitmaster supports the three fundamental git network operations: clone (initial download), fetch (incremental download), and push (upload). SSH URLs (ssh://..., user@host:path) use the SSH transport; other URLs use smart HTTP, and clone and fetch fall back to dumb HTTP when the smart exchange fails (see Protocols).

All three start network transports, so they throw RuntimeException on a handle opened with ['processes' => false] or Pitmaster::processFreeOptions().

Clone

PHP
use Pitmaster\Pitmaster;

// public static function clone(string $url, string $path, ?int $depth = null, array $options = []): Repository
$repo = Pitmaster::clone('https://github.com/user/repo.git', '/path/to/destination');
$repo = Pitmaster::clone('https://github.com/user/repo.git', '/path/to/shallow', 1);

The new repository always uses SHA-1. If the clone fails and the destination did not exist before, the partly created directory is removed.

Clone flow

  1. Initialize: create a new empty repository at the destination path
  2. Configure remote: save the URL as remote.origin.url and set up the fetch refspec
  3. Discover refs: GET /info/refs?service=git-upload-pack to learn what the server has
  4. Negotiate: send want lines for all remote refs (deduplicated by hash)
  5. Receive pack: download the pack file containing all requested objects
  6. Write pack: save the pack file to .git/objects/pack/
  7. Index pack: generate the .idx file for fast lookups
  8. Set up refs: create remote tracking branches (refs/remotes/origin/*) and tags
  9. Set HEAD: point HEAD to the default branch (from the server's symref=HEAD:... capability, or refs/heads/main when the server sends none), and set branch.<name>.remote and branch.<name>.merge
  10. Checkout: check out that branch, which writes the working tree and the index

Errors during the final checkout are caught and ignored, so a clone can return with an empty or partial working tree (#19).

PHP
$repo = Pitmaster::clone($url, $path);
$head = $repo->head();
echo $head->message;

$branches = $repo->branches();
$files = $repo->status();

Working tree materialization

After receiving the pack, Pitmaster calls $repo->checkout() for the default branch. That writes every file of the HEAD commit's tree to the working directory and builds the index from the tree entries, so status() reports a clean working tree.

Fetch

PHP
// public function fetch(string $remote = 'origin', ?int $depth = null): void
$repo->fetch();              // Fetch from 'origin'
$repo->fetch('upstream');    // Fetch from a named remote
$repo->fetch('origin', 1);   // Shallow fetch

Fetch flow

  1. Read remote URL: from .git/config (remote.<name>.url)
  2. Discover refs: get the current state of all remote refs
  3. Negotiate wants/haves:
    • Want: remote refs matched by the remote.<name>.fetch refspecs (default +refs/heads/*:refs/remotes/<name>/*) whose objects are missing locally (all of them when a depth is given)
    • Have: every local ref tip
  4. Receive pack: download only the new objects
  5. Write pack: save to .git/objects/pack/ and build its index
  6. Update tracking refs: update refs/remotes/<remote>/<branch> for each matched remote branch, and write every advertised tag ref. Tag objects are not requested separately, so a new annotated tag can be written as a ref to an object that was not downloaded (#24)
PHP
// Fetch is incremental: only downloads what's new
$repo->fetch();

// After fetching, merge or rebase to incorporate changes (use the full ref name)
$result = $repo->merge('refs/remotes/origin/main');

resolve() looks names up as refs/heads/<name>, refs/tags/<name>, and the name itself, so the short form origin/main is not found.

Pitmaster requests thin packs, which it cannot index yet (#16). A server that answers an incremental fetch with a thin pack makes fetch() throw PackParseException.

Already up to date

If every object the remote refs point to already exists locally, no pack is requested and fetch() returns after ref discovery. In that case the tracking refs are not updated either, even if the remote branches moved to commits you already have (#23).

Push

PHP
$repo->push();                        // Push current branch to 'origin'
$repo->push('origin', 'main');        // Push a specific branch
$repo->push('upstream', 'feature');   // Push to a named remote

Push flow

  1. Read remote URL: from .git/config
  2. Determine what to push: resolve the local branch to its commit hash
  3. Discover remote refs: get the current state of the remote branch (to compute the old hash)
  4. Check fast-forward: if the remote branch is not an ancestor of the local branch, throw RuntimeException("Push rejected: non-fast-forward update to <branch>") before sending anything
  5. Send ref update: <old-hash> <new-hash> refs/heads/<branch>
  6. Send pack data: pack containing the objects the remote needs
  7. Check result: server responds with success/failure per ref

The pre-push hook runs before the update is sent, unless hooks are disabled.

PHP
// Push the current branch
$repo->push();

// Push a specific branch to a specific remote
$repo->push('origin', 'feature/login');

Push requirements

  • The current branch must exist locally
  • A remote must be configured with a URL
  • The remote must accept the push (authentication, permissions, non-fast-forward policies)

Force push

Pitmaster sends the old hash from the remote's live ref advertisement, and push() refuses non-fast-forward updates locally. To overwrite a remote branch, use force-with-lease:

PHP
// Lease defaults to refs/remotes/<remote>/<branch>; pass an ObjectId to set it explicitly
$repo->pushForceWithLease('origin', 'main');
$repo->pushForceWithLease('origin', 'main', $repo->resolve('refs/remotes/origin/main'));

// Update several branches in one atomic request
$repo->pushAtomic('origin', ['main', 'release']);

// Make the remote mirror local refs (also deletes remote refs that no longer exist locally)
$repo->pushMirror('origin');

UploadPackClient (fetch internals)

PHP
use Pitmaster\Protocol\UploadPackClient;
use Pitmaster\Protocol\SmartHttpClient;

$http = new SmartHttpClient();
$uploadPack = new UploadPackClient($http);

// Fetch with explicit wants and haves
$packData = $uploadPack->fetch($url, $wants, $haves);

The want/have negotiation builds a pkt-line request:

Code
want <hash1> <capabilities>\n
want <hash2>\n
deepen <depth>\n        (only when a depth is given)
0000
have <hash3>\n
have <hash4>\n
done\n

The server responds with acknowledgments and then the pack data (possibly side-band wrapped).

ReceivePackClient (push internals)

PHP
use Pitmaster\Protocol\ReceivePackClient;
use Pitmaster\Protocol\SmartHttpClient;

$http = new SmartHttpClient();
$receivePack = new ReceivePackClient($http);

$receivePack->push($url, [
    [
        'old' => $oldId,      // Current remote hash (or zero hash for new branch)
        'new' => $localId,    // Local commit hash to push
        'ref' => 'refs/heads/main',
    ],
], $packData);

The push request format:

Code
<old-hash> <new-hash> refs/heads/main\0<capabilities>\n
0000
PACK<pack data>

Error handling

PHP
use Pitmaster\Exceptions\ProtocolException;

try {
    $repo->fetch();
} catch (ProtocolException $e) {
    // Network unreachable, auth failed, server error, etc.
    echo $e->getMessage();
}

try {
    $repo->push();
} catch (\RuntimeException $e) {
    // Remote not configured, not on a branch, branch not found, non-fast-forward
    echo $e->getMessage();
}

fetch() can also throw a plain RuntimeException (unknown remote, process-free handle) or PackParseException (a pack that cannot be indexed). ProtocolException and PackParseException both extend \RuntimeException, so catching \RuntimeException covers all of them.

Remote configuration

Remotes are stored in .git/config:

Ini
[remote "origin"]
    url = https://github.com/user/repo.git
    fetch = +refs/heads/*:refs/remotes/origin/*

Access via the config API:

PHP
$config = $repo->config();
$url = $config->get('remote.origin.url');
$config->set('remote.upstream.url', 'https://github.com/other/repo.git');
$config->set('remote.upstream.fetch', '+refs/heads/*:refs/remotes/upstream/*');

// set() changes the in-memory config only; write it to persist the change
$config->writeToFile($repo->commonGitDir() . '/config');

Pack data for push

Currently, Pitmaster collects the objects reachable from the commits being pushed that are not reachable from the remote's current refs, and writes them as base objects (no delta compression) into a pack. This is functionally correct but produces larger packs than git push. Delta compression in pack writing is a planned optimization.