Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/gitrepo/gitrepo.go

Raw
package gitrepo

import (
	"bytes"
	"fmt"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
)

func RepoPath(roots []string, repo string) (string, error) {
	if !cleanRepo(repo) {
		return "", fmt.Errorf("unsafe repo %q", repo)
	}
	for _, root := range roots {
		for _, name := range []string{repo, repo + ".git"} {
			path := filepath.Join(root, name)
			if isGitDir(path) {
				return path, nil
			}
		}
	}
	return "", fmt.Errorf("repo %q not found", repo)
}

func Resolve(repoPath, ref, rev string) (string, error) {
	if strings.TrimSpace(rev) != "" {
		return gitOutput("--git-dir", repoPath, "rev-parse", "--verify", "--end-of-options", rev+"^{commit}")
	}
	if strings.TrimSpace(ref) == "" {
		return "", fmt.Errorf("ref or rev required")
	}
	return gitOutput("--git-dir", repoPath, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}")
}

func DefaultBranch(repoPath string) (string, string, error) {
	ref, err := gitOutput("--git-dir", repoPath, "symbolic-ref", "HEAD")
	if err != nil {
		return "", "", err
	}
	if !strings.HasPrefix(ref, "refs/heads/") {
		return "", "", fmt.Errorf("default ref %q is not a branch", ref)
	}
	rev, err := Resolve(repoPath, ref, "")
	return ref, rev, err
}

func ChangedPaths(repoPath, oldRev, newRev string) ([]string, error) {
	if !objectID(newRev) || (strings.Trim(oldRev, "0") != "" && !objectID(oldRev)) {
		return nil, fmt.Errorf("changed paths require object IDs")
	}
	var args []string
	if strings.Trim(oldRev, "0") == "" {
		args = []string{"--git-dir", repoPath, "ls-tree", "-r", "--name-only", "-z", newRev}
	} else {
		args = []string{"--git-dir", repoPath, "diff", "--name-only", "--no-renames", "-z", oldRev, newRev, "--"}
	}
	cmd := exec.Command("git", args...)
	var stdout, stderr bytes.Buffer
	cmd.Stdout, cmd.Stderr = &stdout, &stderr
	if err := cmd.Run(); err != nil {
		return nil, fmt.Errorf("git changed paths: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	data := stdout.Bytes()
	if len(data) == 0 {
		return nil, nil
	}
	parts := bytes.Split(data[:len(data)-1], []byte{0})
	paths := make([]string, len(parts))
	for i, part := range parts {
		paths[i] = string(part)
	}
	return paths, nil
}

var mkdirAll = os.MkdirAll

func Checkout(repoPath, rev, workspace string) error {
	if !objectID(rev) {
		return fmt.Errorf("valid revision required")
	}
	if err := os.RemoveAll(workspace); err != nil {
		return err
	}
	if err := mkdirAll(workspace, 0o755); err != nil {
		return err
	}
	absoluteRepoPath, err := filepath.Abs(repoPath)
	if err != nil {
		return err
	}
	if out, err := exec.Command("git", "init", "--quiet", workspace).CombinedOutput(); err != nil {
		return fmt.Errorf("git init: %w: %s", err, strings.TrimSpace(string(out)))
	}
	repoURL := (&url.URL{Scheme: "file", Path: absoluteRepoPath}).String()
	cmd := exec.Command("git", "-C", workspace, "fetch", "--quiet", "--depth=1", "--no-tags", repoURL, rev)
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git fetch: %w: %s", err, strings.TrimSpace(string(out)))
	}
	cmd = exec.Command("git", "-C", workspace, "checkout", "--quiet", "--detach", "FETCH_HEAD")
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git checkout: %w: %s", err, strings.TrimSpace(string(out)))
	}
	return nil
}

func objectID(value string) bool {
	if len(value) != 40 && len(value) != 64 {
		return false
	}
	_, err := strconv.ParseUint(value[:16], 16, 64)
	if err != nil {
		return false
	}
	for _, char := range value[16:] {
		if !strings.ContainsRune("0123456789abcdefABCDEF", char) {
			return false
		}
	}
	return true
}

func cleanRepo(repo string) bool {
	return strings.TrimSpace(repo) != "" && repo == filepath.Base(repo) && repo != "." && repo != ".." && !strings.Contains(repo, string(filepath.Separator))
}

func isGitDir(path string) bool {
	if info, err := os.Stat(filepath.Join(path, "HEAD")); err == nil && !info.IsDir() {
		return true
	}
	return false
}

func gitOutput(args ...string) (string, error) {
	cmd := exec.Command("git", args...)
	out, err := cmd.CombinedOutput()
	text := strings.TrimSpace(string(out))
	if err != nil {
		return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, text)
	}
	return text, nil
}

// Subject returns the one-line commit subject of a revision.
func Subject(repoPath, rev string) string {
	if !objectID(rev) {
		return ""
	}
	subject, err := gitOutput("--git-dir", repoPath, "log", "-1", "--format=%s", rev)
	if err != nil {
		return ""
	}
	return subject
}

// FilesAtRev returns the contents of every file under a tree directory at a
// revision, keyed by path relative to that directory.
func FilesAtRev(repoPath, rev, dir string) (map[string]string, error) {
	if !objectID(rev) {
		return nil, fmt.Errorf("valid revision required")
	}
	listing, err := gitOutput("--git-dir", repoPath, "ls-tree", "-r", "--name-only", rev, "--", dir)
	if err != nil {
		return nil, err
	}
	files := map[string]string{}
	for _, path := range strings.Split(listing, "\n") {
		path = strings.TrimSpace(path)
		if path == "" {
			continue
		}
		content, err := gitOutput("--git-dir", repoPath, "show", rev+":"+path)
		if err != nil {
			return nil, err
		}
		files[strings.TrimPrefix(strings.TrimPrefix(path, dir), "/")] = content
	}
	return files, nil
}