package gitutil import ( "bufio" "bytes" "context" "encoding/binary" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" ) type Git struct { Dir string } func (g Git) command(ctx context.Context, args ...string) *exec.Cmd { if g.Dir != "" { args = append([]string{"-C", g.Dir}, args...) } // #nosec G204 -- arguments are passed directly to Git without a shell. return exec.CommandContext(ctx, "git", args...) } func (g Git) Run(ctx context.Context, args ...string) ([]byte, error) { cmd := g.command(ctx, args...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { message := strings.TrimSpace(stderr.String()) if message == "" { message = err.Error() } return nil, fmt.Errorf("git %s: %s", strings.Join(args, " "), message) } return out, nil } // RunLimited captures at most limit bytes while continuing to drain Git's // output. This keeps generated-site features from allocating without bound on // unusually large objects while still allowing Git to exit cleanly. func (g Git) RunLimited(ctx context.Context, limit int, args ...string) ([]byte, bool, error) { cmd := g.command(ctx, args...) output := limitedOutput{limit: limit} var stderr bytes.Buffer cmd.Stdout = &output cmd.Stderr = &stderr if err := cmd.Run(); err != nil { message := strings.TrimSpace(stderr.String()) if message == "" { message = err.Error() } return nil, false, fmt.Errorf("git %s: %s", strings.Join(args, " "), message) } return output.Bytes(), output.truncated, nil } type limitedOutput struct { bytes.Buffer limit int truncated bool } func (w *limitedOutput) Write(value []byte) (int, error) { written := len(value) remaining := w.limit - w.Len() if remaining <= 0 { w.truncated = w.truncated || written > 0 return written, nil } if len(value) > remaining { value = value[:remaining] w.truncated = true } _, _ = w.Buffer.Write(value) return written, nil } func (g Git) RevParse(ctx context.Context, rev string) (string, error) { out, err := g.Run(ctx, "rev-parse", "--verify", rev) if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } func (g Git) SymbolicRef(ctx context.Context, name string) (string, error) { out, err := g.Run(ctx, "symbolic-ref", "--quiet", name) if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } func (g Git) Peel(ctx context.Context, oid string) (string, error) { out, err := g.Run(ctx, "rev-parse", "--verify", oid+"^{}") if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } func (g Git) ObjectExists(ctx context.Context, oid string) bool { cmd := g.command(ctx, "cat-file", "-e", oid+"^{object}") return cmd.Run() == nil } func (g Git) IsShallow(ctx context.Context) (bool, error) { out, err := g.Run(ctx, "rev-parse", "--is-shallow-repository") if err != nil { return false, err } return strings.TrimSpace(string(out)) == "true", nil } func (g Git) IsAncestor(ctx context.Context, older, newer string) (bool, error) { cmd := g.command(ctx, "merge-base", "--is-ancestor", older, newer) err := cmd.Run() if err == nil { return true, nil } var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { return false, nil } return false, fmt.Errorf("check whether %s is an ancestor of %s: %w", older, newer, err) } func (g Git) CheckRef(ctx context.Context, ref string) error { cmd := g.command(ctx, "check-ref-format", ref) if err := cmd.Run(); err != nil { return fmt.Errorf("invalid destination ref %q", ref) } return nil } type Pack struct { Hash string PackPath string IndexPath string Objects uint32 cleanup func() } type LooseObject struct { OID string Path string Size int64 } func (p *Pack) Close() { if p != nil && p.cleanup != nil { p.cleanup() } } func (g Git) PackObjects(ctx context.Context, wants, possibleHaves []string) (*Pack, error) { wants = unique(wants) if len(wants) == 0 { return nil, nil } haves := make([]string, 0, len(possibleHaves)) for _, oid := range unique(possibleHaves) { if g.ObjectExists(ctx, oid) { haves = append(haves, oid) } } tmpDir, err := os.MkdirTemp("", "dumbforge-pack-*") if err != nil { return nil, err } cleanup := func() { _ = os.RemoveAll(tmpDir) } base := filepath.Join(tmpDir, "pack") cmd := g.command(ctx, "pack-objects", "--revs", "--delta-base-offset", base) var input strings.Builder for _, oid := range wants { fmt.Fprintln(&input, oid) } for _, oid := range haves { fmt.Fprintf(&input, "^%s\n", oid) } cmd.Stdin = strings.NewReader(input.String()) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { cleanup() return nil, fmt.Errorf("create incremental pack: %s", strings.TrimSpace(stderr.String())) } hash := strings.TrimSpace(string(out)) packPath := base + "-" + hash + ".pack" indexPath := base + "-" + hash + ".idx" count, err := packObjectCount(packPath) if err != nil { cleanup() return nil, err } return &Pack{ Hash: hash, PackPath: packPath, IndexPath: indexPath, Objects: count, cleanup: cleanup, }, nil } // SmallLooseObjects returns the objects represented by a small pack when all // of them still exist in the repository's loose-object store. Recent commits // normally satisfy this, allowing an object-store remote to avoid publishing a // pack index and pack-list update for tiny pushes. func (g Git) SmallLooseObjects(ctx context.Context, pack *Pack, maxObjects int, maxBytes int64) ([]LooseObject, bool, error) { if pack == nil || pack.Objects == 0 || int(pack.Objects) > maxObjects { return nil, false, nil } out, err := g.Run(ctx, "verify-pack", "-v", pack.IndexPath) if err != nil { return nil, false, err } var oids []string for _, line := range strings.Split(string(out), "\n") { fields := strings.Fields(line) if len(fields) < 2 || !isObjectID(fields[0]) { continue } oids = append(oids, fields[0]) } if len(oids) != int(pack.Objects) { return nil, false, nil } objectsOutput, err := g.Run(ctx, "rev-parse", "--path-format=absolute", "--git-path", "objects") if err != nil { return nil, false, err } objectsDir := strings.TrimSpace(string(objectsOutput)) result := make([]LooseObject, 0, len(oids)) var total int64 for _, oid := range oids { filename := filepath.Join(objectsDir, oid[:2], oid[2:]) info, err := os.Stat(filename) if err != nil { if os.IsNotExist(err) { return nil, false, nil } return nil, false, err } if !info.Mode().IsRegular() { return nil, false, nil } total += info.Size() if total > maxBytes { return nil, false, nil } result = append(result, LooseObject{OID: oid, Path: filename, Size: info.Size()}) } return result, true, nil } func isObjectID(value string) bool { if len(value) != 40 && len(value) != 64 { return false } for _, char := range []byte(value) { if (char < '0' || char > '9') && (char < 'a' || char > 'f') { return false } } return true } func packObjectCount(filename string) (uint32, error) { // #nosec G304 -- filename is an internal path in our private temporary directory. f, err := os.Open(filename) if err != nil { return 0, err } defer f.Close() header := make([]byte, 12) if _, err := io.ReadFull(f, header); err != nil { return 0, fmt.Errorf("read pack header: %w", err) } if string(header[:4]) != "PACK" { return 0, fmt.Errorf("invalid pack header") } return binary.BigEndian.Uint32(header[8:12]), nil } func unique(values []string) []string { seen := make(map[string]struct{}, len(values)) result := make([]string, 0, len(values)) for _, value := range values { if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} result = append(result, value) } sort.Strings(result) return result } type NameStatus struct { Status string Path string OldPath string } func (g Git) DiffNameStatus(ctx context.Context, oldOID, newOID string) ([]NameStatus, error) { out, err := g.Run(ctx, "diff", "--name-status", "-z", "--find-renames", oldOID, newOID) if err != nil { return nil, err } scanner := bufio.NewScanner(bytes.NewReader(out)) scanner.Split(splitNUL) var result []NameStatus for scanner.Scan() { status := scanner.Text() if !scanner.Scan() { return nil, fmt.Errorf("malformed git diff output") } first := scanner.Text() entry := NameStatus{Status: status, Path: first} if strings.HasPrefix(status, "R") || strings.HasPrefix(status, "C") { if !scanner.Scan() { return nil, fmt.Errorf("malformed git rename output") } entry.OldPath = first entry.Path = scanner.Text() } result = append(result, entry) } return result, scanner.Err() } func splitNUL(data []byte, atEOF bool) (advance int, token []byte, err error) { if i := bytes.IndexByte(data, 0); i >= 0 { return i + 1, data[:i], nil } if atEOF && len(data) > 0 { return len(data), data, nil } return 0, nil, nil }