Illustrative, not compile-ready. The goal is to show where the seams go.
Imports stdlib only, so there is no cycle with plumbing,
plumbing/format/packfile, plumbing/revlist, plumbing/storer or git.
// Package progress reports progress of local packfile work: counting,
// compressing, writing, receiving and resolving objects.
package progress
// Phase is a unit of work a caller can show progress for. Phases name
// operations rather than implementation steps, so that changes inside the
// delta selector or the index encoder do not alter this vocabulary.
type Phase uint8
const (
Counting Phase = iota // enumerating the objects to send
Compressing // selecting delta bases for outgoing objects
Writing // encoding objects into the outgoing packfile
Receiving // reading the incoming packfile
Resolving // reconstructing deltas in the incoming packfile
)
func (p Phase) String() string
// Update is a snapshot of one phase. It is plain data and safe to copy.
type Update struct {
Phase Phase
// Current counts objects handled so far.
Current uint64
// Total counts objects expected. It is meaningful only when TotalKnown
// is set. Counting does not learn its total until it finishes.
Total uint64
TotalKnown bool
// Bytes counts bytes transferred for phases that track them, which is
// Receiving only. It is zero elsewhere.
Bytes uint64
// Done marks the last update for the phase and is the only completion
// signal. A phase that fails never reports Done.
Done bool
}
// Reporter receives progress updates.
//
// Report runs synchronously on the goroutine doing the work. That goroutine
// belongs to go-git, not to the caller of Fetch or Push, and during delta
// selection several of them report concurrently. An implementation must be
// safe for concurrent use, must return promptly, and must not call back into
// the operation it is reporting on.
//
// go-git stops calling Report before the operation returns, on success and on
// failure. go-git cannot interrupt a Reporter that blocks.
type Reporter interface {
Report(Update)
}
// ReporterFunc adapts an ordinary function to Reporter.
type ReporterFunc func(Update)
func (f ReporterFunc) Report(u Update) { f(u) }Inferring completion from Current == Total has two failure modes. It is true
of the zero value, so a phase with no objects reports complete on its first
update. And it is never true for Counting, whose total is unknown until the
walk ends. An explicit flag costs one byte and removes both.
// State is a Reporter that keeps the latest update per phase so a caller can
// poll at its own rate. It holds a mutex only long enough to store one value,
// and it stays usable after the operation returns.
//
// This is the right choice for a progress bar. A caller that needs every
// update rather than the latest should implement Reporter directly.
type State struct {
mu sync.Mutex
latest [numPhases]Update
seen [numPhases]bool
}
func NewState() *State
func (s *State) Report(u Update) {
s.mu.Lock()
s.latest[u.Phase], s.seen[u.Phase] = u, true
s.mu.Unlock()
}
// Snapshot returns the latest update for each phase that has reported, in
// phase order.
func (s *State) Snapshot() []Update*State satisfies Reporter, so polling is not a second seam. It is one
shipped implementation of the only seam.
// NewTextReporter returns a Reporter that writes git-style progress lines to
// w, at most one line per phase per interval, plus a final line per phase.
// Writes are serialised. A w that blocks still stalls the operation.
//
// Resolving deltas: 45% (1234/2743)
func NewTextReporter(w io.Writer, interval time.Duration) Reporter
// SyncWriter serialises concurrent writes to w, so that local progress and
// the remote's sideband output can share one destination.
func SyncWriter(w io.Writer) io.WriterThis is how the design meets sideband.Progress without touching it. That
type is already interface{ io.Writer }, so the two compose directly.
// Tracker accumulates progress for one phase and forwards sampled updates to
// a Reporter. A nil Reporter disables it at a cost of one comparison per step.
type Tracker struct {
r progress.Reporter
phase progress.Phase
current atomic.Uint64
bytes atomic.Uint64
lastNano atomic.Int64
// ...
}
// clockStride is how many steps pass between clock reads. Reading the clock
// per object costs more than the work being measured for small objects.
const clockStride = 256
func (t *Tracker) Step() {
if t.r == nil {
return
}
if n := t.current.Add(1); n%clockStride == 0 {
t.emitIfDue(n)
}
}
func (t *Tracker) AddBytes(n uint64)
// Done emits the final update for the phase and is always delivered.
func (t *Tracker) Done()Counters stay exact. Only the calls into caller code are rate-limited, and the limiting happens before the call, not inside the caller's callback.
This mirrors git: progress.c counts every object but renders only when the
percentage changes or a timer fires.
Note the rightmost column. Every seam extends a function that already exists,
using a variadic option parameter, so no *WithStatus style twin is needed and
every existing call site compiles unchanged.
| Phase | Op | Seam | New exported funcs |
|---|---|---|---|
| Counting | push | revlist.Objects(…, opts ...revlist.Option) |
0 |
| Compressing, Writing | push | existing EncoderOption, add WithEncoderProgress |
0 |
| Receiving (bytes) | fetch | counting reader in WritePackfileToObjectStorage |
0 |
| Receiving, Resolving | fetch | existing ParserOption, add WithParserProgress |
0 |
| Resolving (fs storer) | fetch | storer.PackfileWriter becomes variadic, see §6 |
0 |
// Source compatible. Existing callers are untouched.
func Objects(
s storer.EncodedObjectStorer,
wants, haves []plumbing.Hash,
opts ...Option,
) ([]plumbing.Hash, error)Parser.Parse already has exactly git's two phases: the scan loop, then
resolveDeltas after it. A seam placed inside Parse can label them
Receiving and Resolving correctly.
The existing packfile.Observer cannot. Its OnInflatedObjectHeader fires for
non-delta objects during the scan and for deltas during resolution, so it
yields one aggregate count across both phases. There are two further reasons
not to route progress through it:
WithScannerObserversreplaces the observer slice (p.observers = ob). Exposing it to callers lets them silently drop the mandatoryidxfile.Writerobserver and produce a corrupt index.Parsediscards some observer errors (_ = p.onHeader(...),_ = p.storeOrCache(...)), so those hooks cannot be advertised as a cancellation mechanism.
packfile.UpdateObjectStorage asserts storer.PackfileWriter and calls
pw.PackfileWriter(), which returns an opaque io.WriteCloser. The filesystem
storer builds its parser inside dotgit.PackWriter.buildIndex, behind that
writer. An interface method has a fixed signature, so nothing can be passed
through it.
Receiving in bytes needs nothing here: WritePackfileToObjectStorage already
holds the io.Reader, so a counting reader covers both storage paths for free.
The gap costs exactly one thing: Resolving progress when the storer writes
packfiles, which is the default path and the long phase of a big clone.
type PackfileWriter interface {
PackfileWriter(opts ...PackfileWriterOption) (io.WriteCloser, error)
}
type PromisorPackfileWriter interface {
PromisorPackfileWriter(marker string, opts ...PackfileWriterOption) (io.WriteCloser, error)
}// PackfileWriterOptions configures one packfile write.
type PackfileWriterOptions struct {
// Progress receives progress for the objects this writer ingests.
// A nil Reporter disables reporting.
Progress progress.Reporter
}
type PackfileWriterOption func(*PackfileWriterOptions)
// WithProgress reports progress for the objects the writer ingests.
func WithProgress(r progress.Reporter) PackfileWriterOption {
return func(o *PackfileWriterOptions) { o.Progress = r }
}
// BuildPackfileWriterOptions applies opts and returns the result.
// Implementations of PackfileWriter use it so option handling stays
// consistent across storers, including out of tree ones.
func BuildPackfileWriterOptions(opts ...PackfileWriterOption) PackfileWriterOptions {
var o PackfileWriterOptions
for _, fn := range opts {
fn(&o)
}
return o
}This is breaking for implementors only. Every call site compiles unchanged,
because pw.PackfileWriter() remains a valid call. v6 is at v6.0.0-alpha.5,
so the window for it is open.
Both interfaces go variadic. They are not merged. Folding the promisor
marker into an option would look tidier and is wrong: remote.go:485 calls
SupportsPromisorPacks(r.s) as a pre-flight check, before a filtered fetch
starts, to refuse storage that writes packs but cannot mark them. That check is
a type assertion on the separate interface. Merging would turn a capability
query answered up front into a runtime error discovered after the fetch is
under way. Keeping them separate also gets progress into the promisor path,
which a filtered clone needs most.
SupportsPromisorPacks in common.go:70 is therefore unchanged.
- A third interface,
ProgressPackfileWriter, additive and non-breaking. Rejected because the family already has two members and this pattern needs a new one per capability. It also forces a type assertion instorage/transactional, whose else branch silently drops the reporter. - Bytes-only Receiving, no Resolving on the filesystem path. Zero API change, worst UX on the case that needs it most.
- A
SetProgresssetter on the returned writer.newPackWritelaunches the parser goroutine at construction, so any later set is a race. - A context-carried reporter. The interface method takes no
ctx, and context cancellation cannot interrupt a blocked callback anyway. - Bypassing the
PackfileWriterpath when progress is on. That would silently change on-disk layout from packed to loose objects. - A reporter installed on the storer. Storers are shared across concurrent operations, so the reporter must be scoped to one write.
| File | Change |
|---|---|
plumbing/storer/object.go |
Both interfaces, plus PackfileWriterOptions / PackfileWriterOption / WithProgress / BuildPackfileWriterOptions. New import of plumbing/progress. |
storage/filesystem/object.go |
PackfileWriter (:435) and PromisorPackfileWriter (:444) take opts and forward the reporter to dotgit. |
storage/filesystem/dotgit/dotgit.go |
NewObjectPack (:441) and NewPromisorObjectPack (:463) become variadic. |
storage/filesystem/dotgit/writers.go |
PackWriter gains a reporter field, newPackWrite takes it, buildIndex passes packfile.WithParserProgress. |
storage/transactional/storage.go |
:147 and :164 become one-line forwards, return s.pw.PackfileWriter(opts...). |
plumbing/format/packfile/common.go |
UpdateObjectStorage, WritePackfileToObjectStorage and UpdatePromisorObjectStorage take and forward opts. The counting reader for Receiving bytes lands here. SupportsPromisorPacks unchanged. |
remote_test.go:549 |
Mock adopts the new signature. |
plumbing/format/packfile/promisor_test.go:22,31 |
Both mocks adopt the new signatures. |
func (s *packageWriter) PackfileWriter(opts ...storer.PackfileWriterOption) (io.WriteCloser, error) {
return s.pw.PackfileWriter(opts...)
}func (w *PackWriter) buildIndex() {
w.writer = new(idxfile.Writer)
w.parser = packfile.NewParser(w.synced,
packfile.WithScannerObservers(w.writer),
packfile.WithObjectFormat(w.format),
packfile.WithParserProgress(w.progress), // nil is a no-op
)
// ...
}repository.go:2146-2157, storage/tests/storage_test.go:104,144,
plumbing/format/packfile/parser_test.go:191,
storage/transactional/storage.go:72,
storage/transactional/storage_test.go:62-63,
storage/filesystem/storage_test.go:37.
internal/transport/transport.go (a LocalProgress field on FetchRequest),
internal/transport/v2.go:268,270, plumbing/transport/fetch.go:50,53,
plumbing/transport/http/dumb.go:422, plus options.go and remote.go for
the LocalProgress fields and the revlist and NewEncoder seams.
plumbing/transport/receive_pack.go:195 is server-side unpack and is out of
scope.
Progress is only safe if reporting provably stops before the operation returns. On push it does not, once a new parking spot exists.
pushHashes spawns the encoder goroutine and, when sess.Push fails, returns
after rd.Close() without draining done. That is benign today: the only
blocking point is wr.Write, which closing the read end unblocks. Any progress
seam adds a parking spot that rd.Close() cannot reach, after which the
goroutine outlives Push and a caller that closes its own channel sees a panic.
Fix the join first, independently of this feature:
if err := sess.Push(ctx, s, req); err != nil {
_ = rd.Close()
<-done // the encoder goroutine must not outlive Push
return err
}On fetch the equivalent join already exists: PackWriter.Close waits on
waitBuildIndex.