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}