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 "--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) SetDefaultBranch(branch string) error {
256 ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))
257 return g.r.Storer.SetReference(ref)
258}
259
260func (g *GitRepo) FindMainBranch() (string, error) {
261 output, err := g.revParse("--abbrev-ref", "HEAD")
262 if err != nil {
263 return "", fmt.Errorf("failed to find main branch: %w", err)
264 }
265
266 return strings.TrimSpace(string(output)), nil
267}
268
269// WriteTar writes itself from a tree into a binary tar file format.
270// prefix is root folder to be appended.
271func (g *GitRepo) WriteTar(w io.Writer, prefix string) error {
272 tw := tar.NewWriter(w)
273 defer tw.Close()
274
275 c, err := g.r.CommitObject(g.h)
276 if err != nil {
277 return fmt.Errorf("commit object: %w", err)
278 }
279
280 tree, err := c.Tree()
281 if err != nil {
282 return err
283 }
284
285 walker := object.NewTreeWalker(tree, true, nil)
286 defer walker.Close()
287
288 name, entry, err := walker.Next()
289 for ; err == nil; name, entry, err = walker.Next() {
290 info, err := newInfoWrapper(name, prefix, &entry, tree)
291 if err != nil {
292 return err
293 }
294
295 header, err := tar.FileInfoHeader(info, "")
296 if err != nil {
297 return err
298 }
299
300 err = tw.WriteHeader(header)
301 if err != nil {
302 return err
303 }
304
305 if !info.IsDir() {
306 file, err := tree.File(name)
307 if err != nil {
308 return err
309 }
310
311 reader, err := file.Blob.Reader()
312 if err != nil {
313 return err
314 }
315
316 _, err = io.Copy(tw, reader)
317 if err != nil {
318 reader.Close()
319 return err
320 }
321 reader.Close()
322 }
323 }
324
325 return nil
326}
327
328func newInfoWrapper(
329 name string,
330 prefix string,
331 entry *object.TreeEntry,
332 tree *object.Tree,
333) (*infoWrapper, error) {
334 var (
335 size int64
336 mode fs.FileMode
337 isDir bool
338 )
339
340 if entry.Mode.IsFile() {
341 file, err := tree.TreeEntryFile(entry)
342 if err != nil {
343 return nil, err
344 }
345 mode = fs.FileMode(file.Mode)
346
347 size, err = tree.Size(name)
348 if err != nil {
349 return nil, err
350 }
351 } else {
352 isDir = true
353 mode = fs.ModeDir | fs.ModePerm
354 }
355
356 fullname := path.Join(prefix, name)
357 return &infoWrapper{
358 name: fullname,
359 size: size,
360 mode: mode,
361 modTime: time.Unix(0, 0),
362 isDir: isDir,
363 }, nil
364}
365
366func (i *infoWrapper) Name() string {
367 return i.name
368}
369
370func (i *infoWrapper) Size() int64 {
371 return i.size
372}
373
374func (i *infoWrapper) Mode() fs.FileMode {
375 return i.mode
376}
377
378func (i *infoWrapper) ModTime() time.Time {
379 return i.modTime
380}
381
382func (i *infoWrapper) IsDir() bool {
383 return i.isDir
384}
385
386func (i *infoWrapper) Sys() any {
387 return nil
388}