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