Parent directory

repo.go

22371 bytes
  1package backend
  2
  3import (
  4	"bufio"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"io/fs"
  9	"os"
 10	"path"
 11	"path/filepath"
 12	"strconv"
 13	"strings"
 14	"time"
 15
 16	"github.com/charmbracelet/soft-serve/git"
 17	"github.com/charmbracelet/soft-serve/pkg/db"
 18	"github.com/charmbracelet/soft-serve/pkg/db/models"
 19	"github.com/charmbracelet/soft-serve/pkg/hooks"
 20	"github.com/charmbracelet/soft-serve/pkg/lfs"
 21	"github.com/charmbracelet/soft-serve/pkg/proto"
 22	"github.com/charmbracelet/soft-serve/pkg/ssrf"
 23	"github.com/charmbracelet/soft-serve/pkg/storage"
 24	"github.com/charmbracelet/soft-serve/pkg/task"
 25	"github.com/charmbracelet/soft-serve/pkg/utils"
 26	"github.com/charmbracelet/soft-serve/pkg/webhook"
 27)
 28
 29// validateImportRemote validates an import remote against private, internal,
 30// and loopback ranges, and returns the git environment that must be applied to
 31// the clone subprocess for that validation to hold.
 32func validateImportRemote(remote string) ([]string, error) {
 33	endpoint, err := lfs.NewEndpoint(remote)
 34	if err != nil || endpoint.Host == "" {
 35		return nil, proto.ErrInvalidRemote
 36	}
 37
 38	// Validate the remote as given rather than the LFS endpoint, which has
 39	// already had its scheme rewritten and its path rewritten to /info/lfs.
 40	v, err := ssrf.ValidateGitRemote(remote)
 41	if err != nil {
 42		return nil, fmt.Errorf("%w: %w", proto.ErrInvalidRemote, err)
 43	}
 44
 45	return ssrf.GitEnv(v), nil
 46}
 47
 48// CreateRepository creates a new repository.
 49//
 50// It implements backend.Backend.
 51func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) {
 52	name = utils.SanitizeRepo(name)
 53	if err := utils.ValidateRepo(name); err != nil {
 54		return nil, err
 55	}
 56
 57	rp := filepath.Join(d.repoPath(name))
 58
 59	var userID int64
 60	if user != nil {
 61		userID = user.ID()
 62	}
 63
 64	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 65		if userID == 0 {
 66			// Anonymous callers (e.g. an anon-access/allow-keyless
 67			// override) have no user of their own, but repos.user_id is
 68			// NOT NULL. Fall back to the default admin as owner, the same
 69			// "ownerless repos belong to the first admin" idiom already
 70			// used when migrating repos from the pre-user schema (see
 71			// 0001_create_tables.go).
 72			adminID, err := d.defaultAdminUserID(ctx, tx)
 73			if err != nil {
 74				return err
 75			}
 76			userID = adminID
 77		}
 78
 79		if err := d.store.CreateRepo(
 80			ctx,
 81			tx,
 82			name,
 83			userID,
 84			opts.ProjectName,
 85			opts.Description,
 86			opts.Private,
 87			opts.Hidden,
 88			opts.Mirror,
 89		); err != nil {
 90			return err
 91		}
 92
 93		_, err := git.Init(rp, true)
 94		if err != nil {
 95			d.logger.Debug("failed to create repository", "err", err)
 96			return err
 97		}
 98
 99		if err := os.WriteFile(filepath.Join(rp, "description"), []byte(opts.Description), fs.ModePerm); err != nil {
100			d.logger.Error("failed to write description", "repo", name, "err", err)
101			return err
102		}
103
104		if !opts.Private {
105			if err := os.WriteFile(filepath.Join(rp, "git-daemon-export-ok"), []byte{}, fs.ModePerm); err != nil {
106				d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
107				return err
108			}
109		}
110
111		return hooks.GenerateHooks(ctx, d.cfg, name)
112	}); err != nil {
113		d.logger.Debug("failed to create repository in database", "err", err)
114		err = db.WrapError(err)
115		if errors.Is(err, db.ErrDuplicateKey) {
116			return nil, proto.ErrRepoExist
117		}
118
119		return nil, err
120	}
121
122	return d.Repository(ctx, name)
123}
124
125// defaultAdminUserID returns the ID of the lowest-ID admin user, used to
126// own repositories created by callers with no user of their own (anonymous
127// access via an anon-access/allow-keyless override). The migration that
128// seeds the initial database always creates an admin user, so this should
129// never fail to find one in practice.
130func (d *Backend) defaultAdminUserID(ctx context.Context, tx *db.Tx) (int64, error) {
131	users, err := d.store.GetAllUsers(ctx, tx)
132	if err != nil {
133		return 0, err
134	}
135
136	var adminID int64
137	for _, u := range users {
138		if u.Admin && (adminID == 0 || u.ID < adminID) {
139			adminID = u.ID
140		}
141	}
142
143	if adminID == 0 {
144		return 0, errors.New("no admin user found to own anonymous repository")
145	}
146
147	return adminID, nil
148}
149
150// ImportRepository imports a repository from remote.
151// XXX: This a expensive operation and should be run in a goroutine.
152func (d *Backend) ImportRepository(_ context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
153	name = utils.SanitizeRepo(name)
154	if err := utils.ValidateRepo(name); err != nil {
155		return nil, err
156	}
157
158	remote = utils.Sanitize(remote)
159	remoteEnv, err := validateImportRemote(remote)
160	if err != nil {
161		return nil, err
162	}
163
164	// The LFS endpoint is user-supplied and gets persisted to the repo config,
165	// where the mirror job later reads it. Validate it up front rather than
166	// relying on the LFS client to refuse the dial.
167	if opts.LFSEndpoint != "" {
168		if _, err := ssrf.ValidateGitRemote(opts.LFSEndpoint); err != nil {
169			return nil, fmt.Errorf("%w: %w", proto.ErrInvalidRemote, err)
170		}
171	}
172
173	rp := filepath.Join(d.repoPath(name))
174
175	tid := "import:" + name
176	if d.manager.Exists(tid) {
177		return nil, task.ErrAlreadyStarted
178	}
179
180	if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
181		return nil, proto.ErrRepoExist
182	}
183
184	done := make(chan error, 1)
185	repoc := make(chan proto.Repository, 1)
186	d.logger.Info("importing repository", "name", name, "remote", remote, "path", rp)
187	d.manager.Add(tid, func(ctx context.Context) (err error) {
188		ctx = proto.WithUserContext(ctx, user)
189
190		// remoteEnv carries the SSRF guard: it disables redirect following
191		// and pins the validated address. Without it the clone can reach a
192		// different host than the one validated above.
193		cloneEnv := append(remoteEnv,
194			fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
195				filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
196				d.cfg.SSH.ClientKeyPath,
197			),
198		)
199
200		copts := git.CloneOptions{
201			Bare:   true,
202			Mirror: opts.Mirror,
203			Quiet:  true,
204			CommandOptions: git.CommandOptions{
205				Timeout: -1,
206				Context: ctx,
207				Envs:    cloneEnv,
208			},
209		}
210
211		if err := git.Clone(remote, rp, copts); err != nil {
212			d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
213			// Cleanup the mess!
214			if rerr := os.RemoveAll(rp); rerr != nil {
215				err = errors.Join(err, rerr)
216			}
217
218			return err
219		}
220
221		r, err := d.CreateRepository(ctx, name, user, opts)
222		if err != nil {
223			d.logger.Error("failed to create repository", "err", err, "name", name)
224			return err
225		}
226
227		defer func() {
228			if err != nil {
229				if rerr := d.DeleteRepository(ctx, name); rerr != nil {
230					d.logger.Error("failed to delete repository", "err", rerr, "name", name)
231				}
232			}
233		}()
234
235		rr, err := r.Open()
236		if err != nil {
237			d.logger.Error("failed to open repository", "err", err, "path", rp)
238			return err
239		}
240
241		repoc <- r
242
243		rcfg, err := rr.Config()
244		if err != nil {
245			d.logger.Error("failed to get repository config", "err", err, "path", rp)
246			return err
247		}
248
249		endpoint := remote
250		if opts.LFSEndpoint != "" {
251			endpoint = opts.LFSEndpoint
252		}
253
254		rcfg.Section("lfs").SetOption("url", endpoint)
255
256		if err := rr.SetConfig(rcfg); err != nil {
257			d.logger.Error("failed to set repository config", "err", err, "path", rp)
258			return err
259		}
260
261		ep, err := lfs.NewEndpoint(endpoint)
262		if err != nil {
263			d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp)
264			return err
265		}
266
267		client := lfs.NewClient(ep)
268		if client == nil {
269			d.logger.Warn("failed to create lfs client: unsupported endpoint", "endpoint", endpoint)
270			return nil
271		}
272
273		if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {
274			d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp)
275			return err
276		}
277
278		return nil
279	})
280
281	go func() {
282		d.logger.Info("running import", "name", name)
283		d.manager.Run(tid, done)
284	}()
285
286	return <-repoc, <-done
287}
288
289// DeleteRepository deletes a repository.
290//
291// It implements backend.Backend.
292func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
293	name = utils.SanitizeRepo(name)
294	rp := filepath.Join(d.repoPath(name))
295
296	// Build the webhook event before deleting so it can be sent after. A
297	// repository whose directory is gone has no event, but its database row
298	// still has to go.
299	var wh *webhook.RepositoryEvent
300	r, err := d.Repository(ctx, name)
301	switch {
302	case err == nil:
303		ev, err := webhook.NewRepositoryEvent(ctx, proto.UserFromContext(ctx), r, webhook.RepositoryEventActionDelete)
304		if err != nil {
305			return err
306		}
307		wh = &ev
308	case !errors.Is(err, proto.ErrRepoNotFound):
309		return err
310	}
311
312	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
313		repom, dberr := d.store.GetRepoByName(ctx, tx, name)
314		_, ferr := os.Stat(rp)
315		if dberr != nil && ferr != nil {
316			return proto.ErrRepoNotFound
317		}
318
319		// If the repo is not in the database but the directory exists, remove it
320		if dberr != nil && ferr == nil {
321			return os.RemoveAll(rp)
322		} else if dberr != nil {
323			return db.WrapError(dberr)
324		}
325
326		repoID := strconv.FormatInt(repom.ID, 10)
327		strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs", repoID))
328		objs, err := d.store.GetLFSObjectsByName(ctx, tx, name)
329		if err != nil {
330			return db.WrapError(err)
331		}
332
333		for _, obj := range objs {
334			p := lfs.Pointer{
335				Oid:  obj.Oid,
336				Size: obj.Size,
337			}
338
339			d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid)
340			if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil {
341				d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid)
342			}
343		}
344
345		if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {
346			return db.WrapError(err)
347		}
348
349		return os.RemoveAll(rp)
350	}); err != nil {
351		if errors.Is(err, db.ErrRecordNotFound) {
352			return proto.ErrRepoNotFound
353		}
354
355		return db.WrapError(err)
356	}
357
358	if wh == nil {
359		return nil
360	}
361
362	return webhook.SendEvent(ctx, *wh)
363}
364
365// RenameRepository renames a repository.
366//
367// It implements backend.Backend.
368func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {
369	oldName = utils.SanitizeRepo(oldName)
370	if err := utils.ValidateRepo(oldName); err != nil {
371		return err
372	}
373
374	newName = utils.SanitizeRepo(newName)
375	if err := utils.ValidateRepo(newName); err != nil {
376		return err
377	}
378
379	if oldName == newName {
380		return nil
381	}
382
383	op := filepath.Join(d.repoPath(oldName))
384	np := filepath.Join(d.repoPath(newName))
385	if _, err := os.Stat(op); err != nil {
386		return proto.ErrRepoNotFound
387	}
388
389	if _, err := os.Stat(np); err == nil {
390		return proto.ErrRepoExist
391	}
392
393	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
394		if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {
395			return err
396		}
397
398		// Make sure the new repository parent directory exists.
399		if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
400			return err
401		}
402
403		return os.Rename(op, np)
404	}); err != nil {
405		return db.WrapError(err)
406	}
407
408	user := proto.UserFromContext(ctx)
409	repo, err := d.Repository(ctx, newName)
410	if err != nil {
411		return err
412	}
413
414	wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)
415	if err != nil {
416		return err
417	}
418
419	return webhook.SendEvent(ctx, wh)
420}
421
422// Repositories returns a list of repositories per page.
423//
424// It implements backend.Backend.
425func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {
426	repos := make([]proto.Repository, 0)
427
428	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
429		ms, err := d.store.GetAllRepos(ctx, tx)
430		if err != nil {
431			return err
432		}
433
434		for _, m := range ms {
435			r := &repo{
436				name: m.Name,
437				path: filepath.Join(d.repoPath(m.Name)),
438				repo: m,
439			}
440
441			repos = append(repos, r)
442		}
443
444		return nil
445	}); err != nil {
446		return nil, db.WrapError(err)
447	}
448
449	return repos, nil
450}
451
452// Repository returns a repository by name.
453//
454// It implements backend.Backend.
455func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {
456	var m models.Repo
457	name = utils.SanitizeRepo(name)
458
459	rp := filepath.Join(d.repoPath(name))
460	if _, err := os.Stat(rp); err != nil {
461		if !errors.Is(err, fs.ErrNotExist) {
462			d.logger.Errorf("failed to stat repository path: %v", err)
463		}
464		return nil, proto.ErrRepoNotFound
465	}
466
467	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
468		var err error
469		m, err = d.store.GetRepoByName(ctx, tx, name)
470		return db.WrapError(err)
471	}); err != nil {
472		if errors.Is(err, db.ErrRecordNotFound) {
473			return nil, proto.ErrRepoNotFound
474		}
475		return nil, db.WrapError(err)
476	}
477
478	r := &repo{
479		name: name,
480		path: rp,
481		repo: m,
482	}
483
484	return r, nil
485}
486
487// Description returns the description of a repository.
488//
489// It implements backend.Backend.
490func (d *Backend) Description(ctx context.Context, name string) (string, error) {
491	name = utils.SanitizeRepo(name)
492	var desc string
493	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
494		var err error
495		desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)
496		return err
497	}); err != nil {
498		return "", db.WrapError(err)
499	}
500
501	return desc, nil
502}
503
504// IsMirror returns true if the repository is a mirror.
505//
506// It implements backend.Backend.
507func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {
508	name = utils.SanitizeRepo(name)
509	var mirror bool
510	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
511		var err error
512		mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)
513		return err
514	}); err != nil {
515		return false, db.WrapError(err)
516	}
517	return mirror, nil
518}
519
520// IsPrivate returns true if the repository is private.
521//
522// It implements backend.Backend.
523func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {
524	name = utils.SanitizeRepo(name)
525	var private bool
526	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
527		var err error
528		private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)
529		return err
530	}); err != nil {
531		return false, db.WrapError(err)
532	}
533
534	return private, nil
535}
536
537// IsHidden returns true if the repository is hidden.
538//
539// It implements backend.Backend.
540func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {
541	name = utils.SanitizeRepo(name)
542	var hidden bool
543	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
544		var err error
545		hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)
546		return err
547	}); err != nil {
548		return false, db.WrapError(err)
549	}
550
551	return hidden, nil
552}
553
554// ProjectName returns the project name of a repository.
555//
556// It implements backend.Backend.
557func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {
558	name = utils.SanitizeRepo(name)
559	var pname string
560	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
561		var err error
562		pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)
563		return err
564	}); err != nil {
565		return "", db.WrapError(err)
566	}
567
568	return pname, nil
569}
570
571// SetHidden sets the hidden flag of a repository.
572//
573// It implements backend.Backend.
574func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {
575	name = utils.SanitizeRepo(name)
576
577	return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
578		return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)
579	}))
580}
581
582// SetMirror sets the mirror flag of a repository.
583// Note: enabling mirror mode requires the repository to have been imported
584// with a remote URL. Use ImportRepository to create a new mirror.
585func (d *Backend) SetMirror(ctx context.Context, name string, mirror bool) error {
586	name = utils.SanitizeRepo(name)
587	rp := filepath.Join(d.repoPath(name))
588
589	return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
590		// Update git config
591		r, err := git.Open(rp)
592		if err != nil {
593			return err
594		}
595
596		rcfg, err := r.Config()
597		if err != nil {
598			return err
599		}
600
601		// Update mirror option for all remotes
602		remoteSection := rcfg.Section("remote")
603		hasRemote := false
604		for _, sub := range remoteSection.Subsections {
605			// Check if this remote has a URL
606			for _, opt := range sub.Options {
607				if opt.Key == "url" && opt.Value != "" {
608					hasRemote = true
609					found := false
610					for i, opt := range sub.Options {
611						if opt.Key == "mirror" {
612							found = true
613							if mirror {
614								sub.Options[i].Value = "true"
615							} else {
616								sub.Options = append(sub.Options[:i], sub.Options[i+1:]...)
617							}
618							break
619						}
620					}
621					if !found && mirror {
622						sub.SetOption("mirror", "true")
623					}
624					break
625				}
626			}
627		}
628
629		if mirror && !hasRemote {
630			return errors.New("cannot enable mirror mode: repository has no remote URL configured")
631		}
632
633		if err := r.SetConfig(rcfg); err != nil {
634			d.logger.Error("failed to set repository config", "err", err, "path", rp)
635			return err
636		}
637
638		return d.store.SetRepoIsMirrorByName(ctx, tx, name, mirror)
639	}))
640}
641
642// SetDescription sets the description of a repository.
643//
644// It implements backend.Backend.
645func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error {
646	name = utils.SanitizeRepo(name)
647	desc = utils.Sanitize(desc)
648	rp := filepath.Join(d.repoPath(name))
649
650	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
651		if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil {
652			d.logger.Error("failed to write description", "repo", name, "err", err)
653			return err
654		}
655
656		return d.store.SetRepoDescriptionByName(ctx, tx, name, desc)
657	})
658}
659
660// SetPrivate sets the private flag of a repository.
661//
662// It implements backend.Backend.
663func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {
664	name = utils.SanitizeRepo(name)
665	rp := filepath.Join(d.repoPath(name))
666
667	var changed bool
668	if err := db.WrapError(
669		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
670			m, err := d.store.GetRepoByName(ctx, tx, name)
671			if err != nil {
672				return err
673			}
674			changed = m.Private != private
675
676			fp := filepath.Join(rp, "git-daemon-export-ok")
677			if !private {
678				if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {
679					d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
680					return err
681				}
682			} else {
683				if _, err := os.Stat(fp); err == nil {
684					if err := os.Remove(fp); err != nil {
685						d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err)
686						return err
687					}
688				}
689			}
690
691			return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
692		}),
693	); err != nil {
694		if errors.Is(err, db.ErrRecordNotFound) {
695			return proto.ErrRepoNotFound
696		}
697		return err
698	}
699
700	if !changed {
701		return nil
702	}
703
704	repo, err := d.Repository(ctx, name)
705	if err != nil {
706		return err
707	}
708
709	wh, err := webhook.NewRepositoryEvent(ctx, proto.UserFromContext(ctx), repo, webhook.RepositoryEventActionVisibilityChange)
710	if err != nil {
711		return err
712	}
713
714	return webhook.SendEvent(ctx, wh)
715}
716
717// SetProjectName sets the project name of a repository.
718//
719// It implements backend.Backend.
720func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {
721	repo = utils.SanitizeRepo(repo)
722	name = utils.Sanitize(name)
723
724	return db.WrapError(
725		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
726			return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)
727		}),
728	)
729}
730
731// repoPath returns the path to a repository.
732func (d *Backend) repoPath(name string) string {
733	name = utils.SanitizeRepo(name)
734	rn := strings.ReplaceAll(name, "/", string(os.PathSeparator))
735	return filepath.Join(filepath.Join(d.cfg.DataPath, "repos"), rn+".git")
736}
737
738var _ proto.Repository = (*repo)(nil)
739
740// repo is a Git repository with metadata stored in a SQLite database.
741type repo struct {
742	name string
743	path string
744	repo models.Repo
745}
746
747// ID returns the repository's ID.
748//
749// It implements proto.Repository.
750func (r *repo) ID() int64 {
751	return r.repo.ID
752}
753
754// UserID returns the repository's owner's user ID.
755// If the repository is not owned by anyone, it returns 0.
756//
757// It implements proto.Repository.
758func (r *repo) UserID() int64 {
759	if r.repo.UserID.Valid {
760		return r.repo.UserID.Int64
761	}
762	return 0
763}
764
765// Description returns the repository's description.
766//
767// It implements backend.Repository.
768func (r *repo) Description() string {
769	return r.repo.Description
770}
771
772// IsMirror returns whether the repository is a mirror.
773//
774// It implements backend.Repository.
775func (r *repo) IsMirror() bool {
776	return r.repo.Mirror
777}
778
779// IsPrivate returns whether the repository is private.
780//
781// It implements backend.Repository.
782func (r *repo) IsPrivate() bool {
783	return r.repo.Private
784}
785
786// Name returns the repository's name.
787//
788// It implements backend.Repository.
789func (r *repo) Name() string {
790	return r.name
791}
792
793// Open opens the repository.
794//
795// It implements backend.Repository.
796func (r *repo) Open() (*git.Repository, error) {
797	return git.Open(r.path)
798}
799
800// ProjectName returns the repository's project name.
801//
802// It implements backend.Repository.
803func (r *repo) ProjectName() string {
804	return r.repo.ProjectName
805}
806
807// IsHidden returns whether the repository is hidden.
808//
809// It implements backend.Repository.
810func (r *repo) IsHidden() bool {
811	return r.repo.Hidden
812}
813
814// CreatedAt returns the repository's creation time.
815func (r *repo) CreatedAt() time.Time {
816	return r.repo.CreatedAt
817}
818
819// UpdatedAt returns the repository's last update time.
820func (r *repo) UpdatedAt() time.Time {
821	// Try to read the last modified time from the info directory.
822	if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {
823		if t, err := time.Parse(time.RFC3339, t); err == nil {
824			return t
825		}
826	}
827
828	rr, err := git.Open(r.path)
829	if err == nil {
830		t, err := rr.LatestCommitTime()
831		if err == nil {
832			return t
833		}
834	}
835
836	return r.repo.UpdatedAt
837}
838
839func (r *repo) writeLastModified(t time.Time) error {
840	fp := filepath.Join(r.path, "info", "last-modified")
841	if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
842		return err
843	}
844
845	return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) //nolint:gosec
846}
847
848func readOneline(path string) (string, error) {
849	f, err := os.Open(path)
850	if err != nil {
851		return "", err
852	}
853
854	defer f.Close() //nolint: errcheck
855	s := bufio.NewScanner(f)
856	s.Scan()
857	return s.Text(), s.Err()
858}