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