fix: add repo owner and separate lfs data for each repository

Ayman Bagabas created

Add repository owner

Change summary

cmd/soft/migrate_config.go                          |   4 
server/backend/lfs.go                               |   4 
server/backend/repo.go                              | 112 +++++++++++---
server/backend/user.go                              |  47 +++++
server/db/migrate/0004_repo_owner.go                |  23 +++
server/db/migrate/0004_repo_owner_postgres.down.sql |   1 
server/db/migrate/0004_repo_owner_postgres.up.sql   |  14 +
server/db/migrate/0004_repo_owner_sqlite.down.sql   |   1 
server/db/migrate/0004_repo_owner_sqlite.up.sql     |  25 +++
server/db/migrate/migrations.go                     |   1 
server/db/models/repo.go                            |  24 +-
server/git/lfs.go                                   |   6 
server/proto/repo.go                                |   5 
server/ssh/cmd/create.go                            |   3 
server/ssh/cmd/import.go                            |   9 +
server/ssh/cmd/repo.go                              |  12 +
server/ssh/git.go                                   |   2 
server/store/database/repo.go                       |  27 ++
server/store/repo.go                                |   3 
server/web/git.go                                   |   2 
server/web/git_lfs.go                               |  13 +
testscript/testdata/mirror.txtar                    |   2 
testscript/testdata/repo-create.txtar               |  22 ++
testscript/testdata/repo-import.txtar               |   1 
testscript/testdata/repo-perms.txtar                |   1 
25 files changed, 307 insertions(+), 57 deletions(-)

Detailed changes

cmd/soft/migrate_config.go 🔗

@@ -177,7 +177,7 @@ var migrateConfig = &cobra.Command{
 					return fmt.Errorf("failed to copy repo: %w", err)
 				}
 
-				if _, err := sb.CreateRepository(ctx, dir.Name(), proto.RepositoryOptions{}); err != nil {
+				if _, err := sb.CreateRepository(ctx, dir.Name(), nil, proto.RepositoryOptions{}); err != nil {
 					fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err)
 				}
 			}
@@ -239,7 +239,7 @@ var migrateConfig = &cobra.Command{
 				}
 
 				// Create `.soft-serve` repository and add readme
-				if _, err := sb.CreateRepository(ctx, ".soft-serve", proto.RepositoryOptions{
+				if _, err := sb.CreateRepository(ctx, ".soft-serve", nil, proto.RepositoryOptions{
 					ProjectName: "Home",
 					Description: "Soft Serve home repository",
 					Hidden:      true,

server/backend/lfs.go 🔗

@@ -6,6 +6,7 @@ import (
 	"io"
 	"path"
 	"path/filepath"
+	"strconv"
 
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/db"
@@ -18,7 +19,8 @@ import (
 // StoreRepoMissingLFSObjects stores missing LFS objects for a repository.
 func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx *db.DB, store store.Store, lfsClient lfs.Client) error {
 	cfg := config.FromContext(ctx)
-	lfsRoot := filepath.Join(cfg.DataPath, "lfs")
+	repoID := strconv.FormatInt(repo.ID(), 10)
+	lfsRoot := filepath.Join(cfg.DataPath, "lfs", repoID)
 
 	// TODO: support S3 storage
 	strg := storage.NewLocalStorage(lfsRoot)

server/backend/repo.go 🔗

@@ -7,8 +7,10 @@ import (
 	"fmt"
 	"io/fs"
 	"os"
+	"os/exec"
 	"path"
 	"path/filepath"
+	"strconv"
 	"time"
 
 	"github.com/charmbracelet/soft-serve/git"
@@ -28,7 +30,7 @@ func (d *Backend) reposPath() string {
 // CreateRepository creates a new repository.
 //
 // It implements backend.Backend.
-func (d *Backend) CreateRepository(ctx context.Context, name string, opts proto.RepositoryOptions) (proto.Repository, error) {
+func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) {
 	name = utils.SanitizeRepo(name)
 	if err := utils.ValidateRepo(name); err != nil {
 		return nil, err
@@ -37,11 +39,17 @@ func (d *Backend) CreateRepository(ctx context.Context, name string, opts proto.
 	repo := name + ".git"
 	rp := filepath.Join(d.reposPath(), repo)
 
+	var userID int64
+	if user != nil {
+		userID = user.ID()
+	}
+
 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 		if err := d.store.CreateRepo(
 			ctx,
 			tx,
 			name,
+			userID,
 			opts.ProjectName,
 			opts.Description,
 			opts.Private,
@@ -72,14 +80,19 @@ func (d *Backend) CreateRepository(ctx context.Context, name string, opts proto.
 		return hooks.GenerateHooks(ctx, d.cfg, repo)
 	}); err != nil {
 		d.logger.Debug("failed to create repository in database", "err", err)
-		return nil, db.WrapError(err)
+		err = db.WrapError(err)
+		if errors.Is(err, db.ErrDuplicateKey) {
+			return nil, proto.ErrRepoExist
+		}
+
+		return nil, err
 	}
 
 	return d.Repository(ctx, name)
 }
 
 // ImportRepository imports a repository from remote.
-func (d *Backend) ImportRepository(ctx context.Context, name string, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
+func (d *Backend) ImportRepository(ctx context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
 	name = utils.SanitizeRepo(name)
 	if err := utils.ValidateRepo(name); err != nil {
 		return nil, err
@@ -92,37 +105,42 @@ func (d *Backend) ImportRepository(ctx context.Context, name string, remote stri
 		return nil, proto.ErrRepoExist
 	}
 
-	copts := git.CloneOptions{
-		Bare:   true,
-		Mirror: opts.Mirror,
-		Quiet:  true,
-		CommandOptions: git.CommandOptions{
-			Timeout: -1,
-			Context: ctx,
-			Envs: []string{
-				fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
-					filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
-					d.cfg.SSH.ClientKeyPath,
-				),
-			},
-		},
+	if err := os.MkdirAll(rp, fs.ModePerm); err != nil {
+		return nil, err
 	}
 
-	if err := git.Clone(remote, rp, copts); err != nil {
+	cmd := exec.CommandContext(ctx, "git", "clone", "--bare", "--mirror", remote, ".")
+	cmd.Env = append(cmd.Env,
+		fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
+			filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
+			d.cfg.SSH.ClientKeyPath,
+		),
+	)
+	cmd.Dir = rp
+	if err := cmd.Run(); err != nil {
 		d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
 		// Cleanup the mess!
 		if rerr := os.RemoveAll(rp); rerr != nil {
 			err = errors.Join(err, rerr)
 		}
+
 		return nil, err
 	}
 
-	r, err := d.CreateRepository(ctx, name, opts)
+	r, err := d.CreateRepository(ctx, name, user, opts)
 	if err != nil {
 		d.logger.Error("failed to create repository", "err", err, "name", name)
 		return nil, err
 	}
 
+	defer func() {
+		if err != nil {
+			if rerr := d.DeleteRepository(ctx, name, opts.LFS); rerr != nil {
+				d.logger.Error("failed to delete repository", "err", rerr, "name", name)
+			}
+		}
+	}()
+
 	rr, err := r.Open()
 	if err != nil {
 		d.logger.Error("failed to open repository", "err", err, "path", rp)
@@ -135,20 +153,28 @@ func (d *Backend) ImportRepository(ctx context.Context, name string, remote stri
 		return nil, err
 	}
 
-	rcfg.Section("lfs").SetOption("url", remote)
+	endpoint := remote
+	if opts.LFSEndpoint != "" {
+		endpoint = opts.LFSEndpoint
+	}
+
+	rcfg.Section("lfs").SetOption("url", endpoint)
 
 	if err := rr.SetConfig(rcfg); err != nil {
 		d.logger.Error("failed to set repository config", "err", err, "path", rp)
 		return nil, err
 	}
 
-	endpoint, err := lfs.NewEndpoint(remote)
+	ep, err := lfs.NewEndpoint(endpoint)
 	if err != nil {
 		d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp)
 		return nil, err
 	}
 
-	client := lfs.NewClient(endpoint)
+	client := lfs.NewClient(ep)
+	if client == nil {
+		return nil, fmt.Errorf("failed to create lfs client: unsupported endpoint %s", endpoint)
+	}
 
 	if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {
 		d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp)
@@ -171,7 +197,13 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string, deleteLFS b
 		defer d.cache.Delete(name)
 
 		if deleteLFS {
-			strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs"))
+			repom, err := d.store.GetRepoByName(ctx, tx, name)
+			if err != nil {
+				return err
+			}
+
+			repoID := strconv.FormatInt(repom.ID, 10)
+			strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs", repoID))
 			objs, err := d.store.GetLFSObjectsByName(ctx, tx, name)
 			if err != nil {
 				return err
@@ -198,6 +230,29 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string, deleteLFS b
 	})
 }
 
+// DeleteUserRepositories deletes all user repositories.
+func (d *Backend) DeleteUserRepositories(ctx context.Context, username string, deleteLFS bool) error {
+	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+		user, err := d.store.FindUserByUsername(ctx, tx, username)
+		if err != nil {
+			return err
+		}
+
+		repos, err := d.store.GetUserRepos(ctx, tx, user.ID)
+		if err != nil {
+			return err
+		}
+
+		for _, repo := range repos {
+			if err := d.DeleteRepository(ctx, repo.Name, deleteLFS); err != nil {
+				return err
+			}
+		}
+
+		return nil
+	})
+}
+
 // RenameRepository renames a repository.
 //
 // It implements backend.Backend.
@@ -501,6 +556,17 @@ func (r *repo) ID() int64 {
 	return r.repo.ID
 }
 
+// UserID returns the repository's owner's user ID.
+// If the repository is not owned by anyone, it returns 0.
+//
+// It implements proto.Repository.
+func (r *repo) UserID() int64 {
+	if r.repo.UserID.Valid {
+		return r.repo.UserID.Int64
+	}
+	return 0
+}
+
 // Description returns the repository's description.
 //
 // It implements backend.Repository.

server/backend/user.go 🔗

@@ -62,6 +62,13 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
 	}
 
 	if r != nil {
+		if user != nil {
+			// If the user is the owner, they have admin access.
+			if r.UserID() == user.ID() {
+				return access.AdminAccess
+			}
+		}
+
 		// If the user is a collaborator, they have read/write access.
 		isCollab, _ := d.IsCollaborator(ctx, repo, username)
 		if isCollab {
@@ -128,6 +135,34 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
 	}, nil
 }
 
+// UserByID finds a user by ID.
+func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) {
+	var m models.User
+	var pks []ssh.PublicKey
+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+		var err error
+		m, err = d.store.GetUserByID(ctx, tx, id)
+		if err != nil {
+			return err
+		}
+
+		pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
+		return err
+	}); err != nil {
+		err = db.WrapError(err)
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return nil, proto.ErrUserNotFound
+		}
+		d.logger.Error("error finding user", "id", id, "error", err)
+		return nil, err
+	}
+
+	return &user{
+		user:       m,
+		publicKeys: pks,
+	}, nil
+}
+
 // UserByPublicKey finds a user by public key.
 //
 // It implements backend.Backend.
@@ -263,11 +298,13 @@ func (d *Backend) DeleteUser(ctx context.Context, username string) error {
 		return err
 	}
 
-	return db.WrapError(
-		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
-			return d.store.DeleteUserByUsername(ctx, tx, username)
-		}),
-	)
+	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+		if err := d.store.DeleteUserByUsername(ctx, tx, username); err != nil {
+			return db.WrapError(err)
+		}
+
+		return d.DeleteUserRepositories(ctx, username)
+	})
 }
 
 // RemovePublicKey removes a public key from a user.

server/db/migrate/0004_repo_owner.go 🔗

@@ -0,0 +1,23 @@
+package migrate
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+)
+
+const (
+	repoOwnerName    = "repo owner"
+	repoOwnerVersion = 4
+)
+
+var repoOwner = Migration{
+	Version: repoOwnerVersion,
+	Name:    repoOwnerName,
+	Migrate: func(ctx context.Context, tx *db.Tx) error {
+		return migrateUp(ctx, tx, repoOwnerVersion, repoOwnerName)
+	},
+	Rollback: func(ctx context.Context, tx *db.Tx) error {
+		return migrateDown(ctx, tx, repoOwnerVersion, repoOwnerName)
+	},
+}

server/db/migrate/0004_repo_owner_postgres.up.sql 🔗

@@ -0,0 +1,14 @@
+ALTER TABLE repos ADD COLUMN user_id INTEGER;
+
+UPDATE repos SET user_id = (
+  SELECT id FROM users WHERE admin = true ORDER BY id LIMIT 1
+);
+
+ALTER TABLE repos
+ALTER COLUMN user_id SET NOT NULL;
+
+ALTER TABLE repos
+ADD CONSTRAINT user_id_fk
+FOREIGN KEY(user_id) REFERENCES users(id)
+ON DELETE CASCADE
+ON UPDATE CASCADE;

server/db/migrate/0004_repo_owner_sqlite.up.sql 🔗

@@ -0,0 +1,25 @@
+ALTER TABLE repos RENAME TO repos_old;
+
+CREATE TABLE repos (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  name TEXT NOT NULL UNIQUE,
+  project_name TEXT NOT NULL,
+  description TEXT NOT NULL,
+  private BOOLEAN NOT NULL,
+  mirror BOOLEAN NOT NULL,
+  hidden BOOLEAN NOT NULL,
+  user_id INTEGER NOT NULL,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME NOT NULL,
+  CONSTRAINT user_id_fk
+  FOREIGN KEY(user_id) REFERENCES users(id)
+  ON DELETE CASCADE
+  ON UPDATE CASCADE
+);
+
+INSERT INTO repos (id, name, project_name, description, private, mirror, hidden, user_id, created_at, updated_at)
+SELECT id, name, project_name, description, private, mirror, hidden, (
+  SELECT id FROM users WHERE admin = true ORDER BY id LIMIT 1
+), created_at, updated_at
+FROM repos_old;
+

server/db/migrate/migrations.go 🔗

@@ -18,6 +18,7 @@ var migrations = []Migration{
 	createTables,
 	createLFSTables,
 	passwordTokens,
+	repoOwner,
 }
 
 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {

server/db/models/repo.go 🔗

@@ -1,16 +1,20 @@
 package models
 
-import "time"
+import (
+	"database/sql"
+	"time"
+)
 
 // Repo is a database model for a repository.
 type Repo struct {
-	ID          int64     `db:"id"`
-	Name        string    `db:"name"`
-	ProjectName string    `db:"project_name"`
-	Description string    `db:"description"`
-	Private     bool      `db:"private"`
-	Mirror      bool      `db:"mirror"`
-	Hidden      bool      `db:"hidden"`
-	CreatedAt   time.Time `db:"created_at"`
-	UpdatedAt   time.Time `db:"updated_at"`
+	ID          int64         `db:"id"`
+	Name        string        `db:"name"`
+	ProjectName string        `db:"project_name"`
+	Description string        `db:"description"`
+	Private     bool          `db:"private"`
+	Mirror      bool          `db:"mirror"`
+	Hidden      bool          `db:"hidden"`
+	UserID      sql.NullInt64 `db:"user_id"`
+	CreatedAt   time.Time     `db:"created_at"`
+	UpdatedAt   time.Time     `db:"updated_at"`
 }

server/git/lfs.go 🔗

@@ -85,6 +85,7 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
 		return err
 	}
 
+	repoID := strconv.FormatInt(repo.ID(), 10)
 	cfg := config.FromContext(ctx)
 	processor := transfer.NewProcessor(handler, &lfsTransfer{
 		ctx:     ctx,
@@ -92,7 +93,7 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
 		dbx:     db.FromContext(ctx),
 		store:   store.FromContext(ctx),
 		logger:  logger,
-		storage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs")),
+		storage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)),
 		repo:    repo,
 	})
 
@@ -132,7 +133,8 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer, _ map[string]
 // Download implements transfer.Backend.
 func (t *lfsTransfer) Download(oid string, _ map[string]string) (fs.File, error) {
 	cfg := config.FromContext(t.ctx)
-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
+	repoID := strconv.FormatInt(t.repo.ID(), 10)
+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
 	pointer := transfer.Pointer{Oid: oid}
 	return strg.Open(path.Join("objects", pointer.RelativePath()))
 }

server/proto/repo.go 🔗

@@ -22,6 +22,9 @@ type Repository interface {
 	IsMirror() bool
 	// IsHidden returns whether the repository is hidden.
 	IsHidden() bool
+	// UserID returns the ID of the user who owns the repository.
+	// It returns 0 if the repository is not owned by a user.
+	UserID() int64
 	// UpdatedAt returns the time the repository was last updated.
 	// If the repository has never been updated, it returns the time it was created.
 	UpdatedAt() time.Time
@@ -36,4 +39,6 @@ type RepositoryOptions struct {
 	ProjectName string
 	Mirror      bool
 	Hidden      bool
+	LFS         bool
+	LFSEndpoint string
 }

server/ssh/cmd/create.go 🔗

@@ -21,8 +21,9 @@ func createCommand() *cobra.Command {
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
 			be := backend.FromContext(ctx)
+			user := proto.UserFromContext(ctx)
 			name := args[0]
-			if _, err := be.CreateRepository(ctx, name, proto.RepositoryOptions{
+			if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{
 				Private:     private,
 				Description: description,
 				ProjectName: projectName,

server/ssh/cmd/import.go 🔗

@@ -13,6 +13,8 @@ func importCommand() *cobra.Command {
 	var projectName string
 	var mirror bool
 	var hidden bool
+	var lfs bool
+	var lfsEndpoint string
 
 	cmd := &cobra.Command{
 		Use:               "import REPOSITORY REMOTE",
@@ -22,14 +24,17 @@ func importCommand() *cobra.Command {
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
 			be := backend.FromContext(ctx)
+			user := proto.UserFromContext(ctx)
 			name := args[0]
 			remote := args[1]
-			if _, err := be.ImportRepository(ctx, name, remote, proto.RepositoryOptions{
+			if _, err := be.ImportRepository(ctx, name, user, remote, proto.RepositoryOptions{
 				Private:     private,
 				Description: description,
 				ProjectName: projectName,
 				Mirror:      mirror,
 				Hidden:      hidden,
+				LFS:         lfs,
+				LFSEndpoint: lfsEndpoint,
 			}); err != nil {
 				return err
 			}
@@ -37,6 +42,8 @@ func importCommand() *cobra.Command {
 		},
 	}
 
+	cmd.Flags().BoolVarP(&lfs, "lfs", "", false, "pull Git LFS objects")
+	cmd.Flags().StringVarP(&lfsEndpoint, "lfs-endpoint", "", "", "set the Git LFS endpoint")
 	cmd.Flags().BoolVarP(&mirror, "mirror", "m", false, "mirror the repository")
 	cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
 	cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")

server/ssh/cmd/repo.go 🔗

@@ -5,6 +5,7 @@ import (
 	"strings"
 
 	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/spf13/cobra"
 )
 
@@ -59,6 +60,14 @@ func repoCommand() *cobra.Command {
 					return err
 				}
 
+				var owner proto.User
+				if rr.UserID() > 0 {
+					owner, err = be.UserByID(ctx, rr.UserID())
+					if err != nil {
+						return err
+					}
+				}
+
 				branches, _ := r.Branches()
 				tags, _ := r.Tags()
 
@@ -70,6 +79,9 @@ func repoCommand() *cobra.Command {
 				cmd.Println("Private:", rr.IsPrivate())
 				cmd.Println("Hidden:", rr.IsHidden())
 				cmd.Println("Mirror:", rr.IsMirror())
+				if owner != nil {
+					cmd.Println(strings.TrimSpace(fmt.Sprint("Owner: ", owner.Username())))
+				}
 				cmd.Println("Default Branch:", head.Name().Short())
 				if len(branches) > 0 {
 					cmd.Println("Branches:")

server/ssh/git.go 🔗

@@ -85,7 +85,7 @@ func handleGit(s ssh.Session) {
 			return
 		}
 		if repo == nil {
-			if _, err := be.CreateRepository(ctx, name, proto.RepositoryOptions{Private: false}); err != nil {
+			if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {
 				log.Errorf("failed to create repo: %s", err)
 				sshFatal(s, err)
 				return

server/store/database/repo.go 🔗

@@ -14,12 +14,21 @@ type repoStore struct{}
 var _ store.RepositoryStore = (*repoStore)(nil)
 
 // CreateRepo implements store.RepositoryStore.
-func (*repoStore) CreateRepo(ctx context.Context, tx db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error {
+func (*repoStore) CreateRepo(ctx context.Context, tx db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error {
 	name = utils.SanitizeRepo(name)
-	query := tx.Rebind(`INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at)
-			VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`)
-	_, err := tx.ExecContext(ctx, query,
-		name, projectName, description, isPrivate, isMirror, isHidden)
+	values := []interface{}{
+		name, projectName, description, isPrivate, isMirror, isHidden,
+	}
+	query := `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at)
+			VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
+	if userID > 0 {
+		query = `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at, user_id)
+			VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?);`
+		values = append(values, userID)
+	}
+
+	query = tx.Rebind(query)
+	_, err := tx.ExecContext(ctx, query, values...)
 	return db.WrapError(err)
 }
 
@@ -39,6 +48,14 @@ func (*repoStore) GetAllRepos(ctx context.Context, tx db.Handler) ([]models.Repo
 	return repos, db.WrapError(err)
 }
 
+// GetUserRepos implements store.RepositoryStore.
+func (*repoStore) GetUserRepos(ctx context.Context, tx db.Handler, userID int64) ([]models.Repo, error) {
+	var repos []models.Repo
+	query := tx.Rebind("SELECT * FROM repos WHERE user_id = ?;")
+	err := tx.SelectContext(ctx, &repos, query, userID)
+	return repos, db.WrapError(err)
+}
+
 // GetRepoByName implements store.RepositoryStore.
 func (*repoStore) GetRepoByName(ctx context.Context, tx db.Handler, name string) (models.Repo, error) {
 	var repo models.Repo

server/store/repo.go 🔗

@@ -11,7 +11,8 @@ import (
 type RepositoryStore interface {
 	GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error)
 	GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error)
-	CreateRepo(ctx context.Context, h db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
+	GetUserRepos(ctx context.Context, h db.Handler, userID int64) ([]models.Repo, error)
+	CreateRepo(ctx context.Context, h db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
 	DeleteRepoByName(ctx context.Context, h db.Handler, name string) error
 	SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error
 

server/web/git.go 🔗

@@ -262,7 +262,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
 
 			// Create the repo if it doesn't exist.
 			if repo == nil {
-				repo, err = be.CreateRepository(ctx, repoName, proto.RepositoryOptions{})
+				repo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{})
 				if err != nil {
 					logger.Error("failed to create repository", "repo", repoName, "err", err)
 					renderInternalServerError(w, r)

server/web/git_lfs.go 🔗

@@ -89,7 +89,8 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 	dbx := db.FromContext(ctx)
 	datastore := store.FromContext(ctx)
 	// TODO: support S3 storage
-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
+	repoID := strconv.FormatInt(repo.ID(), 10)
+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
 
 	baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git")
 
@@ -257,7 +258,8 @@ func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
 	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
 	datastore := store.FromContext(ctx)
 	dbx := db.FromContext(ctx)
-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
+	repoID := strconv.FormatInt(repo.ID(), 10)
+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
 
 	obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid)
 	if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
@@ -306,7 +308,9 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
 	dbx := db.FromContext(ctx)
 	datastore := store.FromContext(ctx)
 	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
+	repo := proto.RepositoryFromContext(ctx)
+	repoID := strconv.FormatInt(repo.ID(), 10)
+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
 	name := mux.Vars(r)["repo"]
 
 	defer r.Body.Close() // nolint: errcheck
@@ -393,7 +397,8 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
 	cfg := config.FromContext(ctx)
 	dbx := db.FromContext(ctx)
 	datastore := store.FromContext(ctx)
-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
+	repoID := strconv.FormatInt(repo.ID(), 10)
+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
 	if stat, err := strg.Stat(path.Join("objects", pointer.RelativePath())); err == nil {
 		// Verify object is in the database.
 		obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid)

testscript/testdata/mirror.txtar 🔗

@@ -82,6 +82,7 @@ Description:
 Private: false
 Hidden: false
 Mirror: true
+Owner: admin
 Default Branch: main
 Branches:
   - main
@@ -92,6 +93,7 @@ Description: testing repo
 Private: true
 Hidden: true
 Mirror: true
+Owner: admin
 Default Branch: main
 Branches:
   - main

testscript/testdata/repo-create.txtar 🔗

@@ -90,6 +90,27 @@ soft repo branch delete repo1 master
 soft repo branch list repo1
 stdout branch1
 
+# create a new user
+soft user create bar --key "$USER1_AUTHORIZED_KEY"
+
+# user create a repo
+usoft repo create repo2 -d 'description' -H -p -n 'repo2'
+usoft repo hidden repo2
+stdout true
+usoft repo private repo2
+stdout true
+! exists $DATA_PATH/repos/repo2.git/git-daemon-export-ok
+usoft repo description repo2
+stdout 'description'
+readfile $DATA_PATH/repos/repo2.git/description 'description'
+usoft repo project-name repo2
+stdout 'repo2'
+
+# user delete a repo
+usoft repo delete repo2
+! exists $DATA_PATH/repos/repo2.git
+
+
 -- readme.md --
 # Project\nfoo
 -- branch_list.1.txt --
@@ -102,6 +123,7 @@ Description: description
 Private: true
 Hidden: true
 Mirror: false
+Owner: admin
 Default Branch: master
 Branches:
   - master