Route slog and sideband progress through the live ticker · Entire

Route slog and sideband progress through the live ticker

f396a8a→main·

Soph·2mo ago·8 files·+109 added/-29 removed

When --verbose and --progress were combined, two stderr streams fought for the same line: slog's INFO lines and go-git's sideband server progress ("Enumerating objects: ...", "Compressing objects: 36% (98/271)") collided mid-character with the in-place ticker frame. Verbose+progress was effectively unusable.

Add a sessionStderr writer that routes through progressReporter. notify() (clearing the frame, printing the message, re-arming the redraw on the next tick) when a reporter is attached to the session, and falls through to os.Stderr otherwise. Both '\n' and '\r' are treated as line ends so sideband percentage updates each become their own line above the ticker rather than overlapping with it.

Plumb the sink in two places: as the slog handler's writer in newSession when Verbose is set, and as Conn.ProgressOut on every gitproto connection so progressSink in fetch.go/push.go writes there instead of os.Stderr. The ticker's own '\r' in-place writes remain the only thing on the live line.

Sessions

36b4fd4a5690View transcript

?\can you rebase soph/progress-indicators onto soph/smart-subdivisionClaude Code·Opus 4.7[1m]·1 step

Changes

8

135 unmodified lines

136
137
138
139
139
140
141
142
51 unmodified lines

194
195
196
197
197
198
199
200
39 unmodified lines

240
241
242
243
243
244
245
246
1 unmodified line

248
249
250
251
251
252
253
254
23 unmodified lines

278
279
280
281
281
282
283
284
16 unmodified lines

301
302
303
304
304
305
306
307
16 unmodified lines

324
325
326
327
327
328
329
330
133 unmodified lines

464
465
466
467
467
468
469
470
28 unmodified lines

499
500
501
502
502
503
504
505

135 unmodified lines

}
    defer ioutil.CheckClose(reader, &err)
    // Commit-graph fetches are short and not user-facing; skip progress.
    return storeV2FetchPack(store, reader, false)
    return storeV2FetchPack(store, reader, false, nil)
}

// Capabilities returns the sorted capability list for display.
51 unmodified lines

return err
    }
    defer ioutil.CheckClose(reader, &err)
    return storeV2FetchPack(store, reader, verbose)
    return storeV2FetchPack(store, reader, verbose, conn.ProgressOut)
}

func fetchPackV2(
39 unmodified lines

if err != nil {
        return nil, err
    }
    packStream, err := openV2PackStream(reader, verbose)
    packStream, err := openV2PackStream(reader, verbose, conn.ProgressOut)
    if err != nil {
        _ = reader.Close()
        return nil, err
    }
    return packStream, nil
}

func storeV2FetchPack(store storer.Storer, r io.Reader, verbose bool) error {
func storeV2FetchPack(store storer.Storer, r io.Reader, verbose bool, progressOut io.Writer) error {
    reader := NewPacketReader(r)
    expectPackfile := false
    for {
23 unmodified lines

switch line {
        case "packfile\n":
            demux := sideband.NewDemuxer(sideband.Sideband64k, reader.BufReader())
            demux.Progress = progressSink(verbose, "source: ")
            demux.Progress = progressSink(verbose, "source: ", progressOut)
            if err := packfile.UpdateObjectStorage(store, demux); err != nil {
                return fmt.Errorf("update object storage: %w", err)
            }
    16 unmodified lines

}

func openV2PackStream(body io.ReadCloser, verbose bool) (io.ReadCloser, error) {
func openV2PackStream(body io.ReadCloser, verbose bool, progressOut io.Writer) (io.ReadCloser, error) {
    reader := NewPacketReader(body)
    for {
        kind, payload, err := reader.ReadPacket()
16 unmodified lines

switch line {
        case "packfile\n":
            demux := sideband.NewDemuxer(sideband.Sideband64k, reader.BufReader())
            demux.Progress = progressSink(verbose, "source: ")
            demux.Progress = progressSink(verbose, "source: ", progressOut)
            return &wrappedRC{
                Reader: demux,
                Closer: body,
133 unmodified lines

if drainErr := drainTrailingNAKs(buffered); drainErr != nil {
        return fmt.Errorf("drain server response: %w", drainErr)
    }
    sbReader := buildSidebandReader(caps, buffered, progressSink(verbose, "source: "))
    sbReader := buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressOut))
    if err := packfile.UpdateObjectStorage(store, sbReader); err != nil {
        return fmt.Errorf("update object storage: %w", err)
    }
28 unmodified lines

return nil, fmt.Errorf("drain server response: %w", drainErr)
    }
    return &wrappedRC{
        Reader: buildSidebandReader(caps, buffered, progressSink(verbose, "source: ")),
        Reader: buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressOut)),
        Closer: reader,
    }, nil
}
``

Minternal/gitproto/fetch.go+9/-9

136 unmodified lines

137 138 139 140 140 141 142 142 143 144 144 145 146 146 147 148 149 663 unmodified lines

813 814 815 816 816 817 818 819 8 unmodified lines

828 829 830 831 831 832 833 834 14 unmodified lines

849 850 851 852 852 853 854 855 14 unmodified lines

870 871 872 873 873 874 875 876 17 unmodified lines

894 895 896 897 897 898 899 900

136 unmodified lines

}

func TestProgressWriter(t *testing.T) { w := progressWriter(false) w := progressWriter(false, nil) if w != nil { t.Error("progressWriter(false) should return nil") t.Error("progressWriter(false, nil) should return nil") } w = progressWriter(true) w = progressWriter(true, nil) if w == nil { t.Error("progressWriter(true) should return non-nil writer") t.Error("progressWriter(true, nil) should return non-nil writer") } }

663 unmodified lines

t.Fatalf("write remote error: %v", err) }

error := storeV2FetchPack(memory.NewStorage(), &wire, false) error := storeV2FetchPack(memory.NewStorage(), &wire, false, nil) if err == nil { t.Fatal("expected remote error") } 8 unmodified lines

t.Fatalf("write remote error: %v", err) }

_, err := openV2PackStream(io.NopCloser(&wire), false) _, err := openV2PackStream(io.NopCloser(&wire), false, nil) if err == nil { t.Fatal("expected remote error") } 14 unmodified lines

t.Fatalf("write flush: %v", err) } error := storeV2FetchPack(memory.NewStorage(), &wire, false) error := storeV2FetchPack(memory.NewStorage(), &wire, false, nil) if err == nil { t.Fatal("expected missing packfile error") } 14 unmodified lines

t.Fatalf("write flush: %v", err) } _, err := openV2PackStream(io.NopCloser(&wire), false) _, err := openV2PackStream(io.NopCloser(&wire), false, nil) if err == nil { t.Fatal("expected missing packfile error") } 17 unmodified lines

t.Fatalf("write flush: %v", err) } error := storeV2FetchPack(memory.NewStorage(), &wire, false) error := storeV2FetchPack(memory.NewStorage(), &wire, false, nil) if err == nil { t.Fatal("expected missing packfile error") }


Minternal/gitproto/fetch_test.go+9/-9

124 unmodified lines

125 126 127 128 128 129 130 131 132 132 133 134 135 97 unmodified lines

233 234 235 236 236 237 238 239 240 240 241 242 243 244 245 246 247 245 246 248 249 250 251 252 253 254 250 255 256 257 258 259 260 261

124 unmodified lines

switch { case req.Capabilities.Supports(capability.Sideband64k): dem := sideband.NewDemuxer(sideband.Sideband64k, reader) dem.Progress = progressSink(verbose, "target: ") dem.Progress = progressSink(verbose, "target: ", conn.ProgressOut) respReader = dem case req.Capabilities.Supports(capability.Sideband): dem := sideband.NewDemuxer(sideband.Sideband, reader) dem.Progress = progressSink(verbose, "target: ") dem.Progress = progressSink(verbose, "target: ", conn.ProgressOut) respReader = dem }

97 unmodified lines

return sendReceivePack(ctx, conn, req, nil, verbose) }

func progressWriter(verbose bool) io.Writer { func progressWriter(verbose bool, dest io.Writer) io.Writer { if !verbose { return nil } return os.Stderr if dest == nil { dest = os.Stderr } return dest }

// progressSink returns a line-prefixing io.Writer suitable for // sideband.Demuxer.Progress. When verbose is false it returns nil so the // demuxer discards progress frames without allocating. func progressSink(verbose bool, prefix string) io.Writer { // demuxer discards progress frames without allocating. Passing a non-nil // dest routes the prefixed lines through that writer instead of os.Stderr, // which lets a live progress reporter coordinate output. func progressSink(verbose bool, prefix string, dest io.Writer) io.Writer { if !verbose { return nil } return &prefixedLineWriter{w: os.Stderr, prefix: prefix, atLineStart: true} if dest == nil { dest = os.Stderr } return &prefixedLineWriter{w: dest, prefix: prefix, atLineStart: true} }

// prefixedLineWriter prepends a fixed prefix to each line of input written


Minternal/gitproto/push.go+15/-7

70 unmodified lines

71 72 73 74 74 75 76 77 77 78 79 80 5 unmodified lines

86 87 88 89 89 90 91 92

70 unmodified lines

}

func TestProgressSinkNilWhenNotVerbose(t *testing.T) { if got := progressSink(false, "anything: "); got != nil { if got := progressSink(false, "anything: ", nil); got != nil { t.Fatalf("progressSink(false) = %T, want nil", got) } if got := progressSink(true, "source: "); got == nil { if got := progressSink(true, "source: ", nil); got == nil { t.Fatal("progressSink(true) returned nil, want non-nil writer") } }

5 unmodified lines

rc, err := openV2PackStream(body, false) rc, err := openV2PackStream(body, false, nil) if err != nil { t.Fatalf("openV2PackStream: %v", err) } }


Minternal/gitproto/push_test.go+3/-3

85 unmodified lines

86 87 88 89 90 91 92 93 94 95 96 97 98 99

85 unmodified lines

// contains the repo path. Off by default to preserve behaviour for // callers that rely on Endpoint being stable. FollowInfoRefsRedirect bool

// ProgressOut is the destination for verbose sideband progress // messages ("Enumerating objects: ...", "Resolving deltas: ..." // streamed by upload-pack and receive-pack). Nil falls back to // os.Stderr. Callers driving a live progress ticker can plug in a // coordinated writer here so server-side progress lines don't // clobber the in-place ticker frame. ProgressOut io.Writer }

// NewConn creates a new connection to the given endpoint. ``

Minternal/gitproto/smarthttp.go+8

193 unmodified lines

194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233

193 unmodified lines

return host[:width-1] + "…"
// sessionStderr is an io.Writer that hands writes to the live progress
// reporter when one is attached to the syncSession, so verbose slog
// lines and server-side sideband progress ("Resolving deltas …") land
// above the in-place ticker frame instead of clobbering it. Falls back
// to os.Stderr when no reporter is active.
//
// Each Write may carry multiple lines or carriage-returned in-place
// updates; both '\n' and '\r' are treated as line ends so the reporter
// receives one notify per logical line.
type sessionStderr struct{ s *syncSession }

func (w sessionStderr) Write(b []byte) (int, error) {
    if w.s == nil || w.s.progress == nil {
        n, err := os.Stderr.Write(b)
        if err != nil {
            return n, fmt.Errorf("stderr write: %w", err)
        }
        return n, nil
    }
    s := string(b)
    for s != "" {
        i := strings.IndexAny(s, "\r\n")
        if i < 0 {
            w.s.progress.notify(s)
            break
        }
        if i > 0 {
            w.s.progress.notify(s[:i])
        }
        s = s[i+1:]
    }
    return len(b), nil
}

// stderrIsTTY reports whether stderr is attached to a terminal. The
// progress ticker is suppressed otherwise because '\r' updates only make
// sense on a TTY and would otherwise spam log files.
``

Minternal/syncer/progress.go+34

204 unmodified lines

205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238

204 unmodified lines

} }

func TestSessionStderrRoutesMultilineThroughNotify(t *testing.T) { t.Parallel() stats := newStats(true) stats.setSideDisplay("source", "github.com") stats.side("source").bytes.Store(1024)

var buf bytes.Buffer p := newProgressReporter(&buf, stats, 0) p.render(false) // give notify something to clear

sess := &syncSession{progress: p} sink := sessionStderr{s: sess}

// Multi-line write (e.g. a slog line followed by a sideband line) // must produce one notify per logical line — both '\n' and '\r' are // treated as line ends so sideband '\r'-driven percentage updates // don't clobber the live progress frame. if _, err := sink.Write([]byte("first line\nsecond line\rthird line\n")); err != nil { t.Fatalf("write: %v", err) }

for _, want := range []string{"first line", "second line", "third line"} { if !strings.Contains(buf.String(), want) { t.Errorf("output missing %q: %q", want, buf.String()) } } }

func TestProgressReporterNotifyClearsAndRedraws(t *testing.T) { t.Parallel() stats := newStats(true) }