// 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" "os" "regexp" "strconv" "strings" "time" "github.com/harness/gitness/git/command" "github.com/rs/zerolog/log" ) type CloneRepoOptions struct { Timeout time.Duration Mirror bool Bare bool Quiet bool Branch string Shared bool NoCheckout bool Depth int Filter string SkipTLSVerify bool } type PushOptions struct { Remote string Branch string Force bool ForceWithLease string Env []string Timeout time.Duration Mirror bool } // ObjectCount represents the parsed information from the `git count-objects -v` command. // For field meanings, see https://git-scm.com/docs/git-count-objects#_options. type ObjectCount struct { Count int Size int64 InPack int Packs int SizePack int64 PrunePackable int Garbage int SizeGarbage int64 } const ( gitReferenceNamePrefixBranch = "refs/heads/" gitReferenceNamePrefixTag = "refs/tags/" ) var lsRemoteHeadRegexp = regexp.MustCompile(`ref: refs/heads/([^\s]+)\s+HEAD`) // InitRepository initializes a new Git repository. func (g *Git) InitRepository( ctx context.Context, repoPath string, bare bool, ) error { if repoPath == "" { return ErrRepositoryPathEmpty } err := os.MkdirAll(repoPath, os.ModePerm) if err != nil { return fmt.Errorf("failed to create directory '%s', err: %w", repoPath, err) } cmd := command.New("init") if bare { cmd.Add(command.WithFlag("--bare")) } return cmd.Run(ctx, command.WithDir(repoPath)) } // SetDefaultBranch sets the default branch of a repo. func (g *Git) SetDefaultBranch( ctx context.Context, repoPath string, defaultBranch string, allowEmpty bool, ) error { if repoPath == "" { return ErrRepositoryPathEmpty } // if requested, error out if branch doesn't exist. Otherwise, blindly set it. exist, err := g.IsBranchExist(ctx, repoPath, defaultBranch) if err != nil { log.Ctx(ctx).Err(err).Msgf("failed to set default branch") } if !allowEmpty && !exist { // TODO: ensure this returns not found error to caller return fmt.Errorf("branch '%s' does not exist", defaultBranch) } // change default branch cmd := command.New("symbolic-ref", command.WithArg("HEAD", gitReferenceNamePrefixBranch+defaultBranch), ) err = cmd.Run(ctx, command.WithDir(repoPath)) if err != nil { return processGitErrorf(err, "failed to set new default branch") } return nil } // GetDefaultBranch gets the default branch of a repo. func (g *Git) GetDefaultBranch( ctx context.Context, repoPath string, ) (string, error) { if repoPath == "" { return "", ErrRepositoryPathEmpty } // get default branch cmd := command.New("symbolic-ref", command.WithArg("HEAD"), ) output := &bytes.Buffer{} err := cmd.Run(ctx, command.WithDir(repoPath), command.WithStdout(output)) if err != nil { return "", processGitErrorf(err, "failed to get default branch") } return output.String(), nil } // GetRemoteDefaultBranch retrieves the default branch of a remote repository. // If the repo doesn't have a default branch, types.ErrNoDefaultBranch is returned. func (g *Git) GetRemoteDefaultBranch( ctx context.Context, remoteURL string, ) (string, error) { cmd := command.New("ls-remote", command.WithConfig("credential.helper", ""), command.WithFlag("--symref"), command.WithFlag("-q"), command.WithArg(remoteURL), command.WithArg("HEAD"), ) output := &bytes.Buffer{} if err := cmd.Run(ctx, command.WithStdout(output)); err != nil { return "", processGitErrorf(err, "failed to ls remote repo") } // git output looks as follows, and we are looking for the ref that HEAD points to // ref: refs/heads/main HEAD // 46963bc7f0b5e8c5f039d50ac9e6e51933c78cdf HEAD match := lsRemoteHeadRegexp.FindStringSubmatch(strings.TrimSpace(output.String())) if match == nil { return "", ErrNoDefaultBranch } return match[1], nil } func (g *Git) Clone( ctx context.Context, from string, to string, opts CloneRepoOptions, ) error { if err := os.MkdirAll(to, os.ModePerm); err != nil { return err } cmd := command.New("clone") if opts.SkipTLSVerify { cmd.Add(command.WithConfig("http.sslVerify", "false")) } if opts.Mirror { cmd.Add(command.WithFlag("--mirror")) } if opts.Bare { cmd.Add(command.WithFlag("--bare")) } if opts.Quiet { cmd.Add(command.WithFlag("--quiet")) } if opts.Shared { cmd.Add(command.WithFlag("-s")) } if opts.NoCheckout { cmd.Add(command.WithFlag("--no-checkout")) } if opts.Depth > 0 { cmd.Add(command.WithFlag("--depth", strconv.Itoa(opts.Depth))) } if opts.Filter != "" { cmd.Add(command.WithFlag("--filter", opts.Filter)) } if len(opts.Branch) > 0 { cmd.Add(command.WithFlag("-b", opts.Branch)) } cmd.Add(command.WithPostSepArg(from, to)) if err := cmd.Run(ctx); err != nil { return fmt.Errorf("failed to clone repository: %w", err) } return nil } // Sync synchronizes the repository to match the provided source. // NOTE: This is a read operation and doesn't trigger any server side hooks. func (g *Git) Sync( ctx context.Context, repoPath string, source string, refSpecs []string, ) error { if repoPath == "" { return ErrRepositoryPathEmpty } if len(refSpecs) == 0 { refSpecs = []string{"+refs/*:refs/*"} } cmd := command.New("fetch", command.WithConfig("advice.fetchShowForcedUpdates", "false"), command.WithConfig("credential.helper", ""), command.WithFlag( "--quiet", "--prune", "--atomic", "--force", "--no-write-fetch-head", "--no-show-forced-updates", ), command.WithArg(source), command.WithArg(refSpecs...), ) err := cmd.Run(ctx, command.WithDir(repoPath)) if err != nil { return processGitErrorf(err, "failed to sync repo") } return nil } func (g *Git) AddFiles( ctx context.Context, repoPath string, all bool, files ...string, ) error { if repoPath == "" { return ErrRepositoryPathEmpty } cmd := command.New("add") if all { cmd.Add(command.WithFlag("--all")) } cmd.Add(command.WithPostSepArg(files...)) err := cmd.Run(ctx, command.WithDir(repoPath)) if err != nil { return processGitErrorf(err, "failed to add changes") } return nil } // Commit commits the changes of the repository. func (g *Git) Commit( ctx context.Context, repoPath string, opts CommitChangesOptions, ) error { if repoPath == "" { return ErrRepositoryPathEmpty } cmd := command.New("commit", command.WithFlag("-m", opts.Message), command.WithAuthorAndDate( opts.Author.Identity.Name, opts.Author.Identity.Email, opts.Author.When, ), command.WithCommitterAndDate( opts.Committer.Identity.Name, opts.Committer.Identity.Email, opts.Committer.When, ), ) err := cmd.Run(ctx, command.WithDir(repoPath)) // No stderr but exit status 1 means nothing to commit (see gitea CommitChanges) if err != nil && err.Error() != "exit status 1" { return processGitErrorf(err, "failed to commit changes") } return nil } // Push pushs local commits to given remote branch. // TODOD: return our own error types and move to above api.Push method func (g *Git) Push( ctx context.Context, repoPath string, opts PushOptions, ) error { if repoPath == "" { return ErrRepositoryPathEmpty } cmd := command.New("push", command.WithConfig("credential.helper", ""), ) if opts.Force { cmd.Add(command.WithFlag("-f")) } if opts.ForceWithLease != "" { cmd.Add(command.WithFlag("--force-with-lease=" + opts.ForceWithLease)) } if opts.Mirror { cmd.Add(command.WithFlag("--mirror")) } cmd.Add(command.WithPostSepArg(opts.Remote)) if len(opts.Branch) > 0 { cmd.Add(command.WithPostSepArg(opts.Branch)) } if g.traceGit { cmd.Add(command.WithEnv(command.GitTrace, "true")) } // remove credentials if there are any if strings.Contains(opts.Remote, "://") && strings.Contains(opts.Remote, "@") { opts.Remote = SanitizeCredentialURLs(opts.Remote) } var outbuf, errbuf strings.Builder err := cmd.Run(ctx, command.WithDir(repoPath), command.WithStdout(&outbuf), command.WithStderr(&errbuf), command.WithEnvs(opts.Env...), ) if g.traceGit { log.Ctx(ctx).Trace(). Str("git", "push"). Err(err). Msgf("IN:\n%#v\n\nSTDOUT:\n%s\n\nSTDERR:\n%s", opts, outbuf.String(), errbuf.String()) } if err != nil { switch { case strings.Contains(errbuf.String(), "non-fast-forward"): return &PushOutOfDateError{ StdOut: outbuf.String(), StdErr: errbuf.String(), Err: err, } case strings.Contains(errbuf.String(), "! [remote rejected]"): err := &PushRejectedError{ StdOut: outbuf.String(), StdErr: errbuf.String(), Err: err, } err.GenerateMessage() return err case strings.Contains(errbuf.String(), "matches more than one"): err := &MoreThanOneError{ StdOut: outbuf.String(), StdErr: errbuf.String(), Err: err, } return err default: // fall through to normal error handling } } if err != nil { // add commandline error output to error if errbuf.Len() > 0 { err = fmt.Errorf("%w\ncmd error output: %s", err, errbuf.String()) } return processGitErrorf(err, "failed to push changes") } return nil } func (g *Git) CountObjects(ctx context.Context, repoPath string) (ObjectCount, error) { var outbuf strings.Builder cmd := command.New("count-objects", command.WithFlag("-v")) err := cmd.Run(ctx, command.WithDir(repoPath), command.WithStdout(&outbuf), ) if err != nil { return ObjectCount{}, fmt.Errorf("error running git count-objects: %w", err) } objectCount := parseGitCountObjectsOutput(ctx, outbuf.String()) return objectCount, nil } func parseGitCountObjectsOutput(ctx context.Context, output string) ObjectCount { info := ObjectCount{} output = strings.TrimSpace(output) lines := strings.Split(output, "\n") for _, line := range lines { fields := strings.Fields(line) switch fields[0] { case "count:": fmt.Sscanf(fields[1], "%d", &info.Count) case "size:": fmt.Sscanf(fields[1], "%d", &info.Size) case "in-pack:": fmt.Sscanf(fields[1], "%d", &info.InPack) case "packs:": fmt.Sscanf(fields[1], "%d", &info.Packs) case "size-pack:": fmt.Sscanf(fields[1], "%d", &info.SizePack) case "prune-packable:": fmt.Sscanf(fields[1], "%d", &info.PrunePackable) case "garbage:": fmt.Sscanf(fields[1], "%d", &info.Garbage) case "size-garbage:": fmt.Sscanf(fields[1], "%d", &info.SizeGarbage) default: log.Ctx(ctx).Warn().Msgf("line '%s: %s' not processed", fields[0], fields[1]) } } return info }