From cbf7e669ad1318022fa621cef737e4ef3f6aee60 Mon Sep 17 00:00:00 2001 From: Sergiy Kulanov Date: Mon, 3 Aug 2026 22:08:18 +0300 Subject: [PATCH] EPMDEDP-17251: fix: Replace clone-based CodebaseBranch git operations with packless transport Branch reconciliation cloned the entire repository per CodebaseBranch only to validate a reference and push a branch pointer. The two-step clone was not atomic: a transient network failure between clone and fetch left a half-initialized workdir cache that later reconciles trusted forever, permanently failing the CR until manual intervention. go-git clone is also documented to exhaust memory on large repositories. - resolve references through the upload-pack advertisement (ls-remote equivalent): branch, tag with peeled hash, HEAD, or full commit hash, bounded by ref count instead of repository size - create remote branches with a receive-pack reference update carrying an empty packfile, the mechanism the git pack protocol mandates when the server already owns the target object - resolve fromRef via upload-pack because receive-pack advertisements omit peeled tag hashes and a branch must point at the peeled commit, not the tag object - fall back to the codebase default branch for an empty fromCommit, since receive-pack advertises no HEAD symref - drop workdir management from the branch chain and remove the superseded clone-based methods Each reconcile attempt is now self-contained: nothing to poison between retries, branches pushed after CR creation are found on the next attempt, and per-branch disk usage is zero. Signed-off-by: Sergiy Kulanov --- .../codebasebranch/chain/check_reference.go | 15 +- .../chain/check_reference_test.go | 33 +- .../put_branch_in_git/put_branch_in_git.go | 38 +- .../put_branch_in_git_test.go | 137 +---- controllers/codebasebranch/chain/util.go | 5 - pkg/git/git.go | 15 +- pkg/git/mocks/git_generated.mock.go | 163 +++--- pkg/git/provider.go | 120 ---- pkg/git/provider_test.go | 528 +----------------- pkg/git/transport.go | 228 ++++++++ pkg/git/transport_test.go | 438 +++++++++++++++ 11 files changed, 787 insertions(+), 933 deletions(-) create mode 100644 pkg/git/transport.go create mode 100644 pkg/git/transport_test.go diff --git a/controllers/codebasebranch/chain/check_reference.go b/controllers/codebasebranch/chain/check_reference.go index 4129effd..2124611e 100644 --- a/controllers/codebasebranch/chain/check_reference.go +++ b/controllers/codebasebranch/chain/check_reference.go @@ -75,27 +75,17 @@ func (c CheckReferenceExists) ServeRequest(ctx context.Context, codebaseBranch * return c.processErr(codebaseBranch, fmt.Errorf("failed to get secret %s: %w", gitServer.Spec.NameSshKeySecret, err)) } - // Create git provider using factory g := c.GitProviderFactory(gitproviderv2.NewConfigFromGitServerAndSecret(gitServer, secret)) - workDir := GetCodebaseBranchWorkingDirectory(codebaseBranch) - if !DirectoryExistsNotEmpty(workDir) { - repoGitUrl := util.GetProjectGitUrl(gitServer, secret, codebase.Spec.GetProjectID()) + repoGitUrl := util.GetProjectGitUrl(gitServer, secret, codebase.Spec.GetProjectID()) - if err := g.Clone(ctx, repoGitUrl, workDir); err != nil { - return c.processErr(codebaseBranch, fmt.Errorf("failed to clone repository: %w", err)) - } - } - - err := g.CheckReference(ctx, workDir, codebaseBranch.Spec.FromCommit) - if err != nil { + if _, err := g.ResolveRemoteReference(ctx, repoGitUrl, codebaseBranch.Spec.FromCommit); err != nil { return c.processErr(codebaseBranch, fmt.Errorf("reference %s doesn't exist: %w", codebaseBranch.Spec.FromCommit, err)) } return c.next(ctx, codebaseBranch) } -// next is a method for serving next chain element. func (c CheckReferenceExists) next(ctx context.Context, codebaseBranch *codebaseApi.CodebaseBranch) error { err := handler.NextServeOrNil(ctx, c.Next, codebaseBranch) if err != nil { @@ -105,7 +95,6 @@ func (c CheckReferenceExists) next(ctx context.Context, codebaseBranch *codebase return nil } -// processErr is a method for processing error in chain. func (c CheckReferenceExists) processErr(codebaseBranch *codebaseApi.CodebaseBranch, err error) error { if err == nil { return nil diff --git a/controllers/codebasebranch/chain/check_reference_test.go b/controllers/codebasebranch/chain/check_reference_test.go index 11cf757e..83cd10df 100644 --- a/controllers/codebasebranch/chain/check_reference_test.go +++ b/controllers/codebasebranch/chain/check_reference_test.go @@ -77,18 +77,11 @@ func TestCheckReferenceExists_ServeRequest(t *testing.T) { gitClient: func() gitproviderv2.Git { mGit := gitServerMocks.NewMockGit(t) mGit.On( - "Clone", + "ResolveRemoteReference", testifymock.Anything, testifymock.Anything, testifymock.Anything, - testifymock.Anything, - ).Return(nil) - mGit.On( - "CheckReference", - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - ).Return(nil) + ).Return("bfba920bd3bdebc9ae1c4475d70391152645b2a4", nil) return mGit }, @@ -136,18 +129,11 @@ func TestCheckReferenceExists_ServeRequest(t *testing.T) { gitClient: func() gitproviderv2.Git { mGit := gitServerMocks.NewMockGit(t) mGit.On( - "Clone", - testifymock.Anything, + "ResolveRemoteReference", testifymock.Anything, testifymock.Anything, testifymock.Anything, - ).Return(nil) - mGit.On( - "CheckReference", - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - ).Return(nil) + ).Return("bfba920bd3bdebc9ae1c4475d70391152645b2a4", nil) return mGit }, @@ -195,18 +181,11 @@ func TestCheckReferenceExists_ServeRequest(t *testing.T) { gitClient: func() gitproviderv2.Git { mGit := gitServerMocks.NewMockGit(t) mGit.On( - "Clone", - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - ).Return(nil) - mGit.On( - "CheckReference", + "ResolveRemoteReference", testifymock.Anything, testifymock.Anything, testifymock.Anything, - ).Return(errors.New("reference not found")) + ).Return("", errors.New("reference not found")) return mGit }, diff --git a/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git.go b/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git.go index c3d32389..6fad49f1 100644 --- a/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git.go +++ b/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git.go @@ -10,7 +10,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1" - "github.com/epam/edp-codebase-operator/v2/controllers/codebasebranch/chain" "github.com/epam/edp-codebase-operator/v2/controllers/codebasebranch/chain/handler" "github.com/epam/edp-codebase-operator/v2/controllers/codebasebranch/service" gitproviderv2 "github.com/epam/edp-codebase-operator/v2/pkg/git" @@ -88,41 +87,20 @@ func (h PutBranchInGit) ServeRequest(ctx context.Context, branch *codebaseApi.Co return err } - // Create git provider using factory gitProvider := h.GitProviderFactory(gitproviderv2.NewConfigFromGitServerAndSecret(gitServer, secret)) - wd := chain.GetCodebaseBranchWorkingDirectory(branch) - if !checkDirectory(wd) { - repoGitUrl := util.GetProjectGitUrl(gitServer, secret, codebase.Spec.GetProjectID()) + repoGitUrl := util.GetProjectGitUrl(gitServer, secret, codebase.Spec.GetProjectID()) - if err := gitProvider.Clone(ctx, repoGitUrl, wd); err != nil { - putGitBranchSetFailedFields(branch, err.Error()) - - return fmt.Errorf("failed to clone repository: %w", err) - } - } - - currentBranchName, err := gitProvider.GetCurrentBranchName(ctx, wd) - if err != nil { - return fmt.Errorf("failed to get current branch name: %w", err) - } - - if currentBranchName != codebase.Spec.DefaultBranch { - if err = gitProvider.CheckoutRemoteBranch(ctx, wd, codebase.Spec.DefaultBranch); err != nil { - return fmt.Errorf("failed to checkout to default branch %s: %w", codebase.Spec.DefaultBranch, err) - } + // An empty FromCommit means the branch starts from the tip of the default branch. + fromRef := branch.Spec.FromCommit + if fromRef == "" { + fromRef = codebase.Spec.DefaultBranch } - err = gitProvider.CreateRemoteBranch(ctx, wd, branch.Spec.BranchName, branch.Spec.FromCommit) + err := gitProvider.CreateRemoteBranchViaRefUpdate(ctx, repoGitUrl, branch.Spec.BranchName, fromRef) if err != nil { putGitBranchSetFailedFields(branch, err.Error()) - // We need to remove work directory if branch creation failed(push error). - // Otherwise, the next time the branch creation will be skipped because local branch already exists. - if err = util.RemoveDirectory(wd); err != nil { - log.Error(err, "failed to remove directory", "path", wd) - } - return fmt.Errorf("failed to create remote branch: %w", err) } @@ -189,7 +167,3 @@ func putGitBranchSetFailedFields(cb *codebaseApi.CodebaseBranch, message string) Conditions: cb.Status.Conditions, } } - -func checkDirectory(path string) bool { - return util.DoesDirectoryExist(path) && !util.IsDirectoryEmpty(path) -} diff --git a/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git_test.go b/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git_test.go index a9802fca..060476f9 100644 --- a/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git_test.go +++ b/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git_test.go @@ -17,7 +17,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1" - "github.com/epam/edp-codebase-operator/v2/controllers/codebasebranch/chain" "github.com/epam/edp-codebase-operator/v2/controllers/codebasebranch/service" gitproviderv2 "github.com/epam/edp-codebase-operator/v2/pkg/git" gitServerMocks "github.com/epam/edp-codebase-operator/v2/pkg/git/mocks" @@ -94,25 +93,7 @@ func TestPutBranchInGit_ShouldBeExecutedSuccessfullyWithDefaultVersioning(t *tes mGit := gitServerMocks.NewMockGit(t) mGit.On( - "Clone", - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - ).Return(nil) - mGit.On( - "GetCurrentBranchName", - testifymock.Anything, - testifymock.Anything, - ).Return("default-branch", nil) - mGit.On( - "CheckoutRemoteBranch", - testifymock.Anything, - testifymock.Anything, - c.Spec.DefaultBranch, - ).Return(nil) - mGit.On( - "CreateRemoteBranch", + "CreateRemoteBranchViaRefUpdate", testifymock.Anything, testifymock.Anything, cb.Spec.BranchName, @@ -129,94 +110,6 @@ func TestPutBranchInGit_ShouldBeExecutedSuccessfullyWithDefaultVersioning(t *tes assert.NoError(t, err) } -func TestPutBranchInGit_ShouldFailgetCurrentbranch(t *testing.T) { - c := &codebaseApi.Codebase{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-app", - Namespace: "default", - }, - Spec: codebaseApi.CodebaseSpec{ - GitServer: "gitserver", - GitUrlPath: "/test-app", - }, - Status: codebaseApi.CodebaseStatus{ - Available: true, - }, - } - - gitUser := "git-user" - gs := &codebaseApi.GitServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gitserver", - Namespace: "default", - }, - Spec: codebaseApi.GitServerSpec{ - NameSshKeySecret: "secret", - GitHost: "git-host", - SshPort: 22, - GitUser: gitUser, - }, - } - - sshKey := "fake" - s := &coreV1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret", - Namespace: "default", - }, - Data: map[string][]byte{ - util.PrivateSShKeyName: []byte(sshKey), - }, - } - - cb := &codebaseApi.CodebaseBranch{ - ObjectMeta: metav1.ObjectMeta{ - Name: "feature-branch", - Namespace: "default", - }, - Spec: codebaseApi.CodebaseBranchSpec{ - CodebaseName: "test-app", - BranchName: "feature-branch", - FromCommit: "commitsha", - }, - } - - scheme := runtime.NewScheme() - require.NoError(t, codebaseApi.AddToScheme(scheme)) - require.NoError(t, coreV1.AddToScheme(scheme)) - - fakeCl := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(c, gs, cb, s). - WithStatusSubresource(cb). - Build() - - mGit := gitServerMocks.NewMockGit(t) - - mGit.On( - "Clone", - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - ).Return(nil) - mGit.On( - "GetCurrentBranchName", - testifymock.Anything, - testifymock.Anything, - ).Return("", errors.New("failed to get current branch")) - - err := PutBranchInGit{ - Client: fakeCl, - GitProviderFactory: func(cfg gitproviderv2.Config) gitproviderv2.Git { - return mGit - }, - }.ServeRequest(ctrl.LoggerInto(context.Background(), logr.Discard()), cb) - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get current branch") -} - func TestPutBranchInGit_ShouldFailCreateRemoteBranch(t *testing.T) { t.Setenv(util.WorkDirEnv, t.TempDir()) @@ -283,21 +176,7 @@ func TestPutBranchInGit_ShouldFailCreateRemoteBranch(t *testing.T) { mGit := gitServerMocks.NewMockGit(t) mGit.On( - "Clone", - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - testifymock.Anything, - ).Return(nil) - - mGit.On( - "GetCurrentBranchName", - testifymock.Anything, - testifymock.Anything, - ).Return("main", nil) - - mGit.On( - "CreateRemoteBranch", + "CreateRemoteBranchViaRefUpdate", testifymock.Anything, testifymock.Anything, fakeName, @@ -453,16 +332,8 @@ func TestPutBranchInGit_ShouldBeExecutedSuccessfullyWithEdpVersioning(t *testing mGit := gitServerMocks.NewMockGit(t) - wd := chain.GetCodebaseBranchWorkingDirectory(cb) - - mGit.On("Clone", testifymock.Anything, testifymock.Anything, wd). - Return(nil) - mGit.On( - "GetCurrentBranchName", - testifymock.Anything, - wd, - ).Return("main", nil) - mGit.On("CreateRemoteBranch", testifymock.Anything, wd, fakeName, "").Return(nil) + // Empty FromCommit must fall back to the codebase default branch. + mGit.On("CreateRemoteBranchViaRefUpdate", testifymock.Anything, testifymock.Anything, fakeName, "main").Return(nil) err := PutBranchInGit{ Client: fakeCl, diff --git a/controllers/codebasebranch/chain/util.go b/controllers/codebasebranch/chain/util.go index cd09208b..5c74304d 100644 --- a/controllers/codebasebranch/chain/util.go +++ b/controllers/codebasebranch/chain/util.go @@ -18,11 +18,6 @@ func HasNewVersion(codebaseBranch *codebaseApi.CodebaseBranch) (bool, error) { return !slices.Contains(codebaseBranch.Status.VersionHistory, *codebaseBranch.Spec.Version), nil } -// DirectoryExistsNotEmpty checks if directory exists and not empty. -func DirectoryExistsNotEmpty(dirPath string) bool { - return util.DoesDirectoryExist(dirPath) && !util.IsDirectoryEmpty(dirPath) -} - func GetCodebaseBranchWorkingDirectory(codebaseBranch *codebaseApi.CodebaseBranch) string { return path.Join( util.GetWorkDir(codebaseBranch.Spec.CodebaseName, codebaseBranch.Namespace), diff --git a/pkg/git/git.go b/pkg/git/git.go index 820d3f15..1e0b9588 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -19,10 +19,6 @@ type Git interface { // If remote is true, fetches from remote first and only creates local branch if it doesn't exist remotely. Checkout(ctx context.Context, directory, branchName string, remote bool) error - // CreateRemoteBranch creates a new branch from a reference and pushes it to remote. - // fromRef: branch name or commit hash to create from (empty string means HEAD). - CreateRemoteBranch(ctx context.Context, directory, branchName, fromRef string) error - // GetCurrentBranchName returns the name of the current branch. GetCurrentBranchName(ctx context.Context, directory string) (string, error) @@ -33,8 +29,15 @@ type Git interface { // without cloning it (equivalent to git ls-remote --heads). ListRemoteBranches(ctx context.Context, repoURL string) ([]string, error) - // CheckReference checks if a reference (branch or commit) exists in the repository. - CheckReference(ctx context.Context, directory, refName string) error + // ResolveRemoteReference resolves a reference (branch, tag, commit hash, or empty for HEAD) + // against the remote repository using only the reference advertisement, without cloning. + // Returns the resolved commit hash or ErrReferenceNotFound. + ResolveRemoteReference(ctx context.Context, repoURL, ref string) (string, error) + + // CreateRemoteBranchViaRefUpdate creates a branch on the remote pointing at fromRef + // (branch, tag, or commit hash; must not be empty) without cloning, by sending a + // reference update with an empty packfile. Skips creation if the branch already exists. + CreateRemoteBranchViaRefUpdate(ctx context.Context, repoURL, branchName, fromRef string) error // RemoveBranch removes a local branch. RemoveBranch(ctx context.Context, directory, branchName string) error diff --git a/pkg/git/mocks/git_generated.mock.go b/pkg/git/mocks/git_generated.mock.go index b03d6c6f..d8ca1445 100644 --- a/pkg/git/mocks/git_generated.mock.go +++ b/pkg/git/mocks/git_generated.mock.go @@ -158,69 +158,6 @@ func (_c *MockGit_CheckPermissions_Call) RunAndReturn(run func(ctx context.Conte return _c } -// CheckReference provides a mock function for the type MockGit -func (_mock *MockGit) CheckReference(ctx context.Context, directory string, refName string) error { - ret := _mock.Called(ctx, directory, refName) - - if len(ret) == 0 { - panic("no return value specified for CheckReference") - } - - var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { - r0 = returnFunc(ctx, directory, refName) - } else { - r0 = ret.Error(0) - } - return r0 -} - -// MockGit_CheckReference_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckReference' -type MockGit_CheckReference_Call struct { - *mock.Call -} - -// CheckReference is a helper method to define mock.On call -// - ctx context.Context -// - directory string -// - refName string -func (_e *MockGit_Expecter) CheckReference(ctx interface{}, directory interface{}, refName interface{}) *MockGit_CheckReference_Call { - return &MockGit_CheckReference_Call{Call: _e.mock.On("CheckReference", ctx, directory, refName)} -} - -func (_c *MockGit_CheckReference_Call) Run(run func(ctx context.Context, directory string, refName string)) *MockGit_CheckReference_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockGit_CheckReference_Call) Return(err error) *MockGit_CheckReference_Call { - _c.Call.Return(err) - return _c -} - -func (_c *MockGit_CheckReference_Call) RunAndReturn(run func(ctx context.Context, directory string, refName string) error) *MockGit_CheckReference_Call { - _c.Call.Return(run) - return _c -} - // Checkout provides a mock function for the type MockGit func (_mock *MockGit) Checkout(ctx context.Context, directory string, branchName string, remote bool) error { ret := _mock.Called(ctx, directory, branchName, remote) @@ -639,38 +576,38 @@ func (_c *MockGit_CreateChildBranch_Call) RunAndReturn(run func(ctx context.Cont return _c } -// CreateRemoteBranch provides a mock function for the type MockGit -func (_mock *MockGit) CreateRemoteBranch(ctx context.Context, directory string, branchName string, fromRef string) error { - ret := _mock.Called(ctx, directory, branchName, fromRef) +// CreateRemoteBranchViaRefUpdate provides a mock function for the type MockGit +func (_mock *MockGit) CreateRemoteBranchViaRefUpdate(ctx context.Context, repoURL string, branchName string, fromRef string) error { + ret := _mock.Called(ctx, repoURL, branchName, fromRef) if len(ret) == 0 { - panic("no return value specified for CreateRemoteBranch") + panic("no return value specified for CreateRemoteBranchViaRefUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { - r0 = returnFunc(ctx, directory, branchName, fromRef) + r0 = returnFunc(ctx, repoURL, branchName, fromRef) } else { r0 = ret.Error(0) } return r0 } -// MockGit_CreateRemoteBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRemoteBranch' -type MockGit_CreateRemoteBranch_Call struct { +// MockGit_CreateRemoteBranchViaRefUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRemoteBranchViaRefUpdate' +type MockGit_CreateRemoteBranchViaRefUpdate_Call struct { *mock.Call } -// CreateRemoteBranch is a helper method to define mock.On call +// CreateRemoteBranchViaRefUpdate is a helper method to define mock.On call // - ctx context.Context -// - directory string +// - repoURL string // - branchName string // - fromRef string -func (_e *MockGit_Expecter) CreateRemoteBranch(ctx interface{}, directory interface{}, branchName interface{}, fromRef interface{}) *MockGit_CreateRemoteBranch_Call { - return &MockGit_CreateRemoteBranch_Call{Call: _e.mock.On("CreateRemoteBranch", ctx, directory, branchName, fromRef)} +func (_e *MockGit_Expecter) CreateRemoteBranchViaRefUpdate(ctx interface{}, repoURL interface{}, branchName interface{}, fromRef interface{}) *MockGit_CreateRemoteBranchViaRefUpdate_Call { + return &MockGit_CreateRemoteBranchViaRefUpdate_Call{Call: _e.mock.On("CreateRemoteBranchViaRefUpdate", ctx, repoURL, branchName, fromRef)} } -func (_c *MockGit_CreateRemoteBranch_Call) Run(run func(ctx context.Context, directory string, branchName string, fromRef string)) *MockGit_CreateRemoteBranch_Call { +func (_c *MockGit_CreateRemoteBranchViaRefUpdate_Call) Run(run func(ctx context.Context, repoURL string, branchName string, fromRef string)) *MockGit_CreateRemoteBranchViaRefUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -698,12 +635,12 @@ func (_c *MockGit_CreateRemoteBranch_Call) Run(run func(ctx context.Context, dir return _c } -func (_c *MockGit_CreateRemoteBranch_Call) Return(err error) *MockGit_CreateRemoteBranch_Call { +func (_c *MockGit_CreateRemoteBranchViaRefUpdate_Call) Return(err error) *MockGit_CreateRemoteBranchViaRefUpdate_Call { _c.Call.Return(err) return _c } -func (_c *MockGit_CreateRemoteBranch_Call) RunAndReturn(run func(ctx context.Context, directory string, branchName string, fromRef string) error) *MockGit_CreateRemoteBranch_Call { +func (_c *MockGit_CreateRemoteBranchViaRefUpdate_Call) RunAndReturn(run func(ctx context.Context, repoURL string, branchName string, fromRef string) error) *MockGit_CreateRemoteBranchViaRefUpdate_Call { _c.Call.Return(run) return _c } @@ -1169,3 +1106,75 @@ func (_c *MockGit_RemoveBranch_Call) RunAndReturn(run func(ctx context.Context, _c.Call.Return(run) return _c } + +// ResolveRemoteReference provides a mock function for the type MockGit +func (_mock *MockGit) ResolveRemoteReference(ctx context.Context, repoURL string, ref string) (string, error) { + ret := _mock.Called(ctx, repoURL, ref) + + if len(ret) == 0 { + panic("no return value specified for ResolveRemoteReference") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok { + return returnFunc(ctx, repoURL, ref) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) string); ok { + r0 = returnFunc(ctx, repoURL, ref) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, repoURL, ref) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGit_ResolveRemoteReference_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveRemoteReference' +type MockGit_ResolveRemoteReference_Call struct { + *mock.Call +} + +// ResolveRemoteReference is a helper method to define mock.On call +// - ctx context.Context +// - repoURL string +// - ref string +func (_e *MockGit_Expecter) ResolveRemoteReference(ctx interface{}, repoURL interface{}, ref interface{}) *MockGit_ResolveRemoteReference_Call { + return &MockGit_ResolveRemoteReference_Call{Call: _e.mock.On("ResolveRemoteReference", ctx, repoURL, ref)} +} + +func (_c *MockGit_ResolveRemoteReference_Call) Run(run func(ctx context.Context, repoURL string, ref string)) *MockGit_ResolveRemoteReference_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGit_ResolveRemoteReference_Call) Return(s string, err error) *MockGit_ResolveRemoteReference_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockGit_ResolveRemoteReference_Call) RunAndReturn(run func(ctx context.Context, repoURL string, ref string) (string, error)) *MockGit_ResolveRemoteReference_Call { + _c.Call.Return(run) + return _c +} diff --git a/pkg/git/provider.go b/pkg/git/provider.go index f5743b8c..6f46b226 100644 --- a/pkg/git/provider.go +++ b/pkg/git/provider.go @@ -11,7 +11,6 @@ import ( "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/storer" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/ssh" @@ -359,57 +358,6 @@ func (p *GitProvider) Checkout(ctx context.Context, directory, branchName string return nil } -// CreateRemoteBranch creates a new branch from a reference and pushes it to remote. -func (p *GitProvider) CreateRemoteBranch(ctx context.Context, directory, branchName, fromRef string) error { - log := ctrl.LoggerFrom(ctx).WithValues("directory", directory, "branch", branchName, "from", fromRef) - log.Info("Creating remote branch") - - repo, err := git.PlainOpen(directory) - if err != nil { - return fmt.Errorf("failed to open repository at %q: %w", directory, err) - } - - branches, err := repo.Branches() - if err != nil { - return fmt.Errorf("failed to get branches iterator: %w", err) - } - - exists, err := branchExists(branchName, branches) - if err != nil { - return err - } - - if exists { - log.Info("Branch already exists. Skip creating") - return nil - } - - targetHash, err := resolveReference(repo, fromRef) - if err != nil { - return err - } - - newRef := plumbing.NewHashReference( - plumbing.NewBranchReferenceName(branchName), - targetHash, - ) - - err = repo.Storer.SetReference(newRef) - if err != nil { - return fmt.Errorf("failed to set reference: %w", err) - } - - // Push all branches - err = p.Push(ctx, directory, RefSpecPushAllBranches) - if err != nil { - return err - } - - log.Info("Remote branch created successfully") - - return nil -} - // GetCurrentBranchName returns the name of the current branch. func (p *GitProvider) GetCurrentBranchName(ctx context.Context, directory string) (string, error) { log := ctrl.LoggerFrom(ctx).WithValues("directory", directory) @@ -522,25 +470,6 @@ func (p *GitProvider) CheckPermissions(ctx context.Context, repoURL string) erro return nil } -// CheckReference checks if a reference (branch or commit) exists in the repository. -func (p *GitProvider) CheckReference(ctx context.Context, directory, refName string) error { - log := ctrl.LoggerFrom(ctx).WithValues("directory", directory, "reference", refName) - log.Info("Checking reference") - - if refName == "" { - return nil - } - - r, err := git.PlainOpen(directory) - if err != nil { - return fmt.Errorf("failed to open git repository: %w", err) - } - - _, err = resolveReference(r, refName) - - return err -} - // RemoveBranch removes a local branch. func (p *GitProvider) RemoveBranch(ctx context.Context, directory, branchName string) error { log := ctrl.LoggerFrom(ctx).WithValues("directory", directory, "branch", branchName) @@ -815,52 +744,3 @@ func (p *GitProvider) CreateRemoteTag(ctx context.Context, directory, branchName return nil } - -func branchExists(branchName string, branches storer.ReferenceIter) (bool, error) { - exist := false - - if err := branches.ForEach(func(ref *plumbing.Reference) error { - if ref.Name().Short() == branchName { - exist = true - return storer.ErrStop - } - - return nil - }); err != nil { - return false, fmt.Errorf("failed to iterate branches: %w", err) - } - - return exist, nil -} - -// resolveReference resolves a reference (branch or commit) to a hash. -func resolveReference(r *git.Repository, ref string) (plumbing.Hash, error) { - if ref == "" { - // If no reference specified, use HEAD - ref, err := r.Head() - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get git HEAD reference: %w", err) - } - - return ref.Hash(), nil - } - - // Try to resolve as a branch first - branchRef, err := r.Reference(plumbing.NewBranchReferenceName(ref), false) - if err == nil { - return branchRef.Hash(), nil - } - - // If not a branch, try to resolve as a commit - commitHash := plumbing.NewHash(ref) - if commitHash.IsZero() { - return plumbing.ZeroHash, fmt.Errorf("invalid reference or commit hash: %s", ref) - } - - _, err = r.CommitObject(commitHash) - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get commit %s: %w", ref, err) - } - - return commitHash, nil -} diff --git a/pkg/git/provider_test.go b/pkg/git/provider_test.go index 8dc52b25..1768990d 100644 --- a/pkg/git/provider_test.go +++ b/pkg/git/provider_test.go @@ -3,9 +3,6 @@ package v2 import ( "context" - "encoding/base64" - "net/http" - "net/http/httptest" "os" "path" "testing" @@ -32,17 +29,9 @@ func TestGitProvider_CheckPermissions(t *testing.T) { } gp := NewGitProvider(config) - bts, err := base64.StdEncoding.DecodeString(`MDAxZSMgc2VydmljZT1naXQtdXBsb2FkLXBhY2sKMDAwMDAxNTY2ZWNmMGVmMmMyZGZmYjc5NjAzM2U1YTAyMjE5YWY4NmVjNjU4NGU1IEhFQUQAbXVsdGlfYWNrIHRoaW4tcGFjayBzaWRlLWJhbmQgc2lkZS1iYW5kLTY0ayBvZnMtZGVsdGEgc2hhbGxvdyBkZWVwZW4tc2luY2UgZGVlcGVuLW5vdCBkZWVwZW4tcmVsYXRpdmUgbm8tcHJvZ3Jlc3MgaW5jbHVkZS10YWcgbXVsdGlfYWNrX2RldGFpbGVkIGFsbG93LXRpcC1zaGExLWluLXdhbnQgYWxsb3ctcmVhY2hhYmxlLXNoYTEtaW4td2FudCBuby1kb25lIHN5bXJlZj1IRUFEOnJlZnMvaGVhZHMvbWFzdGVyIGZpbHRlciBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0L2dpdGh1Yi1nNzhiNDUyNDEzZThiCjAwM2ZlOGQzZmZhYjU1Mjg5NWMxOWI5ZmNmN2FhMjY0ZDI3N2NkZTMzODgxIHJlZnMvaGVhZHMvYnJhbmNoCjAwM2Y2ZWNmMGVmMmMyZGZmYjc5NjAzM2U1YTAyMjE5YWY4NmVjNjU4NGU1IHJlZnMvaGVhZHMvbWFzdGVyCjAwM2ViOGU0NzFmNThiY2JjYTYzYjA3YmRhMjBlNDI4MTkwNDA5YzJkYjQ3IHJlZnMvcHVsbC8xL2hlYWQKMDAzZTk2MzJmMDI4MzNiMmY5NjEzYWZiNWU3NTY4MjEzMmIwYjIyZTRhMzEgcmVmcy9wdWxsLzIvaGVhZAowMDNmYzM3ZjU4YTEzMGNhNTU1ZTQyZmY5NmEwNzFjYjljY2IzZjQzNzUwNCByZWZzL3B1bGwvMi9tZXJnZQowMDAw`) // nolint:lll - require.NoError(t, err) - - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, err = w.Write(bts) - assert.NoError(t, err, "failed to write response") - })) - defer s.Close() + s := uploadPackServer(t) - err = gp.CheckPermissions(context.Background(), s.URL) + err := gp.CheckPermissions(context.Background(), s.URL) require.NoError(t, err, "repo must be accessible") } @@ -56,21 +45,13 @@ func TestGitProvider_CheckPermissions_NoRefs(t *testing.T) { } gp := NewGitProvider(config) - bts, err := base64.StdEncoding.DecodeString(`MDAxZSMgc2VydmljZT1naXQtdXBsb2FkLXBhY2sKMDAwMDAwZGUwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIGNhcGFiaWxpdGllc157fQAgaW5jbHVkZS10YWcgbXVsdGlfYWNrX2RldGFpbGVkIG11bHRpX2FjayBvZnMtZGVsdGEgc2lkZS1iYW5kIHNpZGUtYmFuZC02NGsgdGhpbi1wYWNrIG5vLXByb2dyZXNzIHNoYWxsb3cgbm8tZG9uZSBhZ2VudD1KR2l0L3Y1LjkuMC4yMDIwMDkwODA1MDEtci00MS1nNWQ5MjVlY2JiCjAwMDA=`) // nolint:lll - require.NoError(t, err) - - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, err = w.Write(bts) - assert.NoError(t, err, "failed to write response") - })) - defer s.Close() + s := emptyUploadPackServer(t) mockLogger := platform.NewLoggerMock() // v2 implementation returns nil for empty repos (they are technically accessible, just empty) // This is different from v1 which logged an error - err = gp.CheckPermissions(ctrl.LoggerInto(context.Background(), mockLogger), s.URL) + err := gp.CheckPermissions(ctrl.LoggerInto(context.Background(), mockLogger), s.URL) require.NoError(t, err, "v2 considers empty repos accessible") } @@ -81,17 +62,9 @@ func TestGitProvider_ListRemoteBranches(t *testing.T) { } gp := NewGitProvider(config) - // Same canned smart-HTTP ref advertisement as TestGitProvider_CheckPermissions: - // refs/heads/branch, refs/heads/master plus pull-request refs that must be filtered out. - bts, err := base64.StdEncoding.DecodeString(`MDAxZSMgc2VydmljZT1naXQtdXBsb2FkLXBhY2sKMDAwMDAxNTY2ZWNmMGVmMmMyZGZmYjc5NjAzM2U1YTAyMjE5YWY4NmVjNjU4NGU1IEhFQUQAbXVsdGlfYWNrIHRoaW4tcGFjayBzaWRlLWJhbmQgc2lkZS1iYW5kLTY0ayBvZnMtZGVsdGEgc2hhbGxvdyBkZWVwZW4tc2luY2UgZGVlcGVuLW5vdCBkZWVwZW4tcmVsYXRpdmUgbm8tcHJvZ3Jlc3MgaW5jbHVkZS10YWcgbXVsdGlfYWNrX2RldGFpbGVkIGFsbG93LXRpcC1zaGExLWluLXdhbnQgYWxsb3ctcmVhY2hhYmxlLXNoYTEtaW4td2FudCBuby1kb25lIHN5bXJlZj1IRUFEOnJlZnMvaGVhZHMvbWFzdGVyIGZpbHRlciBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0L2dpdGh1Yi1nNzhiNDUyNDEzZThiCjAwM2ZlOGQzZmZhYjU1Mjg5NWMxOWI5ZmNmN2FhMjY0ZDI3N2NkZTMzODgxIHJlZnMvaGVhZHMvYnJhbmNoCjAwM2Y2ZWNmMGVmMmMyZGZmYjc5NjAzM2U1YTAyMjE5YWY4NmVjNjU4NGU1IHJlZnMvaGVhZHMvbWFzdGVyCjAwM2ViOGU0NzFmNThiY2JjYTYzYjA3YmRhMjBlNDI4MTkwNDA5YzJkYjQ3IHJlZnMvcHVsbC8xL2hlYWQKMDAzZTk2MzJmMDI4MzNiMmY5NjEzYWZiNWU3NTY4MjEzMmIwYjIyZTRhMzEgcmVmcy9wdWxsLzIvaGVhZAowMDNmYzM3ZjU4YTEzMGNhNTU1ZTQyZmY5NmEwNzFjYjljY2IzZjQzNzUwNCByZWZzL3B1bGwvMi9tZXJnZQowMDAw`) // nolint:lll - require.NoError(t, err) - - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, err = w.Write(bts) - assert.NoError(t, err, "failed to write response") - })) - defer s.Close() + // Advertisement contains refs/heads/branch, refs/heads/master plus + // pull-request refs that must be filtered out. + s := uploadPackServer(t) branches, err := gp.ListRemoteBranches(context.Background(), s.URL) require.NoError(t, err) @@ -105,16 +78,7 @@ func TestGitProvider_ListRemoteBranches_EmptyRepo(t *testing.T) { } gp := NewGitProvider(config) - // Same empty-repo advertisement as TestGitProvider_CheckPermissions_NoRefs. - bts, err := base64.StdEncoding.DecodeString(`MDAxZSMgc2VydmljZT1naXQtdXBsb2FkLXBhY2sKMDAwMDAwZGUwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIGNhcGFiaWxpdGllc157fQAgaW5jbHVkZS10YWcgbXVsdGlfYWNrX2RldGFpbGVkIG11bHRpX2FjayBvZnMtZGVsdGEgc2lkZS1iYW5kIHNpZGUtYmFuZC02NGsgdGhpbi1wYWNrIG5vLXByb2dyZXNzIHNoYWxsb3cgbm8tZG9uZSBhZ2VudD1KR2l0L3Y1LjkuMC4yMDIwMDkwODA1MDEtci00MS1nNWQ5MjVlY2JiCjAwMDA=`) // nolint:lll - require.NoError(t, err) - - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, err = w.Write(bts) - assert.NoError(t, err, "failed to write response") - })) - defer s.Close() + s := emptyUploadPackServer(t) branches, err := gp.ListRemoteBranches(context.Background(), s.URL) require.NoError(t, err) @@ -416,140 +380,6 @@ func TestGitProvider_AddRemoteLink(t *testing.T) { } } -func TestGitProvider_CheckReference(t *testing.T) { - tests := []struct { - name string - initRepo func(t *testing.T) string - from string - wantErr require.ErrorAssertionFunc - }{ - { - name: "should return nil for empty reference", - initRepo: func(t *testing.T) string { - dir := t.TempDir() - _, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - return dir - }, - from: "", - wantErr: require.NoError, - }, - { - name: "should find existing branch reference", - initRepo: func(t *testing.T) string { - dir := t.TempDir() - r, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - // Create initial commit - w, err := r.Worktree() - require.NoError(t, err) - - f, err := os.Create(path.Join(dir, "test.txt")) - require.NoError(t, err) - _, err = f.WriteString("test content") - require.NoError(t, err) - require.NoError(t, f.Close()) - - _, err = w.Add("test.txt") - require.NoError(t, err) - - _, err = w.Commit("initial commit", &gogit.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@example.com", - When: time.Now(), - }, - }) - require.NoError(t, err) - - // Create and checkout a new branch - err = w.Checkout(&gogit.CheckoutOptions{ - Branch: plumbing.NewBranchReferenceName("test-branch"), - Create: true, - }) - require.NoError(t, err) - - return dir - }, - from: "test-branch", - wantErr: require.NoError, - }, - { - name: "should find existing commit reference", - initRepo: func(t *testing.T) string { - dir := t.TempDir() - r, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - // Create initial commit - w, err := r.Worktree() - require.NoError(t, err) - - f, err := os.Create(path.Join(dir, "test.txt")) - require.NoError(t, err) - _, err = f.WriteString("test content") - require.NoError(t, err) - require.NoError(t, f.Close()) - - _, err = w.Add("test.txt") - require.NoError(t, err) - - commit, err := w.Commit("initial commit", &gogit.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@example.com", - When: time.Now(), - }, - }) - require.NoError(t, err) - - // Store the commit hash for the test - t.Logf("Created commit with hash: %s", commit.String()) - - return dir - }, - from: "", // Will be set dynamically - wantErr: require.NoError, - }, - { - name: "should return error for non-existent reference", - initRepo: func(t *testing.T) string { - dir := t.TempDir() - _, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - return dir - }, - from: "non-existent", - wantErr: require.Error, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) - dir := tt.initRepo(t) - - // For the commit reference test, we need to get the actual commit hash - if tt.name == "should find existing commit reference" { - r, err := gogit.PlainOpen(dir) - require.NoError(t, err) - - ref, err := r.Head() - require.NoError(t, err) - - tt.from = ref.Hash().String() - t.Logf("Using commit hash: %s", tt.from) - } - - err := gp.CheckReference(context.Background(), dir, tt.from) - tt.wantErr(t, err) - }) - } -} - func TestGitProvider_getAuth(t *testing.T) { tests := []struct { name string @@ -1021,278 +851,6 @@ func TestGitProvider_CommitExists(t *testing.T) { } } -func TestBranchExists(t *testing.T) { - tests := []struct { - name string - initRepo func(t *testing.T) string - branchName string - wantExists bool - wantErr bool - }{ - { - name: "branch exists", - initRepo: func(t *testing.T) string { - dir := t.TempDir() - r, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - w, err := r.Worktree() - require.NoError(t, err) - - f, err := os.Create(path.Join(dir, "test.txt")) - require.NoError(t, err) - _, err = f.WriteString("test") - require.NoError(t, err) - require.NoError(t, f.Close()) - - _, err = w.Add("test.txt") - require.NoError(t, err) - - _, err = w.Commit("initial", &gogit.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@example.com", - When: time.Now(), - }, - }) - require.NoError(t, err) - - // Create test-branch - err = w.Checkout(&gogit.CheckoutOptions{ - Branch: plumbing.NewBranchReferenceName("test-branch"), - Create: true, - }) - require.NoError(t, err) - - return dir - }, - branchName: "test-branch", - wantExists: true, - wantErr: false, - }, - { - name: "branch does not exist", - initRepo: func(t *testing.T) string { - dir := t.TempDir() - r, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - w, err := r.Worktree() - require.NoError(t, err) - - f, err := os.Create(path.Join(dir, "test.txt")) - require.NoError(t, err) - _, err = f.WriteString("test") - require.NoError(t, err) - require.NoError(t, f.Close()) - - _, err = w.Add("test.txt") - require.NoError(t, err) - - _, err = w.Commit("initial", &gogit.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@example.com", - When: time.Now(), - }, - }) - require.NoError(t, err) - - return dir - }, - branchName: "non-existent-branch", - wantExists: false, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir := tt.initRepo(t) - r, err := gogit.PlainOpen(dir) - require.NoError(t, err) - - branches, err := r.Branches() - require.NoError(t, err) - - exists, err := branchExists(tt.branchName, branches) - - if tt.wantErr { - require.Error(t, err) - return - } - - require.NoError(t, err) - assert.Equal(t, tt.wantExists, exists) - }) - } -} - -func TestResolveReference(t *testing.T) { - tests := []struct { - name string - initRepo func(t *testing.T) (string, string) - ref string - wantErr bool - }{ - { - name: "empty ref uses HEAD", - initRepo: func(t *testing.T) (string, string) { - dir := t.TempDir() - r, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - w, err := r.Worktree() - require.NoError(t, err) - - f, err := os.Create(path.Join(dir, "test.txt")) - require.NoError(t, err) - _, err = f.WriteString("test") - require.NoError(t, err) - require.NoError(t, f.Close()) - - _, err = w.Add("test.txt") - require.NoError(t, err) - - _, err = w.Commit("initial", &gogit.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@example.com", - When: time.Now(), - }, - }) - require.NoError(t, err) - - return dir, "" - }, - ref: "", - wantErr: false, - }, - { - name: "branch reference resolution", - initRepo: func(t *testing.T) (string, string) { - dir := t.TempDir() - r, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - w, err := r.Worktree() - require.NoError(t, err) - - f, err := os.Create(path.Join(dir, "test.txt")) - require.NoError(t, err) - _, err = f.WriteString("test") - require.NoError(t, err) - require.NoError(t, f.Close()) - - _, err = w.Add("test.txt") - require.NoError(t, err) - - _, err = w.Commit("initial", &gogit.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@example.com", - When: time.Now(), - }, - }) - require.NoError(t, err) - - return dir, "master" - }, - ref: "master", - wantErr: false, - }, - { - name: "commit hash resolution", - initRepo: func(t *testing.T) (string, string) { - dir := t.TempDir() - r, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - w, err := r.Worktree() - require.NoError(t, err) - - f, err := os.Create(path.Join(dir, "test.txt")) - require.NoError(t, err) - _, err = f.WriteString("test") - require.NoError(t, err) - require.NoError(t, f.Close()) - - _, err = w.Add("test.txt") - require.NoError(t, err) - - hash, err := w.Commit("initial", &gogit.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@example.com", - When: time.Now(), - }, - }) - require.NoError(t, err) - - return dir, hash.String() - }, - wantErr: false, - }, - { - name: "invalid reference", - initRepo: func(t *testing.T) (string, string) { - dir := t.TempDir() - r, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - w, err := r.Worktree() - require.NoError(t, err) - - f, err := os.Create(path.Join(dir, "test.txt")) - require.NoError(t, err) - _, err = f.WriteString("test") - require.NoError(t, err) - require.NoError(t, f.Close()) - - _, err = w.Add("test.txt") - require.NoError(t, err) - - _, err = w.Commit("initial", &gogit.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@example.com", - When: time.Now(), - }, - }) - require.NoError(t, err) - - return dir, "invalid-ref-12345" - }, - ref: "invalid-ref-12345", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir, ref := tt.initRepo(t) - r, err := gogit.PlainOpen(dir) - require.NoError(t, err) - - // Use the ref from initRepo if one was provided - testRef := tt.ref - if testRef == "" && ref != "" { - testRef = ref - } - - hash, err := resolveReference(r, testRef) - - if tt.wantErr { - require.Error(t, err) - return - } - - require.NoError(t, err) - assert.NotNil(t, hash) - }) - } -} - func TestGitProvider_Checkout_LocalMode(t *testing.T) { tests := []struct { name string @@ -1373,76 +931,6 @@ func TestGitProvider_Checkout_LocalMode(t *testing.T) { } } -func TestGitProvider_CreateRemoteBranch_Errors(t *testing.T) { - tests := []struct { - name string - initRepo func(t *testing.T) string - branchName string - fromRef string - wantErr bool - }{ - { - name: "repository not found", - initRepo: func(t *testing.T) string { - return t.TempDir() - }, - branchName: "new-branch", - fromRef: "master", - wantErr: true, - }, - { - name: "invalid fromRef", - initRepo: func(t *testing.T) string { - dir := t.TempDir() - r, err := gogit.PlainInit(dir, false) - require.NoError(t, err) - - w, err := r.Worktree() - require.NoError(t, err) - - f, err := os.Create(path.Join(dir, "test.txt")) - require.NoError(t, err) - _, err = f.WriteString("test") - require.NoError(t, err) - require.NoError(t, f.Close()) - - _, err = w.Add("test.txt") - require.NoError(t, err) - - _, err = w.Commit("initial", &gogit.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@example.com", - When: time.Now(), - }, - }) - require.NoError(t, err) - - return dir - }, - branchName: "new-branch", - fromRef: "non-existent-ref", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) - dir := tt.initRepo(t) - - err := gp.CreateRemoteBranch(context.Background(), dir, tt.branchName, tt.fromRef) - - if tt.wantErr { - require.Error(t, err) - return - } - - require.NoError(t, err) - }) - } -} - func TestGitProvider_CreateRemoteTag_Errors(t *testing.T) { tests := []struct { name string diff --git a/pkg/git/transport.go b/pkg/git/transport.go new file mode 100644 index 00000000..3243b38a --- /dev/null +++ b/pkg/git/transport.go @@ -0,0 +1,228 @@ +package v2 + +// This file implements packless git operations: reference resolution and remote +// branch creation without cloning or transferring packfiles. Memory is bounded +// by the size of the remote's reference advertisement, never by repository size, +// which makes these operations safe for arbitrarily large repositories where +// go-git clone/fetch is known to exhaust memory. +// +// NOTE: go-git v6 rewrites the plumbing/transport layer; this file must be +// ported when the dependency is upgraded. + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/binary" + "errors" + "fmt" + "io" + "strings" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/protocol/packp" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/client" + ctrl "sigs.k8s.io/controller-runtime" +) + +// ErrReferenceNotFound is returned when a reference cannot be resolved in the +// remote repository's advertisement. +var ErrReferenceNotFound = errors.New("reference not found in remote repository") + +// ResolveRemoteReference resolves a reference (branch name, tag name, full +// commit hash, or empty for HEAD) against the remote repository without cloning +// it, using only the reference advertisement (equivalent to git ls-remote). +// A full commit hash is returned as-is when it does not match any advertised +// reference, because reachability can only be verified by the server. +// Returns ErrReferenceNotFound if the reference cannot be resolved. +func (p *GitProvider) ResolveRemoteReference(ctx context.Context, repoURL, ref string) (string, error) { + log := ctrl.LoggerFrom(ctx).WithValues("repository", repoURL, "reference", ref) + log.Info("Resolving reference from remote advertisement") + + advRefs, closeSession, err := p.advertisedReferences(ctx, repoURL) + if err != nil { + return "", err + } + + defer closeSession() + + hash, err := resolveAdvertisedRef(advRefs, ref) + if err != nil { + return "", err + } + + log.Info("Reference resolved successfully", "hash", hash) + + return hash, nil +} + +// CreateRemoteBranchViaRefUpdate creates a branch on the remote repository +// pointing at fromRef (branch name, tag name, or full commit hash) without +// cloning: it sends a single create command with an empty packfile, which the +// git pack protocol mandates when the server already has the target object. +// Creation is skipped if the branch already exists on the remote. fromRef must +// not be empty: the caller must pass an explicit reference (e.g. the codebase +// default branch). +// +// fromRef is resolved through the upload-pack advertisement rather than the +// receive-pack one: only upload-pack advertises peeled tag hashes, and a branch +// created from an annotated tag must point at the peeled commit — servers +// reject a branch pointing at a tag object. +func (p *GitProvider) CreateRemoteBranchViaRefUpdate(ctx context.Context, repoURL, branchName, fromRef string) error { + log := ctrl.LoggerFrom(ctx).WithValues("repository", repoURL, "branch", branchName, "reference", fromRef) + log.Info("Creating remote branch via reference update") + + if fromRef == "" { + return fmt.Errorf("fromRef must not be empty: %w", ErrReferenceNotFound) + } + + hash, err := p.ResolveRemoteReference(ctx, repoURL, fromRef) + if err != nil { + return err + } + + c, ep, auth, err := p.newTransportClient(repoURL) + if err != nil { + return err + } + + session, err := c.NewReceivePackSession(ep, auth) + if err != nil { + return fmt.Errorf("failed to open receive-pack session: %w", err) + } + + defer func() { + _ = session.Close() + }() + + advRefs, err := session.AdvertisedReferencesContext(ctx) + if err != nil { + return fmt.Errorf("failed to get advertised references: %w", err) + } + + branchRef := plumbing.NewBranchReferenceName(branchName) + if _, exists := advRefs.References[branchRef.String()]; exists { + log.Info("Branch already exists on remote, skipping creation") + + return nil + } + + req := packp.NewReferenceUpdateRequestFromCapabilities(advRefs.Capabilities) + req.Commands = []*packp.Command{ + {Name: branchRef, Old: plumbing.ZeroHash, New: plumbing.NewHash(hash)}, + } + req.Packfile = emptyPackfile() + + reportStatus, err := session.ReceivePack(ctx, req) + if err != nil { + return fmt.Errorf("failed to create remote branch %s: %w", branchName, err) + } + + if reportStatus != nil { + if err = reportStatus.Error(); err != nil { + return fmt.Errorf("remote rejected branch %s creation: %w", branchName, err) + } + } + + log.Info("Remote branch created successfully", "hash", hash) + + return nil +} + +func (p *GitProvider) newTransportClient( + repoURL string, +) (transport.Transport, *transport.Endpoint, transport.AuthMethod, error) { + auth, err := p.getAuth() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to get authentication: %w", err) + } + + ep, err := transport.NewEndpoint(repoURL) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to parse repository URL %q: %w", repoURL, err) + } + + c, err := client.NewClient(ep) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create transport client: %w", err) + } + + return c, ep, auth, nil +} + +// advertisedReferences fetches the remote's reference advertisement over an +// upload-pack session that stops before any packfile negotiation; the returned +// close function must be called once the advertisement is no longer needed. +func (p *GitProvider) advertisedReferences(ctx context.Context, repoURL string) (*packp.AdvRefs, func(), error) { + c, ep, auth, err := p.newTransportClient(repoURL) + if err != nil { + return nil, nil, err + } + + session, err := c.NewUploadPackSession(ep, auth) + if err != nil { + return nil, nil, fmt.Errorf("failed to open upload-pack session: %w", err) + } + + advRefs, err := session.AdvertisedReferencesContext(ctx) + if err != nil { + _ = session.Close() + + return nil, nil, fmt.Errorf("failed to get advertised references: %w", err) + } + + return advRefs, func() { _ = session.Close() }, nil +} + +// resolveAdvertisedRef resolves ref within an advertisement in the order: +// HEAD (empty ref) -> branch -> tag (peeled first) -> full commit hash +// passthrough. Tag resolution prefers the peeled hash so annotated tags resolve +// to the commit they point at, matching git's behaviour. +func resolveAdvertisedRef(advRefs *packp.AdvRefs, ref string) (string, error) { + if ref == "" { + if advRefs.Head != nil { + return advRefs.Head.String(), nil + } + + return "", fmt.Errorf("remote advertisement has no HEAD: %w", ErrReferenceNotFound) + } + + if hash, ok := advRefs.References[plumbing.NewBranchReferenceName(ref).String()]; ok { + return hash.String(), nil + } + + tagRef := plumbing.NewTagReferenceName(ref).String() + if hash, ok := advRefs.Peeled[tagRef]; ok { + return hash.String(), nil + } + + if hash, ok := advRefs.References[tagRef]; ok { + return hash.String(), nil + } + + // plumbing.NewHash hex-decodes case-insensitively; comparing the canonical + // form case-insensitively accepts uppercase hashes while rejecting inputs + // that are not full 40-character hex strings. + if hash := plumbing.NewHash(ref); !hash.IsZero() && strings.EqualFold(hash.String(), ref) { + return hash.String(), nil + } + + return "", fmt.Errorf("failed to resolve %q: %w", ref, ErrReferenceNotFound) +} + +// emptyPackfile returns a packfile containing zero objects: the "PACK" header +// with version 2 and object count 0, followed by the SHA-1 checksum trailer. +// The git pack protocol requires it when a create/update command needs no +// objects because the server already has the target +// (https://git-scm.com/docs/gitprotocol-pack). +func emptyPackfile() io.ReadCloser { + header := make([]byte, 0, 32) + header = append(header, "PACK"...) + header = binary.BigEndian.AppendUint32(header, 2) + header = binary.BigEndian.AppendUint32(header, 0) + + checksum := sha1.Sum(header) + + return io.NopCloser(bytes.NewReader(append(header, checksum[:]...))) +} diff --git a/pkg/git/transport_test.go b/pkg/git/transport_test.go new file mode 100644 index 00000000..4effc820 --- /dev/null +++ b/pkg/git/transport_test.go @@ -0,0 +1,438 @@ +// nolint:dupl // Duplicate test setup is acceptable in tests for readability +package v2 + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/protocol/packp" + "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testMasterHash = "6ecf0ef2c2dffb796033e5a02219af86ec6584e5" + testBranchHash = "e8d3ffab552895c19b9fcf7aa264d277cde33881" + testTagHash = "b8e471f58bcbca63b07bda20e428190409c2db47" + testPeeledHash = "9632f02833b2f9613afb5e75682132b0b22e4a31" +) + +// uploadPackAdvertisement is a canned smart-HTTP ref advertisement containing +// a HEAD symref to refs/heads/master, refs/heads/branch, refs/heads/master and +// pull-request refs that resolution must ignore. +func uploadPackAdvertisement(t *testing.T) []byte { + t.Helper() + + bts, err := base64.StdEncoding.DecodeString(`MDAxZSMgc2VydmljZT1naXQtdXBsb2FkLXBhY2sKMDAwMDAxNTY2ZWNmMGVmMmMyZGZmYjc5NjAzM2U1YTAyMjE5YWY4NmVjNjU4NGU1IEhFQUQAbXVsdGlfYWNrIHRoaW4tcGFjayBzaWRlLWJhbmQgc2lkZS1iYW5kLTY0ayBvZnMtZGVsdGEgc2hhbGxvdyBkZWVwZW4tc2luY2UgZGVlcGVuLW5vdCBkZWVwZW4tcmVsYXRpdmUgbm8tcHJvZ3Jlc3MgaW5jbHVkZS10YWcgbXVsdGlfYWNrX2RldGFpbGVkIGFsbG93LXRpcC1zaGExLWluLXdhbnQgYWxsb3ctcmVhY2hhYmxlLXNoYTEtaW4td2FudCBuby1kb25lIHN5bXJlZj1IRUFEOnJlZnMvaGVhZHMvbWFzdGVyIGZpbHRlciBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0L2dpdGh1Yi1nNzhiNDUyNDEzZThiCjAwM2ZlOGQzZmZhYjU1Mjg5NWMxOWI5ZmNmN2FhMjY0ZDI3N2NkZTMzODgxIHJlZnMvaGVhZHMvYnJhbmNoCjAwM2Y2ZWNmMGVmMmMyZGZmYjc5NjAzM2U1YTAyMjE5YWY4NmVjNjU4NGU1IHJlZnMvaGVhZHMvbWFzdGVyCjAwM2ViOGU0NzFmNThiY2JjYTYzYjA3YmRhMjBlNDI4MTkwNDA5YzJkYjQ3IHJlZnMvcHVsbC8xL2hlYWQKMDAzZTk2MzJmMDI4MzNiMmY5NjEzYWZiNWU3NTY4MjEzMmIwYjIyZTRhMzEgcmVmcy9wdWxsLzIvaGVhZAowMDNmYzM3ZjU4YTEzMGNhNTU1ZTQyZmY5NmEwNzFjYjljY2IzZjQzNzUwNCByZWZzL3B1bGwvMi9tZXJnZQowMDAw`) // nolint:lll + require.NoError(t, err) + + return bts +} + +// emptyUploadPackAdvertisement is the canned advertisement of an empty +// repository: capabilities only, no refs. +func emptyUploadPackAdvertisement(t *testing.T) []byte { + t.Helper() + + bts, err := base64.StdEncoding.DecodeString(`MDAxZSMgc2VydmljZT1naXQtdXBsb2FkLXBhY2sKMDAwMDAwZGUwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIGNhcGFiaWxpdGllc157fQAgaW5jbHVkZS10YWcgbXVsdGlfYWNrX2RldGFpbGVkIG11bHRpX2FjayBvZnMtZGVsdGEgc2lkZS1iYW5kIHNpZGUtYmFuZC02NGsgdGhpbi1wYWNrIG5vLXByb2dyZXNzIHNoYWxsb3cgbm8tZG9uZSBhZ2VudD1KR2l0L3Y1LjkuMC4yMDIwMDkwODA1MDEtci00MS1nNWQ5MjVlY2JiCjAwMDA=`) // nolint:lll + require.NoError(t, err) + + return bts +} + +func staticResponseServer(t *testing.T, bts []byte) *httptest.Server { + t.Helper() + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + _, err := w.Write(bts) + assert.NoError(t, err, "failed to write response") + })) + t.Cleanup(s.Close) + + return s +} + +func uploadPackServer(t *testing.T) *httptest.Server { + t.Helper() + + return staticResponseServer(t, uploadPackAdvertisement(t)) +} + +func emptyUploadPackServer(t *testing.T) *httptest.Server { + t.Helper() + + return staticResponseServer(t, emptyUploadPackAdvertisement(t)) +} + +// buildAdvertisement encodes a smart-HTTP ref advertisement for the given +// service. Peeled entries and HEAD only appear for git-upload-pack, mirroring +// real servers: git-receive-pack advertises plain refs only. +func buildAdvertisement(t *testing.T, service string, refs, peeled map[string]string, head string) []byte { + t.Helper() + + advRefs := packp.NewAdvRefs() + for name, hash := range refs { + advRefs.References[name] = plumbing.NewHash(hash) + } + + require.NoError(t, advRefs.Capabilities.Add(capability.ReportStatus)) + require.NoError(t, advRefs.Capabilities.Add(capability.DeleteRefs)) + require.NoError(t, advRefs.Capabilities.Add(capability.OFSDelta)) + require.NoError(t, advRefs.Capabilities.Add(capability.Agent, "test/1.0")) + + if service == "git-upload-pack" { + for name, hash := range peeled { + advRefs.Peeled[name] = plumbing.NewHash(hash) + } + + if head != "" { + headHash := plumbing.NewHash(head) + advRefs.Head = &headHash + } + } + + buf := &bytes.Buffer{} + fmt.Fprintf(buf, "%04x# service=%s\n0000", len(service)+15+4, service) + require.NoError(t, advRefs.Encode(buf)) + + return buf.Bytes() +} + +// receivePackServer serves upload-pack and receive-pack advertisements for the +// given refs (peeled entries visible to upload-pack only) and captures the body +// POSTed to /git-receive-pack, responding with the given report-status. +func receivePackServer( + t *testing.T, + refs, peeled map[string]string, + report *packp.ReportStatus, +) (*httptest.Server, *bytes.Buffer, *int) { + t.Helper() + + uploadAdv := buildAdvertisement(t, "git-upload-pack", refs, peeled, "") + receiveAdv := buildAdvertisement(t, "git-receive-pack", refs, nil, "") + postBody := &bytes.Buffer{} + postCount := new(int) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + *postCount++ + + _, err := io.Copy(postBody, r.Body) + assert.NoError(t, err, "failed to read request body") + + w.WriteHeader(http.StatusOK) + assert.NoError(t, report.Encode(w), "failed to encode report status") + + return + } + + adv := receiveAdv + if r.URL.Query().Get("service") == "git-upload-pack" { + adv = uploadAdv + } + + w.WriteHeader(http.StatusOK) + + _, err := w.Write(adv) + assert.NoError(t, err, "failed to write advertisement") + })) + t.Cleanup(s.Close) + + return s, postBody, postCount +} + +func okReportStatus() *packp.ReportStatus { + rs := packp.NewReportStatus() + rs.UnpackStatus = "ok" + rs.CommandStatuses = []*packp.CommandStatus{ + {ReferenceName: "refs/heads/feature", Status: "ok"}, + } + + return rs +} + +func TestGitProvider_ResolveRemoteReference(t *testing.T) { + tests := []struct { + name string + ref string + wantHash string + wantErr require.ErrorAssertionFunc + }{ + { + name: "resolves branch name", + ref: "master", + wantHash: testMasterHash, + wantErr: require.NoError, + }, + { + name: "resolves another branch name", + ref: "branch", + wantHash: testBranchHash, + wantErr: require.NoError, + }, + { + name: "resolves empty reference to HEAD", + ref: "", + wantHash: testMasterHash, + wantErr: require.NoError, + }, + { + name: "passes through full commit hash not in advertisement", + ref: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + wantHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + wantErr: require.NoError, + }, + { + name: "returns ErrReferenceNotFound for unknown reference", + ref: "no-such-branch", + wantErr: func(t require.TestingT, err error, _ ...any) { + require.ErrorIs(t, err, ErrReferenceNotFound) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := uploadPackServer(t) + gp := NewGitProvider(Config{Username: "user", Token: "pass"}) + + hash, err := gp.ResolveRemoteReference(context.Background(), s.URL, tt.ref) + + tt.wantErr(t, err) + assert.Equal(t, tt.wantHash, hash) + }) + } +} + +func TestGitProvider_CreateRemoteBranchViaRefUpdate(t *testing.T) { + existingRefs := map[string]string{"refs/heads/master": testMasterHash} + + tests := []struct { + name string + refs map[string]string + peeled map[string]string + report *packp.ReportStatus + branch string + fromRef string + wantPosts int + checkBody func(t *testing.T, body []byte) + wantErr require.ErrorAssertionFunc + }{ + { + name: "creates branch from existing branch with empty packfile", + refs: existingRefs, + report: okReportStatus(), + branch: "feature", + fromRef: "master", + wantPosts: 1, + checkBody: func(t *testing.T, body []byte) { + command := plumbing.ZeroHash.String() + " " + testMasterHash + " refs/heads/feature" + assert.Contains(t, string(body), command, "create command must target the resolved hash") + + pack := emptyPackfileBytes() + assert.True(t, bytes.HasSuffix(body, pack), "request must end with the empty packfile") + }, + wantErr: require.NoError, + }, + { + name: "creates branch from full commit hash", + refs: existingRefs, + report: okReportStatus(), + branch: "feature", + fromRef: testBranchHash, + wantPosts: 1, + checkBody: func(t *testing.T, body []byte) { + command := plumbing.ZeroHash.String() + " " + testBranchHash + " refs/heads/feature" + assert.Contains(t, string(body), command) + }, + wantErr: require.NoError, + }, + { + name: "creates branch from annotated tag at the peeled commit", + refs: map[string]string{ + "refs/heads/master": testMasterHash, + "refs/tags/annotated": testTagHash, + }, + peeled: map[string]string{"refs/tags/annotated": testPeeledHash}, + report: okReportStatus(), + branch: "feature", + fromRef: "annotated", + wantPosts: 1, + checkBody: func(t *testing.T, body []byte) { + command := plumbing.ZeroHash.String() + " " + testPeeledHash + " refs/heads/feature" + assert.Contains(t, string(body), command, + "branch must target the peeled commit, not the tag object") + }, + wantErr: require.NoError, + }, + { + name: "skips creation when branch already exists", + refs: map[string]string{ + "refs/heads/master": testMasterHash, + "refs/heads/feature": testBranchHash, + }, + report: okReportStatus(), + branch: "feature", + fromRef: "master", + wantPosts: 0, + wantErr: require.NoError, + }, + { + name: "fails when fromRef cannot be resolved", + refs: existingRefs, + report: okReportStatus(), + branch: "feature", + fromRef: "no-such-ref", + wantPosts: 0, + wantErr: func(t require.TestingT, err error, _ ...any) { + require.ErrorIs(t, err, ErrReferenceNotFound) + }, + }, + { + name: "fails when fromRef is empty", + refs: existingRefs, + report: okReportStatus(), + branch: "feature", + fromRef: "", + wantPosts: 0, + wantErr: func(t require.TestingT, err error, _ ...any) { + require.ErrorIs(t, err, ErrReferenceNotFound) + }, + }, + { + name: "fails when remote rejects the reference update", + refs: existingRefs, + report: func() *packp.ReportStatus { + rs := packp.NewReportStatus() + rs.UnpackStatus = "ok" + rs.CommandStatuses = []*packp.CommandStatus{ + {ReferenceName: "refs/heads/feature", Status: "pre-receive hook declined"}, + } + + return rs + }(), + branch: "feature", + fromRef: "master", + wantPosts: 1, + wantErr: func(t require.TestingT, err error, _ ...any) { + require.Error(t, err) + require.Contains(t, err.Error(), "pre-receive hook declined", "error must carry the server's rejection reason") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, postBody, postCount := receivePackServer(t, tt.refs, tt.peeled, tt.report) + gp := NewGitProvider(Config{Username: "user", Token: "pass"}) + + err := gp.CreateRemoteBranchViaRefUpdate(context.Background(), s.URL, tt.branch, tt.fromRef) + + tt.wantErr(t, err) + assert.Equal(t, tt.wantPosts, *postCount, "unexpected number of receive-pack POSTs") + + if tt.checkBody != nil { + tt.checkBody(t, postBody.Bytes()) + } + }) + } +} + +func TestResolveAdvertisedRef(t *testing.T) { + headHash := plumbing.NewHash(testMasterHash) + + advRefs := packp.NewAdvRefs() + advRefs.Head = &headHash + advRefs.References = map[string]plumbing.Hash{ + "refs/heads/master": plumbing.NewHash(testMasterHash), + "refs/tags/lightweight": plumbing.NewHash(testBranchHash), + "refs/tags/annotated": plumbing.NewHash(testTagHash), + "refs/heads/shadowed-name": plumbing.NewHash(testMasterHash), + "refs/tags/shadowed-name": plumbing.NewHash(testTagHash), + } + advRefs.Peeled = map[string]plumbing.Hash{ + "refs/tags/annotated": plumbing.NewHash(testPeeledHash), + } + + tests := []struct { + name string + ref string + wantHash string + wantErr require.ErrorAssertionFunc + }{ + {name: "branch", ref: "master", wantHash: testMasterHash, wantErr: require.NoError}, + {name: "lightweight tag", ref: "lightweight", wantHash: testBranchHash, wantErr: require.NoError}, + {name: "annotated tag resolves to peeled", ref: "annotated", wantHash: testPeeledHash, wantErr: require.NoError}, + {name: "branch takes precedence over tag", ref: "shadowed-name", wantHash: testMasterHash, wantErr: require.NoError}, + {name: "empty ref resolves HEAD", ref: "", wantHash: testMasterHash, wantErr: require.NoError}, + {name: "full hash passthrough", ref: testPeeledHash, wantHash: testPeeledHash, wantErr: require.NoError}, + { + name: "uppercase hash is normalized to lowercase", + ref: strings.ToUpper(testPeeledHash), + wantHash: testPeeledHash, + wantErr: require.NoError, + }, + { + name: "short hash is not accepted", + ref: "6ecf0ef", + wantErr: func(t require.TestingT, err error, _ ...any) { + require.ErrorIs(t, err, ErrReferenceNotFound) + }, + }, + { + name: "unknown ref", + ref: "missing", + wantErr: func(t require.TestingT, err error, _ ...any) { + require.ErrorIs(t, err, ErrReferenceNotFound) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash, err := resolveAdvertisedRef(advRefs, tt.ref) + + tt.wantErr(t, err) + assert.Equal(t, tt.wantHash, hash) + }) + } +} + +func TestResolveAdvertisedRef_NoHead(t *testing.T) { + _, err := resolveAdvertisedRef(packp.NewAdvRefs(), "") + + require.ErrorIs(t, err, ErrReferenceNotFound) +} + +func TestEmptyPackfile(t *testing.T) { + got := emptyPackfileBytes() + + require.Len(t, got, 32, "empty packfile is header (12 bytes) + SHA-1 trailer (20 bytes)") + assert.Equal(t, []byte("PACK"), got[:4]) + assert.Equal(t, []byte{0, 0, 0, 2}, got[4:8], "packfile version must be 2") + assert.Equal(t, []byte{0, 0, 0, 0}, got[8:12], "object count must be 0") + + checksum := sha1.Sum(got[:12]) + assert.Equal(t, checksum[:], got[12:], "trailer must be the SHA-1 of the header") +} + +func emptyPackfileBytes() []byte { + pack, err := io.ReadAll(emptyPackfile()) + if err != nil { + panic(err) + } + + return pack +}