DEV Community

Cover image for What Actually Happens When You Run `git push`
Syed Anzar
Syed Anzar

Posted on

What Actually Happens When You Run `git push`

You type git push origin main, wait half a second, and see a short progress output. Your new commits are live on the remote repository.

Most developers have a hand-wavy mental model of this interaction: "Git zips up my commits and uploads them to GitHub."

In reality, git push runs a tightly coordinated client-server wire protocol. It advertises refs, performs local graph reachability checks, constructs an incomplete "thin pack" using objects it knows the server already holds, streams raw byte frames, verifies cryptographic object hashes on the fly, and fires server-side lifecycle hooks.

Here is what actually happens under the hood between the moment you hit Enter and the moment your terminal returns to the shell prompt.


1. The Two Actors: send-pack and receive-pack

When you trigger git push, Git does not run generic file upload logic. It spawns two specialized processes that communicate over a bidirectional pipe (via SSH, Smart HTTP, or the raw Git protocol):

  1. git-send-pack (Client side): Discovers what the remote holds, computes the delta graph, packages objects, and sends update commands.
  2. git-receive-pack (Server side): Advertises remote branch tips, validates incoming object streams, runs server hooks, and atomically updates reference pointers.

If you connect over SSH (git@github.com:owner/repo.git), your local Git client executes the equivalent of:

ssh git@github.com "git-receive-pack 'owner/repo.git'"
Enter fullscreen mode Exit fullscreen mode

If you connect over HTTPS, Git initiates a Smart HTTP handshake by issuing a GET /info/refs?service=git-receive-pack request.


2. The Wire Format: pkt-line Framing

Every byte exchanged between client and server (before the raw packfile stream) uses Git's pkt-line format.

A pkt-line is a length-prefixed frame:

  • The first 4 hexadecimal characters declare the total byte length of the line (including the 4-byte prefix itself).
  • Maximum line length is 65,520 bytes (or 1,000 bytes in legacy modes).
  • 0000 is a special flush packet (flush-pkt), signaling the end of a transmission section.
000ahello\n   -> 4 bytes length ('000a' = 10 in hex) + 'hello\n' (6 bytes) = 10 bytes
0000          -> Flush packet (signals "I am done with this phase")
Enter fullscreen mode Exit fullscreen mode

This framing allows both sides to stream variable-length strings and capabilities without ambiguous delimiters.


3. Phase 1: Reference Advertisement

Before the client sends a single byte of code, the server speaks first. git-receive-pack lists every single branch, tag, and HEAD reference it currently possesses, along with its full 40-character SHA-1 (or 64-character SHA-256) object ID.

Server -> Client:
009a74730d410fcb6603ace96f1dc55ea6196122532d HEAD\0report-status delete-refs ofs-delta atomic push-options
003e7d1665144a3a975c05f1f43902ddaf084e784dbe refs/heads/feature-auth
003f74730d410fcb6603ace96f1dc55ea6196122532d refs/heads/main
0000
Enter fullscreen mode Exit fullscreen mode

Notice the first line: appended right after the object ID and a NUL byte (\0) is the server's capability declaration. The server informs the client what features it supports:

  • report-status: Can report per-ref success or failure codes.
  • atomic: Can execute all branch updates in a single transaction (all succeed or none do).
  • push-options: Accepts metadata flags (like git push -o ci.skip).
  • ofs-delta: Supports modern, efficient offset-based packfile deltas.

The server ends its advertisement with 0000.


4. Phase 2: Local Graph Analysis and Fast-Forward Checks

Now the client has a complete map of the remote's refs.

Your local Git runs a revision walk (git rev-list) to determine:

  1. What commit is currently at your local refs/heads/main?
  2. What commit was advertised by the server for refs/heads/main?

Git checks if the server's commit is an ancestor of your local commit.

Remote main:   A --- B
                      \
Local main:            C --- D   (Fast-forward: B is ancestor of D -> Allowed)

Remote main:   A --- B --- X
                      \
Local main:            C --- D   (Non-fast-forward: X is not in your tree -> Rejected)
Enter fullscreen mode Exit fullscreen mode

If the remote commit is not an ancestor of your local commit, and you did not pass --force (or --force-with-lease), your client rejects the push immediately. It does not build a packfile. It does not transfer objects. It halts right here with:

! [rejected]        main -> main (fetch first)
error: failed to push some refs to 'github.com:owner/repo.git'
Enter fullscreen mode Exit fullscreen mode

5. Phase 3: Thin Pack Generation

If the update is valid, Git needs to send the missing commits, trees, and blobs to the server. But sending whole object files would waste huge amounts of bandwidth.

Git solves this with Thin Packs.

When your client runs git pack-objects, it builds a compressed packfile containing only the new objects reachable from your local branch tip that the server does not already have.

[Your Local Object Store]
   Base Commit (on server)  <--- Git uses this as a Delta Base!
        |
   Delta 1: Modified lines in index.ts
   Delta 2: New image asset
        |
   [Thin Pack Created: Contains only deltas, NOT the base objects]
Enter fullscreen mode Exit fullscreen mode

Here is the clever optimization: Git will delta-compress your new objects against base objects that exist only on the remote server. The generated packfile is "thin" because it contains deltas pointing to base objects not included in the packfile itself. A standalone Git client cannot unpack a thin pack on its own, but the target server can, because it already has those base objects in its database.

This reduces typical push payload sizes by 80% to 95%.


6. Phase 4: Command Transmission & Packfile Streaming

The client sends its update requests as pkt-lines, specifying:
OLD_SHA NEW_SHA REF_NAME

Client -> Server:
007b74730d410fcb6603ace96f1dc55ea6196122532d 5a3f6be755bbb7deae50065988cbfa1ffa9ab68a refs/heads/main\0report-status ofs-delta
0000
[RAW PACKFILE BINARY STREAM]
Enter fullscreen mode Exit fullscreen mode
  • For updating a branch: OLD_SHA NEW_SHA refs/heads/main
  • For creating a branch: 0000000000000000000000000000000000000000 NEW_SHA refs/heads/new-feature
  • For deleting a branch: OLD_SHA 0000000000000000000000000000000000000000 refs/heads/old-feature

Immediately following the 0000 flush packet, the client streams the raw binary packfile:

  1. 4-byte header: PACK (0x50 0x41 0x43 0x4B)
  2. 4-byte version number: 2 (0x00 0x00 0x00 0x02)
  3. 4-byte object count: total number of objects in the pack
  4. Deflate-compressed object data with delta chains
  5. 20-byte SHA-1 checksum of all preceding pack data

7. Phase 5: Server-Side Indexing and "Thickening"

Once git-receive-pack reads the packfile into a temporary file on disk, it invokes git index-pack --fix-thin.

The server must now "thicken" the pack:

  1. It resolves every delta in the incoming thin pack against its own existing object database.
  2. It appends the missing base objects to the packfile on disk to make it a self-contained, valid .pack archive.
  3. It builds a .idx index file for fast O(log N) binary search lookups of all object offsets.
  4. It runs integrity checks (verifying cryptographic hashes and checking for tree corruption).
Incoming Thin Pack  +  Server's Existing Database  ==>  Thickened Pack (.pack) + Index (.idx)
Enter fullscreen mode Exit fullscreen mode

8. Phase 6: Server Hooks and Reference Locking

With all objects safely stored in the server's .git/objects/ store, the server decides whether to actually update the branch pointers.

The server runs hooks in strict sequence:

[Incoming Update Requests]
            │
            ▼
┌───────────────────────┐
│   pre-receive hook    │  -> Runs ONCE for the entire batch.
└───────────┬───────────┘     Inspects (old_sha, new_sha, refname).
            │ Pass            Exit != 0 aborts ALL updates.
            ▼
┌───────────────────────┐
│     update hook       │  -> Runs ONCE PER REFERENCE.
└───────────┬───────────┘     Enforces per-branch policies (e.g., protected branches).
            │ Pass            Exit != 0 rejects this specific ref.
            ▼
┌───────────────────────┐
│  Atomic Ref Locking   │  -> Validates old_sha still matches on disk.
└───────────┬───────────┘     Updates .git/refs/heads/main to new_sha.
            │ Success
            ▼
┌───────────────────────┐
│   post-receive hook   │  -> Fires webhooks, CI builds, notifications.
└───────────────────────┘
Enter fullscreen mode Exit fullscreen mode

If any hook fails or if another developer pushed to the same ref milliseconds earlier (causing a ref lock collision), the reference is rejected.


9. Phase 7: Status Reporting

If the client requested report-status during capability negotiation (which modern Git always does), git-receive-pack sends back a structured report:

Server -> Client:
000eunpack ok\n
0018ok refs/heads/main\n
0000
Enter fullscreen mode Exit fullscreen mode

If something went wrong, the server returns an ng (not good) line:

Server -> Client:
000eunpack ok\n
002ang refs/heads/main non-fast-forward\n
0000
Enter fullscreen mode Exit fullscreen mode

The client receives these packets, prints the summary to your terminal, and exits with code 0 (or non-zero on error):

To github.com:owner/repo.git
   74730d4..5a3f6be  main -> main
Enter fullscreen mode Exit fullscreen mode

Practical Developer Takeaways

Understanding this protocol directly changes how you use Git:

1. Why --force-with-lease is safe, but --force is reckless

When you run git push --force, the client tells the server: "Set refs/heads/main to NEW_SHA, ignoring whatever was there before."

When you run git push --force-with-lease, the client inspects its local remote-tracking branch (origin/main) to find the SHA it last saw (OLD_SHA). It sends:
OLD_SHA NEW_SHA refs/heads/main

If a teammate pushed a commit while you were working, the server's actual main pointer will no longer match OLD_SHA. The server rejects your push with a lease conflict, preventing you from silently overwriting your teammate's work.

2. Multi-branch updates should always use --atomic

If you push three branches at once (git push origin feature-1 feature-2 feature-3) and feature-2 is rejected due to a conflict, standard Git will still update feature-1 and feature-3.

Adding --atomic tells the server to wrap all ref updates in a single transaction:

git push --atomic origin feature-1 feature-2 feature-3
Enter fullscreen mode Exit fullscreen mode

If one ref fails, none are updated.

3. Server push options bypass CI cleanly

Because Git 2.10+ supports the push-options wire capability, you can pass custom flags directly to remote server hooks without embedding weird strings into your commit messages:

git push -o ci.skip origin main
Enter fullscreen mode Exit fullscreen mode

Summary Mental Model

When you run git push:

  1. Connect: Spawns git-receive-pack remotely.
  2. Advertise: Server sends its current refs and capabilities via pkt-line.
  3. Analyze: Local client checks ancestors and halts immediately if non-fast-forward.
  4. Thin Pack: Client builds a delta pack referencing objects already on the server.
  5. Stream: Transmits update command lines followed by raw pack bytes.
  6. Thicken & Verify: Server resolves delta bases, creates .idx, and checks hash integrity.
  7. Hooks & Lock: Server executes pre-receive and updates ref pointers.
  8. Report: Server confirms unpack ok and ok <ref>.

The next time you push code, you know it is not just a file upload: it is an optimized, distributed delta-synchronization handshake.

Top comments (0)