1package git
2
3import (
4 "archive/tar"
5 "bytes"
6 "errors"
7 "fmt"
8 "io"
9 "io/fs"
10 "path"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/go-git/go-git/v5"
16 "github.com/go-git/go-git/v5/config"
17 "github.com/go-git/go-git/v5/plumbing"
18 "github.com/go-git/go-git/v5/plumbing/object"
19)
20
21var (
22 ErrBinaryFile = errors.New("binary file")
23 ErrNotBinaryFile = errors.New("not binary file")
24 ErrMissingGitModules = errors.New("no .gitmodules file found")
25 ErrInvalidGitModules = errors.New("invalid .gitmodules file")
26 ErrNotSubmodule = errors.New("path is not a submodule")
27)
28
29type GitRepo struct {
30 path string
31 r *git.Repository
32 h plumbing.Hash
33}
34
35// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
36// to tar WriteHeader
37type infoWrapper struct {
38 name string
39 size int64
40 mode fs.FileMode
41 modTime time.Time
42 isDir bool
43}
44
45func Open(path string, ref string) (*GitRepo, error) {
46 var err error
47 g := GitRepo{path: path}
48 g.r, err = git.PlainOpen(path)
49 if err != nil {
50 return nil, fmt.Errorf("opening %s: %w", path, err)
51 }
52
53 if ref == "" {
54 head, err := g.r.Head()
55 if err != nil {
56 return nil, fmt.Errorf("getting head of %s: %w", path, err)
57 }
58 g.h = head.Hash()
59 } else {
60 hash, err := g.r.ResolveRevision(plumbing.Revision(ref))
61 if err != nil {
62 return nil, fmt.Errorf("resolving rev %s for %s: %w", ref, path, err)
63 }
64 g.h = *hash
65 }
66 return &g, nil
67}
68
69func PlainOpen(path string) (*GitRepo, error) {
70 var err error
71 g := GitRepo{path: path}
72 g.r, err = git.PlainOpen(path)
73 if err != nil {
74 return nil, fmt.Errorf("opening %s: %w", path, err)
75 }
76 return &g, nil
77}
78
79func (g *GitRepo) Hash() plumbing.Hash {
80 return g.h
81}
82
83// re-open a repository and update references
84func (g *GitRepo) Refresh() error {
85 refreshed, err := PlainOpen(g.path)
86 if err != nil {
87 return err
88 }
89
90 *g = *refreshed
91 return nil
92}
93
94func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) {
95 commits := []*object.Commit{}
96
97 output, err := g.revList(
98 g.h.String(),
99 fmt.Sprintf("--skip=%d", offset),
100 fmt.Sprintf("--max-count=%d", limit),
101 )
102 if err != nil {
103 return nil, fmt.Errorf("commits from ref: %w", err)
104 }
105
106 lines := strings.Split(strings.TrimSpace(string(output)), "\n")
107 if len(lines) == 1 && lines[0] == "" {
108 return commits, nil
109 }
110
111 for _, item := range lines {
112 obj, err := g.r.CommitObject(plumbing.NewHash(item))
113 if err != nil {
114 continue
115 }
116 commits = append(commits, obj)
117 }
118
119 return commits, nil
120}
121
122func (g *GitRepo) TotalCommits() (int, error) {
123 output, err := g.revList(
124 g.h.String(),
125 fmt.Sprintf("--count"),
126 )
127 if err != nil {
128 return 0, fmt.Errorf("failed to run rev-list: %w", err)
129 }
130
131 count, err := strconv.Atoi(strings.TrimSpace(string(output)))
132 if err != nil {
133 return 0, err
134 }
135
136 return count, nil
137}
138
139func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) {
140 return g.r.CommitObject(h)
141}
142
143func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
144 c, err := g.r.CommitObject(g.h)
145 if err != nil {
146 return nil, fmt.Errorf("commit object: %w", err)
147 }
148
149 tree, err := c.Tree()
150 if err != nil {
151 return nil, fmt.Errorf("file tree: %w", err)
152 }
153
154 file, err := tree.File(path)
155 if err != nil {
156 return nil, err
157 }
158
159 isbin, _ := file.IsBinary()
160 if isbin {
161 return nil, ErrBinaryFile
162 }
163
164 reader, err := file.Reader()
165 if err != nil {
166 return nil, err
167 }
168
169 buf := new(bytes.Buffer)
170 if _, err = buf.ReadFrom(io.LimitReader(reader, cap)); err != nil {
171 return nil, err
172 }
173
174 return buf.Bytes(), nil
175}
176
177func (g *GitRepo) RawContent(path string) ([]byte, error) {
178 c, err := g.r.CommitObject(g.h)
179 if err != nil {
180 return nil, fmt.Errorf("commit object: %w", err)
181 }
182
183 tree, err := c.Tree()
184 if err != nil {
185 return nil, fmt.Errorf("file tree: %w", err)
186 }
187
188 file, err := tree.File(path)
189 if err != nil {
190 return nil, err
191 }
192
193 reader, err := file.Reader()
194 if err != nil {
195 return nil, fmt.Errorf("opening file reader: %w", err)
196 }
197 defer reader.Close()
198
199 return io.ReadAll(reader)
200}
201
202// read and parse .gitmodules
203func (g *GitRepo) Submodules() (*config.Modules, error) {
204 c, err := g.r.CommitObject(g.h)
205 if err != nil {
206 return nil, fmt.Errorf("commit object: %w", err)
207 }
208
209 tree, err := c.Tree()
210 if err != nil {
211 return nil, fmt.Errorf("tree: %w", err)
212 }
213
214 // read .gitmodules file
215 modulesEntry, err := tree.FindEntry(".gitmodules")
216 if err != nil {
217 return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err)
218 }
219
220 modulesFile, err := tree.TreeEntryFile(modulesEntry)
221 if err != nil {
222 return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err)
223 }
224
225 content, err := modulesFile.Contents()
226 if err != nil {
227 return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err)
228 }
229
230 // parse .gitmodules
231 modules := config.NewModules()
232 if err = modules.Unmarshal([]byte(content)); err != nil {
233 return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err)
234 }
235
236 return modules, nil
237}
238
239func (g *GitRepo) Submodule(path string) (*config.Submodule, error) {
240 modules, err := g.Submodules()
241 if err != nil {
242 return nil, err
243 }
244
245 for _, submodule := range modules.Submodules {
246 if submodule.Path == path {
247 return submodule, nil
248 }
249 }
250
251 // path is not a submodule
252 return nil, ErrNotSubmodule
253}
254
255func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
256 ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
257 if err != nil {
258 return nil, fmt.Errorf("branch: %w", err)
259 }
260
261 if !ref.Name().IsBranch() {
262 return nil, fmt.Errorf("branch: %s is not a branch", ref.Name())
263 }
264
265 return ref, nil
266}
267
268func (g *GitRepo) SetDefaultBranch(branch string) error {
269 ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))
270 return g.r.Storer.SetReference(ref)
271}
272
273func (g *GitRepo) FindMainBranch() (string, error) {
274 output, err := g.revParse("--abbrev-ref", "HEAD")
275 if err != nil {
276 return "", fmt.Errorf("failed to find main branch: %w", err)
277 }
278
279 return strings.TrimSpace(string(output)), nil
280}
281
282// WriteTar writes itself from a tree into a binary tar file format.
283// prefix is root folder to be appended.
284func (g *GitRepo) WriteTar(w io.Writer, prefix string) error {
285 tw := tar.NewWriter(w)
286 defer tw.Close()
287
288 c, err := g.r.CommitObject(g.h)
289 if err != nil {
290 return fmt.Errorf("commit object: %w", err)
291 }
292
293 tree, err := c.Tree()
294 if err != nil {
295 return err
296 }
297
298 walker := object.NewTreeWalker(tree, true, nil)
299 defer walker.Close()
300
301 name, entry, err := walker.Next()
302 for ; err == nil; name, entry, err = walker.Next() {
303 info, err := newInfoWrapper(name, prefix, &entry, tree)
304 if err != nil {
305 return err
306 }
307
308 header, err := tar.FileInfoHeader(info, "")
309 if err != nil {
310 return err
311 }
312
313 err = tw.WriteHeader(header)
314 if err != nil {
315 return err
316 }
317
318 if !info.IsDir() {
319 file, err := tree.File(name)
320 if err != nil {
321 return err
322 }
323
324 reader, err := file.Blob.Reader()
325 if err != nil {
326 return err
327 }
328
329 _, err = io.Copy(tw, reader)
330 if err != nil {
331 reader.Close()
332 return err
333 }
334 reader.Close()
335 }
336 }
337
338 return nil
339}
340
341func newInfoWrapper(
342 name string,
343 prefix string,
344 entry *object.TreeEntry,
345 tree *object.Tree,
346) (*infoWrapper, error) {
347 var (
348 size int64
349 mode fs.FileMode
350 isDir bool
351 )
352
353 if entry.Mode.IsFile() {
354 file, err := tree.TreeEntryFile(entry)
355 if err != nil {
356 return nil, err
357 }
358 mode = fs.FileMode(file.Mode)
359
360 size, err = tree.Size(name)
361 if err != nil {
362 return nil, err
363 }
364 } else {
365 isDir = true
366 mode = fs.ModeDir | fs.ModePerm
367 }
368
369 fullname := path.Join(prefix, name)
370 return &infoWrapper{
371 name: fullname,
372 size: size,
373 mode: mode,
374 modTime: time.Unix(0, 0),
375 isDir: isDir,
376 }, nil
377}
378
379func (i *infoWrapper) Name() string {
380 return i.name
381}
382
383func (i *infoWrapper) Size() int64 {
384 return i.size
385}
386
387func (i *infoWrapper) Mode() fs.FileMode {
388 return i.mode
389}
390
391func (i *infoWrapper) ModTime() time.Time {
392 return i.modTime
393}
394
395func (i *infoWrapper) IsDir() bool {
396 return i.isDir
397}
398
399func (i *infoWrapper) Sys() any {
400 return nil
401}