~bigbes/sr-ht-compare

ref: a4853d05281d40832e011b6f03a558c7555b997d sr-ht-compare/gitx/gitx.go -rw-r--r-- 5.3 KiB
a4853d05 — Eugene Blikh rename module to sourcecraft.dev/bigbes/sr-ht-compare; depend on sourcecraft sr-ht-core a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
// Package gitx is the git access layer of compare.sr.ht. It opens bare
// repositories on disk with go-git and answers ref, log, and diff queries
// entirely in-process (no git binary is executed at runtime). Every operation
// is bounded by a context timeout, generated patch text is capped in size (so
// a pathological diff cannot be streamed unbounded to a browser), and every
// user-controlled revision is validated with core.ValidRef before it reaches
// go-git's revision parser.
package gitx

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing/object"

	"sourcecraft.dev/bigbes/sr-ht-compare/core"
)

const (
	// defaultTimeout bounds a single gitx operation.
	defaultTimeout = 10 * time.Second
	// pageDiffLimit caps an in-page rendered diff; overflow sets Truncated.
	pageDiffLimit = 5 << 20 // 5 MiB
	// rawDiffLimit caps a downloadable .patch.
	rawDiffLimit = 50 << 20 // 50 MiB
	// shortSHALen is how many hex chars an abbreviated SHA carries.
	shortSHALen = 8
)

// diffOpts are the go-git tree-diff options used everywhere: rename detection
// on, matching git's default behaviour.
var diffOpts = object.DefaultDiffTreeOptions

// Repo is a handle to a bare git repository on disk. It is safe for concurrent
// use: go-git's object reads are read-only and the struct holds no mutable
// per-request state.
type Repo struct {
	dir  string
	repo *git.Repository

	// limitOverride, when > 0, replaces the diff-family byte cap. It exists so
	// tests can exercise truncation with a tiny cap instead of huge fixtures.
	limitOverride int64

	// timeout overrides defaultTimeout when > 0 (tests may shorten it).
	timeout time.Duration
}

// Open validates owner/name, resolves the bare-repo path under reposRoot,
// verifies it looks like a bare repository (its HEAD file exists), and opens it
// with go-git. Invalid names and missing/broken repositories all yield
// core.ErrNotFound, so repository existence is never leaked and no crafted name
// can escape reposRoot: the validators reject '/', '..' and a leading '-'
// before any path is built.
func Open(reposRoot, owner, name string) (*Repo, error) {
	if !core.ValidOwner(owner) {
		return nil, fmt.Errorf("%w: invalid owner", core.ErrNotFound)
	}
	if !core.ValidRepoName(name) {
		return nil, fmt.Errorf("%w: invalid repo name", core.ErrNotFound)
	}

	dir := filepath.Join(reposRoot, "~"+owner, name)

	// Cheap bare-repo sanity check before handing the path to go-git.
	if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() {
		return nil, fmt.Errorf("%w: ~%s/%s", core.ErrNotFound, owner, name)
	}

	repo, err := git.PlainOpen(dir)
	if err != nil {
		return nil, fmt.Errorf("%w: ~%s/%s: %v", core.ErrNotFound, owner, name, err)
	}
	return &Repo{dir: dir, repo: repo}, nil
}

// Dir returns the on-disk path of the bare repository.
func (r *Repo) Dir() string { return r.dir }

// withTimeout derives a per-operation timeout context.
func (r *Repo) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
	d := r.timeout
	if d <= 0 {
		d = defaultTimeout
	}
	return context.WithTimeout(ctx, d)
}

// diffLimit returns the effective byte cap for a diff-family command, honoring
// a test override.
func (r *Repo) diffLimit(def int64) int64 {
	if r.limitOverride > 0 {
		return r.limitOverride
	}
	return def
}

// badRef wraps a go-git revision-resolution failure as core.ErrBadRef.
func badRef(rev string, err error) error {
	return fmt.Errorf("%w: %q: %v", core.ErrBadRef, rev, err)
}

// commitInfo projects a go-git commit into the package's CommitInfo.
func commitInfo(c *object.Commit) *CommitInfo {
	subject, body := splitMessage(c.Message)
	sha := c.Hash.String()
	short := sha
	if len(short) > shortSHALen {
		short = short[:shortSHALen]
	}
	ci := &CommitInfo{
		SHA:         sha,
		ShortSHA:    short,
		AuthorName:  c.Author.Name,
		AuthorEmail: c.Author.Email,
		Date:        c.Author.When,
		Subject:     subject,
		Body:        body,
	}
	for _, p := range c.ParentHashes {
		ci.ParentSHAs = append(ci.ParentSHAs, p.String())
	}
	return ci
}

// splitMessage splits a commit message into its subject (first line) and body
// (the remainder, with the separating blank line removed).
func splitMessage(msg string) (subject, body string) {
	msg = strings.TrimRight(msg, "\n")
	if i := strings.IndexByte(msg, '\n'); i >= 0 {
		return msg[:i], strings.TrimLeft(msg[i+1:], "\n")
	}
	return msg, ""
}

// cutPatch enforces the size cap on generated patch text. go-git materializes
// the whole patch in memory, so the cap is applied after generation. To keep
// the browser-side parser from ever seeing a torn hunk, the text is cut at a
// file boundary: the last "\ndiff --git " that starts at or before the limit.
// If a single leading file already exceeds the limit there is no earlier
// boundary and the text is cut hard at the limit.
func cutPatch(text string, limit int64) (string, bool) {
	if limit <= 0 || int64(len(text)) <= limit {
		return text, false
	}
	head := text[:limit]
	// A unified-diff file header is the only place "diff --git" starts a line;
	// content lines always carry a +/-/space prefix after the newline. Cutting
	// before the last such header keeps only whole files.
	if idx := strings.LastIndex(head, "\ndiff --git"); idx >= 0 {
		return text[:idx+1], true
	}
	return head, true
}