package forge import ( "bytes" "context" "errors" "io" "os" "os/exec" "path" "path/filepath" "runtime" "slices" "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 Signature string Key string PGP string } var signersFile string var gnupgHome string var passEnv []string type Entry struct { Mode string Type string Hash string Size int64 Name string } type Ref struct { Name string Hash string Time time.Time Tag bool } 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...) } const maxOutput = 16 << 20 var ( gitSlots = make(chan struct{}, max(4, 2*runtime.NumCPU())) archiveSlots = make(chan struct{}, 2) errTooLarge = errors.New("output too large") ) type cappedBuffer struct { bytes.Buffer over bool } func (b *cappedBuffer) Write(p []byte) (int, error) { if b.Len()+len(p) > maxOutput { b.over = true return 0, errTooLarge } return b.Buffer.Write(p) } func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error) { gitSlots <- struct{}{} defer func() { <-gitSlots }() 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", "PATH=" + os.Getenv("PATH")}, passEnv...) cmd.Env = append(cmd.Env, env...) if signersFile != "" { cmd.Env = append(cmd.Env, "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile", "GIT_CONFIG_VALUE_0="+signersFile) } if gnupgHome != "" { cmd.Env = append(cmd.Env, "GNUPGHOME="+gnupgHome) } if stdin != nil { cmd.Stdin = bytes.NewReader(stdin) } var out cappedBuffer var errb bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errb if err := cmd.Run(); err != nil { if out.over { return nil, errTooLarge } 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) mergeTree(base, ours, theirs string) (string, error) { args := []string{"merge-tree", "--write-tree"} if base != "" { args = append(args, "--merge-base", base) } out, err := g.run(nil, append(args, ours, theirs)...) if err != nil { return "", errors.New("merge conflicts, resolve them locally and update the pull request") } tree, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") return tree, nil } func timeNow() time.Time { return time.Now() } func ident(name, email string, when time.Time) []string { stamp := strconv.FormatInt(when.Unix(), 10) + " +0000" return []string{"GIT_AUTHOR_NAME=" + name, "GIT_AUTHOR_EMAIL=" + email, "GIT_AUTHOR_DATE=" + stamp, "GIT_COMMITTER_NAME=" + name, "GIT_COMMITTER_EMAIL=" + email, "GIT_COMMITTER_DATE=" + stamp} } func (g Git) Squash(target, head, message, authorName, authorEmail, 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") } tree, err := g.mergeTree("", targetHash, headHash) if err != nil { return "", err } now := time.Now() env := ident(authorName, authorEmail, now) env[3], env[4] = "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email commit, err := g.runEnv(env, []byte(message), "commit-tree", tree, "-p", targetHash) 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) Rebase(target, head, 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") } base, err := g.MergeBase(targetHash, headHash) if err != nil { return "", errors.New("no common history") } if base == headHash { return "", errors.New("already merged") } out, err := g.run(nil, "rev-list", "--reverse", "--no-merges", base+".."+headHash) if err != nil { return "", err } tip := targetHash for _, c := range strings.Fields(string(out)) { info, err := g.run(nil, "log", "-n", "1", "--format=%P%x00%an%x00%ae%x00%at%x00%B", c) if err != nil { return "", err } f := strings.SplitN(string(info), "\x00", 5) if len(f) != 5 { return "", errors.New("could not read commit " + c) } parent := strings.Fields(f[0]) if len(parent) == 0 { return "", errors.New("cannot rebase a root commit") } tree, err := g.mergeTree(parent[0], tip, c) if err != nil { return "", err } t, _ := strconv.ParseInt(f[3], 10, 64) env := ident(f[1], f[2], time.Unix(t, 0)) env[3], env[4], env[5] = "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email, "GIT_COMMITTER_DATE="+strconv.FormatInt(time.Now().Unix(), 10)+" +0000" commit, err := g.runEnv(env, []byte(strings.TrimRight(f[4], "\n")+"\n"), "commit-tree", tree, "-p", tip) if err != nil { return "", err } tip = strings.TrimSpace(string(commit)) } if _, err := g.run(nil, "update-ref", "refs/heads/"+target, tip, targetHash); err != nil { return "", errors.New("target branch moved, try again") } return tip, 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 { b, err := os.ReadFile(filepath.Join(g.Dir, "HEAD")) if branch, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "ref: refs/heads/"); err == nil && ok && branch != "" { return branch } return "main" } 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%G?%x00%GK%x00%GP%x00" func parseCommits(out []byte) []Commit { fields := strings.Split(string(out), "\x00") var commits []Commit for len(fields) >= 10 { 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]), Signature: fields[7], Key: fields[8], PGP: strings.ToUpper(fields[9])} if fields[4] != "" { c.Parents = strings.Split(fields[4], " ") } commits = append(commits, c) fields = fields[10:] if len(fields) > 0 { fields[0] = strings.TrimLeft(fields[0], "\n") } } return commits } func (g Git) Log(commit string, n int, filePath string, extra ...string) ([]Commit, error) { args := append([]string{"log", "--format=" + logFormat, "-n", strconv.Itoa(n)}, extra...) args = append(args, 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) } slices.SortStableFunc(entries, func(a, b Entry) int { if (a.Type == "tree") != (b.Type == "tree") { if a.Type == "tree" { return -1 } return 1 } return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) return entries, nil } type Match struct { Path string Line int Text string } func (g Git) Grep(commit, query string, limit int) ([]Match, bool, error) { out, err := g.run(nil, "grep", "-n", "-i", "-I", "-F", "-z", "--no-color", "-e", query, commit) if err != nil { if strings.TrimSpace(err.Error()) == "" || strings.Contains(err.Error(), "exit status 1") { return nil, false, nil } return nil, false, err } var matches []Match prefix := commit + ":" for _, line := range strings.Split(string(out), "\n") { f := strings.Split(line, "\x00") if len(f) != 3 || !strings.HasPrefix(f[0], prefix) { continue } n, _ := strconv.Atoi(f[1]) matches = append(matches, Match{Path: strings.TrimPrefix(f[0], prefix), Line: n, Text: strings.TrimLeft(f[2], " \t")}) if len(matches) > limit { return matches[:limit], true, nil } } return matches, false, 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) Stream(w io.Writer, commit, filePath string) error { archiveSlots <- struct{}{} defer func() { <-archiveSlots }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "git", "-C", g.Dir, "cat-file", "blob", commit+":"+filePath) cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")} cmd.Stdout = w return cmd.Run() } 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(prefixes ...string) ([]Ref, error) { out, err := g.run(nil, append([]string{"for-each-ref", "--sort=-creatordate", "--format=%(refname)%00%(objectname)%00%(creatordate:unix)%00%(*objectname)"}, prefixes...)...) 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) != 4 { continue } t, _ := strconv.ParseInt(f[2], 10, 64) if f[3] != "" { f[1] = f[3] } name, tag := strings.CutPrefix(f[0], "refs/tags/") if !tag { name = strings.TrimPrefix(f[0], "refs/heads/") } refs = append(refs, Ref{Name: name, Hash: f[1], Time: time.Unix(t, 0), Tag: tag}) } 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 validBranch(name string) bool { if name == "" || len(name) > 255 || name == "@" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.HasSuffix(name, ".") || strings.HasSuffix(name, ".lock") || strings.Contains(name, "..") || strings.Contains(name, "//") || strings.Contains(name, "@{") || strings.Contains(name, "/.") || strings.HasPrefix(name, ".") { return false } for _, r := range name { if r < 0x20 || r == 0x7f || strings.ContainsRune(" ~^:?*[\\", r) { return false } } return true } func cleanPath(p string) (string, bool) { for _, r := range p { if r < 0x20 || r == 0x7f { return "", false } } 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 }