repo.go

  1package backend
  2
  3import (
  4	"bufio"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"os"
  9	"path/filepath"
 10	"time"
 11
 12	"github.com/charmbracelet/soft-serve/git"
 13	"github.com/charmbracelet/soft-serve/server/db"
 14	"github.com/charmbracelet/soft-serve/server/db/models"
 15	"github.com/charmbracelet/soft-serve/server/hooks"
 16	"github.com/charmbracelet/soft-serve/server/proto"
 17	"github.com/charmbracelet/soft-serve/server/utils"
 18)
 19
 20func (d *Backend) reposPath() string {
 21	return filepath.Join(d.cfg.DataPath, "repos")
 22}
 23
 24// CreateRepository creates a new repository.
 25//
 26// It implements backend.Backend.
 27func (d *Backend) CreateRepository(ctx context.Context, name string, opts proto.RepositoryOptions) (proto.Repository, error) {
 28	name = utils.SanitizeRepo(name)
 29	if err := utils.ValidateRepo(name); err != nil {
 30		return nil, err
 31	}
 32
 33	repo := name + ".git"
 34	rp := filepath.Join(d.reposPath(), repo)
 35
 36	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 37		if err := d.store.CreateRepo(
 38			ctx,
 39			tx,
 40			name,
 41			opts.ProjectName,
 42			opts.Description,
 43			opts.Private,
 44			opts.Hidden,
 45			opts.Mirror,
 46		); err != nil {
 47			return err
 48		}
 49
 50		_, err := git.Init(rp, true)
 51		if err != nil {
 52			d.logger.Debug("failed to create repository", "err", err)
 53			return err
 54		}
 55
 56		return hooks.GenerateHooks(ctx, d.cfg, repo)
 57	}); err != nil {
 58		d.logger.Debug("failed to create repository in database", "err", err)
 59		return nil, db.WrapError(err)
 60	}
 61
 62	return d.Repository(ctx, name)
 63}
 64
 65// ImportRepository imports a repository from remote.
 66func (d *Backend) ImportRepository(ctx context.Context, name string, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
 67	name = utils.SanitizeRepo(name)
 68	if err := utils.ValidateRepo(name); err != nil {
 69		return nil, err
 70	}
 71
 72	repo := name + ".git"
 73	rp := filepath.Join(d.reposPath(), repo)
 74
 75	if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
 76		return nil, proto.ErrRepoExist
 77	}
 78
 79	copts := git.CloneOptions{
 80		Bare:   true,
 81		Mirror: opts.Mirror,
 82		Quiet:  true,
 83		CommandOptions: git.CommandOptions{
 84			Timeout: -1,
 85			Context: ctx,
 86			Envs: []string{
 87				fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
 88					filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
 89					d.cfg.SSH.ClientKeyPath,
 90				),
 91			},
 92		},
 93		// Timeout: time.Hour,
 94	}
 95
 96	if err := git.Clone(remote, rp, copts); err != nil {
 97		d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
 98		// Cleanup the mess!
 99		if rerr := os.RemoveAll(rp); rerr != nil {
100			err = errors.Join(err, rerr)
101		}
102		return nil, err
103	}
104
105	return d.CreateRepository(ctx, name, opts)
106}
107
108// DeleteRepository deletes a repository.
109//
110// It implements backend.Backend.
111func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
112	name = utils.SanitizeRepo(name)
113	repo := name + ".git"
114	rp := filepath.Join(d.reposPath(), repo)
115
116	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
117		// Delete repo from cache
118		defer d.cache.Delete(name)
119
120		if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {
121			return err
122		}
123
124		return os.RemoveAll(rp)
125	})
126}
127
128// RenameRepository renames a repository.
129//
130// It implements backend.Backend.
131func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {
132	oldName = utils.SanitizeRepo(oldName)
133	if err := utils.ValidateRepo(oldName); err != nil {
134		return err
135	}
136
137	newName = utils.SanitizeRepo(newName)
138	if err := utils.ValidateRepo(newName); err != nil {
139		return err
140	}
141	oldRepo := oldName + ".git"
142	newRepo := newName + ".git"
143	op := filepath.Join(d.reposPath(), oldRepo)
144	np := filepath.Join(d.reposPath(), newRepo)
145	if _, err := os.Stat(op); err != nil {
146		return proto.ErrRepoNotExist
147	}
148
149	if _, err := os.Stat(np); err == nil {
150		return proto.ErrRepoExist
151	}
152
153	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
154		// Delete cache
155		defer d.cache.Delete(oldName)
156
157		if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {
158			return err
159		}
160
161		// Make sure the new repository parent directory exists.
162		if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
163			return err
164		}
165
166		return os.Rename(op, np)
167	}); err != nil {
168		return db.WrapError(err)
169	}
170
171	return nil
172}
173
174// Repositories returns a list of repositories per page.
175//
176// It implements backend.Backend.
177func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {
178	repos := make([]proto.Repository, 0)
179
180	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
181		ms, err := d.store.GetAllRepos(ctx, tx)
182		if err != nil {
183			return err
184		}
185
186		for _, m := range ms {
187			r := &repo{
188				name: m.Name,
189				path: filepath.Join(d.reposPath(), m.Name+".git"),
190				repo: m,
191			}
192
193			// Cache repositories
194			d.cache.Set(m.Name, r)
195
196			repos = append(repos, r)
197		}
198
199		return nil
200	}); err != nil {
201		return nil, db.WrapError(err)
202	}
203
204	return repos, nil
205}
206
207// Repository returns a repository by name.
208//
209// It implements backend.Backend.
210func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {
211	var m models.Repo
212	name = utils.SanitizeRepo(name)
213
214	if r, ok := d.cache.Get(name); ok && r != nil {
215		return r, nil
216	}
217
218	rp := filepath.Join(d.reposPath(), name+".git")
219	if _, err := os.Stat(rp); err != nil {
220		return nil, os.ErrNotExist
221	}
222
223	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
224		var err error
225		m, err = d.store.GetRepoByName(ctx, tx, name)
226		return err
227	}); err != nil {
228		return nil, db.WrapError(err)
229	}
230
231	r := &repo{
232		name: name,
233		path: rp,
234		repo: m,
235	}
236
237	// Add to cache
238	d.cache.Set(name, r)
239
240	return r, nil
241}
242
243// Description returns the description of a repository.
244//
245// It implements backend.Backend.
246func (d *Backend) Description(ctx context.Context, name string) (string, error) {
247	name = utils.SanitizeRepo(name)
248	var desc string
249	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
250		var err error
251		desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)
252		return err
253	}); err != nil {
254		return "", db.WrapError(err)
255	}
256
257	return desc, nil
258}
259
260// IsMirror returns true if the repository is a mirror.
261//
262// It implements backend.Backend.
263func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {
264	name = utils.SanitizeRepo(name)
265	var mirror bool
266	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
267		var err error
268		mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)
269		return err
270	}); err != nil {
271		return false, db.WrapError(err)
272	}
273	return mirror, nil
274}
275
276// IsPrivate returns true if the repository is private.
277//
278// It implements backend.Backend.
279func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {
280	name = utils.SanitizeRepo(name)
281	var private bool
282	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
283		var err error
284		private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)
285		return err
286	}); err != nil {
287		return false, db.WrapError(err)
288	}
289
290	return private, nil
291}
292
293// IsHidden returns true if the repository is hidden.
294//
295// It implements backend.Backend.
296func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {
297	name = utils.SanitizeRepo(name)
298	var hidden bool
299	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
300		var err error
301		hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)
302		return err
303	}); err != nil {
304		return false, db.WrapError(err)
305	}
306
307	return hidden, nil
308}
309
310// ProjectName returns the project name of a repository.
311//
312// It implements backend.Backend.
313func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {
314	name = utils.SanitizeRepo(name)
315	var pname string
316	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
317		var err error
318		pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)
319		return err
320	}); err != nil {
321		return "", db.WrapError(err)
322	}
323
324	return pname, nil
325}
326
327// SetHidden sets the hidden flag of a repository.
328//
329// It implements backend.Backend.
330func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {
331	name = utils.SanitizeRepo(name)
332
333	// Delete cache
334	d.cache.Delete(name)
335
336	return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
337		return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)
338	}))
339}
340
341// SetDescription sets the description of a repository.
342//
343// It implements backend.Backend.
344func (d *Backend) SetDescription(ctx context.Context, repo string, desc string) error {
345	repo = utils.SanitizeRepo(repo)
346
347	// Delete cache
348	d.cache.Delete(repo)
349
350	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
351		return d.store.SetRepoDescriptionByName(ctx, tx, repo, desc)
352	})
353}
354
355// SetPrivate sets the private flag of a repository.
356//
357// It implements backend.Backend.
358func (d *Backend) SetPrivate(ctx context.Context, repo string, private bool) error {
359	repo = utils.SanitizeRepo(repo)
360
361	// Delete cache
362	d.cache.Delete(repo)
363
364	return db.WrapError(
365		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
366			return d.store.SetRepoIsPrivateByName(ctx, tx, repo, private)
367		}),
368	)
369}
370
371// SetProjectName sets the project name of a repository.
372//
373// It implements backend.Backend.
374func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {
375	repo = utils.SanitizeRepo(repo)
376
377	// Delete cache
378	d.cache.Delete(repo)
379
380	return db.WrapError(
381		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
382			return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)
383		}),
384	)
385}
386
387var _ proto.Repository = (*repo)(nil)
388
389// repo is a Git repository with metadata stored in a SQLite database.
390type repo struct {
391	name string
392	path string
393	repo models.Repo
394}
395
396// Description returns the repository's description.
397//
398// It implements backend.Repository.
399func (r *repo) Description() string {
400	return r.repo.Description
401}
402
403// IsMirror returns whether the repository is a mirror.
404//
405// It implements backend.Repository.
406func (r *repo) IsMirror() bool {
407	return r.repo.Mirror
408}
409
410// IsPrivate returns whether the repository is private.
411//
412// It implements backend.Repository.
413func (r *repo) IsPrivate() bool {
414	return r.repo.Private
415}
416
417// Name returns the repository's name.
418//
419// It implements backend.Repository.
420func (r *repo) Name() string {
421	return r.name
422}
423
424// Open opens the repository.
425//
426// It implements backend.Repository.
427func (r *repo) Open() (*git.Repository, error) {
428	return git.Open(r.path)
429}
430
431// ProjectName returns the repository's project name.
432//
433// It implements backend.Repository.
434func (r *repo) ProjectName() string {
435	return r.repo.ProjectName
436}
437
438// IsHidden returns whether the repository is hidden.
439//
440// It implements backend.Repository.
441func (r *repo) IsHidden() bool {
442	return r.repo.Hidden
443}
444
445// UpdatedAt returns the repository's last update time.
446func (r *repo) UpdatedAt() time.Time {
447	// Try to read the last modified time from the info directory.
448	if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {
449		if t, err := time.Parse(time.RFC3339, t); err == nil {
450			return t
451		}
452	}
453
454	rr, err := git.Open(r.path)
455	if err == nil {
456		t, err := rr.LatestCommitTime()
457		if err == nil {
458			return t
459		}
460	}
461
462	return r.repo.UpdatedAt
463}
464
465func (r *repo) writeLastModified(t time.Time) error {
466	fp := filepath.Join(r.path, "info", "last-modified")
467	if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
468		return err
469	}
470
471	return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm)
472}
473
474func readOneline(path string) (string, error) {
475	f, err := os.Open(path)
476	if err != nil {
477		return "", err
478	}
479
480	defer f.Close() // nolint: errcheck
481	s := bufio.NewScanner(f)
482	s.Scan()
483	return s.Text(), s.Err()
484}