repo.go
23118 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 user := proto.UserFromContext(ctx)
297 r, err := d.Repository(ctx, name)
298 if err != nil {
299 return err
300 }
301
302 // We create the webhook event before deleting the repository so we can
303 // send the event after deleting the repository.
304 wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)
305 if err != nil {
306 return err
307 }
308
309 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
310 // Delete repo from cache
311 defer d.cache.Delete(name)
312
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 return webhook.SendEvent(ctx, wh)
359}
360
361// DeleteUserRepositories deletes all user repositories.
362func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {
363 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
364 user, err := d.store.FindUserByUsername(ctx, tx, username)
365 if err != nil {
366 return err
367 }
368
369 repos, err := d.store.GetUserRepos(ctx, tx, user.ID)
370 if err != nil {
371 return err
372 }
373
374 for _, repo := range repos {
375 if err := d.DeleteRepository(ctx, repo.Name); err != nil {
376 return err
377 }
378 }
379
380 return nil
381 }); err != nil {
382 return db.WrapError(err)
383 }
384
385 return nil
386}
387
388// RenameRepository renames a repository.
389//
390// It implements backend.Backend.
391func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {
392 oldName = utils.SanitizeRepo(oldName)
393 if err := utils.ValidateRepo(oldName); err != nil {
394 return err
395 }
396
397 newName = utils.SanitizeRepo(newName)
398 if err := utils.ValidateRepo(newName); err != nil {
399 return err
400 }
401
402 if oldName == newName {
403 return nil
404 }
405
406 op := filepath.Join(d.repoPath(oldName))
407 np := filepath.Join(d.repoPath(newName))
408 if _, err := os.Stat(op); err != nil {
409 return proto.ErrRepoNotFound
410 }
411
412 if _, err := os.Stat(np); err == nil {
413 return proto.ErrRepoExist
414 }
415
416 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
417 // Delete cache
418 defer d.cache.Delete(oldName)
419
420 if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {
421 return err
422 }
423
424 // Make sure the new repository parent directory exists.
425 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
426 return err
427 }
428
429 return os.Rename(op, np)
430 }); err != nil {
431 return db.WrapError(err)
432 }
433
434 user := proto.UserFromContext(ctx)
435 repo, err := d.Repository(ctx, newName)
436 if err != nil {
437 return err
438 }
439
440 wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)
441 if err != nil {
442 return err
443 }
444
445 return webhook.SendEvent(ctx, wh)
446}
447
448// Repositories returns a list of repositories per page.
449//
450// It implements backend.Backend.
451func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {
452 repos := make([]proto.Repository, 0)
453
454 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
455 ms, err := d.store.GetAllRepos(ctx, tx)
456 if err != nil {
457 return err
458 }
459
460 for _, m := range ms {
461 r := &repo{
462 name: m.Name,
463 path: filepath.Join(d.repoPath(m.Name)),
464 repo: m,
465 }
466
467 // Cache repositories
468 d.cache.Set(m.Name, r)
469
470 repos = append(repos, r)
471 }
472
473 return nil
474 }); err != nil {
475 return nil, db.WrapError(err)
476 }
477
478 return repos, nil
479}
480
481// Repository returns a repository by name.
482//
483// It implements backend.Backend.
484func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {
485 var m models.Repo
486 name = utils.SanitizeRepo(name)
487
488 if r, ok := d.cache.Get(name); ok && r != nil {
489 return r, nil
490 }
491
492 rp := filepath.Join(d.repoPath(name))
493 if _, err := os.Stat(rp); err != nil {
494 if !errors.Is(err, fs.ErrNotExist) {
495 d.logger.Errorf("failed to stat repository path: %v", err)
496 }
497 return nil, proto.ErrRepoNotFound
498 }
499
500 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
501 var err error
502 m, err = d.store.GetRepoByName(ctx, tx, name)
503 return db.WrapError(err)
504 }); err != nil {
505 if errors.Is(err, db.ErrRecordNotFound) {
506 return nil, proto.ErrRepoNotFound
507 }
508 return nil, db.WrapError(err)
509 }
510
511 r := &repo{
512 name: name,
513 path: rp,
514 repo: m,
515 }
516
517 // Add to cache
518 d.cache.Set(name, r)
519
520 return r, nil
521}
522
523// Description returns the description of a repository.
524//
525// It implements backend.Backend.
526func (d *Backend) Description(ctx context.Context, name string) (string, error) {
527 name = utils.SanitizeRepo(name)
528 var desc string
529 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
530 var err error
531 desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)
532 return err
533 }); err != nil {
534 return "", db.WrapError(err)
535 }
536
537 return desc, nil
538}
539
540// IsMirror returns true if the repository is a mirror.
541//
542// It implements backend.Backend.
543func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {
544 name = utils.SanitizeRepo(name)
545 var mirror bool
546 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
547 var err error
548 mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)
549 return err
550 }); err != nil {
551 return false, db.WrapError(err)
552 }
553 return mirror, nil
554}
555
556// IsPrivate returns true if the repository is private.
557//
558// It implements backend.Backend.
559func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {
560 name = utils.SanitizeRepo(name)
561 var private bool
562 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
563 var err error
564 private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)
565 return err
566 }); err != nil {
567 return false, db.WrapError(err)
568 }
569
570 return private, nil
571}
572
573// IsHidden returns true if the repository is hidden.
574//
575// It implements backend.Backend.
576func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {
577 name = utils.SanitizeRepo(name)
578 var hidden bool
579 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
580 var err error
581 hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)
582 return err
583 }); err != nil {
584 return false, db.WrapError(err)
585 }
586
587 return hidden, nil
588}
589
590// ProjectName returns the project name of a repository.
591//
592// It implements backend.Backend.
593func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {
594 name = utils.SanitizeRepo(name)
595 var pname string
596 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
597 var err error
598 pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)
599 return err
600 }); err != nil {
601 return "", db.WrapError(err)
602 }
603
604 return pname, nil
605}
606
607// SetHidden sets the hidden flag of a repository.
608//
609// It implements backend.Backend.
610func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {
611 name = utils.SanitizeRepo(name)
612
613 // Delete cache
614 d.cache.Delete(name)
615
616 return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
617 return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)
618 }))
619}
620
621// SetMirror sets the mirror flag of a repository.
622// Note: enabling mirror mode requires the repository to have been imported
623// with a remote URL. Use ImportRepository to create a new mirror.
624func (d *Backend) SetMirror(ctx context.Context, name string, mirror bool) error {
625 name = utils.SanitizeRepo(name)
626 rp := filepath.Join(d.repoPath(name))
627
628 // Delete cache
629 d.cache.Delete(name)
630
631 return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
632 // Update git config
633 r, err := git.Open(rp)
634 if err != nil {
635 return err
636 }
637
638 rcfg, err := r.Config()
639 if err != nil {
640 return err
641 }
642
643 // Update mirror option for all remotes
644 remoteSection := rcfg.Section("remote")
645 hasRemote := false
646 for _, sub := range remoteSection.Subsections {
647 // Check if this remote has a URL
648 for _, opt := range sub.Options {
649 if opt.Key == "url" && opt.Value != "" {
650 hasRemote = true
651 found := false
652 for i, opt := range sub.Options {
653 if opt.Key == "mirror" {
654 found = true
655 if mirror {
656 sub.Options[i].Value = "true"
657 } else {
658 sub.Options = append(sub.Options[:i], sub.Options[i+1:]...)
659 }
660 break
661 }
662 }
663 if !found && mirror {
664 sub.SetOption("mirror", "true")
665 }
666 break
667 }
668 }
669 }
670
671 if mirror && !hasRemote {
672 return errors.New("cannot enable mirror mode: repository has no remote URL configured")
673 }
674
675 if err := r.SetConfig(rcfg); err != nil {
676 d.logger.Error("failed to set repository config", "err", err, "path", rp)
677 return err
678 }
679
680 return d.store.SetRepoIsMirrorByName(ctx, tx, name, mirror)
681 }))
682}
683
684// SetDescription sets the description of a repository.
685//
686// It implements backend.Backend.
687func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error {
688 name = utils.SanitizeRepo(name)
689 desc = utils.Sanitize(desc)
690 rp := filepath.Join(d.repoPath(name))
691
692 // Delete cache
693 d.cache.Delete(name)
694
695 return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
696 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil {
697 d.logger.Error("failed to write description", "repo", name, "err", err)
698 return err
699 }
700
701 return d.store.SetRepoDescriptionByName(ctx, tx, name, desc)
702 })
703}
704
705// SetPrivate sets the private flag of a repository.
706//
707// It implements backend.Backend.
708func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {
709 name = utils.SanitizeRepo(name)
710 rp := filepath.Join(d.repoPath(name))
711
712 // Delete cache
713 d.cache.Delete(name)
714
715 if err := db.WrapError(
716 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
717 fp := filepath.Join(rp, "git-daemon-export-ok")
718 if !private {
719 if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {
720 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
721 return err
722 }
723 } else {
724 if _, err := os.Stat(fp); err == nil {
725 if err := os.Remove(fp); err != nil {
726 d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err)
727 return err
728 }
729 }
730 }
731
732 return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
733 }),
734 ); err != nil {
735 return err
736 }
737
738 user := proto.UserFromContext(ctx)
739 repo, err := d.Repository(ctx, name)
740 if err != nil {
741 return err
742 }
743
744 if repo.IsPrivate() != !private {
745 wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange)
746 if err != nil {
747 return err
748 }
749
750 if err := webhook.SendEvent(ctx, wh); err != nil {
751 return err
752 }
753 }
754
755 return nil
756}
757
758// SetProjectName sets the project name of a repository.
759//
760// It implements backend.Backend.
761func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {
762 repo = utils.SanitizeRepo(repo)
763 name = utils.Sanitize(name)
764
765 // Delete cache
766 d.cache.Delete(repo)
767
768 return db.WrapError(
769 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
770 return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)
771 }),
772 )
773}
774
775// repoPath returns the path to a repository.
776func (d *Backend) repoPath(name string) string {
777 name = utils.SanitizeRepo(name)
778 rn := strings.ReplaceAll(name, "/", string(os.PathSeparator))
779 return filepath.Join(filepath.Join(d.cfg.DataPath, "repos"), rn+".git")
780}
781
782var _ proto.Repository = (*repo)(nil)
783
784// repo is a Git repository with metadata stored in a SQLite database.
785type repo struct {
786 name string
787 path string
788 repo models.Repo
789}
790
791// ID returns the repository's ID.
792//
793// It implements proto.Repository.
794func (r *repo) ID() int64 {
795 return r.repo.ID
796}
797
798// UserID returns the repository's owner's user ID.
799// If the repository is not owned by anyone, it returns 0.
800//
801// It implements proto.Repository.
802func (r *repo) UserID() int64 {
803 if r.repo.UserID.Valid {
804 return r.repo.UserID.Int64
805 }
806 return 0
807}
808
809// Description returns the repository's description.
810//
811// It implements backend.Repository.
812func (r *repo) Description() string {
813 return r.repo.Description
814}
815
816// IsMirror returns whether the repository is a mirror.
817//
818// It implements backend.Repository.
819func (r *repo) IsMirror() bool {
820 return r.repo.Mirror
821}
822
823// IsPrivate returns whether the repository is private.
824//
825// It implements backend.Repository.
826func (r *repo) IsPrivate() bool {
827 return r.repo.Private
828}
829
830// Name returns the repository's name.
831//
832// It implements backend.Repository.
833func (r *repo) Name() string {
834 return r.name
835}
836
837// Open opens the repository.
838//
839// It implements backend.Repository.
840func (r *repo) Open() (*git.Repository, error) {
841 return git.Open(r.path)
842}
843
844// ProjectName returns the repository's project name.
845//
846// It implements backend.Repository.
847func (r *repo) ProjectName() string {
848 return r.repo.ProjectName
849}
850
851// IsHidden returns whether the repository is hidden.
852//
853// It implements backend.Repository.
854func (r *repo) IsHidden() bool {
855 return r.repo.Hidden
856}
857
858// CreatedAt returns the repository's creation time.
859func (r *repo) CreatedAt() time.Time {
860 return r.repo.CreatedAt
861}
862
863// UpdatedAt returns the repository's last update time.
864func (r *repo) UpdatedAt() time.Time {
865 // Try to read the last modified time from the info directory.
866 if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {
867 if t, err := time.Parse(time.RFC3339, t); err == nil {
868 return t
869 }
870 }
871
872 rr, err := git.Open(r.path)
873 if err == nil {
874 t, err := rr.LatestCommitTime()
875 if err == nil {
876 return t
877 }
878 }
879
880 return r.repo.UpdatedAt
881}
882
883func (r *repo) writeLastModified(t time.Time) error {
884 fp := filepath.Join(r.path, "info", "last-modified")
885 if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
886 return err
887 }
888
889 return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) //nolint:gosec
890}
891
892func readOneline(path string) (string, error) {
893 f, err := os.Open(path)
894 if err != nil {
895 return "", err
896 }
897
898 defer f.Close() //nolint: errcheck
899 s := bufio.NewScanner(f)
900 s.Scan()
901 return s.Text(), s.Err()
902}