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