Skip to main content
Every time you deploy on Upsun, you use a Git server. You run git push, and something on the other end receives your code and turns it into a running application. Strip away the dashboards and the CLI, and that push is the most basic API we offer. A Git server sounds like a big thing to operate. It isn’t. You can write one that serves real clones and pushes in about as long as it takes to read this post. And once you have, one feature that sounds trivial turns out to be the interesting part: showing different branches to different users. That last part deserves a second look. When you git clone a repository from GitHub, you get every branch. You don’t pick a subset. The client asks for the repository and the server hands over its full set of refs. How would a server ever hide a branch from someone? The answer lives in a corner of Git most people never open.

A Git server is an SSH server that runs two commands

When you clone over SSH, Git opens an SSH connection and runs a single command on the far side. The URL you typed is really an instruction to execute a program on the server:
$ git clone ssh://git@example.com/myrepo.git

# under the hood, Git runs this over SSH:
$ ssh git@example.com "git-upload-pack '/myrepo.git'"
Two commands matter. git-upload-pack serves fetches and clones. git-receive-pack serves pushes. The names read from the server’s point of view, which is why they feel inverted the first time you meet them. When you clone, the server uploads objects to you, so it runs upload-pack. When you push, the server receives objects from you, so it runs receive-pack. SSH is only the transport. On top of it, the client and server speak a protocol Git defines. The server advertises its refs, the branches and tags it holds and the commit each one points at. The client replies with the object IDs it wants. The server works out which objects the client is missing and streams them back as a packfile, a compressed bundle of Git objects. One step in that exchange is where branch permissions live. The server advertises its refs first, and the client can only ask for what it has been shown. Control the advertisement, and you control what a user can see and fetch. That is the whole trick.

The pieces

Two Python libraries do the heavy lifting. Dulwich is a pure-Python implementation of Git that already speaks the protocol on the server side. Paramiko is a pure-Python SSH implementation that can act as a server. Your job is to connect the two and slip your own authorization in between.
$ pip install dulwich paramiko
Start with authentication, and keep it to a bare minimum: the username. A real server maps an SSH public key to a user account. Here you take whatever username the client connects as and trust it, so the code stays about permissions rather than credentials.
This server authenticates nobody. It trusts the username the client claims and accepts a connection with no key and no password. That is fine for reading the code and running it on your laptop, and completely unfit for anything reachable by other people. Wire in public-key authentication before this touches a network you don’t control.
The SSH side is a Paramiko ServerInterface. It records the username, accepts the session, and captures the command Git asks to run:
import shlex
import socket
import threading

import paramiko
from dulwich.repo import Repo
from dulwich.server import (
    FileSystemBackend,
    ReceivePackHandler,
    UploadPackHandler,
    serve_command,
)

host_key = paramiko.RSAKey.generate(2048)


class GitServer(paramiko.ServerInterface):
    def __init__(self):
        self.username = None
        self.command = None
        self.exec_event = threading.Event()

    def get_allowed_auths(self, username):
        return "none"

    def check_auth_none(self, username):
        self.username = username
        return paramiko.AUTH_SUCCESSFUL

    def check_channel_request(self, kind, chanid):
        if kind == "session":
            return paramiko.OPEN_SUCCEEDED
        return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED

    def check_channel_exec_request(self, channel, command):
        self.command = command.decode()
        self.exec_event.set()
        return True
When Git connects, it opens a channel and asks to execute git-upload-pack or git-receive-pack. You parse that command, pick the matching Dulwich handler, and hand the channel’s input and output to Dulwich’s serve_command, which runs the whole protocol conversation:
HANDLERS = {
    "git-upload-pack": UploadPackHandler,
    "git-receive-pack": ReceivePackHandler,
}

REPO_ROOT = "/srv/git/"


class ChannelWriter:
    def __init__(self, channel):
        self.channel = channel

    def write(self, data):
        self.channel.sendall(data)

    def flush(self):
        pass


def handle(client):
    transport = paramiko.Transport(client)
    transport.add_server_key(host_key)
    server = GitServer()
    transport.start_server(server=server)
    channel = transport.accept(20)
    if channel is None:
        return
    server.exec_event.wait(20)

    argv = shlex.split(server.command)
    handler_cls = HANDLERS.get(argv[0])
    if handler_cls is None:
        channel.close()
        return

    argv[1] = argv[1].lstrip("/")
    backend = FileSystemBackend(REPO_ROOT)
    serve_command(
        handler_cls,
        argv,
        backend=backend,
        inf=channel.makefile("rb"),
        outf=ChannelWriter(channel),
    )
    channel.send_exit_status(0)
    channel.close()
    transport.close()


def main():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(("0.0.0.0", 2222))
    sock.listen()
    while True:
        client, _ = sock.accept()
        threading.Thread(target=handle, args=(client,), daemon=True).start()


if __name__ == "__main__":
    main()
Point REPO_ROOT at a directory of bare repositories and run it. That is a working Git server. You can clone from it, push to it, and it behaves like any other. Every user sees every branch, because FileSystemBackend advertises whatever refs the repository holds. That is exactly the behavior you now want to change.

The feature: per-branch permissions

To hide a branch from a user, change what counts as “the refs” for that user. Dulwich reads a repository through a Repo object, and the ref advertisement flows through its get_refs method. Subclass Repo, override that one method, and filter the result by the connected user:
PERMISSIONS = {
    "alice": {"refs/heads/main", "refs/heads/dev", "refs/heads/secret"},
    "bob": {"refs/heads/main"},
}


def is_allowed(username, ref):
    return ref.decode() in PERMISSIONS.get(username, set())


class PermissionedRepo(Repo):
    username = None

    def get_refs(self):
        refs = super().get_refs()
        visible = {
            ref: sha
            for ref, sha in refs.items()
            if ref != b"HEAD" and is_allowed(self.username, ref)
        }
        head = refs.get(b"HEAD")
        if head in visible.values():
            visible[b"HEAD"] = head
        return visible
A small backend hands Dulwich this repo class instead of the default one, carrying the username through:
class PermissionedBackend(FileSystemBackend):
    def __init__(self, root, username):
        super().__init__(root)
        self.username = username

    def open_repository(self, path):
        repo = PermissionedRepo(self.root + path)
        repo.username = self.username
        return repo
Swap the backend line in handle to build a PermissionedBackend(REPO_ROOT, server.username), and the filtering is in force. The HEAD handling earns its two extra lines. HEAD tells the client which branch is the default, and a clone needs it. You drop it during the filter, then add it back only when it points at a branch the user is allowed to see. A user with no access to the default branch gets no HEAD, which is the right outcome.

Trying it out

The server hands out bare repositories, the kind without a working tree. You can turn any repository you already have into one with git clone --bare, which copies its full set of branches:
$ git clone --bare /path/to/some/repo /srv/git/myrepo.git
Cloning into bare repository '/srv/git/myrepo.git'...
done.

$ git -C /srv/git/myrepo.git branch
  dev
* main
  secret

$ python gitserver.py &
On the client side, skip host-key prompts so the demo stays quiet, then ask each user what they can see:
$ export GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'

$ git ls-remote ssh://alice@localhost:2222/myrepo.git
6a86220cc92fe113ead0a10056cf1da5a3a1fb4e	HEAD
26ce3be07260bac0cd3f976bf4c10cf7851743a0	refs/heads/dev
6a86220cc92fe113ead0a10056cf1da5a3a1fb4e	refs/heads/main
629d16e9a78f0c46a6695e11b4dc2ed50de3aa4d	refs/heads/secret

$ git ls-remote ssh://bob@localhost:2222/myrepo.git
6a86220cc92fe113ead0a10056cf1da5a3a1fb4e	HEAD
6a86220cc92fe113ead0a10056cf1da5a3a1fb4e	refs/heads/main

$ git ls-remote ssh://carol@localhost:2222/myrepo.git
Alice is allowed all three branches, so she sees all three. Bob is allowed only main, so dev and secret never reach him. Carol is in nobody’s permission list, so the repository looks empty to her. A clone as Bob pulls exactly one branch:
$ git clone ssh://bob@localhost:2222/myrepo.git
$ git -C myrepo branch -r
  origin/HEAD -> origin/main
  origin/main
This is not only about what gets advertised. If Bob learns the commit ID of the secret branch some other way and asks for it directly, the server refuses the request. Dulwich checks every requested object ID against the refs it advertised to that user, so a branch Bob can’t see is a branch Bob can’t fetch.

Where this stops being a toy

The core idea scales further than the code does. A production Git server built on this shape still needs a few things this one skips.
  • Real authentication. Map SSH public keys to user accounts, and reject unknown keys.
  • Write permissions. The same idea applies to git-receive-pack. Check the user against the refs they are trying to update before accepting a push, and reject the ones they aren’t allowed to change.
  • Reachability rules. This server advertises no capability that lets clients request arbitrary commits by ID, which keeps hidden history hidden. A larger server has to stay deliberate about that.
  • Auditing, quotas, and the hooks that run your actual deploy.
The shape holds underneath all of it. A Git server is an SSH server that runs git-upload-pack and git-receive-pack, plus whatever policy you insert at the ref advertisement. This isn’t a toy stack, either. Upsun runs Dulwich in production. The Git server that receives your git push and turns it into a deployment is built on the same library you used here, wired to real accounts, real infrastructure, and the policy checks this version leaves out. When you push code and only see the environments you’re allowed to see, that’s the ref advertisement doing its job at scale. The version you built is the version that’s running.
Last modified on July 2, 2026