1package repository
2
3import (
4 "bytes"
5 "fmt"
6 "io/ioutil"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sort"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/blevesearch/bleve"
16 "github.com/go-git/go-billy/v5"
17 "github.com/go-git/go-billy/v5/osfs"
18 gogit "github.com/go-git/go-git/v5"
19 "github.com/go-git/go-git/v5/config"
20 "github.com/go-git/go-git/v5/plumbing"
21 "github.com/go-git/go-git/v5/plumbing/filemode"
22 "github.com/go-git/go-git/v5/plumbing/object"
23
24 "github.com/MichaelMure/git-bug/util/lamport"
25)
26
27var _ ClockedRepo = &GoGitRepo{}
28var _ TestedRepo = &GoGitRepo{}
29
30type GoGitRepo struct {
31 r *gogit.Repository
32 path string
33
34 clocksMutex sync.Mutex
35 clocks map[string]lamport.Clock
36
37 indexesMutex sync.Mutex
38 indexes map[string]bleve.Index
39
40 keyring Keyring
41 localStorage billy.Filesystem
42}
43
44// OpenGoGitRepo open an already existing repo at the given path
45func OpenGoGitRepo(path string, clockLoaders []ClockLoader) (*GoGitRepo, error) {
46 path, err := detectGitPath(path)
47 if err != nil {
48 return nil, err
49 }
50
51 r, err := gogit.PlainOpen(path)
52 if err != nil {
53 return nil, err
54 }
55
56 k, err := defaultKeyring()
57 if err != nil {
58 return nil, err
59 }
60
61 repo := &GoGitRepo{
62 r: r,
63 path: path,
64 clocks: make(map[string]lamport.Clock),
65 indexes: make(map[string]bleve.Index),
66 keyring: k,
67 localStorage: osfs.New(filepath.Join(path, "git-bug")),
68 }
69
70 for _, loader := range clockLoaders {
71 allExist := true
72 for _, name := range loader.Clocks {
73 if _, err := repo.getClock(name); err != nil {
74 allExist = false
75 }
76 }
77
78 if !allExist {
79 err = loader.Witnesser(repo)
80 if err != nil {
81 return nil, err
82 }
83 }
84 }
85
86 return repo, nil
87}
88
89// InitGoGitRepo create a new empty git repo at the given path
90func InitGoGitRepo(path string) (*GoGitRepo, error) {
91 r, err := gogit.PlainInit(path, false)
92 if err != nil {
93 return nil, err
94 }
95
96 k, err := defaultKeyring()
97 if err != nil {
98 return nil, err
99 }
100
101 return &GoGitRepo{
102 r: r,
103 path: filepath.Join(path, ".git"),
104 clocks: make(map[string]lamport.Clock),
105 indexes: make(map[string]bleve.Index),
106 keyring: k,
107 localStorage: osfs.New(filepath.Join(path, ".git", "git-bug")),
108 }, nil
109}
110
111// InitBareGoGitRepo create a new --bare empty git repo at the given path
112func InitBareGoGitRepo(path string) (*GoGitRepo, error) {
113 r, err := gogit.PlainInit(path, true)
114 if err != nil {
115 return nil, err
116 }
117
118 k, err := defaultKeyring()
119 if err != nil {
120 return nil, err
121 }
122
123 return &GoGitRepo{
124 r: r,
125 path: path,
126 clocks: make(map[string]lamport.Clock),
127 indexes: make(map[string]bleve.Index),
128 keyring: k,
129 localStorage: osfs.New(filepath.Join(path, "git-bug")),
130 }, nil
131}
132
133func detectGitPath(path string) (string, error) {
134 // normalize the path
135 path, err := filepath.Abs(path)
136 if err != nil {
137 return "", err
138 }
139
140 for {
141 fi, err := os.Stat(filepath.Join(path, ".git"))
142 if err == nil {
143 if !fi.IsDir() {
144 return "", fmt.Errorf(".git exist but is not a directory")
145 }
146 return filepath.Join(path, ".git"), nil
147 }
148 if !os.IsNotExist(err) {
149 // unknown error
150 return "", err
151 }
152
153 // detect bare repo
154 ok, err := isGitDir(path)
155 if err != nil {
156 return "", err
157 }
158 if ok {
159 return path, nil
160 }
161
162 if parent := filepath.Dir(path); parent == path {
163 return "", fmt.Errorf(".git not found")
164 } else {
165 path = parent
166 }
167 }
168}
169
170func isGitDir(path string) (bool, error) {
171 markers := []string{"HEAD", "objects", "refs"}
172
173 for _, marker := range markers {
174 _, err := os.Stat(filepath.Join(path, marker))
175 if err == nil {
176 continue
177 }
178 if !os.IsNotExist(err) {
179 // unknown error
180 return false, err
181 } else {
182 return false, nil
183 }
184 }
185
186 return true, nil
187}
188
189func (repo *GoGitRepo) Close() error {
190 var firstErr error
191 for _, index := range repo.indexes {
192 err := index.Close()
193 if err != nil && firstErr == nil {
194 firstErr = err
195 }
196 }
197 return firstErr
198}
199
200// LocalConfig give access to the repository scoped configuration
201func (repo *GoGitRepo) LocalConfig() Config {
202 return newGoGitLocalConfig(repo.r)
203}
204
205// GlobalConfig give access to the global scoped configuration
206func (repo *GoGitRepo) GlobalConfig() Config {
207 return newGoGitGlobalConfig()
208}
209
210// AnyConfig give access to a merged local/global configuration
211func (repo *GoGitRepo) AnyConfig() ConfigRead {
212 return mergeConfig(repo.LocalConfig(), repo.GlobalConfig())
213}
214
215// Keyring give access to a user-wide storage for secrets
216func (repo *GoGitRepo) Keyring() Keyring {
217 return repo.keyring
218}
219
220// GetUserName returns the name the the user has used to configure git
221func (repo *GoGitRepo) GetUserName() (string, error) {
222 return repo.AnyConfig().ReadString("user.name")
223}
224
225// GetUserEmail returns the email address that the user has used to configure git.
226func (repo *GoGitRepo) GetUserEmail() (string, error) {
227 return repo.AnyConfig().ReadString("user.email")
228}
229
230// GetCoreEditor returns the name of the editor that the user has used to configure git.
231func (repo *GoGitRepo) GetCoreEditor() (string, error) {
232 // See https://git-scm.com/docs/git-var
233 // The order of preference is the $GIT_EDITOR environment variable, then core.editor configuration, then $VISUAL, then $EDITOR, and then the default chosen at compile time, which is usually vi.
234
235 if val, ok := os.LookupEnv("GIT_EDITOR"); ok {
236 return val, nil
237 }
238
239 val, err := repo.AnyConfig().ReadString("core.editor")
240 if err == nil && val != "" {
241 return val, nil
242 }
243 if err != nil && err != ErrNoConfigEntry {
244 return "", err
245 }
246
247 if val, ok := os.LookupEnv("VISUAL"); ok {
248 return val, nil
249 }
250
251 if val, ok := os.LookupEnv("EDITOR"); ok {
252 return val, nil
253 }
254
255 priorities := []string{
256 "editor",
257 "nano",
258 "vim",
259 "vi",
260 "emacs",
261 }
262
263 for _, cmd := range priorities {
264 if _, err = exec.LookPath(cmd); err == nil {
265 return cmd, nil
266 }
267
268 }
269
270 return "ed", nil
271}
272
273// GetRemotes returns the configured remotes repositories.
274func (repo *GoGitRepo) GetRemotes() (map[string]string, error) {
275 cfg, err := repo.r.Config()
276 if err != nil {
277 return nil, err
278 }
279
280 result := make(map[string]string, len(cfg.Remotes))
281 for name, remote := range cfg.Remotes {
282 if len(remote.URLs) > 0 {
283 result[name] = remote.URLs[0]
284 }
285 }
286
287 return result, nil
288}
289
290// LocalStorage return a billy.Filesystem giving access to $RepoPath/.git/git-bug
291func (repo *GoGitRepo) LocalStorage() billy.Filesystem {
292 return repo.localStorage
293}
294
295// GetBleveIndex return a bleve.Index that can be used to index documents
296func (repo *GoGitRepo) GetBleveIndex(name string) (bleve.Index, error) {
297 repo.indexesMutex.Lock()
298 defer repo.indexesMutex.Unlock()
299
300 if index, ok := repo.indexes[name]; ok {
301 return index, nil
302 }
303
304 path := filepath.Join(repo.path, "git-bug", "indexes", name)
305
306 index, err := bleve.Open(path)
307 if err == nil {
308 repo.indexes[name] = index
309 return index, nil
310 }
311
312 err = os.MkdirAll(path, os.ModePerm)
313 if err != nil {
314 return nil, err
315 }
316
317 mapping := bleve.NewIndexMapping()
318 mapping.DefaultAnalyzer = "en"
319
320 index, err = bleve.New(path, mapping)
321 if err != nil {
322 return nil, err
323 }
324
325 repo.indexes[name] = index
326
327 return index, nil
328}
329
330// ClearBleveIndex will wipe the given index
331func (repo *GoGitRepo) ClearBleveIndex(name string) error {
332 repo.indexesMutex.Lock()
333 defer repo.indexesMutex.Unlock()
334
335 path := filepath.Join(repo.path, "indexes", name)
336
337 err := os.RemoveAll(path)
338 if err != nil {
339 return err
340 }
341
342 if index, ok := repo.indexes[name]; ok {
343 err = index.Close()
344 if err != nil {
345 return err
346 }
347 delete(repo.indexes, name)
348 }
349
350 return nil
351}
352
353// FetchRefs fetch git refs from a remote
354func (repo *GoGitRepo) FetchRefs(remote string, refSpec string) (string, error) {
355 buf := bytes.NewBuffer(nil)
356
357 err := repo.r.Fetch(&gogit.FetchOptions{
358 RemoteName: remote,
359 RefSpecs: []config.RefSpec{config.RefSpec(refSpec)},
360 Progress: buf,
361 })
362 if err == gogit.NoErrAlreadyUpToDate {
363 return "already up-to-date", nil
364 }
365 if err != nil {
366 return "", err
367 }
368
369 return buf.String(), nil
370}
371
372// PushRefs push git refs to a remote
373func (repo *GoGitRepo) PushRefs(remote string, refSpec string) (string, error) {
374 buf := bytes.NewBuffer(nil)
375
376 err := repo.r.Push(&gogit.PushOptions{
377 RemoteName: remote,
378 RefSpecs: []config.RefSpec{config.RefSpec(refSpec)},
379 Progress: buf,
380 })
381 if err == gogit.NoErrAlreadyUpToDate {
382 return "already up-to-date", nil
383 }
384 if err != nil {
385 return "", err
386 }
387
388 return buf.String(), nil
389}
390
391// StoreData will store arbitrary data and return the corresponding hash
392func (repo *GoGitRepo) StoreData(data []byte) (Hash, error) {
393 obj := repo.r.Storer.NewEncodedObject()
394 obj.SetType(plumbing.BlobObject)
395
396 w, err := obj.Writer()
397 if err != nil {
398 return "", err
399 }
400
401 _, err = w.Write(data)
402 if err != nil {
403 return "", err
404 }
405
406 h, err := repo.r.Storer.SetEncodedObject(obj)
407 if err != nil {
408 return "", err
409 }
410
411 return Hash(h.String()), nil
412}
413
414// ReadData will attempt to read arbitrary data from the given hash
415func (repo *GoGitRepo) ReadData(hash Hash) ([]byte, error) {
416 obj, err := repo.r.BlobObject(plumbing.NewHash(hash.String()))
417 if err != nil {
418 return nil, err
419 }
420
421 r, err := obj.Reader()
422 if err != nil {
423 return nil, err
424 }
425
426 return ioutil.ReadAll(r)
427}
428
429// StoreTree will store a mapping key-->Hash as a Git tree
430func (repo *GoGitRepo) StoreTree(mapping []TreeEntry) (Hash, error) {
431 var tree object.Tree
432
433 // TODO: can be removed once https://github.com/go-git/go-git/issues/193 is resolved
434 sorted := make([]TreeEntry, len(mapping))
435 copy(sorted, mapping)
436 sort.Slice(sorted, func(i, j int) bool {
437 nameI := sorted[i].Name
438 if sorted[i].ObjectType == Tree {
439 nameI += "/"
440 }
441 nameJ := sorted[j].Name
442 if sorted[j].ObjectType == Tree {
443 nameJ += "/"
444 }
445 return nameI < nameJ
446 })
447
448 for _, entry := range sorted {
449 mode := filemode.Regular
450 if entry.ObjectType == Tree {
451 mode = filemode.Dir
452 }
453
454 tree.Entries = append(tree.Entries, object.TreeEntry{
455 Name: entry.Name,
456 Mode: mode,
457 Hash: plumbing.NewHash(entry.Hash.String()),
458 })
459 }
460
461 obj := repo.r.Storer.NewEncodedObject()
462 obj.SetType(plumbing.TreeObject)
463 err := tree.Encode(obj)
464 if err != nil {
465 return "", err
466 }
467
468 hash, err := repo.r.Storer.SetEncodedObject(obj)
469 if err != nil {
470 return "", err
471 }
472
473 return Hash(hash.String()), nil
474}
475
476// ReadTree will return the list of entries in a Git tree
477func (repo *GoGitRepo) ReadTree(hash Hash) ([]TreeEntry, error) {
478 h := plumbing.NewHash(hash.String())
479
480 // the given hash could be a tree or a commit
481 obj, err := repo.r.Storer.EncodedObject(plumbing.AnyObject, h)
482 if err != nil {
483 return nil, err
484 }
485
486 var tree *object.Tree
487 switch obj.Type() {
488 case plumbing.TreeObject:
489 tree, err = object.DecodeTree(repo.r.Storer, obj)
490 case plumbing.CommitObject:
491 var commit *object.Commit
492 commit, err = object.DecodeCommit(repo.r.Storer, obj)
493 if err != nil {
494 return nil, err
495 }
496 tree, err = commit.Tree()
497 default:
498 return nil, fmt.Errorf("given hash is not a tree")
499 }
500 if err != nil {
501 return nil, err
502 }
503
504 treeEntries := make([]TreeEntry, len(tree.Entries))
505 for i, entry := range tree.Entries {
506 objType := Blob
507 if entry.Mode == filemode.Dir {
508 objType = Tree
509 }
510
511 treeEntries[i] = TreeEntry{
512 ObjectType: objType,
513 Hash: Hash(entry.Hash.String()),
514 Name: entry.Name,
515 }
516 }
517
518 return treeEntries, nil
519}
520
521// StoreCommit will store a Git commit with the given Git tree
522func (repo *GoGitRepo) StoreCommit(treeHash Hash) (Hash, error) {
523 return repo.StoreCommitWithParent(treeHash, "")
524}
525
526// StoreCommit will store a Git commit with the given Git tree
527func (repo *GoGitRepo) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) {
528 cfg, err := repo.r.Config()
529 if err != nil {
530 return "", err
531 }
532
533 commit := object.Commit{
534 Author: object.Signature{
535 Name: cfg.Author.Name,
536 Email: cfg.Author.Email,
537 When: time.Now(),
538 },
539 Committer: object.Signature{
540 Name: cfg.Committer.Name,
541 Email: cfg.Committer.Email,
542 When: time.Now(),
543 },
544 Message: "",
545 TreeHash: plumbing.NewHash(treeHash.String()),
546 }
547
548 if parent != "" {
549 commit.ParentHashes = []plumbing.Hash{plumbing.NewHash(parent.String())}
550 }
551
552 obj := repo.r.Storer.NewEncodedObject()
553 obj.SetType(plumbing.CommitObject)
554 err = commit.Encode(obj)
555 if err != nil {
556 return "", err
557 }
558
559 hash, err := repo.r.Storer.SetEncodedObject(obj)
560 if err != nil {
561 return "", err
562 }
563
564 return Hash(hash.String()), nil
565}
566
567// GetTreeHash return the git tree hash referenced in a commit
568func (repo *GoGitRepo) GetTreeHash(commit Hash) (Hash, error) {
569 obj, err := repo.r.CommitObject(plumbing.NewHash(commit.String()))
570 if err != nil {
571 return "", err
572 }
573
574 return Hash(obj.TreeHash.String()), nil
575}
576
577// FindCommonAncestor will return the last common ancestor of two chain of commit
578func (repo *GoGitRepo) FindCommonAncestor(commit1 Hash, commit2 Hash) (Hash, error) {
579 obj1, err := repo.r.CommitObject(plumbing.NewHash(commit1.String()))
580 if err != nil {
581 return "", err
582 }
583 obj2, err := repo.r.CommitObject(plumbing.NewHash(commit2.String()))
584 if err != nil {
585 return "", err
586 }
587
588 commits, err := obj1.MergeBase(obj2)
589 if err != nil {
590 return "", err
591 }
592
593 return Hash(commits[0].Hash.String()), nil
594}
595
596// UpdateRef will create or update a Git reference
597func (repo *GoGitRepo) UpdateRef(ref string, hash Hash) error {
598 return repo.r.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(ref), plumbing.NewHash(hash.String())))
599}
600
601// RemoveRef will remove a Git reference
602func (repo *GoGitRepo) RemoveRef(ref string) error {
603 return repo.r.Storer.RemoveReference(plumbing.ReferenceName(ref))
604}
605
606// ListRefs will return a list of Git ref matching the given refspec
607func (repo *GoGitRepo) ListRefs(refPrefix string) ([]string, error) {
608 refIter, err := repo.r.References()
609 if err != nil {
610 return nil, err
611 }
612
613 refs := make([]string, 0)
614
615 err = refIter.ForEach(func(ref *plumbing.Reference) error {
616 if strings.HasPrefix(ref.Name().String(), refPrefix) {
617 refs = append(refs, ref.Name().String())
618 }
619 return nil
620 })
621 if err != nil {
622 return nil, err
623 }
624
625 return refs, nil
626}
627
628// RefExist will check if a reference exist in Git
629func (repo *GoGitRepo) RefExist(ref string) (bool, error) {
630 _, err := repo.r.Reference(plumbing.ReferenceName(ref), false)
631 if err == nil {
632 return true, nil
633 } else if err == plumbing.ErrReferenceNotFound {
634 return false, nil
635 }
636 return false, err
637}
638
639// CopyRef will create a new reference with the same value as another one
640func (repo *GoGitRepo) CopyRef(source string, dest string) error {
641 r, err := repo.r.Reference(plumbing.ReferenceName(source), false)
642 if err != nil {
643 return err
644 }
645 return repo.r.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(dest), r.Hash()))
646}
647
648// ListCommits will return the list of tree hashes of a ref, in chronological order
649func (repo *GoGitRepo) ListCommits(ref string) ([]Hash, error) {
650 r, err := repo.r.Reference(plumbing.ReferenceName(ref), false)
651 if err != nil {
652 return nil, err
653 }
654
655 commit, err := repo.r.CommitObject(r.Hash())
656 if err != nil {
657 return nil, err
658 }
659 hashes := []Hash{Hash(commit.Hash.String())}
660
661 for {
662 commit, err = commit.Parent(0)
663 if err == object.ErrParentNotFound {
664 break
665 }
666 if err != nil {
667 return nil, err
668 }
669
670 if commit.NumParents() > 1 {
671 return nil, fmt.Errorf("multiple parents")
672 }
673
674 hashes = append([]Hash{Hash(commit.Hash.String())}, hashes...)
675 }
676
677 return hashes, nil
678}
679
680// GetOrCreateClock return a Lamport clock stored in the Repo.
681// If the clock doesn't exist, it's created.
682func (repo *GoGitRepo) GetOrCreateClock(name string) (lamport.Clock, error) {
683 repo.clocksMutex.Lock()
684 defer repo.clocksMutex.Unlock()
685
686 c, err := repo.getClock(name)
687 if err == nil {
688 return c, nil
689 }
690 if err != ErrClockNotExist {
691 return nil, err
692 }
693
694 c, err = lamport.NewPersistedClock(repo.localStorage, name+"-clock")
695 if err != nil {
696 return nil, err
697 }
698
699 repo.clocks[name] = c
700 return c, nil
701}
702
703func (repo *GoGitRepo) getClock(name string) (lamport.Clock, error) {
704 if c, ok := repo.clocks[name]; ok {
705 return c, nil
706 }
707
708 c, err := lamport.LoadPersistedClock(repo.localStorage, name+"-clock")
709 if err == nil {
710 repo.clocks[name] = c
711 return c, nil
712 }
713 if err == lamport.ErrClockNotExist {
714 return nil, ErrClockNotExist
715 }
716 return nil, err
717}
718
719// AddRemote add a new remote to the repository
720// Not in the interface because it's only used for testing
721func (repo *GoGitRepo) AddRemote(name string, url string) error {
722 _, err := repo.r.CreateRemote(&config.RemoteConfig{
723 Name: name,
724 URLs: []string{url},
725 })
726
727 return err
728}
729
730// GetLocalRemote return the URL to use to add this repo as a local remote
731func (repo *GoGitRepo) GetLocalRemote() string {
732 return repo.path
733}
734
735// EraseFromDisk delete this repository entirely from the disk
736func (repo *GoGitRepo) EraseFromDisk() error {
737 err := repo.Close()
738 if err != nil {
739 return err
740 }
741
742 path := filepath.Clean(strings.TrimSuffix(repo.path, string(filepath.Separator)+".git"))
743
744 // fmt.Println("Cleaning repo:", path)
745 return os.RemoveAll(path)
746}