package gitrepo
import ( "context" "crypto/sha256" "errors" "fmt" "os" "path" "sort" "strings" "sync"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-billy/v5/util"
"github.com/go-git/go-git/v5/plumbing/protocol/packp"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/go-git/go-git/v5/storage/filesystem"
"gitweb/internal/githttp"
"gitweb/internal/store"
)
// ErrConcurrentModification is returned when a repo's store Sequence changed // between materialization and write-back, meaning the tree was modified out of // band during the push. The push is abandoned without writing anything. var ErrConcurrentModification = errors.New("remote repository changed during push; please retry")
// defaultPushConcurrency caps pushes in flight across all repos; each one // holds a full private copy of its repo on disk while it runs. const defaultPushConcurrency = 2
// Pusher applies a git receive-pack (push) to an upspin-stored repo. It // materializes a private, writable copy of the repo, runs the push against it // with go-git, and writes the changed files back to the store. Pushes to the // same repo are serialized, and total pushes in flight are bounded. type Pusher struct { st store.WriteStore cache *Cache sem chan struct{} // bounds concurrent pushes
mu sync.Mutex
locks map[string]*sync.Mutex
}
// NewPusher returns a Pusher writing through st and invalidating c on success. func NewPusher(st store.WriteStore, c *Cache) *Pusher { return &Pusher{ st: st, cache: c, sem: make(chan struct{}, defaultPushConcurrency), locks: map[string]*sync.Mutex{}, } }
func (p *Pusher) lockFor(repoPath string) *sync.Mutex { p.mu.Lock() defer p.mu.Unlock() l := p.locks[repoPath] if l == nil { l = &sync.Mutex{} p.locks[repoPath] = l } return l }
// noPackfileWriter hides the storer's optional PackfileWriter capability from // the receive-pack session. With it visible, go-git copies the client's // packfile verbatim into objects/pack, and that path cannot resolve the // REF_DELTA entries of a thin pack — which git sends on every incremental // push, deltifying against objects that live in the repo, not in the pack — // so such pushes fail with "reference delta not found". Hidden, go-git parses // the pack against the storer, resolving thin deltas and storing the new // objects loose. type noPackfileWriter struct{ storer.Storer }
// ReceivePack runs the push described by req against repoPath and writes the // result back to the store. The returned ReportStatus should be sent to the // client even when err is non-nil (it carries per-ref status). func (p *Pusher) ReceivePack(ctx context.Context, repoPath string, req *packp.ReferenceUpdateRequest) (*packp.ReportStatus, error) { lock := p.lockFor(repoPath) lock.Lock() defer lock.Unlock()
// Bound pushes in flight (each holds a private on-disk repo copy). Taken
// after the per-repo lock: holders always release, so no cycle.
select {
case p.sem <- struct{}{}:
defer func() { <-p.sem }()
case <-ctx.Done():
return nil, ctx.Err()
}
seq0, err := p.st.Seq(repoPath)
if err != nil {
return nil, err
}
// A private on-disk copy: never the shared read cache, which serves
// concurrent readers and must not be mutated.
dir, err := os.MkdirTemp(p.cache.dir, "push-")
if err != nil {
return nil, err
}
defer removeDir(dir)
fs := osfs.New(dir)
before, err := materialize(p.st, repoPath, fs, p.cache.maxRepo)
if err != nil {
return nil, err
}
sto := filesystem.NewStorage(fs, p.cache.objects)
sess, err := githttp.NewReceivePackSession(noPackfileWriter{sto})
if err != nil {
return nil, err
}
defer sess.Close()
rs, rpErr := sess.ReceivePack(ctx, req)
if rpErr != nil {
// Unpack or ref update failed; the storer may be in a partial state, so
// do not write anything back. The report status still goes to the client.
return rs, rpErr
}
after, err := walkManifest(fs, p.cache.maxRepo)
if err != nil {
return failReport(rs, err), err
}
// Guard against an out-of-band change to the tree since we materialized.
// The per-repo lock already excludes other pushes through this server.
seq1, err := p.st.Seq(repoPath)
if err != nil {
return failReport(rs, err), err
}
if seq1 != seq0 {
return failReport(rs, ErrConcurrentModification), ErrConcurrentModification
}
if err := p.writeBack(fs, before, after, repoPath); err != nil {
return failReport(rs, err), err
}
p.cache.Invalidate(repoPath)
return rs, nil
}
// failReport downgrades every "ok" per-ref status in rs to err's message. The // session updated only the private copy; when persisting to the store fails, // the client must see its refs rejected, not an "ok" report for updates that // were never written. The message is flattened to one line so it survives // pkt-line framing. func failReport(rs *packp.ReportStatus, err error) *packp.ReportStatus { if rs == nil { return nil } msg := strings.Join(strings.Fields(err.Error()), " ") for _, cs := range rs.CommandStatuses { if cs.Error() == nil { // "ok" cs.Status = msg } } return rs }
// writeBack applies the difference between the before and after manifests to the // store: new/changed files are written objects-first then refs, and files that // disappeared are deleted last. This ordering keeps a concurrent reader's view // consistent — it may briefly see extra unreachable objects, never a ref that // points at a missing object. func (p *Pusher) writeBack(fs billy.Filesystem, before, after Manifest, repoPath string) error { var objectPuts, refPuts, dels []string for name, info := range after { if old, ok := before[name]; ok && old.Sum == info.Sum { continue // unchanged } if strings.HasPrefix(name, "objects/") { objectPuts = append(objectPuts, name) } else { refPuts = append(refPuts, name) } } for name := range before { if _, ok := after[name]; !ok { dels = append(dels, name) } } // Deterministic order (aids testing and reproducibility). sort.Strings(objectPuts) sort.Strings(refPuts) sort.Strings(dels)
put := func(name string) error {
data, err := util.ReadFile(fs, name)
if err != nil {
return err
}
return p.st.Put(path.Join(repoPath, name), data)
}
for _, name := range objectPuts {
if err := put(name); err != nil {
return fmt.Errorf("push write object %s: %w", name, err)
}
}
for _, name := range refPuts {
if err := put(name); err != nil {
return fmt.Errorf("push write ref %s: %w", name, err)
}
}
for _, name := range dels {
if err := p.st.Delete(path.Join(repoPath, name)); err != nil && err != store.ErrNotFound {
return fmt.Errorf("push delete %s: %w", name, err)
}
}
return nil
}
// walkManifest builds a manifest of every regular file currently in fs, // enforcing the size cap and skipping go-git's dotgit temp files. func walkManifest(fs billy.Filesystem, maxBytes int64) (Manifest, error) { man := Manifest{} var total int64 err := util.Walk(fs, "/", func(p string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } rel := strings.TrimPrefix(p, "/") base := path.Base(rel) if strings.HasPrefix(base, "tmp_") || strings.HasPrefix(base, "._packed-refs") { return nil // go-git dotgit scratch file, not part of the repo } total += info.Size() if total > maxBytes { return fmt.Errorf("%w (limit %d bytes)", ErrRepoTooLarge, maxBytes) } data, err := util.ReadFile(fs, rel) if err != nil { return err } man[rel] = FileInfo{Size: info.Size(), Sum: sha256.Sum256(data)} return nil }) if err != nil { return nil, err } return man, nil }