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