diff --git a/git/gogit/client.go b/git/gogit/client.go index 096837b5..9dd4e9bf 100644 --- a/git/gogit/client.go +++ b/git/gogit/client.go @@ -351,7 +351,7 @@ func (g *Client) Commit(info git.Commit, commitOpts ...repository.CommitOption) return commit.String(), nil } -func (g *Client) Push(ctx context.Context) error { +func (g *Client) Push(ctx context.Context, cfg repository.PushConfig) error { if g.repository == nil { return git.ErrNoGitRepository } @@ -361,7 +361,26 @@ func (g *Client) Push(ctx context.Context) error { return fmt.Errorf("failed to construct auth method with options: %w", err) } + var refspecs []config.RefSpec + for _, ref := range cfg.Refspecs { + refspecs = append(refspecs, config.RefSpec(ref)) + } + + // If no refspecs were provided, we need to push the current ref HEAD points to. + // The format of a refspec for a Git push is generally something like + // "refs/heads/branch:refs/heads/branch". + if len(refspecs) == 0 { + head, err := g.repository.Head() + if err != nil { + return err + } + + headRefspec := config.RefSpec(fmt.Sprintf("%s:%[1]s", head.Name())) + refspecs = append(refspecs, headRefspec) + } + return g.repository.PushContext(ctx, &extgogit.PushOptions{ + RefSpecs: refspecs, Force: g.forcePush, RemoteName: extgogit.DefaultRemoteName, Auth: authMethod, diff --git a/git/gogit/client_test.go b/git/gogit/client_test.go index 3323a483..57a9149b 100644 --- a/git/gogit/client_test.go +++ b/git/gogit/client_test.go @@ -176,20 +176,12 @@ func TestCommit(t *testing.T) { func TestPush(t *testing.T) { g := NewWithT(t) - server, err := gittestserver.NewTempGitServer() + server, repoURL, err := setupGitServer(true) g.Expect(err).ToNot(HaveOccurred()) defer os.RemoveAll(server.Root()) - server.Auth("test-user", "test-pass") - - err = server.InitRepo("../testdata/git/repo", git.DefaultBranch, "test.git") - g.Expect(err).ToNot(HaveOccurred()) - - err = server.StartHTTP() - g.Expect(err).ToNot(HaveOccurred()) defer server.StopHTTP() tmp := t.TempDir() - repoURL := server.HTTPAddressWithCredentials() + "/" + "test.git" auth, err := transportAuth(&git.AuthOptions{ Transport: git.HTTP, Username: "test-user", @@ -209,10 +201,29 @@ func TestPush(t *testing.T) { g.Expect(err).ToNot(HaveOccurred()) ggc.repository = repo + // make a commit on master and push it. cc, err := commitFile(repo, "test", "testing gogit push", time.Now()) g.Expect(err).ToNot(HaveOccurred()) + err = ggc.Push(context.TODO(), repository.PushConfig{}) + g.Expect(err).ToNot(HaveOccurred()) + + // make a dummy commit on master. this helps us make sure we don't push all + // refs and push only the ref HEAD points to. + dummyCC, err := commitFile(repo, "test", "dummy commit", time.Now()) + g.Expect(err).ToNot(HaveOccurred()) + + // switch HEAD to a different branch + wt, err := repo.Worktree() + g.Expect(err).ToNot(HaveOccurred()) + err = wt.Checkout(&extgogit.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("test"), + Create: true, + }) + g.Expect(err).ToNot(HaveOccurred()) - err = ggc.Push(context.TODO()) + testCC, err := commitFile(repo, "test", "testing gogit push on test branch", time.Now()) + g.Expect(err).ToNot(HaveOccurred()) + err = ggc.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).ToNot(HaveOccurred()) repo, err = extgogit.PlainClone(t.TempDir(), false, &extgogit.CloneOptions{ @@ -223,35 +234,109 @@ func TestPush(t *testing.T) { ref, err := repo.Head() g.Expect(err).ToNot(HaveOccurred()) g.Expect(ref.Hash().String()).To(Equal(cc.String())) + // this assertion is not required but highlights the fact that we + // indeed only push the ref HEAD points to. + g.Expect(ref.Hash().String()).ToNot(Equal(dummyCC.String())) + + ref, err = repo.Reference(plumbing.NewRemoteReferenceName(git.DefaultRemote, "test"), true) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ref.Hash().String()).To(Equal(testCC.String())) } -func TestForcePush(t *testing.T) { +func TestPush_pushConfig_refspecs(t *testing.T) { g := NewWithT(t) - server, err := gittestserver.NewTempGitServer() + server, repoURL, err := setupGitServer(false) g.Expect(err).ToNot(HaveOccurred()) defer os.RemoveAll(server.Root()) - server.Auth("test-user", "test-pass") + defer server.StopHTTP() + g.Expect(err).ToNot(HaveOccurred()) - err = server.InitRepo("../testdata/git/repo", git.DefaultBranch, "test.git") + tmp := t.TempDir() + repo, err := extgogit.PlainClone(tmp, false, &extgogit.CloneOptions{ + URL: repoURL, + RemoteName: git.DefaultRemote, + Tags: extgogit.NoTags, + }) g.Expect(err).ToNot(HaveOccurred()) - err = server.StartHTTP() + ggc, err := NewClient(tmp, nil) g.Expect(err).ToNot(HaveOccurred()) - defer server.StopHTTP() + ggc.repository = repo - repoURL := server.HTTPAddressWithCredentials() + "/" + "test.git" - auth, err := transportAuth(&git.AuthOptions{ - Transport: git.HTTP, - Username: "test-user", - Password: "test-pass", - }, false) + head, err := repo.Head() + g.Expect(err).ToNot(HaveOccurred()) + _, err = tag(repo, head.Hash(), false, "v0.1.0", time.Now()) + g.Expect(err).ToNot(HaveOccurred()) + + wt, err := repo.Worktree() + g.Expect(err).ToNot(HaveOccurred()) + err = wt.Checkout(&extgogit.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("feature/refspecs"), + Create: true, + }) + g.Expect(err).ToNot(HaveOccurred()) + headOnFeature, err := commitFile(repo, "test", "testing it on feature/refspecs", time.Now()) + g.Expect(err).ToNot(HaveOccurred()) + + // Create an extra tag to check later that we push only using the provided refspec, + _, err = tag(repo, headOnFeature, false, "v0.2.0", time.Now()) + g.Expect(err).ToNot(HaveOccurred()) + + err = ggc.Push(context.TODO(), repository.PushConfig{ + Refspecs: []string{ + "refs/heads/feature/refspecs:refs/heads/feature/refspecs", + }, + }) + g.Expect(err).ToNot(HaveOccurred()) + + err = ggc.Push(context.TODO(), repository.PushConfig{ + Refspecs: []string{ + "refs/heads/feature/refspecs:refs/heads/prod/refspecs", + }, + }) + + err = ggc.Push(context.TODO(), repository.PushConfig{ + Refspecs: []string{ + "refs/tags/v0.1.0:refs/tags/v0.1.0", + }, + }) + + repo, err = extgogit.PlainClone(t.TempDir(), false, &extgogit.CloneOptions{ + URL: repoURL, + }) + g.Expect(err).ToNot(HaveOccurred()) + + remRefName := plumbing.NewRemoteReferenceName(extgogit.DefaultRemoteName, "feature/refspecs") + remRef, err := repo.Reference(remRefName, true) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(remRef.Hash().String()).To(Equal(headOnFeature.String())) + + remRefName = plumbing.NewRemoteReferenceName(extgogit.DefaultRemoteName, "prod/refspecs") + remRef, err = repo.Reference(remRefName, true) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(remRef.Hash().String()).To(Equal(headOnFeature.String())) + + tagRef, err := repo.Reference(plumbing.NewTagReferenceName("v0.1.0"), true) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(tagRef.Hash().String()).To(Equal(head.Hash().String())) + + tagRef, err = repo.Reference(plumbing.NewTagReferenceName("v0.2.0"), true) + g.Expect(err).To(HaveOccurred()) +} + +func TestForcePush(t *testing.T) { + g := NewWithT(t) + + server, repoURL, err := setupGitServer(false) + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(server.Root()) + defer server.StopHTTP() g.Expect(err).ToNot(HaveOccurred()) tmp1 := t.TempDir() repo1, err := extgogit.PlainClone(tmp1, false, &extgogit.CloneOptions{ URL: repoURL, - Auth: auth, RemoteName: git.DefaultRemote, Tags: extgogit.NoTags, }) @@ -267,7 +352,6 @@ func TestForcePush(t *testing.T) { tmp2 := t.TempDir() repo2, err := extgogit.PlainClone(tmp2, false, &extgogit.CloneOptions{ URL: repoURL, - Auth: auth, RemoteName: git.DefaultRemote, Tags: extgogit.NoTags, }) @@ -281,23 +365,22 @@ func TestForcePush(t *testing.T) { ggc2.repository = repo2 // First push from ggc1 should work. - err = ggc1.Push(context.TODO()) + err = ggc1.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).ToNot(HaveOccurred()) // Force push from ggc2 should override ggc1. - err = ggc2.Push(context.TODO()) + err = ggc2.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).ToNot(HaveOccurred()) // Follow-up push from ggc1 errors. _, err = commitFile(repo1, "test", "amend file again", time.Now()) g.Expect(err).ToNot(HaveOccurred()) - err = ggc1.Push(context.TODO()) + err = ggc1.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).To(HaveOccurred()) repo, err := extgogit.PlainClone(t.TempDir(), false, &extgogit.CloneOptions{ - URL: repoURL, - Auth: auth, + URL: repoURL, }) g.Expect(err).ToNot(HaveOccurred()) ref, err := repo.Head() @@ -554,15 +637,9 @@ func TestSwitchBranch(t *testing.T) { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) - server, err := gittestserver.NewTempGitServer() + server, repoURL, err := setupGitServer(false) g.Expect(err).ToNot(HaveOccurred()) defer os.RemoveAll(server.Root()) - - err = server.InitRepo("../testdata/git/repo", git.DefaultBranch, "test.git") - g.Expect(err).ToNot(HaveOccurred()) - - err = server.StartHTTP() - g.Expect(err).ToNot(HaveOccurred()) defer server.StopHTTP() var expectedHash string @@ -570,7 +647,6 @@ func TestSwitchBranch(t *testing.T) { expectedHash = tt.setupFunc(g, filepath.Join(server.Root(), "test.git")) } - repoURL := server.HTTPAddressWithCredentials() + "/" + "test.git" tmp := t.TempDir() repo, err := extgogit.PlainClone(tmp, false, &extgogit.CloneOptions{ URL: repoURL, @@ -756,3 +832,30 @@ func TestValidateUrl(t *testing.T) { }) } } + +// setupGitServer sets up, starts an HTTP Git server. It initialzes +// a repo on the server and then returns the server and the URL of the +// initialized repository. The auth argument can be set to true to enable +// basic auth. +func setupGitServer(auth bool) (*gittestserver.GitServer, string, error) { + server, err := gittestserver.NewTempGitServer() + if err != nil { + return nil, "", err + } + if auth { + server.Auth("test-user", "test-pass") + } + + err = server.InitRepo("../testdata/git/repo", git.DefaultBranch, "test.git") + if err != nil { + return nil, "", err + } + + err = server.StartHTTP() + if err != nil { + return nil, "", err + } + + repoURL := server.HTTPAddressWithCredentials() + "/" + "test.git" + return server, repoURL, nil +} diff --git a/git/internal/e2e/utils.go b/git/internal/e2e/utils.go index f06de44a..3d6dbc74 100644 --- a/git/internal/e2e/utils.go +++ b/git/internal/e2e/utils.go @@ -62,7 +62,7 @@ func testUsingClone(g *WithT, client repository.Client, repoURL *url.URL, upstre ) g.Expect(err).ToNot(HaveOccurred(), "first commit") - err = client.Push(context.TODO()) + err = client.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).ToNot(HaveOccurred()) headCommit, _, err := headCommitWithBranch(upstreamRepo.url, "main", upstreamRepo.username, upstreamRepo.password) @@ -82,7 +82,7 @@ func testUsingClone(g *WithT, client repository.Client, repoURL *url.URL, upstre ) g.Expect(err).ToNot(HaveOccurred(), "second commit") - err = client.Push(context.TODO()) + err = client.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).ToNot(HaveOccurred()) headCommit, branch, err := headCommitWithBranch(upstreamRepo.url, "new", upstreamRepo.username, upstreamRepo.password) g.Expect(err).ToNot(HaveOccurred()) @@ -100,7 +100,7 @@ func testUsingClone(g *WithT, client repository.Client, repoURL *url.URL, upstre }), ) g.Expect(err).ToNot(HaveOccurred(), "third commit") - err = client.Push(context.TODO()) + err = client.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).ToNot(HaveOccurred()) headCommit, _, err = headCommitWithBranch(upstreamRepo.url, "new", upstreamRepo.username, upstreamRepo.password) g.Expect(err).ToNot(HaveOccurred()) @@ -120,7 +120,7 @@ func testUsingInit(g *WithT, client repository.Client, repoURL *url.URL, upstrea ) g.Expect(err).ToNot(HaveOccurred(), "first commit") - err = client.Push(context.TODO()) + err = client.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).ToNot(HaveOccurred()) headCommit, _, err := headCommitWithBranch(upstreamRepo.url, "main", upstreamRepo.username, upstreamRepo.password) @@ -138,7 +138,7 @@ func testUsingInit(g *WithT, client repository.Client, repoURL *url.URL, upstrea ) g.Expect(err).ToNot(HaveOccurred(), "second commit") - err = client.Push(context.TODO()) + err = client.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).ToNot(HaveOccurred()) headCommit, branch, err := headCommitWithBranch(upstreamRepo.url, "new", upstreamRepo.username, upstreamRepo.password) g.Expect(err).ToNot(HaveOccurred()) @@ -155,7 +155,7 @@ func testUsingInit(g *WithT, client repository.Client, repoURL *url.URL, upstrea }), ) g.Expect(err).ToNot(HaveOccurred(), "third commit") - err = client.Push(context.TODO()) + err = client.Push(context.TODO(), repository.PushConfig{}) g.Expect(err).ToNot(HaveOccurred()) headCommit, _, err = headCommitWithBranch(upstreamRepo.url, "new", upstreamRepo.username, upstreamRepo.password) g.Expect(err).ToNot(HaveOccurred()) diff --git a/git/repository/client.go b/git/repository/client.go index 1485fe2a..19a33e2a 100644 --- a/git/repository/client.go +++ b/git/repository/client.go @@ -44,8 +44,10 @@ type Writer interface { // Init initializes a repository at the configured path with the remote // origin set to url on the provided branch. Init(ctx context.Context, url, branch string) error - // Push pushes the current branch of the repository to origin. - Push(ctx context.Context) error + // Push performs a Git push to origin. By default, it pushes the + // reference pointed to by the HEAD, to its equivalent destination at + // the origin, but this is configurable via PushConfig. + Push(ctx context.Context, cfg PushConfig) error // SwitchBranch switches from the current branch of the repository to the // provided branch. If the branch doesn't exist, it is created. SwitchBranch(ctx context.Context, branch string) error diff --git a/git/repository/options.go b/git/repository/options.go index 9e218f68..4336ee0b 100644 --- a/git/repository/options.go +++ b/git/repository/options.go @@ -53,6 +53,14 @@ type CloneOptions struct { ShallowClone bool } +// PushConfig provides configuration options for a Git push. +type PushConfig struct { + // Refspecs is a list of refspecs to use for the push operation. + // For details about Git Refspecs, please see: + // https://git-scm.com/book/en/v2/Git-Internals-The-Refspec + Refspecs []string +} + // CheckoutStrategy provides options to checkout a repository to a target. type CheckoutStrategy struct { // Branch to checkout. If supported by the client, it can be combined