Today, push hooks (-allow-hooks) run asynchronously in a goroutine after
transport.ReceivePack has already sent the client its complete response. Hook
stdout/stderr is captured into bytes.Buffers and only ever reaches slog —
the pusher never sees it. We want hook output to appear in the git push
client's terminal as remote: ... lines, streamed live as the hook writes them.
In the git smart protocol, server-side progress reaches the client over the
sideband channel (sideband.ProgressMessage, band 2), which the client
renders prefixed with remote: . The blocker: go-git's
transport.ReceivePack constructs the sideband Muxer internally, writes
report-status through it, and sends the closing flush-pkt before returning.
Once the client sees that flush-pkt it stops reading the sideband, so there is
no public seam to append remote: lines after the call, and no way to reach the
internal muxer during the call.
Decision (confirmed with user): output-only, non-rejecting. Hooks keep
post-receive semantics — they run after refs are updated and report-status is
sent; output streams but a hook failure cannot undo the push. This also flips
hooks from async to synchronous: the client now waits for hooks to finish
(bounded by -hook-timeout, default 60s) before the push completes. That wait
is inherent to streaming output live and is the accepted trade-off.
Fork the small ReceivePack server command into objgitd with a hook seam, and
make hook execution stream to a band-2 writer instead of buffering. This mirrors
the repo's existing pattern of vendoring third-party internals (internal/s3fs,
internal/kefkash).
New file cmd/objgitd/receivepack.go. Copy transport.ReceivePack
(receive_pack.go:30–168) plus its unexported helpers sendReportStatus,
closeWriter, setStatus, referenceExists, updateReferences
(receive_pack.go:170–256) verbatim. Everything else it touches is already
exported: AdvertiseRefs, ProtocolVersion, ReceivePackService,
ErrUnsupportedVersion, ErrUpdateReference, packfile.UpdateObjectStorage,
sideband.NewMuxer, the capability/packp/pktline packages.
Add one parameter: a callback invoked after updateReferences +
sendReportStatus, before the final pktline.WriteFlush(w):
func receivePackStreaming(ctx, st, r, w, opts,
onUpdated func(progress io.Writer)) error- When sideband was negotiated (
useSideband), build a band-2 progress writer that wraps the same*sideband.Muxer(writer), e.g. a tiny adapter whoseWritecallsmux.WriteChannel(sideband.ProgressMessage, p), and pass it toonUpdated. The flush already at line 159–163 then closes the stream after hooks have run. - When sideband was not negotiated (client sent
no-progress/ no sideband cap — rare;git pushnegotiatessideband-64kby default), passnil; hooks fall back to slog-only (no band to write to without corrupting report-status). - Keep the
streamingStorer/PackfileWriter and no-op-closer behavior intact: the fork callspackfile.UpdateObjectStorage(st, rd)on whatever storer the caller passes, exactly as today.
d.receivePack (hooks.go:83) stops calling transport.ReceivePack and the
async goroutine. Instead it calls receivePackStreaming and runs hooks inside
the onUpdated callback:
- Take the before snapshot, call
receivePackStreaming(...). - Inside
onUpdated(progress): take the after snapshot,diffRefs, and for each non-deleted update callrunHooksynchronously, passingprogress(ornil). Snapshots still usereadStorer. - Because hooks now complete before
receivePackStreamingreturns, thed.hookWGgoroutine machinery is no longer needed.
runHook (hooks.go:131) gains a progress io.Writer parameter:
- Replace the
outBuf/errBufbytes.Buffers wired intointerp.StdIOandkefkash.CallHandlerwith a writer that targetsprogresswhen non-nil. git does not distinguish hook stdout from stderr on the wire, so route both to the single band-2progresswriter. - When
progress == nil(no sideband), keep the current buffer→slog path so the log-only fallback still works. - Continue logging the exit status via
slogeither way (theinterp.ExitStatus/ "hook: finished" record). Full output need not be duplicated into the log once it streams; a short summary line is enough.
git:// and SSH write through a live socket, so band-2 packets reach the client
immediately. HTTP buffers in net/http, so for output to appear live rather
than at the end, each band-2 write must be flushed. In handleRPC
(http.go:118–132), for the receive-pack path wrap out so writes call
http.Flusher.Flush() (when w implements it) after each pktline. A small
flush-on-write io.Writer wrapper around the ResponseWriter, still wrapped by
ioutil.WriteNopCloser, is enough. SSH (ssh.go:202) and git://
(git_protocol.go:161) call sites need no change beyond pointing at the new
receivePack signature (unchanged externally).
- Drop
hookWG sync.WaitGroup(git_protocol.go:65–66) and itsd.hookWG.Add/Doneuse inhooks.go. - Remove the shutdown drain in
main.go:149(go func(){ d.hookWG.Wait(); ... }) and the surroundingdrainedplumbing. Hooks are synchronous now, so a push in flight already holds the connection until hooks finish; normal connection draining covers shutdown. - Keep
allowHooksandhookTimeoutexactly as they are.
Update CLAUDE.md "Push hooks" section: hooks now run synchronously and
stream stdout/stderr to the client over sideband band 2 (remote: lines); they
still cannot reject a push (output-only, post-update); the client waits for
hooks (bounded by -hook-timeout); falls back to slog-only when the client
doesn't negotiate sideband. Note the cmd/objgitd/receivepack.go fork and why
(go-git keeps the muxer internal and flushes before returning).
cmd/objgitd/receivepack.go— new, forkedreceivePackStreaming+ helpers.cmd/objgitd/hooks.go— synchronousreceivePack, streamingrunHook, drophookWGuse.cmd/objgitd/http.go— flush-on-write wrapper for the receive-pack response.cmd/objgitd/git_protocol.go— removehookWGfield; call sites unchanged otherwise.cmd/objgitd/main.go— removehookWGshutdown drain.CLAUDE.md— update the Push hooks section.
sideband.NewMuxer,sideband.ProgressMessage,Muxer.WriteChannel— go-git v6plumbing/protocol/packp/sideband(already an indirect dep).snapshotRefs/diffRefs/runHooks(hooks.go:38–126) — reused as-is bar the signature/flow changes above.kefkash.CallHandler/FsysOpenHandleretc. and themountfs/treefssandbox wiring inrunHook— unchanged except where the output buffers are swapped for the progress writer.ioutil.WriteNopCloser(go-git) — keep wrapping the HTTP/ssh/git writers.
go build ./...andgo test ./...(protocol + SSH tests gated ongit/sshon PATH).- Add a streaming assertion to the hook tests: seed a repo whose
.objgit/hooks/receive-packechoes a sentinel line, push it with-allow-hooks, and assert the sentinel appears as aremote:line in thegit pushstderr — across HTTP, git://, and SSH. Reuse the existing table-driven harness (runGit/seedRepoingit_protocol_test.go). - Manual:
./objgitd -bucket $BUCKET -http-bind :8080 -allow-push -allow-hooks, push a repo with a hook that prints +sleeps, and confirmremote:lines appear incrementally (not all at once at the end) over HTTP, then repeat with-ssh-bindand-git-bind.