all repos

mugit @ 2c8d7d2

🐮 git server that your cow will love

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

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