blob: 24bebd7cfc65e1690f0476fa2b8d5d0e55473784 [file] [log] [blame] [edit]
package git
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/vcsinfo"
)
/*
Utility for managing a Git checkout.
*/
// Checkout is used for managing a local git checkout.
type Checkout interface {
GitDir
// FetchRefFromRepo syncs the specified ref from the repo without modifying the
// working copy.
FetchRefFromRepo(ctx context.Context, repo, ref string) error
// Fetch syncs refs from the remote without modifying the working copy.
Fetch(ctx context.Context) error
// AddRemote checks to see if a remote already exists in the checkout, if it
// exists then the URL is matched with the repoURL. If the remote does not exist
// then it is added.
AddRemote(ctx context.Context, remote, repoUrl string) error
// CleanupBranch forcibly resets all changes and checks out the given branch,
// forcing it to match the same branch from origin. All local changes will be
// lost.
CleanupBranch(ctx context.Context, branch string) error
// Cleanup forcibly resets all changes and checks out the main branch to match
// that of the remote. All local changes will be lost.
Cleanup(ctx context.Context) error
// UpdateBranch syncs the Checkout from its remote. Forcibly resets and checks
// out the given branch, forcing it to match the same branch from origin. All
// local changes will be lost. Equivalent to c.Fetch() + c.CleanupBranch().
UpdateBranch(ctx context.Context, branch string) error
// Update syncs the Checkout from its remote. Forcibly resets and checks out
// the main branch to match the remote. All local changes will be lost.
// Equivalent to c.Fetch() + c.Cleanup().
Update(ctx context.Context) error
// IsDirty returns true if the Checkout is dirty, ie. any of the following are
// true:
// 1. There are unstaged changes.
// 2. There are untracked files (not including .gitignore'd files).
// 3. HEAD is not an ancestor of origin/main.
//
// Also returns the output of "git status", for human consumption if desired.
IsDirty(ctx context.Context) (bool, string, error)
}
// CheckoutDir implements Checkout.
type CheckoutDir string
// NewCheckout returns a Checkout instance based in the given working directory.
// Uses any existing checkout in the given directory, or clones one if
// necessary. In general, servers should use Repo instead of Checkout unless it
// is absolutely necessary to have a working copy.
func NewCheckout(ctx context.Context, repoUrl, workdir string) (CheckoutDir, error) {
g, err := newGitDir(ctx, repoUrl, workdir, false)
if err != nil {
return "", skerr.Wrap(err)
}
return CheckoutDir(g), nil
}
// Dir returns the working directory of the GitDir.
func (c CheckoutDir) Dir() string {
return string(c)
}
// Git runs the given git command in the Checkout.
func (c CheckoutDir) Git(ctx context.Context, cmd ...string) (string, error) {
git, err := Executable(ctx)
if err != nil {
return "", skerr.Wrap(err)
}
baseCmd := []string{git}
return exec.RunCwd(ctx, c.Dir(), append(baseCmd, cmd...)...)
}
// FetchRefFromRepo syncs the specified ref from the repo without modifying the
// working copy.
func (c CheckoutDir) FetchRefFromRepo(ctx context.Context, repo, ref string) error {
_, err := c.Git(ctx, "fetch", repo, ref)
return skerr.Wrap(err)
}
// Fetch syncs refs from the remote without modifying the working copy.
func (c CheckoutDir) Fetch(ctx context.Context) error {
_, err := c.Git(ctx, "fetch", "--prune", DefaultRemote)
return skerr.Wrap(err)
}
// AddRemote checks to see if a remote already exists in the checkout, if it
// exists then the URL is matched with the repoURL. If the remote does not exist
// then it is added.
func (c CheckoutDir) AddRemote(ctx context.Context, remote, repoUrl string) error {
// Check to see whether there is an upstream yet.
remoteOutput, err := c.Git(ctx, "remote", "get-url", remote)
if err != nil {
if strings.Contains(err.Error(), "No such remote") {
if _, err := c.Git(ctx, "remote", "add", remote, repoUrl); err != nil {
return skerr.Wrap(err)
}
} else {
return skerr.Wrap(err)
}
} else {
// Remote already exists. Make sure that the URLs match.
if strings.TrimSpace(remoteOutput) != repoUrl {
return skerr.Fmt("%s points to %s instead of %s", remote, strings.TrimSpace(remoteOutput), repoUrl)
}
}
return nil
}
// CleanupBranch forcibly resets all changes and checks out the given branch,
// forcing it to match the same branch from origin. All local changes will be
// lost.
func (c CheckoutDir) CleanupBranch(ctx context.Context, branch string) error {
if _, err := c.Git(ctx, "checkout", branch, "-f"); err != nil {
return skerr.Wrap(err)
}
if _, err := c.Git(ctx, "clean", "-d", "-f"); err != nil {
return skerr.Wrap(err)
}
if _, err := c.Git(ctx, "reset", "--hard", fmt.Sprintf("origin/%s", branch)); err != nil {
return skerr.Wrap(err)
}
return nil
}
// Cleanup forcibly resets all changes and checks out the main branch to match
// that of the remote. All local changes will be lost.
func (c CheckoutDir) Cleanup(ctx context.Context) error {
return c.CleanupBranch(ctx, MainBranch)
}
// UpdateBranch syncs the Checkout from its remote. Forcibly resets and checks
// out the given branch, forcing it to match the same branch from origin. All
// local changes will be lost. Equivalent to c.Fetch() + c.CleanupBranch().
func (c CheckoutDir) UpdateBranch(ctx context.Context, branch string) error {
if err := c.Fetch(ctx); err != nil {
return skerr.Wrap(err)
}
if err := c.CleanupBranch(ctx, branch); err != nil {
return skerr.Wrap(err)
}
return nil
}
// Update syncs the Checkout from its remote. Forcibly resets and checks out
// the main branch to match the remote. All local changes will be lost.
// Equivalent to c.Fetch() + c.Cleanup().
func (c CheckoutDir) Update(ctx context.Context) error {
return c.UpdateBranch(ctx, MainBranch)
}
// IsDirty returns true if the Checkout is dirty, ie. any of the following are
// true:
// 1. There are unstaged changes.
// 2. There are untracked files (not including .gitignore'd files).
// 3. HEAD is not an ancestor of origin/main.
//
// Also returns the output of "git status", for human consumption if desired.
func (c CheckoutDir) IsDirty(ctx context.Context) (bool, string, error) {
status, err := c.Git(ctx, "status")
if err != nil {
return false, "", skerr.Wrap(err)
}
if _, err := c.Git(ctx, "update-index", "--refresh"); err != nil {
return true, status, nil
}
if _, err := c.Git(ctx, "diff-index", "--quiet", "HEAD", "--"); err != nil {
return true, status, nil
}
output, err := c.Git(ctx, "ls-files", "--other", "--directory", "--exclude-standard")
if err != nil {
return false, status, skerr.Wrap(err)
}
output = strings.TrimSpace(output)
if output != "" {
return true, status, nil
}
if anc, err := c.IsAncestor(ctx, "HEAD", DefaultRemoteBranch); err != nil {
return false, status, skerr.Wrap(err)
} else if !anc {
return true, status, nil
}
return false, status, nil
}
// Details returns a vcsinfo.LongCommit instance representing the given commit.
func (c CheckoutDir) Details(ctx context.Context, name string) (*vcsinfo.LongCommit, error) {
return gitRunner_Details(ctx, c, name)
}
// RevParse runs "git rev-parse <name>" and returns the result.
func (c CheckoutDir) RevParse(ctx context.Context, args ...string) (string, error) {
return gitRunner_RevParse(ctx, c, args...)
}
// RevList runs "git rev-list <name>" and returns a slice of commit hashes.
func (c CheckoutDir) RevList(ctx context.Context, args ...string) ([]string, error) {
return gitRunner_RevList(ctx, c, args...)
}
// GetBranchHead returns the commit hash at the HEAD of the given branch.
func (c CheckoutDir) GetBranchHead(ctx context.Context, branchName string) (string, error) {
return gitRunner_GetBranchHead(ctx, c, branchName)
}
// Branches runs "git branch" and returns a slice of Branch instances.
func (c CheckoutDir) Branches(ctx context.Context) ([]*Branch, error) {
return gitRunner_Branches(ctx, c)
}
// GetFile returns the contents of the given file at the given commit.
func (c CheckoutDir) GetFile(ctx context.Context, fileName, commit string) (string, error) {
return gitRunner_GetFile(ctx, c, fileName, commit)
}
// IsSubmodule returns true if the given path is submodule, ie contains gitlink.
func (c CheckoutDir) IsSubmodule(ctx context.Context, path, commit string) (bool, error) {
return gitRunner_IsSubmodule(ctx, c, path, commit)
}
// ReadSubmodule returns commit hash of the given path, if the path is git
// submodule. ErrorNotFound is returned if path is not found in the git
// worktree. ErrorNotSubmodule is returned if path exists, but it's not a
// submodule.
func (c CheckoutDir) ReadSubmodule(ctx context.Context, path, commit string) (string, error) {
return gitRunner_ReadSubmodule(ctx, c, path, commit)
}
// UpdateSubmodule updates git submodule of the given path to the given commit.
// If submodule doesn't exist, it returns ErrorNotFound since it doesn't have
// all necessary information to create a valid submodule (requires an entry in
// .gitmodules).
func (c CheckoutDir) UpdateSubmodule(ctx context.Context, path, commit string) error {
return gitRunner_UpdateSubmodule(ctx, c, path, commit)
}
// NumCommits returns the number of commits in the repo.
func (c CheckoutDir) NumCommits(ctx context.Context) (int64, error) {
return gitRunner_NumCommits(ctx, c)
}
// IsAncestor returns true iff A is an ancestor of B.
func (c CheckoutDir) IsAncestor(ctx context.Context, a, b string) (bool, error) {
return gitRunner_IsAncestor(ctx, c, a, b)
}
// Version returns the Git version.
func (c CheckoutDir) Version(ctx context.Context) (int, int, error) {
return gitRunner_Version(ctx)
}
// FullHash gives the full commit hash for the given ref.
func (c CheckoutDir) FullHash(ctx context.Context, ref string) (string, error) {
return gitRunner_FullHash(ctx, c, ref)
}
// CatFile runs "git cat-file -p <ref>:<path>".
func (c CheckoutDir) CatFile(ctx context.Context, ref, path string) ([]byte, error) {
return gitRunner_CatFile(ctx, c, ref, path)
}
// ReadDir is analogous to os.File.Readdir for a particular ref.
func (c CheckoutDir) ReadDir(ctx context.Context, ref, path string) ([]os.FileInfo, error) {
return gitRunner_ReadDir(ctx, c, ref, path)
}
// GetRemotes returns a mapping of remote repo name to URL.
func (c CheckoutDir) GetRemotes(ctx context.Context) (map[string]string, error) {
return gitRunner_GetRemotes(ctx, c)
}
// VFS returns a vfs.FS using Git for the given revision.
func (c CheckoutDir) VFS(ctx context.Context, ref string) (*FS, error) {
return VFS(ctx, c, ref)
}
// TempCheckout is a temporary Git Checkout.
type TempCheckout struct {
Checkout
}
// NewTempCheckout returns a TempCheckout instance. Creates a temporary
// directory and then clones the repoUrl into a subdirectory, based on default
// "git clone" behavior.
func NewTempCheckout(ctx context.Context, repoUrl string) (*TempCheckout, error) {
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
return nil, skerr.Wrap(err)
}
c, err := NewCheckout(ctx, repoUrl, tmpDir)
if err != nil {
return nil, skerr.Wrap(err)
}
return &TempCheckout{
Checkout: c,
}, nil
}
// Delete removes the TempCheckout's working directory.
func (c *TempCheckout) Delete() {
deleteDir := filepath.Dir(c.Dir())
if err := os.RemoveAll(deleteDir); err != nil {
// Some processes (eg. gclient) leave files that are owned by us but not
// writeable. Make everything writeable before attempting to delete. We
// do this only after the first RemoveAll attempt, in case there are a
// large number of files.
if _, err2 := exec.RunCwd(context.TODO(), ".", "chmod", "-R", "+w", deleteDir); err2 != nil {
sklog.Errorf("Failed to remove git.TempCheckout with: %s; and failed to make writeable with: %s", err, err2)
return
}
if err := os.RemoveAll(deleteDir); err != nil {
sklog.Errorf("Failed to remove git.TempCheckout despite making writeable: %s", err)
}
}
}
// Assert that CheckoutDir implements Checkout.
var _ Checkout = CheckoutDir("")