diff options
Diffstat (limited to 'network/receivepack/int_test.go')
| -rw-r--r-- | network/receivepack/int_test.go | 1047 |
1 files changed, 1047 insertions, 0 deletions
diff --git a/network/receivepack/int_test.go b/network/receivepack/int_test.go new file mode 100644 index 00000000..d17e6cd4 --- /dev/null +++ b/network/receivepack/int_test.go @@ -0,0 +1,1047 @@ +package receivepack_test + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "codeberg.org/lindenii/furgit/internal/testgit" + "codeberg.org/lindenii/furgit/network/protocol/pktline" + "codeberg.org/lindenii/furgit/network/protocol/sideband64k" + objectid "codeberg.org/lindenii/furgit/object/id" + receivepack "codeberg.org/lindenii/furgit/network/receivepack" + receivepackhooks "codeberg.org/lindenii/furgit/network/receivepack/hooks" +) + +func TestReceivePackDeleteOnlyAtomicDeleteSucceeds(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 atomic delete-refs object-format=" + algo.String() + "\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, "ok refs/heads/main\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if 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) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if err != nil { + t.Fatalf("Resolve(main): %v", err) + } + + _, err = repo.Refs().Resolve("refs/heads/topic") + if 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) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if err != nil { + t.Fatalf("Resolve(main): %v", err) + } + + _, err = repo.Refs().Resolve("refs/heads/topic") + if err != nil { + t.Fatalf("Resolve(topic): %v", err) + } + }) +} + +func TestReceivePackAdvertisesResolvedHEAD(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) + testRepo.SymbolicRef(t, "HEAD", "refs/heads/main") + + repo := testRepo.OpenRepository(t) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + 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() + + want := commitID.String() + " HEAD" + if !strings.Contains(got, want) { + t.Fatalf("HEAD advertisement missing %q in %q", want, got) + } + }) +} + +func TestReceivePackVersion2FallsBackToV0(t *testing.T) { + t.Parallel() + + testReceivePackProtocolFallback(t, "version=2") +} + +func TestReceivePackHighestRequestedVersionFallsBackToV0ForV2(t *testing.T) { + t.Parallel() + + testReceivePackProtocolFallback(t, "version=1:version=2") +} + +func TestReceivePackWithoutReportStatusWritesNoStatusPayload(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\x00delete-refs atomic 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, "unpack ") || strings.Contains(got, "ng refs/heads/main ") || strings.Contains(got, "ok refs/heads/main\n") { + t.Fatalf("unexpected status payload %q", got) + } + }) +} + +func testReceivePackProtocolFallback(t *testing.T, gitProtocol string) { + t.Helper() + + //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 atomic delete-refs object-format=" + algo.String() + "\n", + )) + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{ + GitProtocol: gitProtocol, + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + if strings.HasPrefix(output.String(), pktlineData("version 1\n")) { + t.Fatalf("receive-pack output started with protocol v1 preface for %q: %q", gitProtocol, output.String()) + } + }) +} + +func TestReceivePackPackRequestWithoutObjectsRootReportsNotConfigured(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() + " " + commitID.String() + " refs/heads/main\x00report-status 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, "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().ResolveToDetached("refs/heads/main") + if err != nil { + t.Fatalf("ResolveToDetached(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 TestReceivePackHookSeesQuarantinedObjectsAndCanRejectBeforePromotion(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 + hookCalled bool + ) + + 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, + Hook: func(ctx context.Context, req receivepack.HookRequest) ([]receivepack.UpdateDecision, error) { + hookCalled = true + + if len(req.Updates) != 1 || req.Updates[0].NewID != commitID { + t.Fatalf("unexpected hook updates: %+v", req.Updates) + } + + _, _, err := req.ExistingObjects.ReadHeader(commitID) + if err == nil { + t.Fatalf("existing objects unexpectedly contained quarantined commit %s", commitID) + } + + _, _, err = req.QuarantinedObjects.ReadHeader(commitID) + if err != nil { + t.Fatalf("quarantined objects missing commit %s: %v", commitID, err) + } + + return []receivepack.UpdateDecision{{ + Accept: false, + Message: "blocked by hook", + }}, nil + }, + }, + ) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + if !hookCalled { + t.Fatal("hook was not called") + } + + got := output.String() + if !strings.Contains(got, "unpack ok\n") || !strings.Contains(got, "ng refs/heads/main blocked by hook\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if err == nil { + t.Fatal("refs/heads/main exists after hook rejection") + } + + packs := receiver.Run(t, "count-objects", "-v") + if !strings.Contains(packs, "packs: 0") { + t.Fatalf("count-objects output shows unexpected promoted pack: %q", packs) + } + }) +} + +func TestReceivePackHookCanRejectSubsetOfNonAtomicDeleteOnlyPush(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) + testRepo.UpdateRef(t, "refs/heads/topic", 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 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{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + Hook: func(ctx context.Context, req receivepack.HookRequest) ([]receivepack.UpdateDecision, error) { + return []receivepack.UpdateDecision{ + {Accept: false, Message: "leave main alone"}, + {Accept: true}, + }, nil + }, + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if !strings.Contains(got, "ng refs/heads/main leave main alone\n") || !strings.Contains(got, "ok refs/heads/topic\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if err != nil { + t.Fatalf("Resolve(main): %v", err) + } + + _, err = repo.Refs().Resolve("refs/heads/topic") + if err == nil { + t.Fatal("refs/heads/topic still exists after successful delete") + } + }) +} + +func TestReceivePackHookProgressUsesSideBand64K(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 side-band-64k 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(), + Hook: func(ctx context.Context, req receivepack.HookRequest) ([]receivepack.UpdateDecision, error) { + _, err := io.WriteString(req.IO.Progress, "hook says hello\n") + if err != nil { + return nil, err + } + + return []receivepack.UpdateDecision{{Accept: true}}, nil + }, + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + _, sidebandWire, ok := strings.Cut(output.String(), "0000") + if !ok { + t.Fatalf("output missing advertisement flush: %q", output.String()) + } + + dec := sideband64k.NewDecoder(strings.NewReader(sidebandWire), sideband64k.ReadOptions{}) + + sawHookProgress := false + + var frame sideband64k.Frame + + for { + var err error + + frame, err = dec.ReadFrame() + if err != nil { + t.Fatalf("ReadFrame: %v", err) + } + + if frame.Type == sideband64k.FrameProgress && string(frame.Payload) == "hook says hello\n" { + sawHookProgress = true + } + + if frame.Type == sideband64k.FrameData { + break + } + } + + if !sawHookProgress { + t.Fatal("missing hook progress frame") + } + + statusDec := pktline.NewDecoder(strings.NewReader(string(frame.Payload)), pktline.ReadOptions{}) + + statusFrame, err := statusDec.ReadFrame() + if err != nil { + t.Fatalf("ReadFrame(status unpack): %v", err) + } + + if statusFrame.Type != pktline.PacketData || string(statusFrame.Payload) != "unpack ok\n" { + t.Fatalf("status frame = %#v", statusFrame) + } + }) +} + +func TestReceivePackPredefinedRejectForcePushHookRejectsNonFastForward(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, Bare: true}) + _, treeID := testRepo.MakeSingleFileTree(t, "base.txt", []byte("base\n")) + baseID := testRepo.CommitTree(t, treeID, "base") + currentID := testRepo.CommitTree(t, treeID, "current", baseID) + forcedID := testRepo.CommitTree(t, treeID, "forced", baseID) + testRepo.UpdateRef(t, "refs/heads/main", currentID) + + repo := testRepo.OpenRepository(t) + objectsRoot := testRepo.OpenObjectsRoot(t) + packStream := testRepo.PackObjectsReader(t, []string{forcedID.String(), "^" + currentID.String()}, false) + t.Cleanup(func() { + _ = packStream.Close() + }) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + input.WriteString(pktlineData( + currentID.String() + " " + forcedID.String() + " refs/heads/main\x00report-status 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, + Hook: receivepackhooks.RejectForcePush(), + }, + ) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if !strings.Contains(got, "ng refs/heads/main non-fast-forward\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + + resolved, err := repo.Refs().ResolveToDetached("refs/heads/main") + if err != nil { + t.Fatalf("ResolveToDetached(main): %v", err) + } + + if resolved.ID != currentID { + t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, currentID) + } + }) +} + +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) + } + }) +} + +func TestReceivePackGitPushCreatesBranch(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + t.Parallel() + + sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo}) + _, _, commitID := sender.MakeCommit(t, "pushed commit") + sender.UpdateRef(t, "refs/heads/main", commitID) + + receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + repo := receiver.OpenRepository(t) + objectsRoot := receiver.OpenObjectsRoot(t) + + stdout, stderr, clientErr, serverErr := runGitPushFD( + t, + sender, + receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + ObjectsRoot: objectsRoot, + }, + "push", "--porcelain", "fd::3,4/test", "refs/heads/main:refs/heads/main", + ) + if clientErr != nil { + t.Fatalf("git push failed: %v\nstdout=%s\nstderr=%s", clientErr, stdout, stderr) + } + + if serverErr != nil { + t.Fatalf("ReceivePack: %v", serverErr) + } + + resolved, err := receiver.OpenRepository(t).Refs().ResolveToDetached("refs/heads/main") + if err != nil { + t.Fatalf("ResolveToDetached(main): %v", err) + } + + if resolved.ID != commitID { + t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, commitID) + } + }) +} + +func TestReceivePackGitPushRefUpdateWithoutNewObjectsSucceeds(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + t.Parallel() + + sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo}) + blobID, treeID := sender.MakeSingleFileTree(t, "base.txt", []byte("base\n")) + commitID := sender.CommitTree(t, treeID, "base") + sender.UpdateRef(t, "refs/heads/main", commitID) + + receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + receiver.HashObject(t, "blob", sender.RunBytes(t, "cat-file", "blob", blobID.String())) + receiver.HashObject(t, "tree", sender.RunBytes(t, "cat-file", "tree", treeID.String())) + receiver.HashObject(t, "commit", sender.RunBytes(t, "cat-file", "commit", commitID.String())) + receiver.UpdateRef(t, "refs/heads/main", commitID) + + repo := receiver.OpenRepository(t) + objectsRoot := receiver.OpenObjectsRoot(t) + + stdout, stderr, clientErr, serverErr := runGitPushFD( + t, + sender, + receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + ObjectsRoot: objectsRoot, + }, + "push", "--porcelain", "fd::3,4/test", "refs/heads/main:refs/heads/topic", + ) + if clientErr != nil { + t.Fatalf("git push failed: %v\nstdout=%s\nstderr=%s", clientErr, stdout, stderr) + } + + if serverErr != nil { + t.Fatalf("ReceivePack: %v", serverErr) + } + + resolved, err := receiver.OpenRepository(t).Refs().ResolveToDetached("refs/heads/topic") + if err != nil { + t.Fatalf("ResolveToDetached(topic): %v", err) + } + + if resolved.ID != commitID { + t.Fatalf("refs/heads/topic = %s, want %s", resolved.ID, commitID) + } + + packs := receiver.Run(t, "count-objects", "-v") + if !strings.Contains(packs, "packs: 0") { + t.Fatalf("count-objects output shows unexpected promoted pack: %q", packs) + } + }) +} + +func TestReceivePackGitPushAtomicDelete(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + t.Parallel() + + sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo}) + receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + _, _, commitID := receiver.MakeCommit(t, "base") + receiver.UpdateRef(t, "refs/heads/main", commitID) + + repo := receiver.OpenRepository(t) + + stdout, stderr, clientErr, serverErr := runGitPushFD( + t, + sender, + receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }, + "push", "--porcelain", "--atomic", "fd::3,4/test", ":refs/heads/main", + ) + if clientErr != nil { + t.Fatalf("git push failed: %v\nstdout=%s\nstderr=%s", clientErr, stdout, stderr) + } + + if serverErr != nil { + t.Fatalf("ReceivePack: %v", serverErr) + } + + _, err := receiver.OpenRepository(t).Refs().Resolve("refs/heads/main") + if err == nil { + t.Fatal("refs/heads/main still exists after delete push") + } + }) +} + +func TestReceivePackGitPushRejectsForcedUpdateViaHook(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + t.Parallel() + + sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo}) + blobID, treeID := sender.MakeSingleFileTree(t, "base.txt", []byte("base\n")) + baseID := sender.CommitTree(t, treeID, "base") + currentID := sender.CommitTree(t, treeID, "current", baseID) + forcedID := sender.CommitTree(t, treeID, "forced", baseID) + sender.UpdateRef(t, "refs/heads/main", forcedID) + + receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + receiver.HashObject(t, "blob", sender.RunBytes(t, "cat-file", "blob", blobID.String())) + receiver.HashObject(t, "tree", sender.RunBytes(t, "cat-file", "tree", treeID.String())) + receiver.HashObject(t, "commit", sender.RunBytes(t, "cat-file", "commit", baseID.String())) + receiver.HashObject(t, "commit", sender.RunBytes(t, "cat-file", "commit", currentID.String())) + receiver.UpdateRef(t, "refs/heads/main", currentID) + + repo := receiver.OpenRepository(t) + objectsRoot := receiver.OpenObjectsRoot(t) + + stdout, stderr, clientErr, serverErr := runGitPushFD( + t, + sender, + receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + ObjectsRoot: objectsRoot, + Hook: receivepackhooks.RejectForcePush(), + }, + "push", "--porcelain", "--force", "fd::3,4/test", "refs/heads/main:refs/heads/main", + ) + if clientErr == nil { + t.Fatalf("git push unexpectedly succeeded\nstdout=%s\nstderr=%s", stdout, stderr) + } + + if serverErr != nil { + t.Fatalf("ReceivePack: %v", serverErr) + } + + if !strings.Contains(stdout, "non-fast-forward") && !strings.Contains(stderr, "non-fast-forward") { + t.Fatalf("git push output missing non-fast-forward message\nstdout=%s\nstderr=%s", stdout, stderr) + } + + resolved, err := receiver.OpenRepository(t).Refs().ResolveToDetached("refs/heads/main") + if err != nil { + t.Fatalf("ResolveToDetached(main): %v", err) + } + + if resolved.ID != currentID { + t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, currentID) + } + }) +} + +type bufferWriteFlusher struct { + strings.Builder +} + +func (bufferWriteFlusher) Flush() error { + return nil +} + +func pktlineData(payload string) string { + return fmt.Sprintf("%04x%s", len(payload)+4, payload) +} + +type fileWriteFlusher struct { + *os.File +} + +func (fileWriteFlusher) Flush() error { + return nil +} + +func runGitPushFD( + tb testing.TB, + sender *testgit.TestRepo, + opts receivepack.Options, + gitArgs ...string, +) (stdout string, stderr string, clientErr error, serverErr error) { + tb.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + serverRead, clientWrite, err := os.Pipe() + if err != nil { + tb.Fatalf("os.Pipe(serverRead/clientWrite): %v", err) + } + + clientRead, serverWrite, err := os.Pipe() + if err != nil { + tb.Fatalf("os.Pipe(clientRead/serverWrite): %v", err) + } + + tb.Cleanup(func() { + _ = serverRead.Close() + _ = clientWrite.Close() + _ = clientRead.Close() + _ = serverWrite.Close() + }) + + go func() { + <-ctx.Done() + + _ = serverRead.Close() + _ = clientWrite.Close() + _ = clientRead.Close() + _ = serverWrite.Close() + }() + + serverErrCh := make(chan error, 1) + + go func() { + defer func() { + _ = serverRead.Close() + _ = serverWrite.Close() + }() + + serverErrCh <- receivepack.ReceivePack( + ctx, + fileWriteFlusher{serverWrite}, + serverRead, + opts, + ) + }() + + stdoutBytes, stderrBytes, clientErr := sender.RunWithExtraFilesEnvContextE( + tb, + ctx, + nil, + []*os.File{clientRead, clientWrite}, + gitArgs..., + ) + _ = clientRead.Close() + _ = clientWrite.Close() + + serverErr = <-serverErrCh + + if ctx.Err() != nil { + tb.Fatalf( + "git push fd:: timed out\nstdout=%s\nstderr=%s\nclientErr=%v\nserverErr=%v", + stdoutBytes, + stderrBytes, + clientErr, + serverErr, + ) + } + + return string(stdoutBytes), string(stderrBytes), clientErr, serverErr +} |
