all repos

mugit @ 3e7e955721c228676202c787a670427eb9113cdb

🐮 git server that your cow will love

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
ui: show last commit for file that's being viewed, 1 month 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
		ci.Next()
187
	}
188
189
	commits := make([]*Commit, 0, CommitsPage)
190
	ci.ForEach(func(c *object.Commit) error {
191
		if len(commits) == CommitsPage {
192
			return storer.ErrStop
193
		}
194
		commits = append(commits, newCommit(c))
195
		return nil
196
	})
197
198
	return commits, nil
199
}
200
201
func (g *Repo) LastCommit() (*Commit, error) {
202
	if g.IsEmpty() {
203
		return &Commit{}, nil
204
	}
205
206
	c, err := g.r.CommitObject(g.h)
207
	if err != nil {
208
		return nil, fmt.Errorf("last commit: %w", err)
209
	}
210
211
	return newCommit(c), nil
212
}
213
214
func (g *Repo) LastFileCommit(ctx context.Context, fpath string) (*Commit, error) {
215
	path := path.Clean(fpath)
216
	hash, err := g.lastFileCommitHash(ctx, path)
217
	if err != nil {
218
		return nil, err
219
	}
220
221
	c, err := g.r.CommitObject(plumbing.NewHash(hash))
222
	if err != nil {
223
		return nil, fmt.Errorf("commit object %s: %w", hash, err)
224
	}
225
226
	return newCommit(c), nil
227
}
228
229
type Branch struct {
230
	Name       string
231
	LastUpdate time.Time
232
}
233
234
func (g *Repo) Branches() ([]*Branch, error) {
235
	bi, err := g.r.Branches()
236
	if err != nil {
237
		return nil, fmt.Errorf("branch: %w", err)
238
	}
239
240
	var branches []*Branch
241
	err = bi.ForEach(func(r *plumbing.Reference) error {
242
		cmt, cerr := g.r.CommitObject(r.Hash())
243
		if cerr != nil {
244
			return cerr
245
		}
246
247
		branches = append(branches, &Branch{
248
			Name:       r.Name().Short(),
249
			LastUpdate: cmt.Committer.When,
250
		})
251
		return nil
252
	})
253
	return branches, err
254
}
255
256
func (g *Repo) IsGoMod() bool {
257
	_, err := g.FileContent("go.mod")
258
	return err == nil
259
}
260
261
func (g *Repo) Fetch(ctx context.Context) (isUpdated bool, err error) {
262
	return g.fetch(ctx, nil)
263
}
264
265
func (g *Repo) FetchFromGithubWithToken(ctx context.Context, token string) (isUpdated bool, err error) {
266
	return g.fetch(ctx, &http.BasicAuth{
267
		Username: "x-access-token", // this can be anything but empty
268
		Password: token,
269
	})
270
}
271
272
func (g *Repo) fetch(ctx context.Context, auth transport.AuthMethod) (bool, error) {
273
	rmt, err := g.r.Remote(originRemote)
274
	if err != nil {
275
		return false, fmt.Errorf("failed to get remote: %w", err)
276
	}
277
278
	err = rmt.FetchContext(ctx, &git.FetchOptions{
279
		Auth:  auth,
280
		Tags:  git.AllTags,
281
		Prune: true,
282
		Force: true,
283
	})
284
285
	isUpdated := !errors.Is(err, git.NoErrAlreadyUpToDate)
286
	if err != nil && isUpdated {
287
		return false, fmt.Errorf("failed to fetch: %w", err)
288
	}
289
290
	if !g.IsEmpty() {
291
		return isUpdated, nil
292
	}
293
294
	refs, err := rmt.List(&git.ListOptions{Auth: auth})
295
	if err != nil {
296
		return false, fmt.Errorf("failed to list references: %w", err)
297
	}
298
299
	for _, ref := range refs {
300
		if ref.Name() == plumbing.HEAD {
301
			if err := g.r.Storer.SetReference(
302
				plumbing.NewSymbolicReference(plumbing.HEAD, ref.Target()),
303
			); err != nil {
304
				return false, fmt.Errorf("failed to set HEAD: %w", err)
305
			}
306
			break
307
		}
308
	}
309
310
	return isUpdated, nil
311
}