repo.go

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