From 781b3547c0715abda7eb728b74c2ee2fd141e78a Mon Sep 17 00:00:00 2001 From: Marko Gacesa Date: Fri, 12 Apr 2024 09:48:35 +0000 Subject: [PATCH] refactor git hook calling and shared repo (#1200) --- app/api/controller/githook/client.go | 2 +- app/api/controller/githook/pre_receive.go | 32 +- app/api/controller/repo/commit.go | 3 +- app/githook/client_rest.go | 2 +- app/services/importer/pipelines.go | 3 +- cmd/gitness/wire_gen.go | 2 +- git/api/ref.go | 168 +----- git/api/shared_repo.go | 648 ---------------------- git/api/tree.go | 5 + git/branch.go | 33 +- git/hook/client.go | 2 +- git/hook/refupdate.go | 285 ++++++++++ git/merge.go | 30 +- git/merge/merge.go | 51 +- git/operations.go | 206 ++++--- git/ref.go | 18 +- git/scan_secrets.go | 20 +- git/service.go | 28 +- git/sharedrepo/run.go | 74 +++ git/sharedrepo/sharedrepo.go | 308 ++++++++-- git/tag.go | 106 ++-- git/wire.go | 2 + 22 files changed, 916 insertions(+), 1112 deletions(-) delete mode 100644 git/api/shared_repo.go create mode 100644 git/hook/refupdate.go create mode 100644 git/sharedrepo/run.go diff --git a/app/api/controller/githook/client.go b/app/api/controller/githook/client.go index d2631da03..07879f996 100644 --- a/app/api/controller/githook/client.go +++ b/app/api/controller/githook/client.go @@ -37,7 +37,7 @@ type ControllerClientFactory struct { git git.Interface } -func (f *ControllerClientFactory) NewClient(_ context.Context, envVars map[string]string) (hook.Client, error) { +func (f *ControllerClientFactory) NewClient(envVars map[string]string) (hook.Client, error) { payload, err := hook.LoadPayloadFromMap[githook.Payload](envVars) if err != nil { return nil, fmt.Errorf("failed to load payload from provided map of environment variables: %w", err) diff --git a/app/api/controller/githook/pre_receive.go b/app/api/controller/githook/pre_receive.go index cacceffca..1b314a7d3 100644 --- a/app/api/controller/githook/pre_receive.go +++ b/app/api/controller/githook/pre_receive.go @@ -61,30 +61,26 @@ func (c *Controller) PreReceive( return output, nil } - if in.Internal { - // It's an internal call, so no need to verify protection rules. - return output, nil - } - - if c.blockPullReqRefUpdate(refUpdates) { + // For external calls (git pushes) block modification of pullreq references. + if !in.Internal && c.blockPullReqRefUpdate(refUpdates) { output.Error = ptr.String(usererror.ErrPullReqRefsCantBeModified.Error()) return output, nil } - // TODO: use store.PrincipalInfoCache once we abstracted principals. - principal, err := c.principalStore.Find(ctx, in.PrincipalID) - if err != nil { - return hook.Output{}, fmt.Errorf("failed to find inner principal with id %d: %w", in.PrincipalID, err) - } + // For internal calls - through the application interface (API) - no need to verify protection rules. + if !in.Internal { + // TODO: use store.PrincipalInfoCache once we abstracted principals. + principal, err := c.principalStore.Find(ctx, in.PrincipalID) + if err != nil { + return hook.Output{}, fmt.Errorf("failed to find inner principal with id %d: %w", in.PrincipalID, err) + } - dummySession := &auth.Session{ - Principal: *principal, - Metadata: nil, - } + dummySession := &auth.Session{Principal: *principal, Metadata: nil} - err = c.checkProtectionRules(ctx, dummySession, repo, refUpdates, &output) - if err != nil { - return hook.Output{}, fmt.Errorf("failed to check protection rules: %w", err) + err = c.checkProtectionRules(ctx, dummySession, repo, refUpdates, &output) + if err != nil { + return hook.Output{}, fmt.Errorf("failed to check protection rules: %w", err) + } } err = c.scanSecrets(ctx, rgit, repo, in, &output) diff --git a/app/api/controller/repo/commit.go b/app/api/controller/repo/commit.go index ec8f0632b..0059ed6b2 100644 --- a/app/api/controller/repo/commit.go +++ b/app/api/controller/repo/commit.go @@ -26,6 +26,7 @@ import ( "github.com/harness/gitness/app/services/protection" "github.com/harness/gitness/errors" "github.com/harness/gitness/git" + "github.com/harness/gitness/git/sha" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" ) @@ -41,7 +42,7 @@ type CommitFileAction struct { // The provided value is compared against the latest sha of the file that's being updated. // If the SHA doesn't match, the update fails. // WARNING: If no SHA is provided, the update action will blindly overwrite the file's content. - SHA string `json:"sha"` + SHA sha.SHA `json:"sha"` } // CommitFilesOptions holds the data for file operations. diff --git a/app/githook/client_rest.go b/app/githook/client_rest.go index d8cb3a1e8..b5dbad043 100644 --- a/app/githook/client_rest.go +++ b/app/githook/client_rest.go @@ -44,7 +44,7 @@ const ( // RestClientFactory creates clients that make rest api calls to gitness to execute githooks. type RestClientFactory struct{} -func (f *RestClientFactory) NewClient(_ context.Context, envVars map[string]string) (hook.Client, error) { +func (f *RestClientFactory) NewClient(envVars map[string]string) (hook.Client, error) { payload, err := hook.LoadPayloadFromMap[Payload](envVars) if err != nil { return nil, fmt.Errorf("failed to load payload from provided map of environment variables: %w", err) diff --git a/app/services/importer/pipelines.go b/app/services/importer/pipelines.go index 440e46b9b..48afaa887 100644 --- a/app/services/importer/pipelines.go +++ b/app/services/importer/pipelines.go @@ -22,6 +22,7 @@ import ( "time" "github.com/harness/gitness/git" + "github.com/harness/gitness/git/sha" "github.com/harness/gitness/store/database/dbtx" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" @@ -62,7 +63,7 @@ func (r *Repository) processPipelines(ctx context.Context, Action: git.CreateAction, Path: file.ConvertedPath, Payload: file.Content, - SHA: "", + SHA: sha.None, } } diff --git a/cmd/gitness/wire_gen.go b/cmd/gitness/wire_gen.go index e74e357bf..c6064dfb2 100644 --- a/cmd/gitness/wire_gen.go +++ b/cmd/gitness/wire_gen.go @@ -150,7 +150,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro return nil, err } storageStore := storage.ProvideLocalStore() - gitInterface, err := git.ProvideService(typesConfig, apiGit, storageStore) + gitInterface, err := git.ProvideService(typesConfig, apiGit, clientFactory, storageStore) if err != nil { return nil, err } diff --git a/git/api/ref.go b/git/api/ref.go index cc1a2e9b9..e032deebe 100644 --- a/git/api/ref.go +++ b/git/api/ref.go @@ -26,10 +26,7 @@ import ( "github.com/harness/gitness/errors" "github.com/harness/gitness/git/api/foreachref" "github.com/harness/gitness/git/command" - "github.com/harness/gitness/git/hook" "github.com/harness/gitness/git/sha" - - "github.com/rs/zerolog/log" ) // GitReferenceField represents the different fields available When listing references. @@ -249,152 +246,29 @@ func (g *Git) GetRef( return sha.New(output.String()) } -// UpdateRef allows to update / create / delete references -// IMPORTANT provide full reference name to limit risk of collisions across reference types -// (e.g `refs/heads/main` instead of `main`). -func (g *Git) UpdateRef( - ctx context.Context, - envVars map[string]string, - repoPath string, - ref string, - oldValue sha.SHA, - newValue sha.SHA, -) error { - if repoPath == "" { - return ErrRepositoryPathEmpty - } +// GetReferenceFromBranchName assumes the provided value is the branch name (not the ref!) +// and first sanitizes the branch name (remove any spaces or 'refs/heads/' prefix) +// It then returns the full form of the branch reference. +func GetReferenceFromBranchName(branchName string) string { + // remove spaces + branchName = strings.TrimSpace(branchName) + // remove `refs/heads/` prefix (shouldn't be there, but if it is remove it to try to avoid complications) + // NOTE: This is used to reduce missconfigurations via api + // TODO: block via CLI, too + branchName = strings.TrimPrefix(branchName, gitReferenceNamePrefixBranch) - // don't break existing interface - user calls with empty value to delete the ref. - if newValue.IsEmpty() { - newValue = sha.Nil - } - - // if no old value was provided, use current value (as required for hooks) - // TODO: technically a delete could fail if someone updated the ref in the meanwhile. - //nolint:gocritic,nestif - if oldValue.IsEmpty() { - val, err := g.GetRef(ctx, repoPath, ref) - if errors.IsNotFound(err) { - // fail in case someone tries to delete a reference that doesn't exist. - if newValue.IsNil() { - return errors.NotFound("reference %q not found", ref) - } - - oldValue = sha.Nil - } else if err != nil { - return fmt.Errorf("failed to get current value of reference: %w", err) - } else { - oldValue = val - } - } - - err := g.updateRefWithHooks( - ctx, - envVars, - repoPath, - ref, - oldValue, - newValue, - ) - if err != nil { - return fmt.Errorf("failed to update reference with hooks: %w", err) - } - - return nil + // return reference + return gitReferenceNamePrefixBranch + branchName } -// updateRefWithHooks performs a git-ref update for the provided reference. -// Requires both old and new value to be provided explcitly, or the call fails (ensures consistency across operation). -// pre-receice will be called before the update, post-receive after. -func (g *Git) updateRefWithHooks( - ctx context.Context, - envVars map[string]string, - repoPath string, - ref string, - oldValue sha.SHA, - newValue sha.SHA, -) error { - if repoPath == "" { - return ErrRepositoryPathEmpty - } +func GetReferenceFromTagName(tagName string) string { + // remove spaces + tagName = strings.TrimSpace(tagName) + // remove `refs/heads/` prefix (shouldn't be there, but if it is remove it to try to avoid complications) + // NOTE: This is used to reduce missconfigurations via api + // TODO: block via CLI, too + tagName = strings.TrimPrefix(tagName, gitReferenceNamePrefixTag) - if oldValue.IsEmpty() { - return fmt.Errorf("oldValue can't be empty") - } - if newValue.IsEmpty() { - return fmt.Errorf("newValue can't be empty") - } - if oldValue.IsNil() && newValue.IsNil() { - return fmt.Errorf("provided values cannot be both empty") - } - - githookClient, err := g.githookFactory.NewClient(ctx, envVars) - if err != nil { - return fmt.Errorf("failed to create githook client: %w", err) - } - - // call pre-receive before updating the reference - out, err := githookClient.PreReceive(ctx, hook.PreReceiveInput{ - RefUpdates: []hook.ReferenceUpdate{ - { - Ref: ref, - Old: oldValue, - New: newValue, - }, - }, - Environment: hook.Environment{ - // TODO: Update once we properly copy quarantine objects only after pre-receive. - }, - }) - if err != nil { - return fmt.Errorf("pre-receive call failed with: %w", err) - } - if out.Error != nil { - return fmt.Errorf("pre-receive call returned error: %q", *out.Error) - } - - if g.traceGit { - log.Ctx(ctx).Trace(). - Str("git", "pre-receive"). - Msgf("pre-receive call succeeded with output:\n%s", strings.Join(out.Messages, "\n")) - } - - cmd := command.New("update-ref") - if newValue.IsNil() { - cmd.Add(command.WithFlag("-d", ref)) - } else { - cmd.Add(command.WithArg(ref, newValue.String())) - } - - cmd.Add(command.WithArg(oldValue.String())) - err = cmd.Run(ctx, command.WithDir(repoPath)) - if err != nil { - return processGitErrorf(err, "update of ref %q from %q to %q failed", ref, oldValue, newValue) - } - - // call post-receive after updating the reference - out, err = githookClient.PostReceive(ctx, hook.PostReceiveInput{ - RefUpdates: []hook.ReferenceUpdate{ - { - Ref: ref, - Old: oldValue, - New: newValue, - }, - }, - Environment: hook.Environment{}, - }) - if err != nil { - return fmt.Errorf("post-receive call failed with: %w", err) - } - if out.Error != nil { - return fmt.Errorf("post-receive call returned error: %q", *out.Error) - } - - if g.traceGit { - log.Ctx(ctx).Trace(). - Str("git", "post-receive"). - Msgf("post-receive call succeeded with output:\n%s", strings.Join(out.Messages, "\n")) - } - - return nil + // return reference + return gitReferenceNamePrefixTag + tagName } diff --git a/git/api/shared_repo.go b/git/api/shared_repo.go deleted file mode 100644 index f087a4781..000000000 --- a/git/api/shared_repo.go +++ /dev/null @@ -1,648 +0,0 @@ -// Copyright 2023 Harness, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package api - -import ( - "bytes" - "context" - "fmt" - "io" - "io/fs" - "os" - "path" - "path/filepath" - "regexp" - "sort" - "strings" - "time" - - "github.com/harness/gitness/errors" - "github.com/harness/gitness/git/command" - "github.com/harness/gitness/git/sha" - "github.com/harness/gitness/git/tempdir" - - "github.com/rs/zerolog/log" -) - -// SharedRepo is a type to wrap our upload repositories as a shallow clone. -type SharedRepo struct { - git *Git - repoUID string - remoteRepoPath string - RepoPath string -} - -// NewSharedRepo creates a new temporary upload repository. -func NewSharedRepo( - adapter *Git, - baseTmpDir string, - repoUID string, - remoteRepoPath string, -) (*SharedRepo, error) { - tmpPath, err := tempdir.CreateTemporaryPath(baseTmpDir, repoUID) // Need better solution - if err != nil { - return nil, err - } - - t := &SharedRepo{ - git: adapter, - repoUID: repoUID, - remoteRepoPath: remoteRepoPath, - RepoPath: tmpPath, - } - return t, nil -} - -// Close the repository cleaning up all files. -func (r *SharedRepo) Close(ctx context.Context) { - if err := tempdir.RemoveTemporaryPath(r.RepoPath); err != nil { - log.Ctx(ctx).Err(err).Msgf("Failed to remove temporary path %s", r.RepoPath) - } -} - -// filePriority is based on https://github.com/git/git/blob/master/tmp-objdir.c#L168 -func filePriority(name string) int { - switch { - case !strings.HasPrefix(name, "pack"): - return 0 - case strings.HasSuffix(name, ".keep"): - return 1 - case strings.HasSuffix(name, ".pack"): - return 2 - case strings.HasSuffix(name, ".rev"): - return 3 - case strings.HasSuffix(name, ".idx"): - return 4 - default: - return 5 - } -} - -type fileEntry struct { - fileName string - fullPath string - relPath string - priority int -} - -func (r *SharedRepo) MoveObjects(ctx context.Context) error { - srcDir := path.Join(r.RepoPath, "objects") - dstDir := path.Join(r.remoteRepoPath, "objects") - - var files []fileEntry - - err := filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - return nil - } - - relPath, err := filepath.Rel(srcDir, path) - if err != nil { - return fmt.Errorf("failed to get relative path: %w", err) - } - - // avoid coping anything in the info/ - if strings.HasPrefix(relPath, "info/") { - return nil - } - - fileName := filepath.Base(relPath) - - files = append(files, fileEntry{ - fileName: fileName, - fullPath: path, - relPath: relPath, - priority: filePriority(fileName), - }) - - return nil - }) - if err != nil { - return fmt.Errorf("failed to walk source directory: %w", err) - } - - sort.Slice(files, func(i, j int) bool { - return files[i].priority < files[j].priority // 0 is top priority, 5 is lowest priority - }) - - for _, f := range files { - dstPath := filepath.Join(dstDir, f.relPath) - - err = os.MkdirAll(filepath.Dir(dstPath), os.ModePerm) - if err != nil { - return err - } - - // Try to move the file - - errRename := os.Rename(f.fullPath, dstPath) - if errRename == nil { - log.Ctx(ctx).Debug(). - Str("object", f.relPath). - Msg("moved git object") - continue - } - - // Try to copy the file - - copyError := func() error { - srcFile, err := os.Open(f.fullPath) - if err != nil { - return fmt.Errorf("failed to open source file: %w", err) - } - defer func() { _ = srcFile.Close() }() - - dstFile, err := os.Create(dstPath) - if err != nil { - return fmt.Errorf("failed to create target file: %w", err) - } - defer func() { _ = dstFile.Close() }() - - _, err = io.Copy(dstFile, srcFile) - if err != nil { - return err - } - - return nil - }() - if copyError != nil { - log.Ctx(ctx).Err(copyError). - Str("object", f.relPath). - Str("renameErr", errRename.Error()). - Msg("failed to move or copy git object") - return copyError - } - - log.Ctx(ctx).Warn(). - Str("object", f.relPath). - Str("renameErr", errRename.Error()). - Msg("copied git object") - } - - return nil -} -func (r *SharedRepo) initRepository(ctx context.Context, alternateObjDirs ...string) error { - cmd := command.New("init", command.WithFlag("--bare")) - - if err := cmd.Run(ctx, command.WithDir(r.RepoPath)); err != nil { - return errors.Internal(err, "error while creating empty repository") - } - - if err := func() error { - alternates := filepath.Join(r.RepoPath, "objects", "info", "alternates") - f, err := os.OpenFile(alternates, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - return fmt.Errorf("failed to open alternates file '%s': %w", alternates, err) - } - defer func() { _ = f.Close() }() - - data := strings.Join( - append( - alternateObjDirs, - filepath.Join(r.remoteRepoPath, "objects"), - ), - "\n", - ) - - if _, err = fmt.Fprintln(f, data); err != nil { - return fmt.Errorf("failed to write alternates file '%s': %w", alternates, err) - } - - return nil - }(); err != nil { - return errors.Internal(err, "failed to create alternate in empty repository: %s", err.Error()) - } - - return nil -} - -func (r *SharedRepo) InitAsShared(ctx context.Context) error { - return r.initRepository(ctx) -} - -// InitAsSharedWithAlternates initializes repository with provided alternate object directories. -func (r *SharedRepo) InitAsSharedWithAlternates(ctx context.Context, alternateObjDirs ...string) error { - return r.initRepository(ctx, alternateObjDirs...) -} - -// Clone the base repository to our path and set branch as the HEAD. -func (r *SharedRepo) Clone(ctx context.Context, branchName string) error { - cmd := command.New("clone", - command.WithFlag("-s"), - command.WithFlag("--bare"), - ) - if branchName != "" { - cmd.Add(command.WithFlag("-b", strings.TrimPrefix(branchName, gitReferenceNamePrefixBranch))) - } - cmd.Add(command.WithArg(r.remoteRepoPath, r.RepoPath)) - - if err := cmd.Run(ctx); err != nil { - cmderr := command.AsError(err) - if cmderr.StdErr == nil { - return errors.Internal(err, "error while cloning repository") - } - stderr := string(cmderr.StdErr) - matched, _ := regexp.MatchString(".*Remote branch .* not found in upstream origin.*", stderr) - if matched { - return errors.NotFound("branch '%s' does not exist", branchName) - } - matched, _ = regexp.MatchString(".* repository .* does not exist.*", stderr) - if matched { - return errors.NotFound("repository '%s' does not exist", r.repoUID) - } - } - return nil -} - -// Init the repository. -func (r *SharedRepo) Init(ctx context.Context) error { - err := r.git.InitRepository(ctx, r.RepoPath, false) - if err != nil { - return fmt.Errorf("failed to initialize shared repo: %w", err) - } - return nil -} - -// SetDefaultIndex sets the git index to our HEAD. -func (r *SharedRepo) SetDefaultIndex(ctx context.Context) error { - return r.SetIndex(ctx, "HEAD") -} - -// SetIndex sets the git index to the provided treeish. -func (r *SharedRepo) SetIndex(ctx context.Context, rev string) error { - cmd := command.New("read-tree", command.WithArg(rev)) - if err := cmd.Run(ctx, command.WithDir(r.RepoPath)); err != nil { - return fmt.Errorf("failed to git read-tree %s: %w", rev, err) - } - return nil -} - -// LsFiles checks if the given filename arguments are in the index. -func (r *SharedRepo) LsFiles( - ctx context.Context, - filenames ...string, -) ([]string, error) { - cmd := command.New("ls-files", - command.WithFlag("-z"), - ) - - for _, arg := range filenames { - if arg != "" { - cmd.Add(command.WithPostSepArg(arg)) - } - } - - stdout := bytes.NewBuffer(nil) - - err := cmd.Run(ctx, - command.WithDir(r.RepoPath), - command.WithStdout(stdout), - ) - if err != nil { - return nil, fmt.Errorf("failed to list files in shared repository's git index: %w", err) - } - - files := make([]string, 0) - for _, line := range bytes.Split(stdout.Bytes(), []byte{'\000'}) { - files = append(files, string(line)) - } - - return files, nil -} - -// RemoveFilesFromIndex removes the given files from the index. -func (r *SharedRepo) RemoveFilesFromIndex( - ctx context.Context, - filenames ...string, -) error { - stdOut := new(bytes.Buffer) - stdIn := new(bytes.Buffer) - for _, file := range filenames { - if file != "" { - stdIn.WriteString("0 0000000000000000000000000000000000000000\t") - stdIn.WriteString(file) - stdIn.WriteByte('\000') - } - } - - cmd := command.New("update-index", - command.WithFlag("--remove"), - command.WithFlag("-z"), - command.WithFlag("--index-info"), - ) - - if err := cmd.Run(ctx, - command.WithDir(r.RepoPath), - command.WithStdin(stdIn), - command.WithStdout(stdOut), - ); err != nil { - return fmt.Errorf("unable to update-index for temporary repo: %s Error: %w\nstdout: %s", - r.repoUID, err, stdOut.String()) - } - return nil -} - -// WriteGitObject writes the provided content to the object db and returns its hash. -func (r *SharedRepo) WriteGitObject( - ctx context.Context, - content io.Reader, -) (sha.SHA, error) { - stdOut := new(bytes.Buffer) - cmd := command.New("hash-object", - command.WithFlag("-w"), - command.WithFlag("--stdin"), - ) - if err := cmd.Run(ctx, - command.WithDir(r.RepoPath), - command.WithStdin(content), - command.WithStdout(stdOut), - ); err != nil { - return sha.None, fmt.Errorf("unable to hash-object to temporary repo: %s Error: %w\nstdout: %s", - r.repoUID, err, stdOut.String()) - } - - return sha.New(stdOut.String()) -} - -// ShowFile dumps show file and write to io.Writer. -func (r *SharedRepo) ShowFile( - ctx context.Context, - filePath string, - commitHash string, - writer io.Writer, -) error { - file := strings.TrimSpace(commitHash) + ":" + strings.TrimSpace(filePath) - cmd := command.New("show", - command.WithArg(file), - ) - if err := cmd.Run(ctx, - command.WithDir(r.RepoPath), - command.WithStdout(writer), - ); err != nil { - return fmt.Errorf("show file: %w", err) - } - return nil -} - -// AddObjectToIndex adds the provided object hash to the index with the provided mode and path. -func (r *SharedRepo) AddObjectToIndex( - ctx context.Context, - mode string, - objectHash string, - objectPath string, -) error { - cmd := command.New("update-index", - command.WithFlag("--add"), - command.WithFlag("--replace"), - command.WithFlag("--cacheinfo"), - command.WithArg(mode, objectHash, objectPath), - ) - if err := cmd.Run(ctx, command.WithDir(r.RepoPath)); err != nil { - if matched, _ := regexp.MatchString(".*Invalid path '.*", err.Error()); matched { - return errors.InvalidArgument("invalid path '%s'", objectPath) - } - return fmt.Errorf("unable to add object to index at %s in temporary repo path %s Error: %w", - objectPath, r.repoUID, err) - } - return nil -} - -// WriteTree writes the current index as a tree to the object db and returns its hash. -func (r *SharedRepo) WriteTree(ctx context.Context) (sha.SHA, error) { - stdout := &bytes.Buffer{} - cmd := command.New("write-tree") - err := cmd.Run(ctx, - command.WithDir(r.RepoPath), - command.WithStdout(stdout), - ) - if err != nil { - return sha.None, fmt.Errorf("unable to write-tree in temporary repo path for: %s Error: %w", - r.repoUID, err) - } - return sha.New(stdout.String()) -} - -// GetLastCommit gets the last commit ID SHA of the repo. -func (r *SharedRepo) GetLastCommit(ctx context.Context) (string, error) { - return r.GetLastCommitByRef(ctx, "HEAD") -} - -// GetLastCommitByRef gets the last commit ID SHA of the repo by ref. -func (r *SharedRepo) GetLastCommitByRef( - ctx context.Context, - ref string, -) (string, error) { - if ref == "" { - ref = "HEAD" - } - stdout := &bytes.Buffer{} - cmd := command.New("rev-parse", - command.WithArg(ref), - ) - err := cmd.Run(ctx, - command.WithDir(r.RepoPath), - command.WithStdout(stdout), - ) - if err != nil { - return "", processGitErrorf(err, "unable to rev-parse %s in temporary repo for: %s", - ref, r.repoUID) - } - return strings.TrimSpace(stdout.String()), nil -} - -// CommitTreeWithDate creates a commit from a given tree for the user with provided message. -func (r *SharedRepo) CommitTreeWithDate( - ctx context.Context, - parent sha.SHA, - author, committer *Identity, - treeHash sha.SHA, - message string, - signoff bool, - authorDate, committerDate time.Time, -) (sha.SHA, error) { - messageBytes := new(bytes.Buffer) - _, _ = messageBytes.WriteString(message) - _, _ = messageBytes.WriteString("\n") - - cmd := command.New("commit-tree", - command.WithAuthorAndDate( - author.Name, - author.Email, - authorDate, - ), - command.WithCommitterAndDate( - committer.Name, - committer.Email, - committerDate, - ), - ) - if !parent.IsEmpty() { - cmd.Add(command.WithFlag("-p", parent.String())) - } - cmd.Add(command.WithArg(treeHash.String())) - - // temporary no signing - cmd.Add(command.WithFlag("--no-gpg-sign")) - - if signoff { - sig := &Signature{ - Identity: Identity{ - Name: committer.Name, - Email: committer.Email, - }, - When: committerDate, - } - // Signed-off-by - _, _ = messageBytes.WriteString("\n") - _, _ = messageBytes.WriteString("Signed-off-by: ") - _, _ = messageBytes.WriteString(sig.String()) - } - - stdout := new(bytes.Buffer) - if err := cmd.Run(ctx, - command.WithDir(r.RepoPath), - command.WithStdin(messageBytes), - command.WithStdout(stdout), - ); err != nil { - return sha.None, processGitErrorf(err, "unable to commit-tree in temporary repo: %s Error: %v\nStdout: %s", - r.repoUID, err, stdout) - } - return sha.New(stdout.String()) -} - -func (r *SharedRepo) PushDeleteBranch( - ctx context.Context, - branch string, - force bool, - env ...string, -) error { - return r.push(ctx, "", GetReferenceFromBranchName(branch), force, env...) -} - -func (r *SharedRepo) PushCommitToBranch( - ctx context.Context, - commitSHA string, - branch string, - force bool, - env ...string, -) error { - return r.push(ctx, - commitSHA, - GetReferenceFromBranchName(branch), - force, - env..., - ) -} - -func (r *SharedRepo) PushBranch( - ctx context.Context, - sourceBranch string, - branch string, - force bool, - env ...string, -) error { - return r.push(ctx, - GetReferenceFromBranchName(sourceBranch), - GetReferenceFromBranchName(branch), - force, - env..., - ) -} -func (r *SharedRepo) PushTag( - ctx context.Context, - tagName string, - force bool, - env ...string, -) error { - refTag := GetReferenceFromTagName(tagName) - return r.push(ctx, refTag, refTag, force, env...) -} - -func (r *SharedRepo) PushDeleteTag( - ctx context.Context, - tagName string, - force bool, - env ...string, -) error { - refTag := GetReferenceFromTagName(tagName) - return r.push(ctx, "", refTag, force, env...) -} - -// push pushes the provided references to the provided branch in the original repository. -func (r *SharedRepo) push( - ctx context.Context, - sourceRef string, - destinationRef string, - force bool, - env ...string, -) error { - // Because calls hooks we need to pass in the environment - if err := r.git.Push(ctx, r.RepoPath, PushOptions{ - Remote: r.remoteRepoPath, - Branch: sourceRef + ":" + destinationRef, - Env: env, - Force: force, - }); err != nil { - return fmt.Errorf("unable to push back to repo from temporary repo: %w", err) - } - - return nil -} - -// GetBranch gets the branch object of the given ref. -func (r *SharedRepo) GetBranch(ctx context.Context, rev string) (*Branch, error) { - return r.git.GetBranch(ctx, r.RepoPath, rev) -} - -// GetCommit Gets the commit object of the given commit ID. -func (r *SharedRepo) GetCommit(ctx context.Context, commitID string) (*Commit, error) { - return r.git.GetCommit(ctx, r.RepoPath, commitID) -} - -// GetTreeNode Gets the tree node object of the given commit ID and path. -func (r *SharedRepo) GetTreeNode(ctx context.Context, commitID, treePath string) (*TreeNode, error) { - return r.git.GetTreeNode(ctx, r.RepoPath, commitID, treePath) -} - -// GetReferenceFromBranchName assumes the provided value is the branch name (not the ref!) -// and first sanitizes the branch name (remove any spaces or 'refs/heads/' prefix) -// It then returns the full form of the branch reference. -func GetReferenceFromBranchName(branchName string) string { - // remove spaces - branchName = strings.TrimSpace(branchName) - // remove `refs/heads/` prefix (shouldn't be there, but if it is remove it to try to avoid complications) - // NOTE: This is used to reduce missconfigurations via api - // TODO: block via CLI, too - branchName = strings.TrimPrefix(branchName, gitReferenceNamePrefixBranch) - - // return reference - return gitReferenceNamePrefixBranch + branchName -} - -func GetReferenceFromTagName(tagName string) string { - // remove spaces - tagName = strings.TrimSpace(tagName) - // remove `refs/heads/` prefix (shouldn't be there, but if it is remove it to try to avoid complications) - // NOTE: This is used to reduce missconfigurations via api - // TODO: block via CLI, too - tagName = strings.TrimPrefix(tagName, gitReferenceNamePrefixTag) - - // return reference - return gitReferenceNamePrefixTag + tagName -} diff --git a/git/api/tree.go b/git/api/tree.go index 0b720a47e..8ca5adfbc 100644 --- a/git/api/tree.go +++ b/git/api/tree.go @@ -237,6 +237,11 @@ func lsFile( // GetTreeNode returns the tree node at the given path as found for the provided reference. func (g *Git) GetTreeNode(ctx context.Context, repoPath, rev, treePath string) (*TreeNode, error) { + return GetTreeNode(ctx, repoPath, rev, treePath) +} + +// GetTreeNode returns the tree node at the given path as found for the provided reference. +func GetTreeNode(ctx context.Context, repoPath, rev, treePath string) (*TreeNode, error) { if repoPath == "" { return nil, ErrRepositoryPathEmpty } diff --git a/git/branch.go b/git/branch.go index 8037bf4f1..391514c0f 100644 --- a/git/branch.go +++ b/git/branch.go @@ -22,6 +22,7 @@ import ( "github.com/harness/gitness/errors" "github.com/harness/gitness/git/api" "github.com/harness/gitness/git/check" + "github.com/harness/gitness/git/hook" "github.com/harness/gitness/git/sha" "github.com/rs/zerolog/log" @@ -101,20 +102,20 @@ func (s *Service) CreateBranch(ctx context.Context, params *CreateBranchParams) if err != nil { return nil, fmt.Errorf("failed to get target commit: %w", err) } + branchRef := api.GetReferenceFromBranchName(params.BranchName) - err = s.git.UpdateRef( - ctx, - params.EnvVars, - repoPath, - branchRef, - sha.Nil, // we want to make sure we don't overwrite any parallel create - targetCommit.SHA, - ) + + refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, branchRef) + if err != nil { + return nil, fmt.Errorf("failed to create ref updater to create the branch: %w", err) + } + + err = refUpdater.Do(ctx, sha.Nil, targetCommit.SHA) if errors.IsConflict(err) { return nil, errors.Conflict("branch %q already exists", params.BranchName) } if err != nil { - return nil, fmt.Errorf("failed to update branch reference: %w", err) + return nil, fmt.Errorf("failed to create branch reference: %w", err) } commit, err := mapCommit(targetCommit) @@ -162,14 +163,12 @@ func (s *Service) DeleteBranch(ctx context.Context, params *DeleteBranchParams) repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID) branchRef := api.GetReferenceFromBranchName(params.BranchName) - err := s.git.UpdateRef( - ctx, - params.EnvVars, - repoPath, - branchRef, - sha.None, // delete whatever is there - sha.Nil, - ) + refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, branchRef) + if err != nil { + return fmt.Errorf("failed to create ref updater to create the branch: %w", err) + } + + err = refUpdater.Do(ctx, sha.None, sha.Nil) // delete whatever is there if errors.IsNotFound(err) { return errors.NotFound("branch %q does not exist", params.BranchName) } diff --git a/git/hook/client.go b/git/hook/client.go index 6e0371e59..bfaf82a53 100644 --- a/git/hook/client.go +++ b/git/hook/client.go @@ -34,7 +34,7 @@ type Client interface { // ClientFactory is an abstraction of a factory that creates a new client based on the provided environment variables. type ClientFactory interface { - NewClient(ctx context.Context, envVars map[string]string) (Client, error) + NewClient(envVars map[string]string) (Client, error) } // TODO: move to single representation once we have our custom Git CLI wrapper. diff --git a/git/hook/refupdate.go b/git/hook/refupdate.go new file mode 100644 index 000000000..949d91517 --- /dev/null +++ b/git/hook/refupdate.go @@ -0,0 +1,285 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hook + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/harness/gitness/errors" + "github.com/harness/gitness/git/command" + "github.com/harness/gitness/git/sha" + + "github.com/rs/zerolog/log" +) + +// CreateRefUpdater creates new RefUpdater object using the provided git hook ClientFactory. +func CreateRefUpdater( + hookClientFactory ClientFactory, + envVars map[string]string, + repoPath string, + ref string, +) (*RefUpdater, error) { + if repoPath == "" { + return nil, errors.Internal(nil, "repo path can't be empty") + } + + client, err := hookClientFactory.NewClient(envVars) + if err != nil { + return nil, fmt.Errorf("failed to create hook.Client: %w", err) + } + + return &RefUpdater{ + state: statePre, + hookClient: client, + envVars: envVars, + repoPath: repoPath, + ref: ref, + oldValue: sha.None, + newValue: sha.None, + }, nil +} + +// RefUpdater is an entity that is responsible for update of a reference. +// It will call pre-receive hook prior to the update and post-receive hook after the update. +// It has a state machine to guarantee that methods are called in the correct order (Init, Pre, Update, Post). +type RefUpdater struct { + state refUpdaterState + hookClient Client + envVars map[string]string + repoPath string + ref string + oldValue sha.SHA + newValue sha.SHA +} + +// refUpdaterState represents state of the ref updater internal state machine. +type refUpdaterState byte + +func (t refUpdaterState) String() string { + switch t { + case statePre: + return "PRE" + case stateUpdate: + return "UPDATE" + case statePost: + return "POST" + case stateDone: + return "DONE" + } + return "INVALID" +} + +const ( + statePre refUpdaterState = iota + stateUpdate + statePost + stateDone +) + +// Do runs full ref update by executing all methods in the correct order. +func (u *RefUpdater) Do(ctx context.Context, oldValue, newValue sha.SHA) error { + if err := u.Init(ctx, oldValue, newValue); err != nil { + return fmt.Errorf("init failed: %w", err) + } + + if err := u.Pre(ctx); err != nil { + return fmt.Errorf("pre failed: %w", err) + } + + if err := u.UpdateRef(ctx); err != nil { + return fmt.Errorf("update failed: %w", err) + } + + if err := u.Post(ctx); err != nil { + return fmt.Errorf("post failed: %w", err) + } + + return nil +} + +func (u *RefUpdater) Init(ctx context.Context, oldValue, newValue sha.SHA) error { + if err := u.InitOld(ctx, oldValue); err != nil { + return fmt.Errorf("init old failed: %w", err) + } + if err := u.InitNew(ctx, newValue); err != nil { + return fmt.Errorf("init new failed: %w", err) + } + + return nil +} + +func (u *RefUpdater) InitNew(_ context.Context, newValue sha.SHA) error { + if u.state != statePre { + return fmt.Errorf("invalid operation order: init requires state=%s, current state=%s", + statePre, u.state) + } + + if newValue.IsEmpty() { + // don't break existing interface - user calls with empty value to delete the ref. + newValue = sha.Nil + } + + u.newValue = newValue + + return nil +} + +func (u *RefUpdater) InitOld(ctx context.Context, oldValue sha.SHA) error { + if u.state != statePre { + return fmt.Errorf("invalid operation order: init requires state=%s, current state=%s", + statePre, u.state) + } + + if oldValue.IsEmpty() { + // if no old value was provided, use current value (as required for hooks) + val, err := u.getRef(ctx) + if errors.IsNotFound(err) { //nolint:gocritic + oldValue = sha.Nil + } else if err != nil { + return fmt.Errorf("failed to get current value of reference: %w", err) + } else { + oldValue = val + } + } + + u.oldValue = oldValue + + return nil +} + +// Pre runs the pre-receive git hook. +func (u *RefUpdater) Pre(ctx context.Context, alternateDirs ...string) error { + if u.state != statePre { + return fmt.Errorf("invalid operation order: pre-receive hook requires state=%s, current state=%s", + statePre, u.state) + } + + // fail in case someone tries to delete a reference that doesn't exist. + if u.oldValue.IsEmpty() && u.newValue.IsNil() { + return errors.NotFound("reference %q not found", u.ref) + } + + if u.oldValue.IsNil() && u.newValue.IsNil() { + return fmt.Errorf("provided values cannot be both empty") + } + + out, err := u.hookClient.PreReceive(ctx, PreReceiveInput{ + RefUpdates: []ReferenceUpdate{ + { + Ref: u.ref, + Old: u.oldValue, + New: u.newValue, + }, + }, + Environment: Environment{ + AlternateObjectDirs: alternateDirs, + }, + }) + if err != nil { + return fmt.Errorf("pre-receive call failed with: %w", err) + } + if out.Error != nil { + log.Ctx(ctx).Debug(). + Str("err", *out.Error). + Msgf("Pre-receive blocked ref update\nMessages\n%v", out.Messages) + return errors.PreconditionFailed("pre-receive hook blocked reference update: %q", *out.Error) + } + + u.state = stateUpdate + + return nil +} + +// UpdateRef updates the git reference. +func (u *RefUpdater) UpdateRef(ctx context.Context) error { + if u.state != stateUpdate { + return fmt.Errorf("invalid operation order: ref update requires state=%s, current state=%s", + stateUpdate, u.state) + } + + cmd := command.New("update-ref") + if u.newValue.IsNil() { + cmd.Add(command.WithFlag("-d", u.ref)) + } else { + cmd.Add(command.WithArg(u.ref, u.newValue.String())) + } + + cmd.Add(command.WithArg(u.oldValue.String())) + + if err := cmd.Run(ctx, command.WithDir(u.repoPath)); err != nil { + msg := err.Error() + if strings.Contains(msg, "reference already exists") { + return errors.Conflict("reference already exists") + } + + return fmt.Errorf("update of ref %q from %q to %q failed: %w", u.ref, u.oldValue, u.newValue, err) + } + + u.state = statePost + + return nil +} + +// Post runs the pre-receive git hook. +func (u *RefUpdater) Post(ctx context.Context, alternateDirs ...string) error { + if u.state != statePost { + return fmt.Errorf("invalid operation order: post-receive hook requires state=%s, current state=%s", + statePost, u.state) + } + + out, err := u.hookClient.PostReceive(ctx, PostReceiveInput{ + RefUpdates: []ReferenceUpdate{ + { + Ref: u.ref, + Old: u.oldValue, + New: u.newValue, + }, + }, + Environment: Environment{ + AlternateObjectDirs: alternateDirs, + }, + }) + if err != nil { + return fmt.Errorf("post-receive call failed with: %w", err) + } + if out.Error != nil { + return fmt.Errorf("post-receive call returned error: %q", *out.Error) + } + + u.state = stateDone + + return nil +} + +func (u *RefUpdater) getRef(ctx context.Context) (sha.SHA, error) { + cmd := command.New("show-ref", + command.WithFlag("--verify"), + command.WithFlag("-s"), + command.WithArg(u.ref), + ) + output := &bytes.Buffer{} + err := cmd.Run(ctx, command.WithDir(u.repoPath), command.WithStdout(output)) + if err != nil { + if command.AsError(err).IsExitCode(128) && strings.Contains(err.Error(), "not a valid ref") { + return sha.None, errors.NotFound("reference %q not found", u.ref) + } + return sha.None, err + } + + return sha.New(output.String()) +} diff --git a/git/merge.go b/git/merge.go index 2bd5dd035..7f338a3d8 100644 --- a/git/merge.go +++ b/git/merge.go @@ -23,6 +23,7 @@ import ( "github.com/harness/gitness/errors" "github.com/harness/gitness/git/api" "github.com/harness/gitness/git/enum" + "github.com/harness/gitness/git/hook" "github.com/harness/gitness/git/merge" "github.com/harness/gitness/git/sha" @@ -275,8 +276,18 @@ func (s *Service) Merge(ctx context.Context, params *MergeParams) (MergeOutput, // merge + refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, refPath) + if err != nil { + return MergeOutput{}, errors.Internal(err, "failed to create ref updater object") + } + + if err := refUpdater.InitOld(ctx, refOldValue); err != nil { + return MergeOutput{}, errors.Internal(err, "failed to set old reference value for ref updater") + } + mergeCommitSHA, conflicts, err := mergeFunc( ctx, + refUpdater, repoPath, s.tmpDir, &author, &committer, mergeMsg, @@ -297,25 +308,6 @@ func (s *Service) Merge(ctx context.Context, params *MergeParams) (MergeOutput, }, nil } - // git reference update - - log.Trace().Msg("merge completed - updating git reference") - - err = s.git.UpdateRef( - ctx, - params.EnvVars, - repoPath, - refPath, - refOldValue, - mergeCommitSHA, - ) - if err != nil { - return MergeOutput{}, - errors.Internal(err, "failed to update branch %q after merging commits", params.HeadBranch) - } - - log.Trace().Msg("merge completed - git reference updated") - return MergeOutput{ BaseSHA: baseCommitSHA, HeadSHA: headCommitSHA, diff --git a/git/merge/merge.go b/git/merge/merge.go index a38580078..9fe0df135 100644 --- a/git/merge/merge.go +++ b/git/merge/merge.go @@ -19,6 +19,7 @@ import ( "fmt" "github.com/harness/gitness/git/api" + "github.com/harness/gitness/git/hook" "github.com/harness/gitness/git/sha" "github.com/harness/gitness/git/sharedrepo" @@ -28,6 +29,7 @@ import ( // Func represents a merge method function. The concrete merge implementation functions must have this signature. type Func func( ctx context.Context, + refUpdater *hook.RefUpdater, repoPath, tmpDir string, author, committer *api.Signature, message string, @@ -37,12 +39,14 @@ type Func func( // Merge merges two the commits (targetSHA and sourceSHA) using the Merge method. func Merge( ctx context.Context, + refUpdater *hook.RefUpdater, repoPath, tmpDir string, author, committer *api.Signature, message string, mergeBaseSHA, targetSHA, sourceSHA sha.SHA, ) (mergeSHA sha.SHA, conflicts []string, err error) { return mergeInternal(ctx, + refUpdater, repoPath, tmpDir, author, committer, message, @@ -53,12 +57,14 @@ func Merge( // Squash merges two the commits (targetSHA and sourceSHA) using the Squash method. func Squash( ctx context.Context, + refUpdater *hook.RefUpdater, repoPath, tmpDir string, author, committer *api.Signature, message string, mergeBaseSHA, targetSHA, sourceSHA sha.SHA, ) (mergeSHA sha.SHA, conflicts []string, err error) { return mergeInternal(ctx, + refUpdater, repoPath, tmpDir, author, committer, message, @@ -69,13 +75,14 @@ func Squash( // mergeInternal is internal implementation of merge used for Merge and Squash methods. func mergeInternal( ctx context.Context, + refUpdater *hook.RefUpdater, repoPath, tmpDir string, author, committer *api.Signature, message string, mergeBaseSHA, targetSHA, sourceSHA sha.SHA, squash bool, ) (mergeSHA sha.SHA, conflicts []string, err error) { - err = runInSharedRepo(ctx, tmpDir, repoPath, func(s *sharedrepo.SharedRepo) error { + err = sharedrepo.Run(ctx, refUpdater, tmpDir, repoPath, func(s *sharedrepo.SharedRepo) error { var err error var treeSHA sha.SHA @@ -100,6 +107,10 @@ func mergeInternal( return fmt.Errorf("commit tree failed: %w", err) } + if err := refUpdater.InitNew(ctx, mergeSHA); err != nil { + return fmt.Errorf("refUpdater.InitNew failed: %w", err) + } + return nil }) if err != nil { @@ -114,12 +125,13 @@ func mergeInternal( //nolint:gocognit // refactor if needed. func Rebase( ctx context.Context, + refUpdater *hook.RefUpdater, repoPath, tmpDir string, _, committer *api.Signature, // commit author isn't used here - it's copied from every commit _ string, // commit message isn't used here mergeBaseSHA, targetSHA, sourceSHA sha.SHA, ) (mergeSHA sha.SHA, conflicts []string, err error) { - err = runInSharedRepo(ctx, tmpDir, repoPath, func(s *sharedrepo.SharedRepo) error { + err = sharedrepo.Run(ctx, refUpdater, tmpDir, repoPath, func(s *sharedrepo.SharedRepo) error { sourceSHAs, err := s.CommitSHAsForRebase(ctx, mergeBaseSHA, sourceSHA) if err != nil { return fmt.Errorf("failed to find commit list in rebase merge: %w", err) @@ -183,6 +195,10 @@ func Rebase( lastTreeSHA = treeSHA } + if err := refUpdater.InitNew(ctx, lastCommitSHA); err != nil { + return fmt.Errorf("refUpdater.InitNew failed: %w", err) + } + mergeSHA = lastCommitSHA return nil @@ -193,34 +209,3 @@ func Rebase( return mergeSHA, conflicts, nil } - -// runInSharedRepo is helper function used to run the provided function inside a shared repository. -func runInSharedRepo( - ctx context.Context, - tmpDir, repoPath string, - fn func(s *sharedrepo.SharedRepo) error, -) error { - s, err := sharedrepo.NewSharedRepo(tmpDir, repoPath) - if err != nil { - return err - } - - defer s.Close(ctx) - - err = s.InitAsBare(ctx) - if err != nil { - return err - } - - err = fn(s) - if err != nil { - return err - } - - err = s.MoveObjects(ctx) - if err != nil { - return err - } - - return nil -} diff --git a/git/operations.go b/git/operations.go index de7819ee7..4f6fed857 100644 --- a/git/operations.go +++ b/git/operations.go @@ -15,23 +15,20 @@ package git import ( - "bytes" "context" "fmt" - "path" "strings" "time" "github.com/harness/gitness/errors" "github.com/harness/gitness/git/api" + "github.com/harness/gitness/git/hook" "github.com/harness/gitness/git/sha" - - "github.com/rs/zerolog/log" - "golang.org/x/exp/slices" + "github.com/harness/gitness/git/sharedrepo" ) const ( - defaultFilePermission = "100644" // 0o644 default file permission + filePermissionDefault = "100644" ) type FileAction string @@ -52,7 +49,7 @@ type CommitFileAction struct { Action FileAction Path string Payload []byte - SHA string + SHA sha.SHA } // CommitFilesParams holds the data for file operations. @@ -92,8 +89,6 @@ func (s *Service) CommitFiles(ctx context.Context, params *CommitFilesParams) (C return CommitFilesResponse{}, err } - log := log.Ctx(ctx).With().Str("repo_uid", params.RepoUID).Logger() - committer := params.Actor if params.Committer != nil { committer = *params.Committer @@ -114,9 +109,8 @@ func (s *Service) CommitFiles(ctx context.Context, params *CommitFilesParams) (C repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID) - log.Debug().Msg("check if empty") - // check if repo is empty + // IMPORTANT: we don't use gitea's repo.IsEmpty() as that only checks whether the default branch exists (in HEAD). // This can be an issue in case someone created a branch already in the repo (just default branch is missing). // In that case the user can accidentally create separate git histories (which most likely is unintended). @@ -126,7 +120,7 @@ func (s *Service) CommitFiles(ctx context.Context, params *CommitFilesParams) (C return CommitFilesResponse{}, fmt.Errorf("CommitFiles: failed to determine if repository is empty: %w", err) } - log.Debug().Msg("validate and prepare input") + // validate and prepare input // ensure input data is valid // the commit will be nil for empty repositories @@ -135,50 +129,46 @@ func (s *Service) CommitFiles(ctx context.Context, params *CommitFilesParams) (C return CommitFilesResponse{}, err } + // ref updater + var oldCommitSHA sha.SHA - if commit != nil { + var newCommitSHA sha.SHA + + branchRef := api.GetReferenceFromBranchName(params.Branch) + if params.Branch != params.NewBranch { + // we are creating a new branch, rather than updating the existing one + oldCommitSHA = sha.Nil + branchRef = api.GetReferenceFromBranchName(params.NewBranch) + } else if commit != nil { oldCommitSHA = commit.SHA } - log.Debug().Msg("create shared repo") + refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, branchRef) - newCommitSHA, err := func() (sha.SHA, error) { - // Create a directory for the temporary shared repository. - shared, err := api.NewSharedRepo(s.git, s.tmpDir, params.RepoUID, repoPath) - if err != nil { - return sha.None, fmt.Errorf("failed to create shared repository: %w", err) - } - defer shared.Close(ctx) + // run the actions in a shared repo - // Create bare repository with alternates pointing to the original repository. - err = shared.InitAsShared(ctx) - if err != nil { - return sha.None, fmt.Errorf("failed to create temp repo with alternates: %w", err) - } + err = sharedrepo.Run(ctx, refUpdater, s.tmpDir, repoPath, func(r *sharedrepo.SharedRepo) error { + var parentCommits []sha.SHA - log.Debug().Msgf("prepare tree (empty: %t)", isEmpty) - - // handle empty repo separately (as branch doesn't exist, no commit exists, ...) if isEmpty { - err = s.prepareTreeEmptyRepo(ctx, shared, params.Actions) + err = s.prepareTreeEmptyRepo(ctx, r, params.Actions) } else { - err = shared.SetIndex(ctx, oldCommitSHA.String()) + parentCommits = append(parentCommits, commit.SHA) + + err = r.SetIndex(ctx, commit.SHA) if err != nil { - return sha.None, fmt.Errorf("failed to set index to temp repo: %w", err) + return fmt.Errorf("failed to set index in shared repository: %w", err) } - err = s.prepareTree(ctx, shared, params.Actions, commit) + err = s.prepareTree(ctx, r, commit.SHA, params.Actions) } if err != nil { - return sha.None, fmt.Errorf("failed to prepare tree: %w", err) + return fmt.Errorf("failed to prepare tree: %w", err) } - log.Debug().Msg("write tree") - - // Now write the tree - treeHash, err := shared.WriteTree(ctx) + treeHash, err := r.WriteTree(ctx) if err != nil { - return sha.None, fmt.Errorf("failed to write tree object: %w", err) + return fmt.Errorf("failed to write tree object: %w", err) } message := strings.TrimSpace(params.Title) @@ -186,62 +176,40 @@ func (s *Service) CommitFiles(ctx context.Context, params *CommitFilesParams) (C message += "\n\n" + strings.TrimSpace(params.Message) } - log.Debug().Msg("commit tree") - - // Now commit the tree - commitSHA, err := shared.CommitTreeWithDate( - ctx, - oldCommitSHA, - &api.Identity{ + authorSig := &api.Signature{ + Identity: api.Identity{ Name: author.Name, Email: author.Email, }, - &api.Identity{ + When: authorDate, + } + + committerSig := &api.Signature{ + Identity: api.Identity{ Name: committer.Name, Email: committer.Email, }, - treeHash, - message, - false, - authorDate, - committerDate, - ) - if err != nil { - return sha.None, fmt.Errorf("failed to commit the tree: %w", err) + When: committerDate, } - err = shared.MoveObjects(ctx) + commitSHA, err := r.CommitTree(ctx, authorSig, committerSig, treeHash, message, false, parentCommits...) if err != nil { - return sha.None, fmt.Errorf("failed to move git objects: %w", err) + return fmt.Errorf("failed to commit the tree: %w", err) } - return commitSHA, nil - }() + newCommitSHA = commitSHA + + if err := refUpdater.Init(ctx, oldCommitSHA, newCommitSHA); err != nil { + return fmt.Errorf("failed to init ref updater old=%s new=%s: %w", oldCommitSHA, newCommitSHA, err) + } + + return nil + }) if err != nil { return CommitFilesResponse{}, fmt.Errorf("CommitFiles: failed to create commit in shared repository: %w", err) } - log.Debug().Msg("update ref") - - branchRef := api.GetReferenceFromBranchName(params.Branch) - if params.Branch != params.NewBranch { - // we are creating a new branch, rather than updating the existing one - oldCommitSHA = sha.Nil - branchRef = api.GetReferenceFromBranchName(params.NewBranch) - } - err = s.git.UpdateRef( - ctx, - params.EnvVars, - repoPath, - branchRef, - oldCommitSHA, - newCommitSHA, - ) - if err != nil { - return CommitFilesResponse{}, fmt.Errorf("CommitFiles: failed to update ref %s: %w", branchRef, err) - } - - log.Debug().Msg("get commit") + // get commit commit, err = s.git.GetCommit(ctx, repoPath, newCommitSHA.String()) if err != nil { @@ -249,8 +217,6 @@ func (s *Service) CommitFiles(ctx context.Context, params *CommitFilesParams) (C newCommitSHA.String(), err) } - log.Debug().Msg("done") - return CommitFilesResponse{ CommitID: commit.SHA, }, nil @@ -258,13 +224,13 @@ func (s *Service) CommitFiles(ctx context.Context, params *CommitFilesParams) (C func (s *Service) prepareTree( ctx context.Context, - shared *api.SharedRepo, + r *sharedrepo.SharedRepo, + treeish sha.SHA, actions []CommitFileAction, - commit *api.Commit, ) error { // execute all actions for i := range actions { - if err := s.processAction(ctx, shared, &actions[i], commit); err != nil { + if err := s.processAction(ctx, r, treeish, &actions[i]); err != nil { return err } } @@ -274,7 +240,7 @@ func (s *Service) prepareTree( func (s *Service) prepareTreeEmptyRepo( ctx context.Context, - shared *api.SharedRepo, + r *sharedrepo.SharedRepo, actions []CommitFileAction, ) error { for _, action := range actions { @@ -287,7 +253,7 @@ func (s *Service) prepareTreeEmptyRepo( return errors.InvalidArgument("invalid path") } - if err := createFile(ctx, shared, nil, filePath, defaultFilePermission, action.Payload); err != nil { + if err := r.CreateFile(ctx, sha.None, filePath, filePermissionDefault, action.Payload); err != nil { return errors.Internal(err, "failed to create file '%s'", action.Path) } } @@ -342,6 +308,71 @@ func (s *Service) validateAndPrepareHeader( return branch.Commit, nil } +func (s *Service) processAction( + ctx context.Context, + r *sharedrepo.SharedRepo, + treeishSHA sha.SHA, + action *CommitFileAction, +) (err error) { + filePath := api.CleanUploadFileName(action.Path) + if filePath == "" { + return errors.InvalidArgument("path cannot be empty") + } + + switch action.Action { + case CreateAction: + err = r.CreateFile(ctx, treeishSHA, filePath, filePermissionDefault, action.Payload) + case UpdateAction: + err = r.UpdateFile(ctx, treeishSHA, filePath, action.SHA, filePermissionDefault, action.Payload) + case MoveAction: + err = r.MoveFile(ctx, treeishSHA, filePath, action.SHA, filePermissionDefault, action.Payload) + case DeleteAction: + err = r.DeleteFile(ctx, filePath) + } + + return err +} + +/* +func (s *Service) prepareTree( + ctx context.Context, + shared *api.SharedRepo, + actions []CommitFileAction, + commit *api.Commit, +) error { + // execute all actions + for i := range actions { + if err := s.processAction(ctx, shared, &actions[i], commit); err != nil { + return err + } + } + + return nil +} + +func prepareTreeEmptyRepo( + ctx context.Context, + shared *api.SharedRepo, + actions []CommitFileAction, +) error { + for _, action := range actions { + if action.Action != CreateAction { + return errors.PreconditionFailed("action not allowed on empty repository") + } + + filePath := api.CleanUploadFileName(action.Path) + if filePath == "" { + return errors.InvalidArgument("invalid path") + } + + if err := createFile(ctx, shared, nil, filePath, defaultFilePermission, action.Payload); err != nil { + return errors.Internal(err, "failed to create file '%s'", action.Path) + } + } + + return nil +} + func (s *Service) processAction( ctx context.Context, shared *api.SharedRepo, @@ -571,3 +602,4 @@ func parseMovePayload(payload []byte) (string, []byte, error) { return newPath, newContent, nil } +*/ diff --git a/git/ref.go b/git/ref.go index 416909a58..9afff459c 100644 --- a/git/ref.go +++ b/git/ref.go @@ -23,6 +23,7 @@ import ( "github.com/harness/gitness/errors" "github.com/harness/gitness/git/api" "github.com/harness/gitness/git/enum" + "github.com/harness/gitness/git/hook" "github.com/harness/gitness/git/sha" ) @@ -60,12 +61,12 @@ func (s *Service) GetRef(ctx context.Context, params GetRefParams) (GetRefRespon return GetRefResponse{}, fmt.Errorf("GetRef: failed to fetch reference '%s': %w", params.Name, err) } - sha, err := s.git.GetRef(ctx, repoPath, reference) + refSHA, err := s.git.GetRef(ctx, repoPath, reference) if err != nil { return GetRefResponse{}, err } - return GetRefResponse{SHA: sha}, nil + return GetRefResponse{SHA: refSHA}, nil } type UpdateRefParams struct { @@ -91,15 +92,12 @@ func (s *Service) UpdateRef(ctx context.Context, params UpdateRefParams) error { return fmt.Errorf("UpdateRef: failed to fetch reference '%s': %w", params.Name, err) } - err = s.git.UpdateRef( - ctx, - params.EnvVars, - repoPath, - reference, - params.OldValue, - params.NewValue, - ) + refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, reference) if err != nil { + return fmt.Errorf("UpdateRef: failed to create ref updater: %w", err) + } + + if err := refUpdater.Do(ctx, params.OldValue, params.NewValue); err != nil { return fmt.Errorf("failed to update ref: %w", err) } diff --git a/git/scan_secrets.go b/git/scan_secrets.go index 7d2dfa40e..4ba4e2a62 100644 --- a/git/scan_secrets.go +++ b/git/scan_secrets.go @@ -19,6 +19,7 @@ import ( "fmt" "github.com/harness/gitness/git/api" + "github.com/harness/gitness/git/sharedrepo" ) type ScanSecretsParams struct { @@ -39,20 +40,13 @@ func (s *Service) ScanSecrets(ctx context.Context, params *ScanSecretsParams) (* repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID) - // Create a directory for the temporary shared repository. - shared, err := api.NewSharedRepo(s.git, s.tmpDir, params.RepoUID, repoPath) - if err != nil { - return nil, fmt.Errorf("failed to create shared repository: %w", err) - } - defer shared.Close(ctx) + var findings []api.Finding - // Create bare repository with alternates pointing to the original repository. - err = shared.InitAsSharedWithAlternates(ctx, params.AlternateObjectDirs...) - if err != nil { - return nil, fmt.Errorf("failed to create temp repo with alternates: %w", err) - } - - findings, err := s.git.ScanSecrets(shared.RepoPath, params.BaseRev, params.Rev) + err := sharedrepo.Run(ctx, nil, s.tmpDir, repoPath, func(sharedRepo *sharedrepo.SharedRepo) error { + var err error + findings, err = s.git.ScanSecrets(sharedRepo.Directory(), params.BaseRev, params.Rev) + return err + }, params.AlternateObjectDirs...) if err != nil { return nil, fmt.Errorf("failed to get leaks on diff: %w", err) } diff --git a/git/service.go b/git/service.go index 889f5d98f..5fdbc7431 100644 --- a/git/service.go +++ b/git/service.go @@ -20,6 +20,7 @@ import ( "github.com/harness/gitness/errors" "github.com/harness/gitness/git/api" + "github.com/harness/gitness/git/hook" "github.com/harness/gitness/git/storage" "github.com/harness/gitness/git/types" ) @@ -30,17 +31,19 @@ const ( ) type Service struct { - reposRoot string - tmpDir string - git *api.Git - store storage.Store - gitHookPath string - reposGraveyard string + reposRoot string + tmpDir string + git *api.Git + hookClientFactory hook.ClientFactory + store storage.Store + gitHookPath string + reposGraveyard string } func New( config types.Config, adapter *api.Git, + hookClientFactory hook.ClientFactory, storage storage.Store, ) (*Service, error) { // Create repos folder @@ -60,11 +63,12 @@ func New( } } return &Service{ - reposRoot: reposRoot, - tmpDir: config.TmpDir, - reposGraveyard: reposGraveyard, - git: adapter, - store: storage, - gitHookPath: config.HookPath, + reposRoot: reposRoot, + tmpDir: config.TmpDir, + reposGraveyard: reposGraveyard, + git: adapter, + hookClientFactory: hookClientFactory, + store: storage, + gitHookPath: config.HookPath, }, nil } diff --git a/git/sharedrepo/run.go b/git/sharedrepo/run.go new file mode 100644 index 000000000..2a4f6c3a6 --- /dev/null +++ b/git/sharedrepo/run.go @@ -0,0 +1,74 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sharedrepo + +import ( + "context" + "fmt" + + "github.com/harness/gitness/git/hook" +) + +// Run is helper function used to run the provided function inside a shared repository. +// If the provided hook.RefUpdater is not nil it will be used to update the reference. +// Inside the provided inline function there should be a call to initialize the ref updater. +// If the provided hook.RefUpdater is nil the entire operation is a read-only. +func Run( + ctx context.Context, + refUpdater *hook.RefUpdater, + tmpDir, repoPath string, + fn func(s *SharedRepo) error, + alternates ...string, +) error { + s, err := NewSharedRepo(tmpDir, repoPath) + if err != nil { + return err + } + + defer s.Close(ctx) + + if err := s.Init(ctx, alternates...); err != nil { + return err + } + + // The refUpdater.Init must be called within the fn (if refUpdater != nil), otherwise the refUpdater.Pre will fail. + if err := fn(s); err != nil { + return err + } + + if refUpdater == nil { + return nil + } + + alternate := s.Directory() + "/objects" + + if err := refUpdater.Pre(ctx, alternate); err != nil { + return fmt.Errorf("pre-receive hook failed: %w", err) + } + + if err := s.MoveObjects(ctx); err != nil { + return fmt.Errorf("failed to move objects: %w", err) + } + + if err := refUpdater.UpdateRef(ctx); err != nil { + return fmt.Errorf("failed to update reference: %w", err) + } + + if err := refUpdater.Post(ctx, alternate); err != nil { + return fmt.Errorf("post-receive hook failed: %w", err) + } + + return nil +} diff --git a/git/sharedrepo/sharedrepo.go b/git/sharedrepo/sharedrepo.go index 36f2ac2f2..a13d4a58d 100644 --- a/git/sharedrepo/sharedrepo.go +++ b/git/sharedrepo/sharedrepo.go @@ -37,64 +37,75 @@ import ( "github.com/harness/gitness/git/tempdir" "github.com/rs/zerolog/log" + "golang.org/x/exp/slices" ) type SharedRepo struct { - temporaryPath string - repositoryPath string + repoPath string + sourceRepoPath string } // NewSharedRepo creates a new temporary bare repository. func NewSharedRepo( baseTmpDir string, - repositoryPath string, + sourceRepoPath string, ) (*SharedRepo, error) { + if sourceRepoPath == "" { + return nil, errors.New("repository path can't be empty") + } + var buf [5]byte _, _ = rand.Read(buf[:]) id := base32.StdEncoding.EncodeToString(buf[:]) - temporaryPath, err := tempdir.CreateTemporaryPath(baseTmpDir, id) + repoPath, err := tempdir.CreateTemporaryPath(baseTmpDir, id) if err != nil { return nil, fmt.Errorf("failed to create shared repository directory: %w", err) } t := &SharedRepo{ - temporaryPath: temporaryPath, - repositoryPath: repositoryPath, + repoPath: repoPath, + sourceRepoPath: sourceRepoPath, } return t, nil } func (r *SharedRepo) Close(ctx context.Context) { - if err := tempdir.RemoveTemporaryPath(r.temporaryPath); err != nil { + if err := tempdir.RemoveTemporaryPath(r.repoPath); err != nil { log.Ctx(ctx).Err(err). - Str("path", r.temporaryPath). + Str("path", r.repoPath). Msg("Failed to remove temporary shared directory") } } -func (r *SharedRepo) InitAsBare(ctx context.Context) error { +func (r *SharedRepo) Init(ctx context.Context, alternates ...string) error { cmd := command.New("init", command.WithFlag("--bare")) - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil { + if err := cmd.Run(ctx, command.WithDir(r.repoPath)); err != nil { return fmt.Errorf("failed to initialize bare git repository directory: %w", err) } if err := func() error { - alternates := filepath.Join(r.temporaryPath, "objects", "info", "alternates") - f, err := os.OpenFile(alternates, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + alternatesFilePath := filepath.Join(r.repoPath, "objects", "info", "alternates") + f, err := os.OpenFile(alternatesFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return fmt.Errorf("failed to create alternates file: %w", err) } defer func() { _ = f.Close() }() - data := filepath.Join(r.repositoryPath, "objects") + data := filepath.Join(r.sourceRepoPath, "objects") if _, err = fmt.Fprintln(f, data); err != nil { return fmt.Errorf("failed to write alternates file: %w", err) } + for _, alternate := range alternates { + if _, err = fmt.Fprintln(f, alternate); err != nil { + return fmt.Errorf("failed to write alternates file: %w", err) + } + } + return nil }(); err != nil { return fmt.Errorf("failed to make the alternates file in shared repository: %w", err) @@ -104,14 +115,14 @@ func (r *SharedRepo) InitAsBare(ctx context.Context) error { } func (r *SharedRepo) Directory() string { - return r.temporaryPath + return r.repoPath } // SetDefaultIndex sets the git index to our HEAD. func (r *SharedRepo) SetDefaultIndex(ctx context.Context) error { cmd := command.New("read-tree", command.WithArg("HEAD")) - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil { + if err := cmd.Run(ctx, command.WithDir(r.repoPath)); err != nil { return fmt.Errorf("failed to initialize shared repository index to HEAD: %w", err) } @@ -119,10 +130,10 @@ func (r *SharedRepo) SetDefaultIndex(ctx context.Context) error { } // SetIndex sets the git index to the provided treeish. -func (r *SharedRepo) SetIndex(ctx context.Context, treeish string) error { - cmd := command.New("read-tree", command.WithArg(treeish)) +func (r *SharedRepo) SetIndex(ctx context.Context, treeish sha.SHA) error { + cmd := command.New("read-tree", command.WithArg(treeish.String())) - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil { + if err := cmd.Run(ctx, command.WithDir(r.repoPath)); err != nil { return fmt.Errorf("failed to initialize shared repository index to %q: %w", treeish, err) } @@ -133,7 +144,7 @@ func (r *SharedRepo) SetIndex(ctx context.Context, treeish string) error { func (r *SharedRepo) ClearIndex(ctx context.Context) error { cmd := command.New("read-tree", command.WithFlag("--empty")) - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil { + if err := cmd.Run(ctx, command.WithDir(r.repoPath)); err != nil { return fmt.Errorf("failed to clear shared repository index: %w", err) } @@ -152,7 +163,7 @@ func (r *SharedRepo) LsFiles( stdout := bytes.NewBuffer(nil) - err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(stdout)) + err := cmd.Run(ctx, command.WithDir(r.repoPath), command.WithStdout(stdout)) if err != nil { return nil, fmt.Errorf("failed to list files in shared repository's git index: %w", err) } @@ -184,7 +195,7 @@ func (r *SharedRepo) RemoveFilesFromIndex( } } - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdin(stdin)); err != nil { + if err := cmd.Run(ctx, command.WithDir(r.repoPath), command.WithStdin(stdin)); err != nil { return fmt.Errorf("failed to update-index in shared repo: %w", err) } @@ -195,7 +206,7 @@ func (r *SharedRepo) RemoveFilesFromIndex( func (r *SharedRepo) WriteGitObject( ctx context.Context, content io.Reader, -) (string, error) { +) (sha.SHA, error) { cmd := command.New("hash-object", command.WithFlag("-w"), command.WithFlag("--stdin")) @@ -203,14 +214,14 @@ func (r *SharedRepo) WriteGitObject( stdout := bytes.NewBuffer(nil) err := cmd.Run(ctx, - command.WithDir(r.temporaryPath), + command.WithDir(r.repoPath), command.WithStdin(content), command.WithStdout(stdout)) if err != nil { - return "", fmt.Errorf("failed to hash-object in shared repo: %w", err) + return sha.None, fmt.Errorf("failed to hash-object in shared repo: %w", err) } - return strings.TrimSpace(stdout.String()), nil + return sha.New(stdout.String()) } // GetTreeSHA returns the tree SHA of the rev. @@ -225,7 +236,7 @@ func (r *SharedRepo) GetTreeSHA( ) stdout := &bytes.Buffer{} err := cmd.Run(ctx, - command.WithDir(r.temporaryPath), + command.WithDir(r.repoPath), command.WithStdout(stdout), ) if err != nil { @@ -249,7 +260,7 @@ func (r *SharedRepo) ShowFile( cmd := command.New("show", command.WithArg(file)) - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(writer)); err != nil { + if err := cmd.Run(ctx, command.WithDir(r.repoPath), command.WithStdout(writer)); err != nil { return fmt.Errorf("failed to show file in shared repo: %w", err) } @@ -260,15 +271,15 @@ func (r *SharedRepo) ShowFile( func (r *SharedRepo) AddObjectToIndex( ctx context.Context, mode string, - objectHash string, + objectHash sha.SHA, objectPath string, ) error { cmd := command.New("update-index", command.WithFlag("--add"), command.WithFlag("--replace"), - command.WithFlag("--cacheinfo", mode, objectHash, objectPath)) + command.WithFlag("--cacheinfo", mode, objectHash.String(), objectPath)) - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath)); err != nil { + if err := cmd.Run(ctx, command.WithDir(r.repoPath)); err != nil { if matched, _ := regexp.MatchString(".*Invalid path '.*", err.Error()); matched { return errors.InvalidArgument("invalid path '%s'", objectPath) } @@ -279,16 +290,16 @@ func (r *SharedRepo) AddObjectToIndex( } // WriteTree writes the current index as a tree to the object db and returns its hash. -func (r *SharedRepo) WriteTree(ctx context.Context) (string, error) { +func (r *SharedRepo) WriteTree(ctx context.Context) (sha.SHA, error) { cmd := command.New("write-tree") stdout := bytes.NewBuffer(nil) - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(stdout)); err != nil { - return "", fmt.Errorf("failed to write-tree in shared repo: %w", err) + if err := cmd.Run(ctx, command.WithDir(r.repoPath), command.WithStdout(stdout)); err != nil { + return sha.None, fmt.Errorf("failed to write-tree in shared repo: %w", err) } - return strings.TrimSpace(stdout.String()), nil + return sha.New(stdout.String()) } // MergeTree merges commits in git index. @@ -310,7 +321,7 @@ func (r *SharedRepo) MergeTree( stdout := bytes.NewBuffer(nil) err := cmd.Run(ctx, - command.WithDir(r.temporaryPath), + command.WithDir(r.repoPath), command.WithStdout(stdout)) // no error: the output is just the tree object SHA @@ -376,7 +387,7 @@ func (r *SharedRepo) CommitTree( stdout := bytes.NewBuffer(nil) err := cmd.Run(ctx, - command.WithDir(r.temporaryPath), + command.WithDir(r.repoPath), command.WithStdout(stdout), command.WithStdin(messageBytes)) if err != nil { @@ -405,7 +416,7 @@ func (r *SharedRepo) CommitSHAsForRebase( stdout := bytes.NewBuffer(nil) - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(stdout)); err != nil { + if err := cmd.Run(ctx, command.WithDir(r.repoPath), command.WithStdout(stdout)); err != nil { return nil, fmt.Errorf("failed to rev-list in shared repo: %w", err) } @@ -432,17 +443,214 @@ func (r *SharedRepo) MergeBase( stdout := bytes.NewBuffer(nil) - if err := cmd.Run(ctx, command.WithDir(r.temporaryPath), command.WithStdout(stdout)); err != nil { + if err := cmd.Run(ctx, command.WithDir(r.repoPath), command.WithStdout(stdout)); err != nil { return "", fmt.Errorf("failed to merge-base in shared repo: %w", err) } return strings.TrimSpace(stdout.String()), nil } +func (r *SharedRepo) CreateFile( + ctx context.Context, + treeishSHA sha.SHA, + filePath, mode string, + payload []byte, +) error { + // only check path availability if a source commit is available (empty repo won't have such a commit) + if !treeishSHA.IsEmpty() { + if err := r.checkPathAvailability(ctx, treeishSHA, filePath, true); err != nil { + return err + } + } + + objectSHA, err := r.WriteGitObject(ctx, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("createFile: error hashing object: %w", err) + } + + // Add the object to the index + if err = r.AddObjectToIndex(ctx, mode, objectSHA, filePath); err != nil { + return fmt.Errorf("createFile: error creating object: %w", err) + } + + return nil +} + +func (r *SharedRepo) UpdateFile( + ctx context.Context, + treeishSHA sha.SHA, + filePath string, + objectSHA sha.SHA, + mode string, + payload []byte, +) error { + // get file mode from existing file (default unless executable) + entry, err := r.getFileEntry(ctx, treeishSHA, objectSHA, filePath) + if err != nil { + return err + } + + if entry.IsExecutable() { + mode = "100755" + } + + objectSHA, err = r.WriteGitObject(ctx, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("updateFile: error hashing object: %w", err) + } + + if err = r.AddObjectToIndex(ctx, mode, objectSHA, filePath); err != nil { + return fmt.Errorf("updateFile: error updating object: %w", err) + } + + return nil +} + +func (r *SharedRepo) MoveFile( + ctx context.Context, + treeishSHA sha.SHA, + filePath string, + objectSHA sha.SHA, + mode string, + payload []byte, +) error { + newPath, newContent, err := parseMovePayload(payload) + if err != nil { + return err + } + + // ensure file exists and matches SHA + entry, err := r.getFileEntry(ctx, treeishSHA, objectSHA, filePath) + if err != nil { + return err + } + + // ensure new path is available + if err = r.checkPathAvailability(ctx, treeishSHA, newPath, false); err != nil { + return err + } + + var fileHash sha.SHA + var fileMode string + if newContent != nil { + hash, err := r.WriteGitObject(ctx, bytes.NewReader(newContent)) + if err != nil { + return fmt.Errorf("moveFile: error hashing object: %w", err) + } + + fileHash = hash + fileMode = mode + if entry.IsExecutable() { + const filePermissionExec = "100755" + fileMode = filePermissionExec + } + } else { + fileHash = entry.SHA + fileMode = entry.Mode.String() + } + + if err = r.AddObjectToIndex(ctx, fileMode, fileHash, newPath); err != nil { + return fmt.Errorf("moveFile: add object error: %w", err) + } + + if err = r.RemoveFilesFromIndex(ctx, filePath); err != nil { + return fmt.Errorf("moveFile: remove object error: %w", err) + } + + return nil +} + +func (r *SharedRepo) DeleteFile(ctx context.Context, filePath string) error { + filesInIndex, err := r.LsFiles(ctx, filePath) + if err != nil { + return fmt.Errorf("deleteFile: listing files error: %w", err) + } + if !slices.Contains(filesInIndex, filePath) { + return errors.NotFound("file path %s not found", filePath) + } + + if err = r.RemoveFilesFromIndex(ctx, filePath); err != nil { + return fmt.Errorf("deleteFile: remove object error: %w", err) + } + return nil +} + +func (r *SharedRepo) getFileEntry( + ctx context.Context, + treeishSHA sha.SHA, + objectSHA sha.SHA, + path string, +) (*api.TreeNode, error) { + entry, err := api.GetTreeNode(ctx, r.repoPath, treeishSHA.String(), path) + if errors.IsNotFound(err) { + return nil, errors.NotFound("path %s not found", path) + } + if err != nil { + return nil, fmt.Errorf("getFileEntry: failed to get tree for path %s: %w", path, err) + } + + // If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error + if !objectSHA.IsEmpty() && !objectSHA.Equal(entry.SHA) { + return nil, errors.InvalidArgument("sha does not match for path %s [given: %s, expected: %s]", + path, objectSHA, entry.SHA) + } + + return entry, nil +} + +// checkPathAvailability ensures that the path is available for the requested operation. +// For the path where this file will be created/updated, we need to make +// sure no parts of the path are existing files or links except for the last +// item in the path which is the file name, and that shouldn't exist IF it is +// a new file OR is being moved to a new path. +func (r *SharedRepo) checkPathAvailability( + ctx context.Context, + treeishSHA sha.SHA, + filePath string, + isNewFile bool, +) error { + parts := strings.Split(filePath, "/") + subTreePath := "" + + for index, part := range parts { + subTreePath = path.Join(subTreePath, part) + + entry, err := api.GetTreeNode(ctx, r.repoPath, treeishSHA.String(), subTreePath) + if err != nil { + if errors.IsNotFound(err) { + // Means there is no item with that name, so we're good + break + } + return fmt.Errorf("checkPathAvailability: failed to get tree entry for path %s: %w", subTreePath, err) + } + + switch { + case index < len(parts)-1: + if !entry.IsDir() { + return errors.Conflict("a file already exists where you're trying to create a subdirectory [path: %s]", + subTreePath) + } + case entry.IsLink(): + return errors.Conflict("a symbolic link already exist where you're trying to create a subdirectory [path: %s]", + subTreePath) + case entry.IsDir(): + return errors.Conflict("a directory already exists where you're trying to create a subdirectory [path: %s]", + subTreePath) + case filePath != "" || isNewFile: + return errors.Conflict("file path %s already exists", filePath) + } + } + return nil +} + // MoveObjects moves git object from the shared repository to the original repository. func (r *SharedRepo) MoveObjects(ctx context.Context) error { - srcDir := path.Join(r.temporaryPath, "objects") - dstDir := path.Join(r.repositoryPath, "objects") + if r.sourceRepoPath == "" { + return errors.New("shared repo not initialized with a repository") + } + + srcDir := path.Join(r.repoPath, "objects") + dstDir := path.Join(r.sourceRepoPath, "objects") var files []fileEntry @@ -565,3 +773,23 @@ type fileEntry struct { relPath string priority int } + +func parseMovePayload(payload []byte) (string, []byte, error) { + var newContent []byte + var newPath string + filePathEnd := bytes.IndexByte(payload, 0) + if filePathEnd < 0 { + newPath = string(payload) + newContent = nil + } else { + newPath = string(payload[:filePathEnd]) + newContent = payload[filePathEnd+1:] + } + + newPath = api.CleanUploadFileName(newPath) + if newPath == "" { + return "", nil, api.ErrInvalidPath + } + + return newPath, newContent, nil +} diff --git a/git/tag.go b/git/tag.go index 86b0b995c..5f28abfb2 100644 --- a/git/tag.go +++ b/git/tag.go @@ -21,7 +21,9 @@ import ( "github.com/harness/gitness/errors" "github.com/harness/gitness/git/api" + "github.com/harness/gitness/git/hook" "github.com/harness/gitness/git/sha" + "github.com/harness/gitness/git/sharedrepo" "github.com/rs/zerolog/log" ) @@ -252,76 +254,58 @@ func (s *Service) CreateCommitTag(ctx context.Context, params *CreateCommitTagPa return nil, errors.Conflict("tag '%s' already exists", tagName) } - err = func() error { - // Create a directory for the temporary shared repository. - sharedRepo, err := api.NewSharedRepo(s.git, s.tmpDir, params.RepoUID, repoPath) - if err != nil { - return fmt.Errorf("failed to create new shared repo: %w", err) - } - defer sharedRepo.Close(ctx) + // create tag request - // Create bare repository with alternates pointing to the original repository. - err = sharedRepo.InitAsShared(ctx) - if err != nil { - return fmt.Errorf("failed to create temp repo with alternates: %w", err) - } + tagger := params.Actor + if params.Tagger != nil { + tagger = *params.Tagger + } + taggerDate := time.Now().UTC() + if params.TaggerDate != nil { + taggerDate = *params.TaggerDate + } - tagger := params.Actor - if params.Tagger != nil { - tagger = *params.Tagger - } - taggerDate := time.Now().UTC() - if params.TaggerDate != nil { - taggerDate = *params.TaggerDate - } - - createTagRequest := &api.CreateTagOptions{ - Message: params.Message, - Tagger: api.Signature{ - Identity: api.Identity{ - Name: tagger.Name, - Email: tagger.Email, - }, - When: taggerDate, + createTagRequest := &api.CreateTagOptions{ + Message: params.Message, + Tagger: api.Signature{ + Identity: api.Identity{ + Name: tagger.Name, + Email: tagger.Email, }, - } - err = s.git.CreateTag( - ctx, - sharedRepo.RepoPath, - tagName, - targetCommit.SHA, - createTagRequest) - if err != nil { + When: taggerDate, + }, + } + + // ref updater + + refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, tagRef) + if err != nil { + return nil, fmt.Errorf("failed to create ref updater to create the tag: %w", err) + } + + // create the tag + + err = sharedrepo.Run(ctx, refUpdater, s.tmpDir, repoPath, func(r *sharedrepo.SharedRepo) error { + if err := s.git.CreateTag(ctx, r.Directory(), tagName, targetCommit.SHA, createTagRequest); err != nil { return fmt.Errorf("failed to create tag '%s': %w", tagName, err) } - tag, err = s.git.GetAnnotatedTag(ctx, sharedRepo.RepoPath, tagName) + tag, err = s.git.GetAnnotatedTag(ctx, r.Directory(), tagName) if err != nil { return fmt.Errorf("failed to read annotated tag after creation: %w", err) } - err = sharedRepo.MoveObjects(ctx) - if err != nil { - return fmt.Errorf("failed to move git objects: %w", err) + if err := refUpdater.Init(ctx, sha.Nil, tag.Sha); err != nil { + return fmt.Errorf("failed to init ref updater: %w", err) } return nil - }() + }) if err != nil { return nil, fmt.Errorf("CreateCommitTag: failed to create tag in shared repository: %w", err) } - err = s.git.UpdateRef( - ctx, - params.EnvVars, - repoPath, - tagRef, - sha.Nil, - tag.Sha, - ) - if err != nil { - return nil, fmt.Errorf("failed to create tag reference: %w", err) - } + // prepare response var commitTag *CommitTag if params.Message != "" { @@ -355,19 +339,17 @@ func (s *Service) DeleteTag(ctx context.Context, params *DeleteTagParams) error repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID) tagRef := api.GetReferenceFromTagName(params.Name) - err := s.git.UpdateRef( - ctx, - params.EnvVars, - repoPath, - tagRef, - sha.None, // delete whatever is there - sha.Nil, - ) + refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, tagRef) + if err != nil { + return fmt.Errorf("failed to create ref updater to delete the tag: %w", err) + } + + err = refUpdater.Do(ctx, sha.None, sha.Nil) // delete whatever is there if errors.IsNotFound(err) { return errors.NotFound("tag %q does not exist", params.Name) } if err != nil { - return fmt.Errorf("failed to delete tag reference: %w", err) + return fmt.Errorf("failed to init ref updater: %w", err) } return nil diff --git a/git/wire.go b/git/wire.go index da45446fb..7111e234f 100644 --- a/git/wire.go +++ b/git/wire.go @@ -45,11 +45,13 @@ func ProvideGITAdapter( func ProvideService( config types.Config, adapter *api.Git, + hookClientFactory hook.ClientFactory, storage storage.Store, ) (Interface, error) { return New( config, adapter, + hookClientFactory, storage, ) }