1// Package file implements a backend that uses the filesystem to store non-Git related data
  2//
  3// The following files and directories are used:
  4//
  5//   - anon-access: contains the access level for anonymous users
  6//   - allow-keyless: contains a boolean value indicating whether or not keyless access is allowed
  7//   - admins: contains a list of authorized keys for admin users
  8//   - host: contains the server's server hostname
  9//   - name: contains the server's name
 10//   - port: contains the server's port
 11//   - repos: is a the directory containing all Git repositories
 12//
 13// Each repository has the following files and directories:
 14//   - collaborators: contains a list of authorized keys for collaborators
 15//   - description: contains the repository's description
 16//   - private: when present, indicates that the repository is private
 17//   - git-daemon-export-ok: when present, indicates that the repository is public
 18//   - project-name: contains the repository's project name
 19package file
 20
 21import (
 22	"bufio"
 23	"errors"
 24	"fmt"
 25	"io"
 26	"io/fs"
 27	"os"
 28	"path/filepath"
 29	"strconv"
 30	"strings"
 31
 32	"github.com/charmbracelet/log"
 33	"github.com/charmbracelet/soft-serve/git"
 34	"github.com/charmbracelet/soft-serve/server/backend"
 35	"github.com/charmbracelet/ssh"
 36	gossh "golang.org/x/crypto/ssh"
 37)
 38
 39// sub file and directory names.
 40const (
 41	anonAccess   = "anon-access"
 42	allowKeyless = "allow-keyless"
 43	admins       = "admins"
 44	serverHost   = "host"
 45	serverName   = "name"
 46	serverPort   = "port"
 47	repos        = "repos"
 48	collabs      = "collaborators"
 49	description  = "description"
 50	exportOk     = "git-daemon-export-ok"
 51	settings     = "settings"
 52)
 53
 54var (
 55	logger = log.WithPrefix("backend.file")
 56
 57	defaults = map[string]string{
 58		serverName:   "Soft Serve",
 59		serverHost:   "localhost",
 60		serverPort:   "23231",
 61		anonAccess:   backend.ReadOnlyAccess.String(),
 62		allowKeyless: "true",
 63	}
 64)
 65
 66var _ backend.Backend = &FileBackend{}
 67
 68var _ backend.AccessMethod = &FileBackend{}
 69
 70// FileBackend is a backend that uses the filesystem.
 71type FileBackend struct { // nolint:revive
 72	// path is the path to the directory containing the repositories and config
 73	// files.
 74	path string
 75	// AdditionalAdmins additional admins to the server.
 76	AdditionalAdmins []string
 77}
 78
 79func (fb *FileBackend) reposPath() string {
 80	return filepath.Join(fb.path, repos)
 81}
 82
 83// RepositoryStorePath returns the path to the repository store.
 84func (fb *FileBackend) RepositoryStorePath() string {
 85	return fb.reposPath()
 86}
 87
 88func (fb *FileBackend) settingsPath() string {
 89	return filepath.Join(fb.path, settings)
 90}
 91
 92func (fb *FileBackend) adminsPath() string {
 93	return filepath.Join(fb.settingsPath(), admins)
 94}
 95
 96func (fb *FileBackend) collabsPath(repo string) string {
 97	return filepath.Join(fb.path, collabs, repo)
 98}
 99
100func sanatizeRepo(repo string) string {
101	return strings.TrimSuffix(repo, ".git")
102}
103
104func readOneLine(path string) (string, error) {
105	f, err := os.Open(path)
106	if err != nil {
107		return "", err
108	}
109	defer f.Close() // nolint:errcheck
110	s := bufio.NewScanner(f)
111	s.Scan()
112	return s.Text(), s.Err()
113}
114
115func readAll(path string) (string, error) {
116	f, err := os.Open(path)
117	if err != nil {
118		return "", err
119	}
120
121	bts, err := io.ReadAll(f)
122	return string(bts), err
123}
124
125// exists returns true if the given path exists.
126func exists(path string) bool {
127	_, err := os.Stat(path)
128	return err == nil
129}
130
131// NewFileBackend creates a new FileBackend.
132func NewFileBackend(path string) (*FileBackend, error) {
133	fb := &FileBackend{path: path}
134	for _, dir := range []string{repos, settings, collabs} {
135		if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
136			return nil, err
137		}
138	}
139	for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} {
140		fp := filepath.Join(fb.settingsPath(), file)
141		_, err := os.Stat(fp)
142		if errors.Is(err, fs.ErrNotExist) {
143			f, err := os.Create(fp)
144			if err != nil {
145				return nil, err
146			}
147			if c, ok := defaults[file]; ok {
148				io.WriteString(f, c) // nolint:errcheck
149			}
150			_ = f.Close()
151		}
152	}
153	return fb, nil
154}
155
156// AccessLevel returns the access level for the given public key and repo.
157//
158// It implements backend.AccessMethod.
159func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.AccessLevel {
160	private := fb.IsPrivate(repo)
161	anon := fb.AnonAccess()
162	if pk != nil {
163		// Check if the key is an admin.
164		if fb.IsAdmin(pk) {
165			return backend.AdminAccess
166		}
167
168		// Check if the key is a collaborator.
169		if fb.IsCollaborator(pk, repo) {
170			if anon > backend.ReadWriteAccess {
171				return anon
172			}
173			return backend.ReadWriteAccess
174		}
175
176		// Check if repo is private.
177		if !private {
178			if anon > backend.ReadOnlyAccess {
179				return anon
180			}
181			return backend.ReadOnlyAccess
182		}
183	}
184
185	if private {
186		return backend.NoAccess
187	}
188
189	return anon
190}
191
192// AddAdmin adds a public key to the list of server admins.
193//
194// It implements backend.Backend.
195func (fb *FileBackend) AddAdmin(pk gossh.PublicKey, memo string) error {
196	// Skip if the key already exists.
197	if fb.IsAdmin(pk) {
198		return fmt.Errorf("key already exists")
199	}
200
201	ak := backend.MarshalAuthorizedKey(pk)
202	f, err := os.OpenFile(fb.adminsPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
203	if err != nil {
204		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
205		return err
206	}
207
208	defer f.Close() //nolint:errcheck
209	if memo != "" {
210		memo = " " + memo
211	}
212	_, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
213	return err
214}
215
216// AddCollaborator adds a public key to the list of collaborators for the given repo.
217//
218// It implements backend.Backend.
219func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, memo string, name string) error {
220	// Check if repo exists
221	if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(name)+".git")) {
222		return fmt.Errorf("repository %s does not exist", name)
223	}
224
225	// Skip if the key already exists.
226	if fb.IsCollaborator(pk, name) {
227		return fmt.Errorf("key already exists")
228	}
229
230	ak := backend.MarshalAuthorizedKey(pk)
231	name = sanatizeRepo(name)
232	if err := os.MkdirAll(filepath.Dir(fb.collabsPath(name)), 0755); err != nil {
233		logger.Debug("failed to create collaborators directory",
234			"err", err, "path", filepath.Dir(fb.collabsPath(name)))
235		return err
236	}
237
238	f, err := os.OpenFile(fb.collabsPath(name), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
239	if err != nil {
240		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(name))
241		return err
242	}
243
244	defer f.Close() //nolint:errcheck
245	if memo != "" {
246		memo = " " + memo
247	}
248	_, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
249	return err
250}
251
252// Admins returns a list of public keys that are admins.
253//
254// It implements backend.Backend.
255func (fb *FileBackend) Admins() ([]string, error) {
256	admins := make([]string, 0)
257	f, err := os.Open(fb.adminsPath())
258	if err != nil {
259		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
260		return nil, err
261	}
262
263	defer f.Close() //nolint:errcheck
264	s := bufio.NewScanner(f)
265	for s.Scan() {
266		admins = append(admins, s.Text())
267	}
268
269	return admins, s.Err()
270}
271
272// Collaborators returns a list of public keys that are collaborators for the given repo.
273//
274// It implements backend.Backend.
275func (fb *FileBackend) Collaborators(repo string) ([]string, error) {
276	// Check if repo exists
277	if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(repo)+".git")) {
278		return nil, fmt.Errorf("repository %s does not exist", repo)
279	}
280
281	collabs := make([]string, 0)
282	f, err := os.Open(fb.collabsPath(repo))
283	if err != nil && errors.Is(err, os.ErrNotExist) {
284		return collabs, nil
285	}
286	if err != nil {
287		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
288		return nil, err
289	}
290
291	defer f.Close() //nolint:errcheck
292	s := bufio.NewScanner(f)
293	for s.Scan() {
294		collabs = append(collabs, s.Text())
295	}
296
297	return collabs, s.Err()
298}
299
300// RemoveAdmin implements backend.Backend
301func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error {
302	f, err := os.OpenFile(fb.adminsPath(), os.O_RDWR, 0644)
303	if err != nil {
304		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
305		return err
306	}
307
308	defer f.Close() //nolint:errcheck
309	s := bufio.NewScanner(f)
310	lines := make([]string, 0)
311	for s.Scan() {
312		apk, _, err := backend.ParseAuthorizedKey(s.Text())
313		if err != nil {
314			logger.Debug("failed to parse admin key", "err", err, "path", fb.adminsPath())
315			continue
316		}
317
318		if !ssh.KeysEqual(apk, pk) {
319			lines = append(lines, s.Text())
320		}
321	}
322
323	if err := s.Err(); err != nil {
324		logger.Debug("failed to scan admin keys file", "err", err, "path", fb.adminsPath())
325		return err
326	}
327
328	if err := f.Truncate(0); err != nil {
329		logger.Debug("failed to truncate admin keys file", "err", err, "path", fb.adminsPath())
330		return err
331	}
332
333	if _, err := f.Seek(0, 0); err != nil {
334		logger.Debug("failed to seek admin keys file", "err", err, "path", fb.adminsPath())
335		return err
336	}
337
338	w := bufio.NewWriter(f)
339	for _, line := range lines {
340		if _, err := fmt.Fprintln(w, line); err != nil {
341			logger.Debug("failed to write admin keys file", "err", err, "path", fb.adminsPath())
342			return err
343		}
344	}
345
346	return w.Flush()
347}
348
349// RemoveCollaborator removes a public key from the list of collaborators for the given repo.
350//
351// It implements backend.Backend.
352func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error {
353	// Check if repo exists
354	if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(repo)+".git")) {
355		return fmt.Errorf("repository %s does not exist", repo)
356	}
357
358	f, err := os.OpenFile(fb.collabsPath(repo), os.O_RDWR, 0644)
359	if err != nil && errors.Is(err, os.ErrNotExist) {
360		return nil
361	}
362
363	if err != nil {
364		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
365		return err
366	}
367
368	defer f.Close() //nolint:errcheck
369	s := bufio.NewScanner(f)
370	lines := make([]string, 0)
371	for s.Scan() {
372		apk, _, err := backend.ParseAuthorizedKey(s.Text())
373		if err != nil {
374			logger.Debug("failed to parse collaborator key", "err", err, "path", fb.collabsPath(repo))
375			continue
376		}
377
378		if !ssh.KeysEqual(apk, pk) {
379			lines = append(lines, s.Text())
380		}
381	}
382
383	if err := s.Err(); err != nil {
384		logger.Debug("failed to scan collaborators file", "err", err, "path", fb.collabsPath(repo))
385		return err
386	}
387
388	if err := f.Truncate(0); err != nil {
389		logger.Debug("failed to truncate collaborators file", "err", err, "path", fb.collabsPath(repo))
390		return err
391	}
392
393	if _, err := f.Seek(0, 0); err != nil {
394		logger.Debug("failed to seek collaborators file", "err", err, "path", fb.collabsPath(repo))
395		return err
396	}
397
398	w := bufio.NewWriter(f)
399	for _, line := range lines {
400		if _, err := fmt.Fprintln(w, line); err != nil {
401			logger.Debug("failed to write collaborators file", "err", err, "path", fb.collabsPath(repo))
402			return err
403		}
404	}
405
406	return w.Flush()
407}
408
409// AllowKeyless returns true if keyless access is allowed.
410//
411// It implements backend.Backend.
412func (fb *FileBackend) AllowKeyless() bool {
413	line, err := readOneLine(filepath.Join(fb.settingsPath(), allowKeyless))
414	if err != nil {
415		logger.Debug("failed to read allow-keyless file", "err", err)
416		return false
417	}
418
419	return line == "true"
420}
421
422// AnonAccess returns the level of anonymous access allowed.
423//
424// It implements backend.Backend.
425func (fb *FileBackend) AnonAccess() backend.AccessLevel {
426	line, err := readOneLine(filepath.Join(fb.settingsPath(), anonAccess))
427	if err != nil {
428		logger.Debug("failed to read anon-access file", "err", err)
429		return backend.NoAccess
430	}
431
432	switch line {
433	case backend.NoAccess.String():
434		return backend.NoAccess
435	case backend.ReadOnlyAccess.String():
436		return backend.ReadOnlyAccess
437	case backend.ReadWriteAccess.String():
438		return backend.ReadWriteAccess
439	case backend.AdminAccess.String():
440		return backend.AdminAccess
441	default:
442		return backend.NoAccess
443	}
444}
445
446// Description returns the description of the given repo.
447//
448// It implements backend.Backend.
449func (fb *FileBackend) Description(repo string) string {
450	repo = sanatizeRepo(repo) + ".git"
451	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
452	return r.Description()
453}
454
455// IsAdmin checks if the given public key is a server admin.
456//
457// It implements backend.Backend.
458func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
459	// Check if the key is an additional admin.
460	ak := backend.MarshalAuthorizedKey(pk)
461	for _, admin := range fb.AdditionalAdmins {
462		if ak == admin {
463			return true
464		}
465	}
466
467	f, err := os.Open(fb.adminsPath())
468	if err != nil {
469		logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath())
470		return false
471	}
472
473	defer f.Close() //nolint:errcheck
474	s := bufio.NewScanner(f)
475	for s.Scan() {
476		apk, _, err := backend.ParseAuthorizedKey(s.Text())
477		if err != nil {
478			continue
479		}
480		if ssh.KeysEqual(apk, pk) {
481			return true
482		}
483	}
484
485	return false
486}
487
488// IsCollaborator returns true if the given public key is a collaborator on the
489// given repo.
490//
491// It implements backend.Backend.
492func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, name string) bool {
493	name = sanatizeRepo(name)
494	_, err := os.Stat(fb.collabsPath(name))
495	if err != nil {
496		return false
497	}
498
499	f, err := os.Open(fb.collabsPath(name))
500	if err != nil {
501		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(name))
502		return false
503	}
504
505	defer f.Close() //nolint:errcheck
506	s := bufio.NewScanner(f)
507	for s.Scan() {
508		apk, _, err := backend.ParseAuthorizedKey(s.Text())
509		if err != nil {
510			continue
511		}
512		if ssh.KeysEqual(apk, pk) {
513			return true
514		}
515	}
516
517	return false
518}
519
520// IsPrivate returns true if the given repo is private.
521//
522// It implements backend.Backend.
523func (fb *FileBackend) IsPrivate(repo string) bool {
524	repo = sanatizeRepo(repo) + ".git"
525	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
526	return r.IsPrivate()
527}
528
529// ServerHost returns the server host.
530//
531// It implements backend.Backend.
532func (fb *FileBackend) ServerHost() string {
533	line, err := readOneLine(filepath.Join(fb.settingsPath(), serverHost))
534	if err != nil {
535		logger.Debug("failed to read server-host file", "err", err)
536		return ""
537	}
538
539	return line
540}
541
542// ServerName returns the server name.
543//
544// It implements backend.Backend.
545func (fb *FileBackend) ServerName() string {
546	line, err := readOneLine(filepath.Join(fb.settingsPath(), serverName))
547	if err != nil {
548		logger.Debug("failed to read server-name file", "err", err)
549		return ""
550	}
551
552	return line
553}
554
555// ServerPort returns the server port.
556//
557// It implements backend.Backend.
558func (fb *FileBackend) ServerPort() string {
559	line, err := readOneLine(filepath.Join(fb.settingsPath(), serverPort))
560	if err != nil {
561		logger.Debug("failed to read server-port file", "err", err)
562		return ""
563	}
564
565	if _, err := strconv.Atoi(line); err != nil {
566		logger.Debug("failed to parse server-port file", "err", err)
567		return ""
568	}
569
570	return line
571}
572
573// SetAllowKeyless sets whether or not to allow keyless access.
574//
575// It implements backend.Backend.
576func (fb *FileBackend) SetAllowKeyless(allow bool) error {
577	f, err := os.OpenFile(filepath.Join(fb.settingsPath(), allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
578	if err != nil {
579		return fmt.Errorf("failed to open allow-keyless file: %w", err)
580	}
581
582	defer f.Close() //nolint:errcheck
583	_, err = fmt.Fprintln(f, allow)
584	return err
585}
586
587// SetAnonAccess sets the anonymous access level.
588//
589// It implements backend.Backend.
590func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
591	f, err := os.OpenFile(filepath.Join(fb.settingsPath(), anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
592	if err != nil {
593		return fmt.Errorf("failed to open anon-access file: %w", err)
594	}
595
596	defer f.Close() //nolint:errcheck
597	_, err = fmt.Fprintln(f, level.String())
598	return err
599}
600
601// SetDescription sets the description of the given repo.
602//
603// It implements backend.Backend.
604func (fb *FileBackend) SetDescription(repo string, desc string) error {
605	repo = sanatizeRepo(repo) + ".git"
606	f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
607	if err != nil {
608		return fmt.Errorf("failed to open description file: %w", err)
609	}
610
611	defer f.Close() //nolint:errcheck
612	_, err = fmt.Fprintln(f, desc)
613	return err
614}
615
616// SetPrivate sets the private status of the given repo.
617//
618// It implements backend.Backend.
619func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
620	repo = sanatizeRepo(repo) + ".git"
621	daemonExport := filepath.Join(fb.reposPath(), repo, exportOk)
622	if priv {
623		_ = os.Remove(daemonExport)
624	} else {
625		// Create git-daemon-export-ok file if repo is public.
626		f, err := os.Create(daemonExport)
627		if err != nil {
628			logger.Warn("failed to create git-daemon-export-ok file", "err", err)
629		} else {
630			_ = f.Close() //nolint:errcheck
631		}
632	}
633	return nil
634}
635
636// SetServerHost sets the server host.
637//
638// It implements backend.Backend.
639func (fb *FileBackend) SetServerHost(host string) error {
640	f, err := os.Create(filepath.Join(fb.settingsPath(), serverHost))
641	if err != nil {
642		return fmt.Errorf("failed to create server-host file: %w", err)
643	}
644
645	defer f.Close() //nolint:errcheck
646	_, err = fmt.Fprintln(f, host)
647	return err
648}
649
650// SetServerName sets the server name.
651//
652// It implements backend.Backend.
653func (fb *FileBackend) SetServerName(name string) error {
654	f, err := os.Create(filepath.Join(fb.settingsPath(), serverName))
655	if err != nil {
656		return fmt.Errorf("failed to create server-name file: %w", err)
657	}
658
659	defer f.Close() //nolint:errcheck
660	_, err = fmt.Fprintln(f, name)
661	return err
662}
663
664// SetServerPort sets the server port.
665//
666// It implements backend.Backend.
667func (fb *FileBackend) SetServerPort(port string) error {
668	f, err := os.Create(filepath.Join(fb.settingsPath(), serverPort))
669	if err != nil {
670		return fmt.Errorf("failed to create server-port file: %w", err)
671	}
672
673	defer f.Close() //nolint:errcheck
674	_, err = fmt.Fprintln(f, port)
675	return err
676}
677
678// CreateRepository creates a new repository.
679//
680// Created repositories are always bare.
681//
682// It implements backend.Backend.
683func (fb *FileBackend) CreateRepository(name string, private bool) (backend.Repository, error) {
684	name = sanatizeRepo(name) + ".git"
685	rp := filepath.Join(fb.reposPath(), name)
686	if _, err := os.Stat(rp); err == nil {
687		return nil, os.ErrExist
688	}
689
690	if _, err := git.Init(rp, true); err != nil {
691		logger.Debug("failed to create repository", "err", err)
692		return nil, err
693	}
694
695	fb.SetPrivate(name, private)
696	fb.SetDescription(name, "")
697
698	return &Repo{path: rp, root: fb.reposPath()}, nil
699}
700
701// DeleteRepository deletes the given repository.
702//
703// It implements backend.Backend.
704func (fb *FileBackend) DeleteRepository(name string) error {
705	name = sanatizeRepo(name) + ".git"
706	return os.RemoveAll(filepath.Join(fb.reposPath(), name))
707}
708
709// RenameRepository renames the given repository.
710//
711// It implements backend.Backend.
712func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
713	oldName = filepath.Join(fb.reposPath(), sanatizeRepo(oldName)+".git")
714	newName = filepath.Join(fb.reposPath(), sanatizeRepo(newName)+".git")
715	if _, err := os.Stat(oldName); errors.Is(err, os.ErrNotExist) {
716		return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldName), ".git"))
717	}
718	if _, err := os.Stat(newName); err == nil {
719		return fmt.Errorf("repository %q already exists", strings.TrimSuffix(filepath.Base(newName), ".git"))
720	}
721
722	return os.Rename(oldName, newName)
723}
724
725// Repository finds the given repository.
726//
727// It implements backend.Backend.
728func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
729	repo = sanatizeRepo(repo) + ".git"
730	rp := filepath.Join(fb.reposPath(), repo)
731	_, err := os.Stat(rp)
732	if err != nil {
733		if errors.Is(err, os.ErrNotExist) {
734			return nil, os.ErrNotExist
735		}
736		return nil, err
737	}
738
739	return &Repo{path: rp, root: fb.reposPath()}, nil
740}
741
742// Repositories returns a list of all repositories.
743//
744// It implements backend.Backend.
745func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
746	repos := make([]backend.Repository, 0)
747	err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
748		// Skip non-directories.
749		if !d.IsDir() {
750			return nil
751		}
752
753		// Skip non-repositories.
754		if !strings.HasSuffix(path, ".git") {
755			return nil
756		}
757
758		repos = append(repos, &Repo{path: path, root: fb.reposPath()})
759
760		return nil
761	})
762	if err != nil {
763		return nil, err
764	}
765
766	return repos, nil
767}