ref: 8aa2e9f0903a80c90a9d8308138439d6f8732050
parent: 563a4dfb78aaa97febd0763e9f81a740af0dd666
author: Runxi Yu <me@runxiyu.org>
date: Sat Mar 7 14:40:27 EST 2026
receivepack: Use refs
--- a/receivepack/int_test.go
+++ b/receivepack/int_test.go
@@ -3,6 +3,7 @@
import (
"context"
"fmt"
+ "io"
"strings"
"testing"
@@ -13,7 +14,7 @@
// TODO: actually test with send-pack
-func TestReceivePackDeleteOnlyReportsNotImplemented(t *testing.T) {+func TestReceivePackDeleteOnlyAtomicDeleteSucceeds(t *testing.T) {t.Parallel()
//nolint:thelper
@@ -32,11 +33,11 @@
)
input.WriteString(pktlineData(
- commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status delete-refs object-format=" + algo.String() + "\n",
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status atomic delete-refs object-format=" + algo.String() + "\n",
))
input.WriteString("0000")- err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{GitProtocol: "",
Algorithm: algo,
Refs: repo.Refs(),
@@ -47,12 +48,122 @@
}
got := output.String()
- if !strings.Contains(got, "ng refs/heads/main ref updates not implemented yet\n") {+ if !strings.Contains(got, "ok refs/heads/main\n") { t.Fatalf("unexpected receive-pack output %q", got)}
+
+ if _, err := repo.Refs().Resolve("refs/heads/main"); err == nil {+ t.Fatal("refs/heads/main still exists after delete push")+ }
})
}
+func TestReceivePackDeleteOnlyNonAtomicAppliesIndependentDeletes(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ _, _, staleID := testRepo.MakeCommit(t, "stale")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+ testRepo.UpdateRef(t, "refs/heads/topic", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ staleID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/topic\n",
+ ))
+ input.WriteString("0000")+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{+ GitProtocol: "",
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {+ t.Fatalf("ReceivePack: %v", err)+ }
+
+ got := output.String()
+ if !strings.Contains(got, "ng refs/heads/main ") || !strings.Contains(got, "ok refs/heads/topic\n") {+ t.Fatalf("unexpected receive-pack output %q", got)+ }
+
+ if _, err := repo.Refs().Resolve("refs/heads/main"); err != nil {+ t.Fatalf("Resolve(main): %v", err)+ }
+
+ if _, err := repo.Refs().Resolve("refs/heads/topic"); err == nil {+ t.Fatal("refs/heads/topic still exists after successful delete")+ }
+ })
+}
+
+func TestReceivePackDeleteOnlyAtomicFailureLeavesAllRefsUntouched(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ _, _, staleID := testRepo.MakeCommit(t, "stale")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+ testRepo.UpdateRef(t, "refs/heads/topic", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ staleID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status atomic delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/topic\n",
+ ))
+ input.WriteString("0000")+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{+ GitProtocol: "",
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {+ t.Fatalf("ReceivePack: %v", err)+ }
+
+ got := output.String()
+ if !strings.Contains(got, "ng refs/heads/main ") || !strings.Contains(got, "ng refs/heads/topic ") {+ t.Fatalf("unexpected receive-pack output %q", got)+ }
+
+ if _, err := repo.Refs().Resolve("refs/heads/main"); err != nil {+ t.Fatalf("Resolve(main): %v", err)+ }
+
+ if _, err := repo.Refs().Resolve("refs/heads/topic"); err != nil {+ t.Fatalf("Resolve(topic): %v", err)+ }
+ })
+}
+
func TestReceivePackAdvertisesResolvedHEAD(t *testing.T) {t.Parallel()
@@ -74,7 +185,7 @@
input.WriteString("0000")- err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{Algorithm: algo,
Refs: repo.Refs(),
ExistingObjects: repo.Objects(),
@@ -123,11 +234,11 @@
)
input.WriteString(pktlineData(
- commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00delete-refs object-format=" + algo.String() + "\n",
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00delete-refs atomic object-format=" + algo.String() + "\n",
))
input.WriteString("0000")- err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{Algorithm: algo,
Refs: repo.Refs(),
ExistingObjects: repo.Objects(),
@@ -162,11 +273,11 @@
)
input.WriteString(pktlineData(
- commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status delete-refs object-format=" + algo.String() + "\n",
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status atomic delete-refs object-format=" + algo.String() + "\n",
))
input.WriteString("0000")- err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{GitProtocol: gitProtocol,
Algorithm: algo,
Refs: repo.Refs(),
@@ -205,7 +316,7 @@
))
input.WriteString("0000")- err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{Algorithm: algo,
Refs: repo.Refs(),
ExistingObjects: repo.Objects(),
@@ -217,6 +328,124 @@
got := output.String()
if !strings.Contains(got, "unpack objects root not configured\n") { t.Fatalf("unexpected receive-pack output %q", got)+ }
+ })
+}
+
+func TestReceivePackPackCreatePromotesObjectsAndUpdatesRef(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})+ _, _, commitID := sender.MakeCommit(t, "pushed commit")
+
+ receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})+ repo := receiver.OpenRepository(t)
+ objectsRoot := receiver.OpenObjectsRoot(t)
+
+ packStream := sender.PackObjectsReader(t, []string{commitID.String()}, false)+ t.Cleanup(func() {+ _ = packStream.Close()
+ })
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ objectid.Zero(algo).String() + " " + commitID.String() + " refs/heads/main\x00report-status-v2 atomic object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")+
+ err := receivepack.ReceivePack(
+ context.Background(),
+ &output,
+ io.MultiReader(strings.NewReader(input.String()), packStream),
+ receivepack.Options{+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ ObjectsRoot: objectsRoot,
+ },
+ )
+ if err != nil {+ t.Fatalf("ReceivePack: %v", err)+ }
+
+ got := output.String()
+ if !strings.Contains(got, "unpack ok\n") || !strings.Contains(got, "ok refs/heads/main\n") {+ t.Fatalf("unexpected receive-pack output %q", got)+ }
+
+ reopened := receiver.OpenRepository(t)
+
+ resolved, err := reopened.Refs().ResolveFully("refs/heads/main")+ if err != nil {+ t.Fatalf("ResolveFully(main): %v", err)+ }
+
+ if resolved.ID != commitID {+ t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, commitID)+ }
+
+ if gotType := receiver.Run(t, "cat-file", "-t", commitID.String()); gotType != "commit" {+ t.Fatalf("cat-file -t = %q, want commit", gotType)+ }
+
+ packs := receiver.Run(t, "count-objects", "-v")
+ if !strings.Contains(packs, "packs: 1") {+ t.Fatalf("count-objects output missing promoted pack: %q", packs)+ }
+ })
+}
+
+func TestReceivePackReportStatusV2IncludesRefDetails(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status-v2 atomic delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {+ t.Fatalf("ReceivePack: %v", err)+ }
+
+ got := output.String()
+ if !strings.Contains(got, "option refname refs/heads/main\n") {+ t.Fatalf("missing option refname in %q", got)+ }
+
+ if !strings.Contains(got, "option old-oid "+commitID.String()+"\n") {+ t.Fatalf("missing option old-oid in %q", got)+ }
+
+ if !strings.Contains(got, "option new-oid "+objectid.Zero(algo).String()+"\n") {+ t.Fatalf("missing option new-oid in %q", got)}
})
}
--- /dev/null
+++ b/receivepack/internal/service/apply.go
@@ -1,0 +1,107 @@
+package service
+
+import (
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/refstore"
+)
+
+func (service *Service) applyAtomic(result *Result, commands []Command) error {+ tx, err := service.opts.Refs.BeginTransaction()
+ if err != nil {+ return err
+ }
+
+ for _, command := range commands {+ err = queueWriteTransaction(tx, command)
+ if err != nil {+ _ = tx.Abort()
+ fillCommandErrors(result, commands, err.Error())
+
+ return nil
+ }
+ }
+
+ err = tx.Commit()
+ if err != nil {+ fillCommandErrors(result, commands, err.Error())
+
+ return nil
+ }
+
+ result.Applied = true
+ for _, command := range commands {+ result.Commands = append(result.Commands, successCommandResult(command))
+ }
+
+ return nil
+}
+
+func (service *Service) applyBatch(result *Result, commands []Command) error {+ batch, err := service.opts.Refs.BeginBatch()
+ if err != nil {+ return err
+ }
+
+ for _, command := range commands {+ queueWriteBatch(batch, command)
+ }
+
+ batchResults, err := batch.Apply()
+ if err != nil && len(batchResults) == 0 {+ return err
+ }
+
+ appliedAny := false
+
+ for i, command := range commands {+ item := successCommandResult(command)
+ if i < len(batchResults) && batchResults[i].Error != nil {+ item.Error = batchResults[i].Error.Error()
+ } else {+ appliedAny = true
+ }
+
+ result.Commands = append(result.Commands, item)
+ }
+
+ result.Applied = appliedAny
+
+ return nil
+}
+
+func queueWriteTransaction(tx refstore.Transaction, command Command) error {+ if isDelete(command) {+ return tx.Delete(command.Name, command.OldID)
+ }
+
+ if command.OldID == objectid.Zero(command.OldID.Algorithm()) {+ return tx.Create(command.Name, command.NewID)
+ }
+
+ return tx.Update(command.Name, command.NewID, command.OldID)
+}
+
+func queueWriteBatch(batch refstore.Batch, command Command) {+ if isDelete(command) {+ batch.Delete(command.Name, command.OldID)
+
+ return
+ }
+
+ if command.OldID == objectid.Zero(command.OldID.Algorithm()) {+ batch.Create(command.Name, command.NewID)
+
+ return
+ }
+
+ batch.Update(command.Name, command.NewID, command.OldID)
+}
+
+func successCommandResult(command Command) CommandResult {+ return CommandResult{+ Name: command.Name,
+ RefName: command.Name,
+ OldID: objectIDPointer(command.OldID),
+ NewID: objectIDPointer(command.NewID),
+ }
+}
--- a/receivepack/internal/service/command.go
+++ b/receivepack/internal/service/command.go
@@ -12,8 +12,11 @@
func fillCommandErrors(result *Result, commands []Command, errText string) { for _, command := range commands { result.Commands = append(result.Commands, CommandResult{- Name: command.Name,
- Error: errText,
+ Name: command.Name,
+ Error: errText,
+ RefName: command.Name,
+ OldID: objectIDPointer(command.OldID),
+ NewID: objectIDPointer(command.NewID),
})
}
}
@@ -20,4 +23,10 @@
func isDelete(command Command) bool {return command.NewID == objectid.Zero(command.NewID.Algorithm())
+}
+
+func objectIDPointer(id objectid.ObjectID) *objectid.ObjectID {+ out := id
+
+ return &out
}
--- a/receivepack/internal/service/command_result.go
+++ b/receivepack/internal/service/command_result.go
@@ -1,7 +1,13 @@
package service
+import "codeberg.org/lindenii/furgit/objectid"
+
// CommandResult is one per-command execution result.
type CommandResult struct {- Name string
- Error string
+ Name string
+ Error string
+ RefName string
+ OldID *objectid.ObjectID
+ NewID *objectid.ObjectID
+ ForcedUpdate bool
}
--- a/receivepack/internal/service/execute.go
+++ b/receivepack/internal/service/execute.go
@@ -2,7 +2,7 @@
import (
"context"
- "log"
+ "os"
"codeberg.org/lindenii/furgit/format/pack/ingest"
)
@@ -12,8 +12,6 @@
//
// TODO: Invoke hook or policy callbacks to decide whether each planned update
// should be allowed.
-// TODO: Apply planned ref updates with one atomic compare-and-swap ref
-// transaction once ref writing exists.
func (service *Service) Execute(ctx context.Context, req *Request) (*Result, error) {_ = ctx
@@ -20,6 +18,11 @@
result := &Result{Commands: make([]CommandResult, 0, len(req.Commands)),
}
+ var (
+ quarantineName string
+ quarantineRoot *os.Root
+ err error
+ )
if req.PackExpected { if req.Pack == nil {@@ -36,7 +39,7 @@
return result, nil
}
- quarantineName, quarantineRoot, err := service.createQuarantineRoot()
+ quarantineName, quarantineRoot, err = service.createQuarantineRoot()
if err != nil {result.UnpackError = err.Error()
fillCommandErrors(result, req.Commands, err.Error())
@@ -46,14 +49,24 @@
defer func() {_ = quarantineRoot.Close()
- // TODO: Promote accepted quarantined objects into the permanent object
- // store once atomic ref application exists.
_ = service.opts.ObjectsRoot.RemoveAll(quarantineName)
}()
+ quarantinePackRoot, err := service.openQuarantinePackRoot(quarantineRoot)
+ if err != nil {+ result.UnpackError = err.Error()
+ fillCommandErrors(result, req.Commands, err.Error())
+
+ return result, nil
+ }
+
+ defer func() {+ _ = quarantinePackRoot.Close()
+ }()
+
ingested, err := ingest.Ingest(
req.Pack,
- quarantineRoot,
+ quarantinePackRoot,
service.opts.Algorithm,
true,
true,
@@ -78,11 +91,35 @@
})
}
- fillCommandErrors(result, req.Commands, "ref updates not implemented yet")
- log.Printf(
- "receivepack: planned %d ref updates, but hook/policy checks and atomic ref writes are not implemented yet",
- len(result.Planned),
- )
+ if len(req.Commands) == 0 {+ return result, nil
+ }
+
+ if req.PackExpected {+ // Git migrates quarantined objects into permanent storage immediately
+ // before starting ref updates.
+ err = service.promoteQuarantine(quarantineName, quarantineRoot)
+ if err != nil {+ result.UnpackError = err.Error()
+ fillCommandErrors(result, req.Commands, err.Error())
+
+ return result, nil
+ }
+ }
+
+ if req.Atomic {+ err := service.applyAtomic(result, req.Commands)
+ if err != nil {+ return result, err
+ }
+
+ return result, nil
+ }
+
+ err = service.applyBatch(result, req.Commands)
+ if err != nil {+ return result, err
+ }
return result, nil
}
--- a/receivepack/internal/service/options.go
+++ b/receivepack/internal/service/options.go
@@ -11,7 +11,7 @@
// Options configures one protocol-independent receive-pack service.
type Options struct {Algorithm objectid.Algorithm
- Refs refstore.ReadingStore
+ Refs refstore.ReadWriteStore
ExistingObjects objectstore.Store
ObjectsRoot *os.Root
// TODO: Hook and such callbacks.
--- a/receivepack/internal/service/quarantine.go
+++ b/receivepack/internal/service/quarantine.go
@@ -1,8 +1,15 @@
package service
import (
+ "bytes"
"crypto/rand"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
"os"
+ "path"
+ "slices"
)
// createQuarantineRoot creates one per-push quarantine directory beneath the
@@ -23,4 +30,199 @@
}
return name, root, nil
+}
+
+func (service *Service) openQuarantinePackRoot(quarantineRoot *os.Root) (*os.Root, error) {+ err := quarantineRoot.Mkdir("pack", 0o755)+ if err != nil && !os.IsExist(err) {+ return nil, err
+ }
+
+ return quarantineRoot.OpenRoot("pack")+}
+
+func (service *Service) promoteQuarantine(quarantineName string, quarantineRoot *os.Root) error {+ if quarantineName == "" || quarantineRoot == nil {+ return nil
+ }
+
+ return service.promoteQuarantineDir(quarantineName, quarantineRoot, ".")
+}
+
+func (service *Service) promoteQuarantineDir(quarantineName string, quarantineRoot *os.Root, rel string) error {+ entries, err := fs.ReadDir(quarantineRoot.FS(), rel)
+ if err != nil && !os.IsNotExist(err) {+ return err
+ }
+
+ slices.SortFunc(entries, func(left, right fs.DirEntry) int {+ return packCopyPriority(left.Name()) - packCopyPriority(right.Name())
+ })
+
+ for _, entry := range entries {+ childRel := entry.Name()
+ if rel != "." {+ childRel = path.Join(rel, entry.Name())
+ }
+
+ if entry.IsDir() {+ err = service.opts.ObjectsRoot.Mkdir(childRel, 0o755)
+ if err != nil && !os.IsExist(err) {+ return err
+ }
+
+ err = service.promoteQuarantineDir(quarantineName, quarantineRoot, childRel)
+ if err != nil {+ return err
+ }
+
+ continue
+ }
+
+ err = finalizeQuarantineFile(
+ service.opts.ObjectsRoot,
+ path.Join(quarantineName, childRel),
+ childRel,
+ isLooseObjectShardPath(rel),
+ )
+ if err == nil {+ continue
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+func packCopyPriority(name string) int {+ if !pathHasPackPrefix(name) {+ return 0
+ }
+
+ switch {+ case path.Ext(name) == ".keep":
+ return 1
+ case path.Ext(name) == ".pack":
+ return 2
+ case path.Ext(name) == ".rev":
+ return 3
+ case path.Ext(name) == ".idx":
+ return 4
+ default:
+ return 5
+ }
+}
+
+func pathHasPackPrefix(name string) bool {+ return len(name) >= 4 && name[:4] == "pack"
+}
+
+func isLooseObjectShardPath(rel string) bool {+ return len(rel) == 2 && isHex(rel[0]) && isHex(rel[1])
+}
+
+func isHex(ch byte) bool {+ return ('0' <= ch && ch <= '9') || ('a' <= ch && ch <= 'f') || ('A' <= ch && ch <= 'F')+}
+
+func finalizeQuarantineFile(root *os.Root, src, dst string, skipCollisionCheck bool) error {+ const maxVanishedRetries = 5
+
+ for retries := 0; ; retries++ {+ err := root.Link(src, dst)
+ switch {+ case err == nil:
+ _ = root.Remove(src)
+
+ return nil
+ case !errors.Is(err, fs.ErrExist):
+ _, statErr := root.Stat(dst)
+ if statErr == nil {+ err = fs.ErrExist
+ } else if errors.Is(statErr, fs.ErrNotExist) {+ if renameErr := root.Rename(src, dst); renameErr == nil {+ return nil
+ }
+
+ return fmt.Errorf("promote quarantine %q -> %q: %w", src, dst, err)+ } else {+ return statErr
+ }
+ }
+
+ if skipCollisionCheck {+ _ = root.Remove(src)
+
+ return nil
+ }
+
+ equal, vanished, cmpErr := compareRootFiles(root, src, dst)
+ if vanished {+ if retries >= maxVanishedRetries {+ return fmt.Errorf("promote quarantine %q -> %q: destination repeatedly vanished", src, dst)+ }
+
+ continue
+ }
+
+ if cmpErr != nil {+ return cmpErr
+ }
+
+ if !equal {+ return fmt.Errorf("promote quarantine %q -> %q: files differ in contents", src, dst)+ }
+
+ _ = root.Remove(src)
+
+ return nil
+ }
+}
+
+func compareRootFiles(root *os.Root, left, right string) (equal bool, vanished bool, err error) {+ leftFile, err := root.Open(left)
+ if err != nil {+ return false, false, err
+ }
+
+ defer func() {+ _ = leftFile.Close()
+ }()
+
+ rightFile, err := root.Open(right)
+ if err != nil {+ if errors.Is(err, fs.ErrNotExist) {+ return false, true, nil
+ }
+
+ return false, false, err
+ }
+
+ defer func() {+ _ = rightFile.Close()
+ }()
+
+ var leftBuf, rightBuf [4096]byte
+
+ for {+ leftN, leftErr := leftFile.Read(leftBuf[:])
+ rightN, rightErr := rightFile.Read(rightBuf[:])
+
+ if leftErr != nil && !errors.Is(leftErr, io.EOF) {+ return false, false, leftErr
+ }
+
+ if rightErr != nil && !errors.Is(rightErr, io.EOF) {+ return false, false, rightErr
+ }
+
+ if leftN != rightN || !bytes.Equal(leftBuf[:leftN], rightBuf[:rightN]) {+ return false, false, nil
+ }
+
+ if leftErr != nil || rightErr != nil {+ return true, false, nil
+ }
+ }
}
--- /dev/null
+++ b/receivepack/internal/service/quarantine_test.go
@@ -1,0 +1,163 @@
+package service
+
+import (
+ "os"
+ "path"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objectstore/memory"
+)
+
+func TestPromoteQuarantineTreatsExistingLooseObjectAsSuccess(t *testing.T) {+ t.Parallel()
+
+ objectsDir := t.TempDir()
+ objectsRoot, err := os.OpenRoot(objectsDir)
+ if err != nil {+ t.Fatalf("os.OpenRoot: %v", err)+ }
+
+ t.Cleanup(func() {+ _ = objectsRoot.Close()
+ })
+
+ svc := New(Options{+ Algorithm: objectid.AlgorithmSHA1,
+ ExistingObjects: memory.New(objectid.AlgorithmSHA1),
+ ObjectsRoot: objectsRoot,
+ })
+
+ quarantineName, quarantineRoot, err := svc.createQuarantineRoot()
+ if err != nil {+ t.Fatalf("createQuarantineRoot: %v", err)+ }
+
+ t.Cleanup(func() {+ _ = quarantineRoot.Close()
+ _ = objectsRoot.RemoveAll(quarantineName)
+ })
+
+ if err := quarantineRoot.Mkdir("ab", 0o755); err != nil {+ t.Fatalf("Mkdir(ab): %v", err)+ }
+
+ if err := objectsRoot.Mkdir("ab", 0o755); err != nil {+ t.Fatalf("Mkdir(dst ab): %v", err)+ }
+
+ const payload = "same object bytes"
+ if err := quarantineRoot.WriteFile(path.Join("ab", "cdef"), []byte(payload), 0o644); err != nil {+ t.Fatalf("WriteFile(quarantine loose): %v", err)+ }
+
+ if err := objectsRoot.WriteFile(path.Join("ab", "cdef"), []byte(payload), 0o644); err != nil {+ t.Fatalf("WriteFile(permanent loose): %v", err)+ }
+
+ if err := svc.promoteQuarantine(quarantineName, quarantineRoot); err != nil {+ t.Fatalf("promoteQuarantine: %v", err)+ }
+}
+
+func TestPromoteQuarantineRejectsDifferentExistingPackFile(t *testing.T) {+ t.Parallel()
+
+ objectsDir := t.TempDir()
+ objectsRoot, err := os.OpenRoot(objectsDir)
+ if err != nil {+ t.Fatalf("os.OpenRoot: %v", err)+ }
+
+ t.Cleanup(func() {+ _ = objectsRoot.Close()
+ })
+
+ svc := New(Options{+ Algorithm: objectid.AlgorithmSHA1,
+ ExistingObjects: memory.New(objectid.AlgorithmSHA1),
+ ObjectsRoot: objectsRoot,
+ })
+
+ quarantineName, quarantineRoot, err := svc.createQuarantineRoot()
+ if err != nil {+ t.Fatalf("createQuarantineRoot: %v", err)+ }
+
+ t.Cleanup(func() {+ _ = quarantineRoot.Close()
+ _ = objectsRoot.RemoveAll(quarantineName)
+ })
+
+ if err := quarantineRoot.Mkdir("pack", 0o755); err != nil {+ t.Fatalf("Mkdir(pack): %v", err)+ }
+
+ if err := objectsRoot.Mkdir("pack", 0o755); err != nil {+ t.Fatalf("Mkdir(dst pack): %v", err)+ }
+
+ if err := quarantineRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte("new bytes"), 0o644); err != nil {+ t.Fatalf("WriteFile(quarantine pack): %v", err)+ }
+
+ if err := objectsRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte("old bytes"), 0o644); err != nil {+ t.Fatalf("WriteFile(permanent pack): %v", err)+ }
+
+ err = svc.promoteQuarantine(quarantineName, quarantineRoot)
+ if err == nil {+ t.Fatal("promoteQuarantine unexpectedly succeeded")+ }
+}
+
+func TestPromoteQuarantineAcceptsMatchingExistingPackFile(t *testing.T) {+ t.Parallel()
+
+ objectsDir := t.TempDir()
+ objectsRoot, err := os.OpenRoot(objectsDir)
+ if err != nil {+ t.Fatalf("os.OpenRoot: %v", err)+ }
+
+ t.Cleanup(func() {+ _ = objectsRoot.Close()
+ })
+
+ svc := New(Options{+ Algorithm: objectid.AlgorithmSHA1,
+ ExistingObjects: memory.New(objectid.AlgorithmSHA1),
+ ObjectsRoot: objectsRoot,
+ })
+
+ quarantineName, quarantineRoot, err := svc.createQuarantineRoot()
+ if err != nil {+ t.Fatalf("createQuarantineRoot: %v", err)+ }
+
+ t.Cleanup(func() {+ _ = quarantineRoot.Close()
+ _ = objectsRoot.RemoveAll(quarantineName)
+ })
+
+ if err := quarantineRoot.Mkdir("pack", 0o755); err != nil {+ t.Fatalf("Mkdir(pack): %v", err)+ }
+
+ if err := objectsRoot.Mkdir("pack", 0o755); err != nil {+ t.Fatalf("Mkdir(dst pack): %v", err)+ }
+
+ const payload = "identical pack bytes"
+ if err := quarantineRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte(payload), 0o644); err != nil {+ t.Fatalf("WriteFile(quarantine pack): %v", err)+ }
+
+ if err := objectsRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte(payload), 0o644); err != nil {+ t.Fatalf("WriteFile(permanent pack): %v", err)+ }
+
+ if err := svc.promoteQuarantine(quarantineName, quarantineRoot); err != nil {+ t.Fatalf("promoteQuarantine: %v", err)+ }
+}
--- a/receivepack/internal/service/request.go
+++ b/receivepack/internal/service/request.go
@@ -6,6 +6,7 @@
type Request struct {Commands []Command
PushOptions []string
+ Atomic bool
DeleteOnly bool
PackExpected bool
Pack io.Reader
--- a/receivepack/options.go
+++ b/receivepack/options.go
@@ -16,7 +16,7 @@
// Algorithm is the repository object ID algorithm used by the push session.
Algorithm objectid.Algorithm
// Refs is the reference store visible to the push.
- Refs refstore.ReadingStore
+ Refs refstore.ReadWriteStore
// ExistingObjects is the object store visible to the push before any newly
// uploaded quarantined objects are promoted.
ExistingObjects objectstore.Store
--- a/receivepack/receivepack.go
+++ b/receivepack/receivepack.go
@@ -14,12 +14,9 @@
func ReceivePack(
ctx context.Context,
w pktline.WriteFlusher,
- e io.Writer,
r io.Reader,
opts Options,
) error {- _ = e // TODO: Use stderr/progress sink explicitly as hook/progress behavior expands.
-
err := validateOptions(opts)
if err != nil {return err
@@ -63,6 +60,7 @@
serviceReq := &service.Request{Commands: translateCommands(req.Commands),
PushOptions: append([]string(nil), req.PushOptions...),
+ Atomic: req.Capabilities.Atomic,
DeleteOnly: req.DeleteOnly,
PackExpected: req.PackExpected,
Pack: r,
--- a/receivepack/translate.go
+++ b/receivepack/translate.go
@@ -26,8 +26,12 @@
for _, command := range result.Commands { out.Commands = append(out.Commands, protoreceive.CommandResult{- Name: command.Name,
- Error: command.Error,
+ Name: command.Name,
+ Error: command.Error,
+ RefName: command.RefName,
+ OldID: command.OldID,
+ NewID: command.NewID,
+ ForcedUpdate: command.ForcedUpdate,
})
}
--
⑨