all repos

mugit @ 4af560ca6ad26b59b39cd04800777c7b2895372f

🐮 git server that your cow will love

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

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