all repos

mugit @ 7218955

🐮 git server that your cow will love

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

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