package main import ( "bytes" "context" "errors" "os/exec" "path" "strconv" "strings" "time" "unicode/utf8" ) type Git struct { Dir string } type Commit struct { Hash string Author string Email string Time time.Time Subject string Body string Parents []string } type Entry struct { Mode string Type string Hash string Size int64 Name string } type Ref struct { Name string Hash string Time time.Time } type Line struct { Kind string Old int New int Text string } type Hunk struct { Header string Lines []Line } type File struct { Old string New string Status string Binary bool Hunks []Hunk Added int Removed int } var ErrMissing = errors.New("missing") func (g Git) run(stdin []byte, args ...string) ([]byte, error) { return g.runEnv(nil, stdin, args...) } func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "git", append([]string{"-C", g.Dir}, args...)...) cmd.Env = append([]string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8"}, env...) if stdin != nil { cmd.Stdin = bytes.NewReader(stdin) } var out, errb bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errb if err := cmd.Run(); err != nil { msg := strings.TrimSpace(errb.String()) if msg == "" { msg = err.Error() } return nil, errors.New(msg) } return out.Bytes(), nil } func initRepo(dir string) error { _, err := Git{Dir: "/"}.run(nil, "init", "--bare", "--quiet", "-b", "main", dir) return err } func cloneRepo(src, dst string) error { _, err := Git{Dir: "/"}.run(nil, "clone", "--bare", "--quiet", "--no-hardlinks", src, dst) if err != nil { return err } Git{Dir: dst}.run(nil, "remote", "remove", "origin") return nil } func (g Git) Fetch(srcDir, srcRef, dstRef string) error { _, err := g.run(nil, "fetch", "--quiet", "--no-tags", "--force", srcDir, srcRef+":"+dstRef) return err } func (g Git) MergeBase(a, b string) (string, error) { out, err := g.run(nil, "merge-base", a, b) if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } func (g Git) LogRange(base, head string, n int) ([]Commit, error) { out, err := g.run(nil, "log", "--format="+logFormat, "-n", strconv.Itoa(n), base+".."+head) if err != nil { return nil, err } return parseCommits(out), nil } func (g Git) DiffRange(base, head string) ([]File, error) { out, err := g.run(nil, "diff", "-M", "--no-color", base, head) if err != nil { return nil, err } return parseDiff(string(out)), nil } func (g Git) Mergeable(target, head string) bool { _, err := g.run(nil, "merge-tree", "--write-tree", "refs/heads/"+target, head) return err == nil } func (g Git) Merge(target, head, message, name, email string) (string, error) { targetHash, err := g.Resolve("refs/heads/" + target) if err != nil { return "", errors.New("target branch is missing") } headHash, err := g.Resolve(head) if err != nil { return "", errors.New("pull request head is missing") } if base, err := g.MergeBase(targetHash, headHash); err == nil && base == headHash { return "", errors.New("already merged") } out, err := g.run(nil, "merge-tree", "--write-tree", targetHash, headHash) if err != nil { return "", errors.New("merge conflicts, resolve them locally and update the pull request") } tree, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") ident := []string{"GIT_AUTHOR_NAME=" + name, "GIT_AUTHOR_EMAIL=" + email, "GIT_COMMITTER_NAME=" + name, "GIT_COMMITTER_EMAIL=" + email} commit, err := g.runEnv(ident, []byte(message), "commit-tree", tree, "-p", targetHash, "-p", headHash) if err != nil { return "", err } hash := strings.TrimSpace(string(commit)) if _, err := g.run(nil, "update-ref", "refs/heads/"+target, hash, targetHash); err != nil { return "", errors.New("target branch moved, try again") } return hash, nil } func (g Git) Empty() bool { _, err := g.run(nil, "rev-parse", "--verify", "--quiet", "HEAD^{commit}") return err != nil } func (g Git) Head() string { out, err := g.run(nil, "symbolic-ref", "--short", "HEAD") if err != nil { return "main" } return strings.TrimSpace(string(out)) } func (g Git) Resolve(ref string) (string, error) { if strings.HasPrefix(ref, "-") { return "", ErrMissing } out, err := g.run(nil, "rev-parse", "--verify", "--quiet", "--end-of-options", ref+"^{commit}") if err != nil { return "", ErrMissing } return strings.TrimSpace(string(out)), nil } const logFormat = "%H%x00%an%x00%ae%x00%at%x00%P%x00%s%x00%b%x00" func parseCommits(out []byte) []Commit { fields := strings.Split(string(out), "\x00") var commits []Commit for len(fields) >= 7 { t, _ := strconv.ParseInt(fields[3], 10, 64) c := Commit{Hash: fields[0], Author: fields[1], Email: fields[2], Time: time.Unix(t, 0), Subject: fields[5], Body: strings.TrimSpace(fields[6])} if fields[4] != "" { c.Parents = strings.Split(fields[4], " ") } commits = append(commits, c) fields = fields[7:] if len(fields) > 0 { fields[0] = strings.TrimLeft(fields[0], "\n") } } return commits } func (g Git) Log(commit string, n int, filePath string) ([]Commit, error) { args := []string{"log", "--format=" + logFormat, "-n", strconv.Itoa(n), commit} if filePath != "" { args = append(args, "--", filePath) } out, err := g.run(nil, args...) if err != nil { return nil, err } return parseCommits(out), nil } func (g Git) Commit(hash string) (Commit, error) { out, err := g.run(nil, "log", "--format="+logFormat, "-n", "1", hash) if err != nil { return Commit{}, ErrMissing } commits := parseCommits(out) if len(commits) == 0 { return Commit{}, ErrMissing } return commits[0], nil } func (g Git) Tree(commit, dir string) ([]Entry, error) { spec := commit + ":" + dir out, err := g.run(nil, "ls-tree", "-l", "-z", spec) if err != nil { return nil, ErrMissing } var entries []Entry for _, rec := range bytes.Split(out, []byte{0}) { if len(rec) == 0 { continue } meta, name, ok := strings.Cut(string(rec), "\t") if !ok { continue } f := strings.Fields(meta) if len(f) != 4 { continue } e := Entry{Mode: f[0], Type: f[1], Hash: f[2], Name: name} if f[3] != "-" { e.Size, _ = strconv.ParseInt(f[3], 10, 64) } entries = append(entries, e) } return entries, nil } func (g Git) Entry(commit, filePath string) *Entry { if filePath == "" { return nil } dir, name := path.Split(filePath) entries, err := g.Tree(commit, strings.TrimSuffix(dir, "/")) if err != nil { return nil } for i := range entries { if entries[i].Name == name { return &entries[i] } } return nil } func (g Git) LastCommits(commit, dir string, names []string) map[string]Commit { want := map[string]bool{} for _, n := range names { want[n] = true } found := map[string]Commit{} args := []string{"log", "--format=%x01%H%x00%at%x00%s", "--name-only", "--no-renames", "-n", "3000", commit} if dir != "" { args = append(args, "--", dir) } out, err := g.run(nil, args...) if err != nil { return found } prefix := "" if dir != "" { prefix = dir + "/" } var cur Commit for _, line := range strings.Split(string(out), "\n") { if strings.HasPrefix(line, "\x01") { f := strings.Split(line[1:], "\x00") if len(f) == 3 { t, _ := strconv.ParseInt(f[1], 10, 64) cur = Commit{Hash: f[0], Time: time.Unix(t, 0), Subject: f[2]} } continue } if line == "" || !strings.HasPrefix(line, prefix) { continue } name, _, _ := strings.Cut(line[len(prefix):], "/") if want[name] { if _, ok := found[name]; !ok { found[name] = cur if len(found) == len(want) { break } } } } return found } func (g Git) Blob(commit, filePath string) ([]byte, error) { out, err := g.run(nil, "cat-file", "blob", commit+":"+filePath) if err != nil { return nil, ErrMissing } return out, nil } func (g Git) Size(commit, filePath string) int64 { out, err := g.run(nil, "cat-file", "-s", commit+":"+filePath) if err != nil { return 0 } n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) return n } func (g Git) Kind(commit, filePath string) string { out, err := g.run(nil, "cat-file", "-t", commit+":"+filePath) if err != nil { return "" } return strings.TrimSpace(string(out)) } func (g Git) Refs(prefix string) ([]Ref, error) { out, err := g.run(nil, "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%00%(objectname)%00%(creatordate:unix)", prefix) if err != nil { return nil, err } var refs []Ref for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { f := strings.Split(line, "\x00") if len(f) != 3 { continue } t, _ := strconv.ParseInt(f[2], 10, 64) refs = append(refs, Ref{Name: f[0], Hash: f[1], Time: time.Unix(t, 0)}) } return refs, nil } func (g Git) Readme(commit string) (string, []byte) { entries, err := g.Tree(commit, "") if err != nil { return "", nil } for _, name := range []string{"README.md", "README", "readme.md", "README.txt", "readme"} { for _, e := range entries { if e.Name == name && e.Type == "blob" { data, err := g.Blob(commit, name) if err == nil { return name, data } } } } return "", nil } func (g Git) Diff(hash string) ([]File, error) { out, err := g.run(nil, "diff-tree", "-p", "--root", "-M", "--no-color", "--find-renames", hash) if err != nil { return nil, err } return parseDiff(string(out)), nil } func parseDiff(text string) []File { var files []File var cur *File var hunk *Hunk oldN, newN := 0, 0 for _, line := range strings.Split(text, "\n") { switch { case strings.HasPrefix(line, "diff --git "): files = append(files, File{Status: "modified"}) cur = &files[len(files)-1] hunk = nil rest := strings.TrimPrefix(line, "diff --git ") if a, b, ok := strings.Cut(rest, " b/"); ok { cur.Old = strings.TrimPrefix(a, "a/") cur.New = b } case cur == nil: continue case strings.HasPrefix(line, "new file mode"): cur.Status = "added" case strings.HasPrefix(line, "deleted file mode"): cur.Status = "deleted" case strings.HasPrefix(line, "rename from "): cur.Status = "renamed" cur.Old = strings.TrimPrefix(line, "rename from ") case strings.HasPrefix(line, "rename to "): cur.New = strings.TrimPrefix(line, "rename to ") case strings.HasPrefix(line, "Binary files"): cur.Binary = true case strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "similarity ") || strings.HasPrefix(line, "old mode") || strings.HasPrefix(line, "new mode"): continue case strings.HasPrefix(line, "@@"): cur.Hunks = append(cur.Hunks, Hunk{Header: line}) hunk = &cur.Hunks[len(cur.Hunks)-1] oldN, newN = hunkStart(line) case hunk == nil: continue case strings.HasPrefix(line, "+"): hunk.Lines = append(hunk.Lines, Line{Kind: "add", New: newN, Text: line[1:]}) newN++ cur.Added++ case strings.HasPrefix(line, "-"): hunk.Lines = append(hunk.Lines, Line{Kind: "del", Old: oldN, Text: line[1:]}) oldN++ cur.Removed++ case strings.HasPrefix(line, " "): hunk.Lines = append(hunk.Lines, Line{Kind: "ctx", Old: oldN, New: newN, Text: line[1:]}) oldN++ newN++ case strings.HasPrefix(line, "\\"): hunk.Lines = append(hunk.Lines, Line{Kind: "meta", Text: line}) } } return files } func hunkStart(header string) (int, int) { f := strings.Fields(header) if len(f) < 3 { return 0, 0 } parse := func(s string) int { s = strings.TrimLeft(s, "-+") if i := strings.Index(s, ","); i >= 0 { s = s[:i] } n, _ := strconv.Atoi(s) return n } return parse(f[1]), parse(f[2]) } func isBinary(data []byte) bool { head := data if len(head) > 8000 { head = head[:8000] } return bytes.IndexByte(head, 0) >= 0 || !utf8.Valid(head) } func cleanPath(p string) (string, bool) { p = strings.Trim(p, "/") if p == "" { return "", true } c := path.Clean(p) if c != p || strings.HasPrefix(c, "..") || strings.Contains(c, "/../") || strings.HasPrefix(c, "-") { return "", false } return c, true }