snapshot/git/repo/repo.go (129 lines of code) (raw):

// Package repo provides utilities for operating on a git repo. // Scoot often ends up with multiple git repos. E.g., one reference repo // and then each checkout is in its own repo. package repo import ( "context" "fmt" "os" "os/exec" "path" "strings" "time" log "github.com/sirupsen/logrus" ) const gitCommandTimeout = 10 * time.Minute const gitCleanupTimeout = 5 * time.Minute const gitIndexLock = ".git/index.lock" // Repository represents a valid Git repository. type Repository struct { dir string } // Where r lives on disk func (r *Repository) Dir() string { return r.dir } // Run a git command in r func (r *Repository) Run(args ...string) (string, error) { cmd, ctx, cancel := r.Command(args...) return r.RunCmd(cmd, ctx, cancel) } func (r *Repository) RunExtraEnv(extraEnv []string, args ...string) (string, error) { cmd, ctx, cancel := r.Command(args...) // If cmd.Env is empty, it uses the current process's environment. // If len(extraEnv) > 0, then we have to append both the current // process's env and extraEnv to cmd.Env if len(cmd.Env) == 0 && len(extraEnv) > 0 { cmd.Env = os.Environ() } cmd.Env = append(cmd.Env, extraEnv...) return r.RunCmd(cmd, ctx, cancel) } // Command creates an exec.Cmd to use to run in this Git Repo // Forcefully adds a Command Context with a timeout set to gitCommandTimeout // as a failsafe against hanging git processes. This requires a CancelFunc // be passed back to the caller - see https://golang.org/pkg/os/exec/#CommandContext func (r *Repository) Command(args ...string) (*exec.Cmd, context.Context, context.CancelFunc) { ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = r.dir return cmd, ctx, cancel } // RunCmd runs cmd (that must have been created by Command), returning its output and error func (r *Repository) RunCmd(cmd *exec.Cmd, ctx context.Context, cancel context.CancelFunc) (string, error) { log.Info("repo.Repository.Run, ", cmd.Args[1:]) defer cancel() data, err := cmd.Output() if ctx.Err() == context.DeadlineExceeded { r.CleanupKill() return string(data), ctx.Err() } log.Info("repo.Repository.Run complete. Err: ", err) if err != nil && err.(*exec.ExitError) != nil { log.Info("repo.Repository.Run error: ", string(err.(*exec.ExitError).Stderr)) } return string(data), err } // Run a git command that returns a sha. func (r *Repository) RunSha(args ...string) (string, error) { out, err := r.Run(args...) if err != nil { return out, err } return validateSha(out) } // RunCmdSha runs cmd (that must have been created by Command) expecting a sha func (r *Repository) RunCmdSha(cmd *exec.Cmd, ctx context.Context, cancel context.CancelFunc) (string, error) { out, err := r.RunCmd(cmd, ctx, cancel) if err != nil { return out, err } return validateSha(out) } func (r *Repository) RunExtraEnvSha(extraEnv []string, args ...string) (string, error) { out, err := r.RunExtraEnv(extraEnv, args...) if err != nil { return out, err } return validateSha(out) } // Returns nil if the Repository contains a given sha, or an error if not func (r *Repository) ShaPresent(sha string) error { _, err := r.Run("rev-parse", "--verify", sha+"^{object}") return err } // validateSha trims and validates sha as a git sha, returning the valid sha xor an error func validateSha(sha string) (string, error) { if len(sha) == 40 || len(sha) == 41 && sha[40] == '\n' { return sha[0:40], nil } return "", fmt.Errorf("sha not 40 or 41 (with a \\n) characters: %q", sha) } // NewRepo creates a new Repository for path `dir`. // It checks that `dir` is a valid path. func NewRepository(dir string) (*Repository, error) { // TODO(dbentley): make sure we handle the case that path is in a git repo, // but is not the root of a git repo r := &Repository{dir} // TODO(dbentley): we'd prefer to use features present in git 2.5+, but are stuck on 2.4 for now // E.g., --resolve-git-dir or --git-path topLevel, err := r.Run("rev-parse", "--show-toplevel") if err != nil { return nil, err } topLevel = strings.Replace(topLevel, "\n", "", -1) log.Info("git.NewRepository: ", dir, ", top: ", topLevel) r.dir = topLevel return r, nil } // Try to initialize a new git repo in the given directory. func InitRepo(dir string) (*Repository, error) { os.MkdirAll(dir, 0755) cmd := exec.Command("git", "init") cmd.Dir = dir if err := cmd.Run(); err != nil { return nil, err } return NewRepository(dir) } // Cleanup actions to take after a git process had to be killed for whatever reason (like timeout). // This cleanup should not assume any state / be as safe as possible. func (r *Repository) CleanupKill() { r.removeGitLockFile() // Don't reuse higher-level public functions for cleanup resetCtx, resetCancel := context.WithTimeout(context.Background(), gitCleanupTimeout) cmd := exec.CommandContext(resetCtx, "git", "reset", "--hard", "HEAD") cmd.Dir = r.dir defer resetCancel() if err := cmd.Run(); err != nil { log.Errorf("Failed to run git reset during cleanup: %v\n", err) } // If we have to kill the reset command, which can timeout, we'll have another stale lock r.removeGitLockFile() // cleanup does not use or leave the lock file behind cleanCtx, cleanCancel := context.WithTimeout(context.Background(), gitCleanupTimeout) cmd = exec.CommandContext(cleanCtx, "git", "clean", "-f", "-f", "-d", "-x") cmd.Dir = r.dir defer cleanCancel() if err := cmd.Run(); err != nil { log.Errorf("Failed to run git clean during cleanup: %v\n", err) } } func (r *Repository) removeGitLockFile() { lockFile := path.Join(r.dir, gitIndexLock) if _, err := os.Stat(lockFile); !os.IsNotExist(err) { if err := os.Remove(lockFile); err != nil { log.Errorf("Failed to remove git index lock file %s: %v\n", lockFile, err) } } }