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