gogit.go

  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}