github.com/bitrise-io/bitrise-step-update-gitops-repository@v0.0.0-20240426081835-1466be593380/pkg/gitops/local_repository.go (about)

     1  package gitops
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"log"
     8  	"os"
     9  	"os/exec"
    10  	"strings"
    11  	"time"
    12  )
    13  
    14  //go:generate moq -out local_repository_moq_test.go . localRepository
    15  type localRepository interface {
    16  	Close(ctx context.Context)
    17  	localPath() string
    18  	gitClone() error
    19  	workingDirectoryClean() (bool, error)
    20  	gitCheckoutNewBranch() error
    21  	gitCommitAndPush(message string) error
    22  	openPullRequest(ctx context.Context, title, body string) (string, error)
    23  }
    24  
    25  // gitRepo implements the localRepository interface.
    26  var _ localRepository = (*gitRepo)(nil)
    27  
    28  type gitRepo struct {
    29  	pr         pullRequestOpener
    30  	githubRepo *githubRepo
    31  	branch     string
    32  
    33  	tmpRepoPath string
    34  }
    35  
    36  // NewGitRepoParams are parameters for NewGitRepo function.
    37  type NewGitRepoParams struct {
    38  	PullRequestOpener pullRequestOpener
    39  	GithubRepo        *githubRepo
    40  	Branch            string
    41  }
    42  
    43  // NewGitRepo returns a new local clone of a remote repository.
    44  // It should be closed after usage.
    45  func NewGitRepo(ctx context.Context, p NewGitRepoParams) (*gitRepo, error) {
    46  	// Temporary directory for local clone of repository.
    47  	tmpRepoPath, err := ioutil.TempDir("", "")
    48  	if err != nil {
    49  		return nil, fmt.Errorf("create temp dir for repo: %w", err)
    50  	}
    51  	repo := &gitRepo{
    52  		pr:          p.PullRequestOpener,
    53  		githubRepo:  p.GithubRepo,
    54  		branch:      p.Branch,
    55  		tmpRepoPath: tmpRepoPath,
    56  	}
    57  	if err := repo.gitClone(); err != nil {
    58  		return nil, fmt.Errorf("git clone repo: %w", err)
    59  	}
    60  	return repo, nil
    61  }
    62  
    63  // Close closes all related resoruces of the repository.
    64  // This is a best-effort operation, possible errors are logged as warning,
    65  // not returned as an actual error.
    66  func (r gitRepo) Close(ctx context.Context) {
    67  	// Delete temporary repository from the local filesystem.
    68  	if err := os.RemoveAll(r.tmpRepoPath); err != nil {
    69  		log.Printf("warning: remove temporary repository: %s\n", err)
    70  	}
    71  }
    72  
    73  func (r gitRepo) localPath() string {
    74  	return r.tmpRepoPath
    75  }
    76  
    77  func (r gitRepo) gitClone() error {
    78  	_, err := r.git("clone",
    79  		"--branch", r.branch, "--single-branch",
    80  		string(r.githubRepo.url), ".")
    81  	return err
    82  }
    83  
    84  func (r gitRepo) workingDirectoryClean() (bool, error) {
    85  	status, err := r.git("status")
    86  	if err != nil {
    87  		return false, err
    88  	}
    89  	return strings.Contains(status, "nothing to commit"), nil
    90  }
    91  
    92  func (r gitRepo) gitCheckoutNewBranch() error {
    93  	// Generate branch name based on the current time.
    94  	t := time.Now()
    95  	branch := fmt.Sprintf("ci-%d-%02d-%02dT%02d-%02d-%02d",
    96  		t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())
    97  	// Execute git checkout to a new branch with that name.
    98  	if _, err := r.git("checkout", "-b", branch); err != nil {
    99  		return fmt.Errorf("checkout new branch %q: %w", branch, err)
   100  	}
   101  	return nil
   102  }
   103  
   104  func (r gitRepo) gitCommitAndPush(message string) error {
   105  	// Stage all changes, commit them to the current branch
   106  	// and push the commit to the remote repository.
   107  	gitArgs := [][]string{
   108  		{"add", "--all"},
   109  		{"commit", "-m", message},
   110  		{"push", "--all", "-u"},
   111  	}
   112  	for _, a := range gitArgs {
   113  		if _, err := r.git(a...); err != nil {
   114  			return err
   115  		}
   116  	}
   117  	return nil
   118  }
   119  
   120  func (r gitRepo) currentBranch() (string, error) {
   121  	branch, err := r.git("rev-parse", "--abbrev-ref", "HEAD")
   122  	if err != nil {
   123  		return "", err
   124  	}
   125  	return strings.TrimSpace(branch), nil
   126  }
   127  
   128  func (r gitRepo) git(args ...string) (string, error) {
   129  	// Change current directory to the repositorys local clone.
   130  	originalDir, err := os.Getwd()
   131  	if err != nil {
   132  		return "", fmt.Errorf("get current dir: %w", err)
   133  	}
   134  	if err := os.Chdir(r.tmpRepoPath); err != nil {
   135  		return "", fmt.Errorf("change dir to %q: %w", r.tmpRepoPath, err)
   136  	}
   137  
   138  	cmd := exec.Command("git", args...)
   139  	// Run git command and returns its combined output of stdout and stderr.
   140  	out, err := cmd.CombinedOutput()
   141  	if err != nil {
   142  		if errChdir := os.Chdir(originalDir); errChdir != nil {
   143  			err = fmt.Errorf("%w (revert to original dir: %s)", err, errChdir)
   144  		}
   145  		return "", fmt.Errorf("run command %v: %w (output: %s)", args, err, out)
   146  	}
   147  	if err := os.Chdir(originalDir); err != nil {
   148  		return "", fmt.Errorf("revert to original dir: %w", err)
   149  	}
   150  	return string(out), nil
   151  }
   152  
   153  func (r gitRepo) openPullRequest(ctx context.Context, title, body string) (string, error) {
   154  	// PR will be open from the current branch.
   155  	currBranch, err := r.currentBranch()
   156  	if err != nil {
   157  		return "", fmt.Errorf("current branch: %w", err)
   158  	}
   159  	// Open pull request from current branch to the base branch.
   160  	url, err := r.pr.OpenPullRequest(ctx, openPullRequestParams{
   161  		title: title,
   162  		body:  body,
   163  		head:  currBranch,
   164  		base:  r.branch,
   165  	})
   166  	if err != nil {
   167  		return "", fmt.Errorf("call github: %w", err)
   168  	}
   169  	return url, nil
   170  }