Skip to main content

How gitGost Works

This page explains the technical implementation of gitGost, including the Git Smart HTTP protocol, metadata stripping process, and PR creation workflow.

Architecture Overview

gitGost acts as a transparent proxy between your Git client and GitHub, intercepting and rewriting commit metadata before creating pull requests through a bot account.

Git Smart HTTP Protocol

gitGost implements the Git Smart HTTP protocol, which GitHub uses for push/pull operations over HTTPS.

Protocol Endpoints

gitGost exposes four endpoints that mirror GitHub’s Smart HTTP protocol:
// GET /v1/gh/{owner}/{repo}/info/refs?service=git-upload-pack
// Used by git clone and git fetch
func UploadPackDiscoveryHandler(c *gin.Context) {
    owner := c.Param("owner")
    repo := c.Param("repo")
    
    githubURL := fmt.Sprintf("https://github.com/%s/%s.git/info/refs?service=git-upload-pack", owner, repo)
    // Proxy to GitHub...
}
Source: internal/http/handlers.go

Pkt-Line Protocol

Git uses a framing protocol called pkt-line to transmit data. Each line is prefixed with a 4-byte hexadecimal length:
0032git-upload-pack /project.git\0host=example.com\0
0000
The length includes the 4-byte prefix itself. 0000 is a special flush packet. gitGost implements pkt-line parsing:
func ParsePktLine(r io.Reader) ([]byte, error) {
    lenBuf := make([]byte, 4)
    _, err := io.ReadFull(r, lenBuf)
    if err != nil {
        return nil, err
    }
    
    lenStr := string(lenBuf)
    if lenStr == "0000" {
        return nil, nil // flush packet
    }
    
    length, err := strconv.ParseInt(lenStr, 16, 32)
    if err != nil {
        return nil, fmt.Errorf("invalid pkt-line length: %s", lenStr)
    }
    
    // Read data (length - 4 bytes for the length prefix)
    dataLen := int(length) - 4
    data := make([]byte, dataLen)
    _, err = io.ReadFull(r, data)
    return data, err
}
Source: internal/git/receive.go:20-50

Push Processing Flow

When you run git push gost my-branch:main, here’s what happens:
1

Client sends packfile

Your Git client sends a packfile containing:
  • Ref update commands (old SHA → new SHA)
  • Commit objects
  • Tree objects
  • Blob objects (file contents)
  • Optional push-options (e.g., pr-hash=a3f8c1d2)
func ExtractPackfile(body []byte) ([]byte, *RefUpdate, string, error) {
    reader := bytes.NewReader(body)
    var refUpdate *RefUpdate
    var prHash string
    
    // Parse ref update commands
    for {
        line, err := ParsePktLine(reader)
        if line == nil { break } // flush packet
        
        // Parse push-option: pr-hash=<value>
        if strings.HasPrefix(lineStr, "push-option=pr-hash=") {
            prHash = strings.TrimPrefix(lineStr, "push-option=pr-hash=")
        }
        
        // Parse command: old-sha new-sha ref
        parts := strings.Fields(lineStr)
        if len(parts) >= 3 {
            refUpdate = &RefUpdate{
                OldSHA: parts[0],
                NewSHA: parts[1],
                Ref:    parts[2],
            }
        }
    }
    
    // Read remaining bytes as packfile
    packfile, err := io.ReadAll(reader)
    return packfile, refUpdate, prHash, nil
}
Source: internal/git/receive.go:61-140
2

gitGost clones the target repository

To have the base objects for unpacking your commits, gitGost clones the target repository:
func ReceivePack(tempDir string, body []byte, owner string, repo string) (string, string, string, error) {
    token := os.Getenv("GITHUB_TOKEN")
    repoURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, repo)
    
    _, err := git.PlainClone(tempDir, false, &git.CloneOptions{
        URL: repoURL,
        Auth: &http.BasicAuth{
            Username: "x-access-token",
            Password: token,
        },
    })
    // If clone fails (private repo), initialize empty
    if err != nil {
        git.PlainInit(tempDir, false)
    }
}
Source: internal/git/receive.go:143-167
3

Unpack the packfile

gitGost uses Git’s index-pack command to unpack your commits:
// Save packfile to disk
packfilePath := tempDir + "/pack.tmp"
os.WriteFile(packfilePath, packfile, 0644)

// Unpack using git index-pack (more robust than unpack-objects)
cmd := exec.Command("git", "index-pack", "-v", "--stdin", "--fix-thin")
cmd.Dir = tempDir + "/.git/objects/pack"
cmd.Stdin = bytes.NewReader(packfile)

output, err := cmd.CombinedOutput()
if err != nil {
    // Fallback to unpack-objects if index-pack fails
    cmd = exec.Command("git", "unpack-objects", "-r")
    cmd.Dir = tempDir
    cmd.Stdin = bytes.NewReader(packfile)
    output, err = cmd.CombinedOutput()
}
Source: internal/git/receive.go:204-225
4

Update HEAD to new commit

// Open repository
r, err := git.PlainOpen(tempDir)

// Update HEAD to the new commit SHA from the ref update
newHash := plumbing.NewHash(refUpdate.NewSHA)
ref := plumbing.NewHashReference(plumbing.HEAD, newHash)
r.Storer.SetReference(ref)
Source: internal/git/receive.go:228-240

Metadata Stripping

This is the core of gitGost’s anonymization. All commits in your push are rewritten to replace identifying metadata.

Commit Rewriting

gitGost recursively rewrites commits, preserving the tree (file contents) but replacing author and committer information:
func AnonymizeCommits(r *git.Repository, targetSHA string) (string, error) {
    targetHash := plumbing.NewHash(targetSHA)
    targetCommit, err := r.CommitObject(targetHash)
    
    // Get all commits that already exist in origin/main (base commits)
    baseCommits := make(map[plumbing.Hash]bool)
    originMain, err := r.Reference(plumbing.NewRemoteReferenceName("origin", "main"), true)
    if err == nil {
        // Mark all base commits (don't rewrite these)
        iter, _ := r.Log(&git.LogOptions{From: originMain.Hash()})
        iter.ForEach(func(c *object.Commit) error {
            baseCommits[c.Hash] = true
            return nil
        })
    }
    
    // Rewrite only new commits
    commitMap := make(map[plumbing.Hash]plumbing.Hash)
    newHash, err := rewriteCommit(r, targetCommit, commitMap, baseCommits)
    
    // Update HEAD to the anonymized commit
    ref := plumbing.NewHashReference(plumbing.HEAD, newHash)
    r.Storer.SetReference(ref)
    
    return newHash.String(), nil
}
Source: internal/git/receive.go:262-304

Recursive Rewriting

func rewriteCommit(r *git.Repository, commit *object.Commit, commitMap map[plumbing.Hash]plumbing.Hash, baseCommits map[plumbing.Hash]bool) (plumbing.Hash, error) {
    // Already rewritten? Return cached result
    if newHash, exists := commitMap[commit.Hash]; exists {
        return newHash, nil
    }
    
    // Base commit (from origin/main)? Don't rewrite
    if baseCommits[commit.Hash] {
        return commit.Hash, nil
    }
    
    // Rewrite parent commits first (depth-first)
    var newParents []plumbing.Hash
    for _, parentHash := range commit.ParentHashes {
        parentCommit, err := r.CommitObject(parentHash)
        if err != nil {
            // Parent doesn't exist (shallow clone?), use original hash
            newParents = append(newParents, parentHash)
            continue
        }
        newParentHash, err := rewriteCommit(r, parentCommit, commitMap, baseCommits)
        newParents = append(newParents, newParentHash)
    }
    
    // Create new commit with anonymized signature
    anonSignature := object.Signature{
        Name:  "@gitgost-anonymous",
        Email: "[email protected]",
        When:  time.Now(), // Current timestamp (obfuscates when you actually worked)
    }
    
    newCommit := &object.Commit{
        Author:       anonSignature,
        Committer:    anonSignature,
        Message:      commit.Message, // Preserve commit message
        TreeHash:     commit.TreeHash, // Preserve file contents
        ParentHashes: newParents,
    }
    
    // Encode and store the new commit
    obj := r.Storer.NewEncodedObject()
    newCommit.Encode(obj)
    newHash, err := r.Storer.SetEncodedObject(obj)
    
    // Cache the mapping
    commitMap[commit.Hash] = newHash
    return newHash, nil
}
Source: internal/git/receive.go:306-368

What Gets Changed

commit a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0
Author: John Doe <[email protected]>
Date:   Mon Mar 5 14:23:45 2026 -0800

    fix: correct spelling in README
    
    Fixed 'recieve' → 'receive' in installation section.

tree 9f8e7d6c5b4a3e2d1c0b9a8f7e6d5c4b3a2e1d0c
parent f0e1d2c3b4a5e6d7c8b9a0f1e2d3c4b5a6e7d8c9
What’s preserved:
  • Commit message (this becomes your PR description)
  • Tree hash (file contents are unchanged)
  • Parent relationships (history structure is preserved)
What’s changed:
  • Author name and email
  • Committer name and email
  • Commit timestamp (replaced with current time)
  • Commit hash (changes due to metadata changes)
The tree hash (file contents) remains identical. Only metadata changes. This ensures your code contribution is exactly what you intended.

Fork Creation and Management

After anonymizing commits, gitGost pushes them to a fork owned by the @gitgost-anonymous bot.

Creating or Reusing a Fork

func ForkRepo(owner, repo string) (string, error) {
    token := os.Getenv("GITHUB_TOKEN")
    
    // Get the bot's username
    userURL := "https://api.github.com/user"
    req, _ := http.NewRequest("GET", userURL, nil)
    req.Header.Set("Authorization", "token "+token)
    resp, _ := http.DefaultClient.Do(req)
    
    var user map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&user)
    forkOwner := user["login"].(string) // e.g., "gitgost-anonymous"
    
    // Check if fork already exists
    forkURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", forkOwner, repo)
    req, _ = http.NewRequest("GET", forkURL, nil)
    req.Header.Set("Authorization", "token "+token)
    resp, _ = http.DefaultClient.Do(req)
    
    if resp.StatusCode == 200 {
        // Fork exists, reuse it
        return forkOwner, nil
    }
    
    // Create new fork
    url := fmt.Sprintf("https://api.github.com/repos/%s/%s/forks", owner, repo)
    req, _ = http.NewRequest("POST", url, nil)
    req.Header.Set("Authorization", "token "+token)
    resp, _ = http.DefaultClient.Do(req)
    
    return forkOwner, nil
}
Source: internal/github/pr.go:431-503

Pushing to the Fork

func PushToGitHub(owner, repo, tempDir, forkOwner, targetBranch string) (string, error) {
    token := os.Getenv("GITHUB_TOKEN")
    
    // Generate unique branch name (or use provided one for updates)
    branch := targetBranch
    if branch == "" {
        timestamp := time.Now().Unix()
        branch = fmt.Sprintf("gitgost-%d", timestamp) // e.g., gitgost-1709654321
    }
    
    r, _ := git.PlainOpen(tempDir)
    
    // Add fork as remote
    forkURL := fmt.Sprintf("https://github.com/%s/%s.git", forkOwner, repo)
    r.CreateRemote(&config.RemoteConfig{
        Name: "fork",
        URLs: []string{forkURL},
    })
    
    // Push to fork (force if updating existing branch)
    refSpec := fmt.Sprintf("HEAD:refs/heads/%s", branch)
    if targetBranch != "" {
        refSpec = "+" + refSpec // Force push for updates
    }
    
    r.Push(&git.PushOptions{
        RemoteName: "fork",
        RefSpecs:   []config.RefSpec{config.RefSpec(refSpec)},
        Auth: &http.BasicAuth{
            Username: "x-access-token",
            Password: token,
        },
        Force: targetBranch != "",
    })
    
    return branch, nil
}
Source: internal/git/push.go:20-80

Fork Cleanup

Due to GitHub’s 40,000 repository limit per account, forks created by @gitgost-anonymous are manually deleted periodically. Your PR remains open, but the fork may be removed.
This is a GitHub platform constraint, not a gitGost limitation. Once your PR is merged or closed, the fork is no longer needed.

Pull Request Creation

The final step is creating a PR from the fork to the original repository.
func CreatePR(owner, repo, branch, forkOwner, commitMessage string) (string, error) {
    token := os.Getenv("GITHUB_TOKEN")
    url := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls", owner, repo)
    
    // Use commit message as PR description
    prBody := fmt.Sprintf(
        "%s\n\n---\n\n*This is an anonymous contribution made via [gitGost](https://gitgost.leapcell.app).*\n\n*The original author's identity has been anonymized to protect their privacy.*",
        commitMessage,
    )
    
    data := map[string]interface{}{
        "title": "Anonymous contribution via gitGost",
        "head":  fmt.Sprintf("%s:%s", forkOwner, branch), // e.g., "gitgost-anonymous:gitgost-1709654321"
        "base":  "main",
        "body":  prBody,
    }
    
    jsonData, _ := json.Marshal(data)
    req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
    req.Header.Set("Authorization", "token "+token)
    req.Header.Set("Content-Type", "application/json")
    
    resp, _ := http.DefaultClient.Do(req)
    
    var result map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&result)
    prURL := result["html_url"].(string)
    
    return prURL, nil
}
Source: internal/github/pr.go:550-605

PR Structure

The created PR looks like this: Title: “Anonymous contribution via gitGost” Description:
fix: correct spelling in README

Fixed 'recieve' → 'receive' in installation section.

---

*This is an anonymous contribution made via [gitGost](https://gitgost.leapcell.app).*

*The original author's identity has been anonymized to protect their privacy.*
Author: @gitgost-anonymous Branch: gitgost-anonymous:gitgost-1709654321owner:main

Updating Existing PRs

gitGost supports updating PRs without creating duplicates using the pr-hash push-option.

PR Hash Generation

The PR hash is deterministic—it’s generated from the owner/repo/branch combination:
func GeneratePRHash(owner, repo, branch string) string {
    input := fmt.Sprintf("%s/%s/%s", owner, repo, branch)
    sum := sha256.Sum256([]byte(input))
    return hex.EncodeToString(sum[:])[:8] // First 8 characters
}
Source: internal/github/pr.go:658-662

Update Workflow

1

Client sends pr-hash

git push gost my-branch:main -o pr-hash=a3f8c1d2
The -o flag sends a push-option with the PR hash.
2

gitGost looks up existing PR

func GetExistingPR(owner, repo, forkOwner, branchName string) (string, bool, error) {
    token := os.Getenv("GITHUB_TOKEN")
    
    // Check if branch exists in fork
    branchURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/branches/%s", forkOwner, repo, branchName)
    req, _ := http.NewRequest("GET", branchURL, nil)
    req.Header.Set("Authorization", "token "+token)
    resp, _ := httpClient.Do(req)
    
    if resp.StatusCode != http.StatusOK {
        return "", false, nil // Branch doesn't exist
    }
    
    // Search for open PR from this branch
    head := fmt.Sprintf("%s:%s", forkOwner, branchName)
    prListURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls?state=open&head=%s", owner, repo, head)
    req, _ = http.NewRequest("GET", prListURL, nil)
    resp, _ = httpClient.Do(req)
    
    var prs []struct{ HTMLURL string `json:"html_url"` }
    json.NewDecoder(resp.Body).Decode(&prs)
    
    if len(prs) == 0 {
        return "", true, nil // Branch exists but PR closed/merged
    }
    
    return prs[0].HTMLURL, true, nil
}
Source: internal/github/pr.go:666-729
3

Force-push to existing branch

If the PR exists, gitGost force-pushes to the same branch:
// Use the branch name from the pr-hash
branch := fmt.Sprintf("gitgost-%s", receivedPRHash)

// Force-push updates
branch, err = git.PushToGitHub(owner, repo, tempDir, forkOwner, branch)
GitHub automatically updates the PR with the new commits.

Security and Abuse Prevention

gitGost implements multiple layers of protection:

Rate Limiting

func checkRateLimit(ip string) bool {
    now := time.Now()
    rateLimitMu.Lock()
    times := rateLimitStore[ip]
    
    // Keep only timestamps within the 1-hour window
    valid := times[:0]
    for _, t := range times {
        if now.Sub(t) < time.Hour {
            valid = append(valid, t)
        }
    }
    valid = append(valid, now)
    rateLimitStore[ip] = valid
    count := len(valid)
    rateLimitMu.Unlock()
    
    return count > 5 // Max 5 PRs per hour per IP
}
Source: internal/http/handlers.go:733-759

Global Burst Detection

gitGost detects coordinated attacks across multiple IPs:
func recordGlobalBurst(ip string) {
    now := time.Now()
    globalBurstMu.Lock()
    defer globalBurstMu.Unlock()
    
    // Slide 60-second window
    cutoff := now.Add(-60 * time.Second)
    newTimes := globalBurstTimes[:0]
    newIPs := globalBurstIPs[:0]
    for i, t := range globalBurstTimes {
        if t.After(cutoff) {
            newTimes = append(newTimes, t)
            newIPs = append(newIPs, globalBurstIPs[i])
        }
    }
    newTimes = append(newTimes, now)
    newIPs = append(newIPs, ip)
    
    total := len(newTimes)
    distinctIPs := len(seenIPs(newIPs))
    
    // Alert if > 20 pushes or > 10 distinct IPs in 60 seconds
    if total >= 20 || distinctIPs >= 10 {
        notifyAdminGlobalBurst(total, distinctIPs)
    }
}
Source: internal/http/handlers.go:665-704

Panic Mode

Operators can instantly suspend the service if abuse is detected:
func isPanicMode() bool {
    panicMu.Lock()
    defer panicMu.Unlock()
    return panicMode
}

func ReceivePackHandler(c *gin.Context) {
    // Check panic mode first
    if isPanicMode() {
        WriteSidebandLine(&errResp, 2, "remote: SERVICE TEMPORARILY SUSPENDED")
        WriteSidebandLine(&errResp, 2, "remote: The panic button has been activated.")
        return
    }
    // ... rest of handler
}
Source: internal/http/handlers.go:649-656, 142-157

Data Flow Summary

1

git push gost my-branch:main

Your Git client connects to gitgost.leapcell.app and sends a packfile over HTTPS.
2

Parse packfile (pkt-line protocol)

gitGost extracts ref updates, push-options, and the PACK data.
3

Clone target repo from GitHub

Provides base objects needed to unpack your commits.
4

Unpack commits (git index-pack)

Your commit objects are written to the temporary repository.
5

Rewrite commits recursively

All new commits are rewritten with anonymized author/committer/timestamp.
6

Create/reuse fork

Fork is created under @gitgost-anonymous (or reused if exists).
7

Push to fork

Anonymized commits are pushed to a unique branch in the fork.
8

Create PR

Pull request is opened from gitgost-anonymous:gitgost-NNNNNNNNNN to owner:main.
9

Return PR URL

Git client receives success message with PR URL.

Limitations and Trade-offs

gitGost makes implementation trade-offs for simplicity and performance. Understanding these limitations is critical for threat modeling.

What gitGost Cannot Hide

Network Identity

Your IP address is visible to the gitGost server and GitHub. Use Tor for IP anonymity.

Code Fingerprints

Coding style, variable naming, and domain knowledge can reveal identity through stylometry.

Timing Correlation

If you push and the PR appears immediately, observers can correlate timing.

Repository Size

Max 500 MB repositories, 10 MB commits. Not suitable for large contributions.

Trust Assumptions

You must trust:
  1. The gitGost operator (doesn’t add telemetry or log IPs)
  2. Your network provider (use VPN/Tor if concerned)
  3. GitHub (sees your IP during PR creation)
For zero-trust scenarios, self-host your own instance.

Source Code References

All code excerpts in this documentation are from the actual gitGost implementation:
  • Protocol handlers: internal/http/handlers.go
  • Git operations: internal/git/receive.go, internal/git/push.go
  • GitHub API: internal/github/pr.go
  • Threat model: THREAT_MODEL.md
  • Privacy guarantees: Privacy Guarantees.md
View the full source: github.com/livrasand/gitGost

Next Steps

Quickstart

Try gitGost with your first anonymous contribution

Threat Model

Understand what gitGost protects against

Self-Hosting

Run your own gitGost instance

API Reference

Complete API documentation

Transparency matters. All gitGost code is open source and auditable. If you find vulnerabilities, please report them via the security policy.

Build docs developers (and LLMs) love