all repos

mugit @ 0238022d4a0252937298e8b3bb6fc39c1a026de1

🐮 git server that your cow will love

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

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