all repos

mugit @ 3b5e3bfc7f5210c80862692323cc192c2c05da26

🐮 git server that your cow will love

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

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