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 }