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:
git push --dry-run origin :cli-testdrives the helper with option dry-run true, which the helper forwards as--dry-runtogit send-pack --stateless-rpc --helper-status --stdin(options.go:54-56).- 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. gitproto.ReadSendPackRequestconsumes that flush and returns an empty body;AppendAgentToReceivePackRequestpasses empty input through unchanged;handlePushthen 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.- 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.
- 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
- New invariant test (
invariants_test.go), following the existing shell-stub pattern: stubgitthat emits only0000thenok refs/heads/cli-test— no second flush — and exits 0. DrivehandlePushwith&Options{dryRun: true}(the option is cosmetic here; the stub defines the wire shape) and afakeTransport. Assert:err == nil,len(ft.rpcCalls) == 0(no POST), and stdout is the status line plus the terminating blank line. A sibling case witherror refs/... non-fast-forward+exit 1pins the all-rejected batch. - Fix the two existing stubs that emit
0000(empty request) +0000(trailing flush): after this change that shape reads the second0000as part of helper-status and relays it to git. SinceTestInvariant_HelperStatusSurfacedOnSendPackErrormodels 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 second0000. I'd do the former so the stubs keep matching real wire shapes. - Optionally, an end-to-end check at the server integration layer (
git push --dry-runfor 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.