aboutsummaryrefslogtreecommitdiffstats
path: root/git.go
diff options
context:
space:
mode:
Diffstat (limited to 'git.go')
-rw-r--r--git.go195
1 files changed, 195 insertions, 0 deletions
diff --git a/git.go b/git.go
new file mode 100644
index 0000000..8a48250
--- /dev/null
+++ b/git.go
@@ -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)
+}