diff options
Diffstat (limited to 'git.go')
| -rw-r--r-- | git.go | 195 |
1 files changed, 195 insertions, 0 deletions
@@ -0,0 +1,195 @@ +package main + +import ( + "archive/tar" + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// resolve finds the commit ref points at in the repository at url. An empty +// ref means the default branch; a full commit hash resolves to itself. +func resolve(ctx context.Context, url, ref string) (string, error) { + if fullRev.MatchString(ref) { + return ref, nil + } + query := []string{"HEAD"} + if ref != "" { + query = []string{"refs/heads/" + ref, "refs/tags/" + ref, "refs/tags/" + ref + "^{}"} + } + out, err := git(ctx, "", append([]string{"ls-remote", url}, query...)...) + if err != nil { + return "", err + } + found := map[string]string{} + sc := bufio.NewScanner(bytes.NewReader(out)) + for sc.Scan() { + rev, name, ok := strings.Cut(sc.Text(), "\t") + if ok { + found[name] = rev + } + } + for _, name := range []string{"HEAD", "refs/heads/" + ref, "refs/tags/" + ref + "^{}", "refs/tags/" + ref} { + if rev, ok := found[name]; ok { + return rev, nil + } + } + if ref == "" { + return "", fmt.Errorf("%s has no default branch", url) + } + return "", fmt.Errorf("%s has no branch or tag named %q", url, ref) +} + +// checkout makes rev of url available as a plain directory and returns it. +// Checkouts are immutable and shared by everything that locks the same commit. +func checkout(ctx context.Context, url, rev string) (string, error) { + key := repoKey(url) + dir := filepath.Join(dataDir(), "checkouts", key, rev) + if _, err := os.Stat(dir); err == nil { + return dir, nil + } + + repo := filepath.Join(dataDir(), "repos", key+".git") + if _, err := os.Stat(repo); err != nil { + if _, err := git(ctx, "", "init", "--bare", "--quiet", repo); err != nil { + return "", err + } + } + if !hasCommit(ctx, repo, rev) { + // Servers hand out branch and tag tips by hash; older commits may + // need the whole history. + if _, err := git(ctx, repo, "fetch", "--quiet", "--depth=1", url, rev); err != nil || !hasCommit(ctx, repo, rev) { + args := []string{"fetch", "--quiet", url, "+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"} + if _, err := os.Stat(filepath.Join(repo, "shallow")); err == nil { + args = append(args, "--unshallow") + } + if _, err := git(ctx, repo, args...); err != nil { + return "", err + } + } + if !hasCommit(ctx, repo, rev) { + return "", fmt.Errorf("%s has no commit %s", url, rev) + } + } + + tmp := fmt.Sprintf("%s.tmp-%d", dir, os.Getpid()) + defer os.RemoveAll(tmp) + if err := os.MkdirAll(tmp, 0o755); err != nil { + return "", err + } + cmd := exec.CommandContext(ctx, "git", "-C", repo, "archive", "--format=tar", rev) + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", err + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return "", gitError(err, &stderr) + } + extractErr := untar(stdout, tmp) + if err := cmd.Wait(); err != nil { + return "", gitError(err, &stderr) + } + if extractErr != nil { + return "", extractErr + } + if err := os.Rename(tmp, dir); err != nil { + return "", err + } + return dir, nil +} + +func hasCommit(ctx context.Context, repo, rev string) bool { + _, err := git(ctx, repo, "cat-file", "-e", rev+"^{commit}") + return err == nil +} + +func untar(r io.Reader, dst string) error { + tr := tar.NewReader(r) + for { + h, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + name := filepath.Clean(h.Name) + if filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, "../") { + return fmt.Errorf("refusing to extract %q", h.Name) + } + path := filepath.Join(dst, name) + switch h.Typeflag { + case tar.TypeDir: + err = os.MkdirAll(path, 0o755) + case tar.TypeReg: + err = writeFile(path, tr, os.FileMode(h.Mode)&0o755|0o644) + case tar.TypeSymlink: + err = os.Symlink(h.Linkname, path) + } + if err != nil { + return err + } + } +} + +func writeFile(path string, r io.Reader, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(f, r); err != nil { + f.Close() + return err + } + return f.Close() +} + +func repoKey(url string) string { + sum := sha256.Sum256([]byte(url)) + return hex.EncodeToString(sum[:8]) +} + +func git(ctx context.Context, repo string, args ...string) ([]byte, error) { + if repo != "" { + args = append([]string{"-C", repo}, args...) + } + cmd := exec.CommandContext(ctx, "git", args...) + // Fail instead of hanging on a password prompt nobody can see. + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return nil, gitError(err, &stderr) + } + return out, nil +} + +func gitError(err error, stderr *bytes.Buffer) error { + if errors.Is(err, exec.ErrNotFound) { + return errors.New("git is not installed; skills needs it to fetch sources (https://git-scm.com)") + } + msg := strings.TrimSpace(stderr.String()) + switch { + case msg == "": + return err + case strings.Contains(msg, "Repository not found"), strings.Contains(msg, "not found"), + strings.Contains(msg, "could not read Username"), strings.Contains(msg, "Permission denied"): + return fmt.Errorf("%s\n(check the URL, and that you can reach it with `git ls-remote`)", msg) + } + return errors.New(msg) +} |
