all repos

mugit @ c543efd

🐮 git server that your cow will love

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
run errcheck, 28 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 = *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
	return nil
96
}
97
98
func (g *Repo) Name() string {
99
	name := filepath.Base(g.path)
100
	return strings.TrimSuffix(name, ".git")
101
}
102
103
func (g *Repo) DefaultBranch() (string, error) {
104
	out, err := g.runGitCmd("rev-parse", "--abbrev-ref", "HEAD")
105
	if err != nil {
106
		return "", fmt.Errorf("failed to get default branch: %w", err)
107
	}
108
	return strings.TrimSpace(string(out)), nil
109
}
110
111
func (g *Repo) SetDefaultBranch(branch string) error {
112
	b := plumbing.NewBranchReferenceName(branch)
113
	_, err := g.r.Reference(b, true)
114
	if err != nil {
115
		return fmt.Errorf("branch %q not found: %w", branch, err)
116
	}
117
	head := plumbing.NewSymbolicReference(plumbing.HEAD, b)
118
	return g.r.Storer.SetReference(head)
119
}
120
121
type Commit struct {
122
	Message        string
123
	AuthorEmail    string
124
	AuthorName     string
125
	CommitterName  string
126
	CommitterEmail string
127
	Authored       time.Time
128
	Committed      time.Time
129
	ChangeID       string
130
	Hash           string
131
	HashShort      string
132
}
133
134
func newShortHash(h plumbing.Hash) string { return h.String()[:7] }
135
func newCommit(c *object.Commit) *Commit {
136
	var changeID string
137
	for _, header := range c.ExtraHeaders {
138
		if header.Key == "change-id" {
139
			changeID = header.Value
140
			break
141
		}
142
	}
143
144
	return &Commit{
145
		Message:        c.Message,
146
		AuthorEmail:    c.Author.Email,
147
		AuthorName:     c.Author.Name,
148
		CommitterName:  c.Committer.Name,
149
		CommitterEmail: c.Committer.Email,
150
		Authored:       c.Author.When,
151
		Committed:      c.Committer.When,
152
		ChangeID:       changeID,
153
		Hash:           c.Hash.String(),
154
		HashShort:      newShortHash(c.Hash),
155
	}
156
}
157
158
const CommitsPage = 150
159
160
// Commits returns [CommitsPage] commits after the given commit hash cursor.
161
// If after is empty, starts from HEAD.
162
func (g *Repo) Commits(after string) ([]*Commit, error) {
163
	if g.IsEmpty() {
164
		return []*Commit{}, nil
165
	}
166
167
	from := g.h
168
	if after != "" {
169
		hash, err := g.r.ResolveRevision(plumbing.Revision(after))
170
		if err != nil {
171
			return nil, fmt.Errorf("invalid cursor: %w", err)
172
		}
173
		from = *hash
174
	}
175
176
	ci, err := g.r.Log(&git.LogOptions{
177
		From:  from,
178
		Order: git.LogOrderCommitterTime,
179
	})
180
	if err != nil {
181
		return nil, fmt.Errorf("commits from ref: %w", err)
182
	}
183
184
	// since after commit was shown on prev page, skip it
185
	if after != "" {
186
		if _, err = ci.Next(); err != nil {
187
			return nil, err
188
		}
189
	}
190
191
	commits := make([]*Commit, 0, CommitsPage)
192
	err = ci.ForEach(func(c *object.Commit) error {
193
		if len(commits) == CommitsPage {
194
			return storer.ErrStop
195
		}
196
		commits = append(commits, newCommit(c))
197
		return nil
198
	})
199
	return commits, err
200
}
201
202
func (g *Repo) LastCommit() (*Commit, error) {
203
	if g.IsEmpty() {
204
		return &Commit{}, nil
205
	}
206
207
	c, err := g.r.CommitObject(g.h)
208
	if err != nil {
209
		return nil, fmt.Errorf("last commit: %w", err)
210
	}
211
212
	return newCommit(c), nil
213
}
214
215
func (g *Repo) LastFileCommit(ctx context.Context, fpath string) (*Commit, error) {
216
	path := path.Clean(fpath)
217
	hash, err := g.lastFileCommitHash(ctx, path)
218
	if err != nil {
219
		return nil, err
220
	}
221
222
	c, err := g.r.CommitObject(plumbing.NewHash(hash))
223
	if err != nil {
224
		return nil, fmt.Errorf("commit object %s: %w", hash, err)
225
	}
226
227
	return newCommit(c), nil
228
}
229
230
type Branch struct {
231
	Name       string
232
	LastUpdate time.Time
233
}
234
235
func (g *Repo) Branches() ([]*Branch, error) {
236
	bi, err := g.r.Branches()
237
	if err != nil {
238
		return nil, fmt.Errorf("branch: %w", err)
239
	}
240
241
	var branches []*Branch
242
	err = bi.ForEach(func(r *plumbing.Reference) error {
243
		cmt, cerr := g.r.CommitObject(r.Hash())
244
		if cerr != nil {
245
			return cerr
246
		}
247
248
		branches = append(branches, &Branch{
249
			Name:       r.Name().Short(),
250
			LastUpdate: cmt.Committer.When,
251
		})
252
		return nil
253
	})
254
	return branches, err
255
}
256
257
func (g *Repo) IsGoMod() bool {
258
	_, err := g.FileContent("go.mod")
259
	return err == nil
260
}
261
262
func (g *Repo) Fetch(ctx context.Context) (isUpdated bool, err error) {
263
	return g.fetch(ctx, nil)
264
}
265
266
func (g *Repo) FetchFromGithubWithToken(ctx context.Context, token string) (isUpdated bool, err error) {
267
	return g.fetch(ctx, &http.BasicAuth{
268
		Username: "x-access-token", // this can be anything but empty
269
		Password: token,
270
	})
271
}
272
273
func (g *Repo) fetch(ctx context.Context, auth transport.AuthMethod) (bool, error) {
274
	rmt, err := g.r.Remote(originRemote)
275
	if err != nil {
276
		return false, fmt.Errorf("failed to get remote: %w", err)
277
	}
278
279
	err = rmt.FetchContext(ctx, &git.FetchOptions{
280
		Auth:  auth,
281
		Tags:  git.AllTags,
282
		Prune: true,
283
		Force: true,
284
	})
285
286
	isUpdated := !errors.Is(err, git.NoErrAlreadyUpToDate)
287
	if err != nil && isUpdated {
288
		return false, fmt.Errorf("failed to fetch: %w", err)
289
	}
290
291
	if !g.IsEmpty() {
292
		return isUpdated, nil
293
	}
294
295
	refs, err := rmt.List(&git.ListOptions{Auth: auth})
296
	if err != nil {
297
		return false, fmt.Errorf("failed to list references: %w", err)
298
	}
299
300
	for _, ref := range refs {
301
		if ref.Name() == plumbing.HEAD {
302
			if err := g.r.Storer.SetReference(
303
				plumbing.NewSymbolicReference(plumbing.HEAD, ref.Target()),
304
			); err != nil {
305
				return false, fmt.Errorf("failed to set HEAD: %w", err)
306
			}
307
			break
308
		}
309
	}
310
311
	return isUpdated, nil
312
}