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}