aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--receivepack/int_test.go249
-rw-r--r--receivepack/internal/service/apply.go107
-rw-r--r--receivepack/internal/service/command.go13
-rw-r--r--receivepack/internal/service/command_result.go10
-rw-r--r--receivepack/internal/service/execute.go61
-rw-r--r--receivepack/internal/service/options.go2
-rw-r--r--receivepack/internal/service/quarantine.go202
-rw-r--r--receivepack/internal/service/quarantine_test.go163
-rw-r--r--receivepack/internal/service/request.go1
-rw-r--r--receivepack/options.go2
-rw-r--r--receivepack/receivepack.go4
-rw-r--r--receivepack/translate.go8
12 files changed, 789 insertions, 33 deletions
diff --git a/receivepack/int_test.go b/receivepack/int_test.go
index a790741b..8f0d02e6 100644
--- a/receivepack/int_test.go
+++ b/receivepack/int_test.go
@@ -3,6 +3,7 @@ package receivepack_test
import (
"context"
"fmt"
+ "io"
"strings"
"testing"
@@ -13,7 +14,7 @@ import (
// TODO: actually test with send-pack
-func TestReceivePackDeleteOnlyReportsNotImplemented(t *testing.T) {
+func TestReceivePackDeleteOnlyAtomicDeleteSucceeds(t *testing.T) {
t.Parallel()
//nolint:thelper
@@ -32,11 +33,11 @@ func TestReceivePackDeleteOnlyReportsNotImplemented(t *testing.T) {
)
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,9 +48,119 @@ func TestReceivePackDeleteOnlyReportsNotImplemented(t *testing.T) {
}
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)
+ }
})
}
@@ -74,7 +185,7 @@ func TestReceivePackAdvertisesResolvedHEAD(t *testing.T) {
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 @@ func TestReceivePackWithoutReportStatusWritesNoStatusPayload(t *testing.T) {
)
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 @@ func testReceivePackProtocolFallback(t *testing.T, gitProtocol string) {
)
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 @@ func TestReceivePackPackRequestWithoutObjectsRootReportsNotConfigured(t *testing
))
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(),
@@ -221,6 +332,124 @@ func TestReceivePackPackRequestWithoutObjectsRootReportsNotConfigured(t *testing
})
}
+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)
+ }
+ })
+}
+
type bufferWriteFlusher struct {
strings.Builder
}
diff --git a/receivepack/internal/service/apply.go b/receivepack/internal/service/apply.go
new file mode 100644
index 00000000..c24d2a95
--- /dev/null
+++ b/receivepack/internal/service/apply.go
@@ -0,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),
+ }
+}
diff --git a/receivepack/internal/service/command.go b/receivepack/internal/service/command.go
index f51461ff..33342e41 100644
--- a/receivepack/internal/service/command.go
+++ b/receivepack/internal/service/command.go
@@ -12,8 +12,11 @@ type Command struct {
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),
})
}
}
@@ -21,3 +24,9 @@ func fillCommandErrors(result *Result, commands []Command, errText string) {
func isDelete(command Command) bool {
return command.NewID == objectid.Zero(command.NewID.Algorithm())
}
+
+func objectIDPointer(id objectid.ObjectID) *objectid.ObjectID {
+ out := id
+
+ return &out
+}
diff --git a/receivepack/internal/service/command_result.go b/receivepack/internal/service/command_result.go
index 1234c8ef..18e39acc 100644
--- 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
}
diff --git a/receivepack/internal/service/execute.go b/receivepack/internal/service/execute.go
index b3d47d29..ebba9003 100644
--- a/receivepack/internal/service/execute.go
+++ b/receivepack/internal/service/execute.go
@@ -2,7 +2,7 @@ package service
import (
"context"
- "log"
+ "os"
"codeberg.org/lindenii/furgit/format/pack/ingest"
)
@@ -12,14 +12,17 @@ import (
//
// 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
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 @@ func (service *Service) Execute(ctx context.Context, req *Request) (*Result, err
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 @@ func (service *Service) Execute(ctx context.Context, req *Request) (*Result, err
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 @@ func (service *Service) Execute(ctx context.Context, req *Request) (*Result, err
})
}
- 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
}
diff --git a/receivepack/internal/service/options.go b/receivepack/internal/service/options.go
index 2bc70058..4ba06827 100644
--- a/receivepack/internal/service/options.go
+++ b/receivepack/internal/service/options.go
@@ -11,7 +11,7 @@ import (
// 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.
diff --git a/receivepack/internal/service/quarantine.go b/receivepack/internal/service/quarantine.go
index 17ff6279..101cadc7 100644
--- 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
@@ -24,3 +31,198 @@ func (service *Service) createQuarantineRoot() (string, *os.Root, error) {
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
+ }
+ }
+}
diff --git a/receivepack/internal/service/quarantine_test.go b/receivepack/internal/service/quarantine_test.go
new file mode 100644
index 00000000..795fb35d
--- /dev/null
+++ b/receivepack/internal/service/quarantine_test.go
@@ -0,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)
+ }
+}
diff --git a/receivepack/internal/service/request.go b/receivepack/internal/service/request.go
index 62764501..7a0b1f33 100644
--- a/receivepack/internal/service/request.go
+++ b/receivepack/internal/service/request.go
@@ -6,6 +6,7 @@ import "io"
type Request struct {
Commands []Command
PushOptions []string
+ Atomic bool
DeleteOnly bool
PackExpected bool
Pack io.Reader
diff --git a/receivepack/options.go b/receivepack/options.go
index 56b4a006..7763854a 100644
--- a/receivepack/options.go
+++ b/receivepack/options.go
@@ -16,7 +16,7 @@ type Options struct {
// 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
diff --git a/receivepack/receivepack.go b/receivepack/receivepack.go
index 9f4a582b..ec5d956b 100644
--- a/receivepack/receivepack.go
+++ b/receivepack/receivepack.go
@@ -14,12 +14,9 @@ import (
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 @@ func ReceivePack(
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,
diff --git a/receivepack/translate.go b/receivepack/translate.go
index ee61b683..d572abd3 100644
--- a/receivepack/translate.go
+++ b/receivepack/translate.go
@@ -26,8 +26,12 @@ func translateResult(result *service.Result) protoreceive.ReportStatusResult {
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,
})
}