all repos

mugit @ 99af93d

🐮 git server that your cow will love

mugit/internal/git/repo.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
git: fix lastCommitForFile, 3 months ago
1
package git
2
3
import (
4
	"context"
5
	"errors"
6
	"fmt"
7
	"path/filepath"
8
	"strings"
9
	"time"
10
11
	"github.com/go-git/go-git/v5"
12
	"github.com/go-git/go-git/v5/plumbing"
13
	"github.com/go-git/go-git/v5/plumbing/object"
14
	"github.com/go-git/go-git/v5/plumbing/storer"
15
	"github.com/go-git/go-git/v5/plumbing/transport"
16
	"github.com/go-git/go-git/v5/plumbing/transport/http"
17
)
18
19
// Thanks https://git.icyphox.sh/legit/blob/master/git/git.go
20
21
var (
22
	ErrEmptyRepo    = errors.New("repository has no commits")
23
	ErrFileNotFound = errors.New("file not found")
24
	ErrPrivate      = errors.New("repository is private")
25
)
26
27
type Repo struct {
28
	path string
29
	r    *git.Repository
30
	h    plumbing.Hash
31
}
32
33
// Open opens a git repository at path. If ref is empty, HEAD is used.
34
func Open(path, ref string) (*Repo, error) {
35
	var err error
36
	g := Repo{}
37
	g.path = path
38
	g.r, err = git.PlainOpen(path)
39
	if err != nil {
40
		return nil, fmt.Errorf("opening %s: %w", path, err)
41
	}
42
43
	if ref == "" {
44
		head, err := g.r.Head()
45
		if err != nil {
46
			if errors.Is(err, plumbing.ErrReferenceNotFound) {
47
				return &g, nil
48
			}
49
			return nil, fmt.Errorf("getting head of %s: %w", path, err)
50
		}
51
		g.h = head.Hash()
52
	} else {
53
		hash, err := g.r.ResolveRevision(plumbing.Revision(ref))
54
		if err != nil {
55
			return nil, fmt.Errorf("resolving rev %s for %s: %w", ref, path, err)
56
		}
57
		g.h = *hash
58
	}
59
	return &g, nil
60
}
61
62
// OpenPublic opens a repository, returns [ErrPrivate] if it's private.
63
func OpenPublic(path, ref string) (*Repo, error) {
64
	r, err := Open(path, ref)
65
	if err != nil {
66
		return nil, err
67
	}
68
69
	isPrivate, err := r.IsPrivate()
70
	if err != nil {
71
		return nil, err
72
	}
73
74
	if isPrivate {
75
		return nil, ErrPrivate
76
	}
77
78
	return r, nil
79
}
80
81
func (g *Repo) IsEmpty() bool {
82
	return g.h == plumbing.ZeroHash
83
}
84
85
// Init initializes a bare repo in path.
86
func Init(path string) error {
87
	if _, err := git.PlainInit(path, true); err != nil {
88
		return fmt.Errorf("failed to initialize repo: %w", err)
89
	}
90
	return nil
91
}
92
93
func (g *Repo) Name() string {
94
	name := filepath.Base(g.path)
95
	return strings.TrimSuffix(name, ".git")
96
}
97
98
func (g *Repo) Checkout(ref string) error {
99
	head := plumbing.NewSymbolicReference(plumbing.HEAD,
100
		plumbing.NewBranchReferenceName(ref))
101
	return g.r.Storer.SetReference(head)
102
}
103
104
type Commit struct {
105
	Message        string
106
	AuthorEmail    string
107
	AuthorName     string
108
	CommitterName  string
109
	CommitterEmail string
110
	Committed      time.Time
111
	ChangeID       string
112
	Hash           string
113
	HashShort      string
114
}
115
116
func newShortHash(h plumbing.Hash) string { return h.String()[:7] }
117
func newCommit(c *object.Commit) *Commit {
118
	var changeID string
119
	for _, header := range c.ExtraHeaders {
120
		if header.Key == "change-id" {
121
			changeID = header.Value
122
			break
123
		}
124
	}
125
126
	return &Commit{
127
		Message:        c.Message,
128
		AuthorEmail:    c.Author.Email,
129
		AuthorName:     c.Author.Name,
130
		CommitterName:  c.Committer.Name,
131
		CommitterEmail: c.Committer.Email,
132
		Committed:      c.Committer.When,
133
		ChangeID:       changeID,
134
		Hash:           c.Hash.String(),
135
		HashShort:      newShortHash(c.Hash),
136
	}
137
}
138
139
const CommitsPage = 150
140
141
// Commits returns [CommitsPage] commits after the given commit hash cursor.
142
// If after is empty, starts from HEAD.
143
func (g *Repo) Commits(after string) ([]*Commit, error) {
144
	if g.IsEmpty() {
145
		return []*Commit{}, nil
146
	}
147
148
	from := g.h
149
	if after != "" {
150
		hash, err := g.r.ResolveRevision(plumbing.Revision(after))
151
		if err != nil {
152
			return nil, fmt.Errorf("invalid cursor: %w", err)
153
		}
154
		from = *hash
155
	}
156
157
	ci, err := g.r.Log(&git.LogOptions{
158
		From:  from,
159
		Order: git.LogOrderCommitterTime,
160
	})
161
	if err != nil {
162
		return nil, fmt.Errorf("commits from ref: %w", err)
163
	}
164
165
	// since after commit was shown on prev page, skip it
166
	if after != "" {
167
		ci.Next()
168
	}
169
170
	commits := make([]*Commit, 0, CommitsPage)
171
	ci.ForEach(func(c *object.Commit) error {
172
		if len(commits) == CommitsPage {
173
			return storer.ErrStop
174
		}
175
		commits = append(commits, newCommit(c))
176
		return nil
177
	})
178
179
	return commits, nil
180
}
181
182
func (g *Repo) LastCommit() (*Commit, error) {
183
	if g.IsEmpty() {
184
		return &Commit{}, nil
185
	}
186
187
	c, err := g.r.CommitObject(g.h)
188
	if err != nil {
189
		return nil, fmt.Errorf("last commit: %w", err)
190
	}
191
192
	return newCommit(c), nil
193
}
194
195
// lastCommitForFilesInTree ...
196
// TODO: at the moment it doesn't work well with merges, ideally i would "shell" out it,
197
// `git log --pretty:format:%H,%ad,%s --date=iso --name-only -- g.path`
198
func (g *Repo) lastCommitForFilesInTree(tree *object.Tree, dirPath string) (map[string]*Commit, error) {
199
	log, err := g.r.Log(&git.LogOptions{From: g.h})
200
	if err != nil {
201
		return nil, err
202
	}
203
204
	result := make(map[string]*Commit)
205
	err = log.ForEach(func(c *object.Commit) error {
206
		if c.NumParents() == 0 {
207
			for _, entry := range tree.Entries {
208
				if _, seen := result[entry.Name]; !seen {
209
					result[entry.Name] = newCommit(c)
210
				}
211
			}
212
			return storer.ErrStop
213
		}
214
215
		// skip merge commits
216
		if c.NumParents() > 1 {
217
			return nil
218
		}
219
220
		parent, perr := c.Parent(0)
221
		if perr != nil {
222
			return perr
223
		}
224
225
		patch, perr := parent.Patch(c)
226
		if perr != nil {
227
			return perr
228
		}
229
230
		for _, fp := range patch.FilePatches() {
231
			from, to := fp.Files()
232
233
			var affectedPath string
234
			if to != nil {
235
				affectedPath = to.Path()
236
			} else if from != nil {
237
				affectedPath = from.Path()
238
			}
239
240
			name := topLevelEntry(affectedPath, dirPath)
241
			if name == "" {
242
				continue
243
			}
244
245
			if _, seen := result[name]; !seen {
246
				result[name] = newCommit(c)
247
			}
248
		}
249
		return nil
250
	})
251
	if err != nil && !errors.Is(err, storer.ErrStop) {
252
		return nil, err
253
	}
254
	return result, nil
255
}
256
257
type Branch struct {
258
	Name       string
259
	LastUpdate time.Time
260
}
261
262
func (g *Repo) Branches() ([]*Branch, error) {
263
	bi, err := g.r.Branches()
264
	if err != nil {
265
		return nil, fmt.Errorf("branch: %w", err)
266
	}
267
268
	var branches []*Branch
269
	err = bi.ForEach(func(r *plumbing.Reference) error {
270
		cmt, cerr := g.r.CommitObject(r.Hash())
271
		if cerr != nil {
272
			return cerr
273
		}
274
275
		branches = append(branches, &Branch{
276
			Name:       r.Name().Short(),
277
			LastUpdate: cmt.Committer.When,
278
		})
279
		return nil
280
	})
281
	return branches, err
282
}
283
284
func (g *Repo) IsGoMod() bool {
285
	_, err := g.FileContent("go.mod")
286
	return err == nil
287
}
288
289
func (g *Repo) FindMasterBranch(masters []string) (string, error) {
290
	if g.IsEmpty() {
291
		return "", ErrEmptyRepo
292
	}
293
294
	for _, b := range masters {
295
		if _, err := g.r.ResolveRevision(plumbing.Revision(b)); err == nil {
296
			return b, nil
297
		}
298
	}
299
	return "", fmt.Errorf("unable to find master branch")
300
}
301
302
func (g *Repo) Fetch(ctx context.Context) error {
303
	return g.fetch(ctx, nil)
304
}
305
306
func (g *Repo) FetchFromGithubWithToken(ctx context.Context, token string) error {
307
	return g.fetch(ctx, &http.BasicAuth{
308
		Username: "x-access-token", // this can be anything but empty
309
		Password: token,
310
	})
311
}
312
313
func (g *Repo) fetch(ctx context.Context, auth transport.AuthMethod) error {
314
	rmt, err := g.r.Remote(originRemote)
315
	if err != nil {
316
		return fmt.Errorf("failed to get remote: %w", err)
317
	}
318
319
	if err = rmt.FetchContext(ctx, &git.FetchOptions{
320
		Auth:  auth,
321
		Tags:  git.AllTags,
322
		Prune: true,
323
		Force: true,
324
	}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
325
		return fmt.Errorf("failed to fetch: %w", err)
326
	}
327
328
	// for some reason fetch doesn't change head for empty repos
329
	if !g.IsEmpty() {
330
		return nil
331
	}
332
333
	refs, err := rmt.List(&git.ListOptions{Auth: auth})
334
	if err != nil {
335
		return fmt.Errorf("failed to list references: %w", err)
336
	}
337
338
	for _, ref := range refs {
339
		if ref.Name() == plumbing.HEAD {
340
			if err := g.r.Storer.SetReference(
341
				plumbing.NewSymbolicReference(plumbing.HEAD, ref.Target()),
342
			); err != nil {
343
				return fmt.Errorf("failed to set HEAD: %w", err)
344
			}
345
			break
346
		}
347
	}
348
349
	return nil
350
}