Fix Remote Helper Dry-Run Push Issue · Entire

The remote helper fails to dry run pushes as per Linear ticket ENT-1199:

Root cause

The bug is in git-remote-entire (the entireio/cli repo), not the server. handlePush in internal/remotehelper/githelper/push.go unconditionally POSTs to /git-receive-pack even when there is nothing to send — which is exactly what happens on every --dry-run push.

The full chain:

  1. git push --dry-run origin :cli-test drives the helper with option dry-run true, which the helper forwards as --dry-run to git send-pack --stateless-rpc --helper-status --stdin (options.go:54-56).
  2. In stateless-rpc mode, upstream send-pack guards the entire request emission with !args->dry_run — on a dry run it writes no receive-pack request at all. I reproduced this on git 2.54.0 by feeding send-pack the exact bytes the helper feeds it: its stdout is just an outer flush packet followed immediately by the helper-status line — 0000ok refs/heads/cli-test\n. A real (non-dry-run) deletion, by contrast, emits a proper command pktline first. This holds for any dry-run push, not just deletions — I verified a branch-update dry run produces the same empty request.
  3. gitproto.ReadSendPackRequest consumes that flush and returns an empty body; AppendAgentToReceivePackRequest passes empty input through unchanged; handlePush then POSTs the zero-byte body (push.go:133) and wraps the failure as the observed posting receive-pack: server error (HTTP 400): empty request body.
  4. The server (server/githttp/receive_pack.go:400 in this repo) deliberately rejects zero-byte bodies with 400 — a documented choice so that helper retries after an already-landed push don't page or trip replica failover. It does accept a flush-only body gracefully, but the helper sends literally zero bytes, since the flush belonged to send-pack's outer framing.
  5. Upstream remote-curl.c (which handlePush mirrors) handles this case explicitly: its rpc_service loop reads the first packet from send-pack and breaks without ever calling post_rpc when it's a flush. That's the behavior the helper failed to copy. (GitHub "working" in the repro is over SSH, so it never hits this path at all — but C git over HTTPS also works, thanks to that skip.)

Where the fix belongs

In entireio/cli push.go: when requestBody is empty after ReadSendPackRequest, skip the POST (and close respCh so the feeder goroutine exits). One trap for whoever implements it: in this case the helper must also skip the trailing-flush drain at push.go:153-159 — the single 0000 already consumed is the only flush send-pack emits on a dry run (the trailing packet_flush is guarded by cmds_sent), so the next bytes on stdout are the helper-status lines themselves. Blindly reading 4 more bytes would consume ok r and fail with "expected trailing flush".

Sketch a fix

In handlePush, branch after ReadSendPackRequest and move the POST and the trailing-flush drain into the non-empty arm:

spOutReader := bufio.NewReader(spOut)
var requestBody bytes.Buffer
if err := gitproto.ReadSendPackRequest(spOutReader, &requestBody); err != nil {
    // ... unchanged ...
}

// A dry-run push — or any batch where every update was rejected
// client-side — produces no receive-pack request: send-pack guards
// the entire request emission with !args->dry_run / cmds_sent, so
// the outer flush ReadSendPackRequest just consumed is the ONLY
// flush on its stdout; the next bytes are the helper-status lines
// themselves. Mirror remote-curl.c:rpc_service, which breaks
// without calling post_rpc when the first packet is a flush: skip
// the POST and skip the trailing-flush drain. Draining 4 more
// bytes here would eat "ok r" and die on "expected trailing
// flush"; POSTing the empty body trips the server's deliberate
// 400 on zero-byte receive-pack requests.
if requestBody.Len() == 0 {
    close(respCh) // feeder sees closed channel, reports nil, closes spIn
} else {
    amendedBody, err := gitproto.AppendAgentToReceivePackRequest(requestBody.Bytes(), Agent)
    if err != nil {
        close(respCh)
        killAndWaitSendPack(sp, "after amending receive-pack agent failed")
        return fmt.Errorf("amending receive-pack agent: %w", err)
    }
    resp, err := t.ServiceRPC(ctx, serviceReceivePack, bytes.NewReader(amendedBody))
    if err != nil {
        close(respCh)
        killAndWaitSendPack(sp, "after posting receive-pack failed")
        return fmt.Errorf("posting receive-pack: %w", err)
    }
    respCh <- resp
    close(respCh)

// Trailing flush exists only when a request was sent (send-pack.c:759
    // is guarded by cmds_sent), so the drain lives inside this branch.
    flushBuf := make([]byte, 4)
    if _, err := io.ReadFull(spOutReader, flushBuf); err != nil {
        return fmt.Errorf("reading send-pack trailing flush: %w", err)
    }
    if string(flushBuf) != "0000" {
        return fmt.Errorf("expected trailing flush from send-pack, got %q", flushBuf)
    }
}

helperStatus, err := io.ReadAll(spOutReader)
// ... rest unchanged: relay status + blank line, sp.Wait(), feedErr ...

Why the empty check is the right predicate (not opts.dryRun)

Keying off body emptiness rather than the dry-run option matches remote-curl's behavior and also covers the second producer of empty requests: a batch where send-pack rejects every update client-side (non-fast-forward without force) — cmds_sent stays 0, nothing is emitted, and the helper-status error lines follow directly. Keying off opts.dryRun would fix the ticket but leave that path broken, and would break if a future send-pack ever emitted something on dry runs.

Tests

  1. New invariant test (invariants_test.go), following the existing shell-stub pattern: stub git that emits only 0000 then ok refs/heads/cli-test — no second flush — and exits 0. Drive handlePush with &Options{dryRun: true} (the option is cosmetic here; the stub defines the wire shape) and a fakeTransport. Assert: err == nil, len(ft.rpcCalls) == 0 (no POST), and stdout is the status line plus the terminating blank line. A sibling case with error refs/... non-fast-forward + exit 1 pins the all-rejected batch.
  2. Fix the two existing stubs that emit 0000 (empty request) + 0000 (trailing flush): after this change that shape reads the second 0000 as part of helper-status and relays it to git. Since TestInvariant_HelperStatusSurfacedOnSendPackError models a server-side rejection (where real send-pack does send a request), the faithful fix is to make those stubs emit a minimal non-empty wrapped request before the trailing flush; the lazier alternative is dropping their second 0000. I'd do the former so the stubs keep matching real wire shapes.
  3. Optionally, an end-to-end check at the server integration layer (git push --dry-run for both a ref update and a deletion against a test server) — that's where the original repro lived, and it confirms git prints the usual (dry run) summary with helper exit 0.

Good Tests

Quality Good Bad
Minimal One thing. "and" in name? Split it. test('validates email and domain and whitespace')
Clear Name describes behavior test('test1')
Shows intent Demonstrates desired API Obscures what code should do

Final Rule

Production code → test exists and failed first
Otherwise → not TDD

No exceptions without your human partner's permission.