ea6b9a46c37dd6323c77670bd27bd8341db8ac08

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

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

Add repository owner

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/soft/migrate_config.go b/cmd/soft/migrate_config.go
  2index 4d85c0bd82accb9ca38600b29b1352ea4027a36f..583153e5d84e1bbd3e73538267ed6bf85f0d36cb 100644
  3--- a/cmd/soft/migrate_config.go
  4+++ b/cmd/soft/migrate_config.go
  5@@ -177,7 +177,7 @@ var migrateConfig = &cobra.Command{
  6 					return fmt.Errorf("failed to copy repo: %w", err)
  7 				}
  8 
  9-				if _, err := sb.CreateRepository(ctx, dir.Name(), proto.RepositoryOptions{}); err != nil {
 10+				if _, err := sb.CreateRepository(ctx, dir.Name(), nil, proto.RepositoryOptions{}); err != nil {
 11 					fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err)
 12 				}
 13 			}
 14@@ -239,7 +239,7 @@ var migrateConfig = &cobra.Command{
 15 				}
 16 
 17 				// Create `.soft-serve` repository and add readme
 18-				if _, err := sb.CreateRepository(ctx, ".soft-serve", proto.RepositoryOptions{
 19+				if _, err := sb.CreateRepository(ctx, ".soft-serve", nil, proto.RepositoryOptions{
 20 					ProjectName: "Home",
 21 					Description: "Soft Serve home repository",
 22 					Hidden:      true,
 23diff --git a/server/backend/lfs.go b/server/backend/lfs.go
 24index eac557ecf07fee83ba8fef0216a8e1074e36e4e4..5b0d5a4064ae92d3fe1044ef8a841bbb8e9663a1 100644
 25--- a/server/backend/lfs.go
 26+++ b/server/backend/lfs.go
 27@@ -6,6 +6,7 @@ import (
 28 	"io"
 29 	"path"
 30 	"path/filepath"
 31+	"strconv"
 32 
 33 	"github.com/charmbracelet/soft-serve/server/config"
 34 	"github.com/charmbracelet/soft-serve/server/db"
 35@@ -18,7 +19,8 @@ import (
 36 // StoreRepoMissingLFSObjects stores missing LFS objects for a repository.
 37 func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx *db.DB, store store.Store, lfsClient lfs.Client) error {
 38 	cfg := config.FromContext(ctx)
 39-	lfsRoot := filepath.Join(cfg.DataPath, "lfs")
 40+	repoID := strconv.FormatInt(repo.ID(), 10)
 41+	lfsRoot := filepath.Join(cfg.DataPath, "lfs", repoID)
 42 
 43 	// TODO: support S3 storage
 44 	strg := storage.NewLocalStorage(lfsRoot)
 45diff --git a/server/backend/repo.go b/server/backend/repo.go
 46index 840385112d5703055ac7ce73087ae55b959fd9d7..b31484ba66eae1bc987d7d312445f085bc08f749 100644
 47--- a/server/backend/repo.go
 48+++ b/server/backend/repo.go
 49@@ -7,8 +7,10 @@ import (
 50 	"fmt"
 51 	"io/fs"
 52 	"os"
 53+	"os/exec"
 54 	"path"
 55 	"path/filepath"
 56+	"strconv"
 57 	"time"
 58 
 59 	"github.com/charmbracelet/soft-serve/git"
 60@@ -28,7 +30,7 @@ func (d *Backend) reposPath() string {
 61 // CreateRepository creates a new repository.
 62 //
 63 // It implements backend.Backend.
 64-func (d *Backend) CreateRepository(ctx context.Context, name string, opts proto.RepositoryOptions) (proto.Repository, error) {
 65+func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) {
 66 	name = utils.SanitizeRepo(name)
 67 	if err := utils.ValidateRepo(name); err != nil {
 68 		return nil, err
 69@@ -37,11 +39,17 @@ func (d *Backend) CreateRepository(ctx context.Context, name string, opts proto.
 70 	repo := name + ".git"
 71 	rp := filepath.Join(d.reposPath(), repo)
 72 
 73+	var userID int64
 74+	if user != nil {
 75+		userID = user.ID()
 76+	}
 77+
 78 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 79 		if err := d.store.CreateRepo(
 80 			ctx,
 81 			tx,
 82 			name,
 83+			userID,
 84 			opts.ProjectName,
 85 			opts.Description,
 86 			opts.Private,
 87@@ -72,14 +80,19 @@ func (d *Backend) CreateRepository(ctx context.Context, name string, opts proto.
 88 		return hooks.GenerateHooks(ctx, d.cfg, repo)
 89 	}); err != nil {
 90 		d.logger.Debug("failed to create repository in database", "err", err)
 91-		return nil, db.WrapError(err)
 92+		err = db.WrapError(err)
 93+		if errors.Is(err, db.ErrDuplicateKey) {
 94+			return nil, proto.ErrRepoExist
 95+		}
 96+
 97+		return nil, err
 98 	}
 99 
100 	return d.Repository(ctx, name)
101 }
102 
103 // ImportRepository imports a repository from remote.
104-func (d *Backend) ImportRepository(ctx context.Context, name string, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
105+func (d *Backend) ImportRepository(ctx context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
106 	name = utils.SanitizeRepo(name)
107 	if err := utils.ValidateRepo(name); err != nil {
108 		return nil, err
109@@ -92,37 +105,42 @@ func (d *Backend) ImportRepository(ctx context.Context, name string, remote stri
110 		return nil, proto.ErrRepoExist
111 	}
112 
113-	copts := git.CloneOptions{
114-		Bare:   true,
115-		Mirror: opts.Mirror,
116-		Quiet:  true,
117-		CommandOptions: git.CommandOptions{
118-			Timeout: -1,
119-			Context: ctx,
120-			Envs: []string{
121-				fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
122-					filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
123-					d.cfg.SSH.ClientKeyPath,
124-				),
125-			},
126-		},
127+	if err := os.MkdirAll(rp, fs.ModePerm); err != nil {
128+		return nil, err
129 	}
130 
131-	if err := git.Clone(remote, rp, copts); err != nil {
132+	cmd := exec.CommandContext(ctx, "git", "clone", "--bare", "--mirror", remote, ".")
133+	cmd.Env = append(cmd.Env,
134+		fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
135+			filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
136+			d.cfg.SSH.ClientKeyPath,
137+		),
138+	)
139+	cmd.Dir = rp
140+	if err := cmd.Run(); err != nil {
141 		d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
142 		// Cleanup the mess!
143 		if rerr := os.RemoveAll(rp); rerr != nil {
144 			err = errors.Join(err, rerr)
145 		}
146+
147 		return nil, err
148 	}
149diff --git a/server/backend/user.go b/server/backend/user.go
150index 8f1f4bb73c3c3cf3ac4a3b1f5ef8130a93cca846..07db904d55d26d8d0cbc28407d0cb4de7960298c 100644
151--- a/server/backend/user.go
152+++ b/server/backend/user.go
153@@ -62,6 +62,13 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
154 	}
155 
156 	if r != nil {
157+		if user != nil {
158+			// If the user is the owner, they have admin access.
159+			if r.UserID() == user.ID() {
160+				return access.AdminAccess
161+			}
162+		}
163+
164 		// If the user is a collaborator, they have read/write access.
165 		isCollab, _ := d.IsCollaborator(ctx, repo, username)
166 		if isCollab {
167@@ -128,6 +135,34 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
168 	}, nil
169 }
170 
171+// UserByID finds a user by ID.
172+func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) {
173+	var m models.User
174+	var pks []ssh.PublicKey
175+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
176+		var err error
177+		m, err = d.store.GetUserByID(ctx, tx, id)
178+		if err != nil {
179+			return err
180+		}
181+
182+		pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
183+		return err
184+	}); err != nil {
185+		err = db.WrapError(err)
186+		if errors.Is(err, db.ErrRecordNotFound) {
187+			return nil, proto.ErrUserNotFound
188+		}
189+		d.logger.Error("error finding user", "id", id, "error", err)
190+		return nil, err
191+	}
192+
193+	return &user{
194+		user:       m,
195+		publicKeys: pks,
196+	}, nil
197+}
198+
199 // UserByPublicKey finds a user by public key.
200 //
201 // It implements backend.Backend.
202@@ -263,11 +298,13 @@ func (d *Backend) DeleteUser(ctx context.Context, username string) error {
203 		return err
204 	}
205 
206-	return db.WrapError(
207-		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
208-			return d.store.DeleteUserByUsername(ctx, tx, username)
209-		}),
210-	)
211+	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
212+		if err := d.store.DeleteUserByUsername(ctx, tx, username); err != nil {
213+			return db.WrapError(err)
214+		}
215+
216+		return d.DeleteUserRepositories(ctx, username)
217+	})
218 }
219 
220 // RemovePublicKey removes a public key from a user.
221diff --git a/server/db/migrate/0004_repo_owner.go b/server/db/migrate/0004_repo_owner.go
222new file mode 100644
223index 0000000000000000000000000000000000000000..07ab4598b3baba6e0dfecd6f0ce329508ca3ee90
224--- /dev/null
225+++ b/server/db/migrate/0004_repo_owner.go
226@@ -0,0 +1,23 @@
227+package migrate
228+
229+import (
230+	"context"
231+
232+	"github.com/charmbracelet/soft-serve/server/db"
233+)
234+
235+const (
236+	repoOwnerName    = "repo owner"
237+	repoOwnerVersion = 4
238+)
239+
240+var repoOwner = Migration{
241+	Version: repoOwnerVersion,
242+	Name:    repoOwnerName,
243+	Migrate: func(ctx context.Context, tx *db.Tx) error {
244+		return migrateUp(ctx, tx, repoOwnerVersion, repoOwnerName)
245+	},
246+	Rollback: func(ctx context.Context, tx *db.Tx) error {
247+		return migrateDown(ctx, tx, repoOwnerVersion, repoOwnerName)
248+	},
249+}
250diff --git a/server/db/migrate/0004_repo_owner_postgres.down.sql b/server/db/migrate/0004_repo_owner_postgres.down.sql
251new file mode 100644
252index 0000000000000000000000000000000000000000..cefbad5d53b9330bd98291c2f0853ff5a3b7a5ce
253--- /dev/null
254+++ b/server/db/migrate/0004_repo_owner_postgres.down.sql
255@@ -0,0 +1 @@
256+ALTER TABLE repos DROP COLUMN user_id;
257diff --git a/server/db/migrate/0004_repo_owner_postgres.up.sql b/server/db/migrate/0004_repo_owner_postgres.up.sql
258new file mode 100644
259index 0000000000000000000000000000000000000000..c0a73d252849562eb111a90fb74a27e6954a1179
260--- /dev/null
261+++ b/server/db/migrate/0004_repo_owner_postgres.up.sql
262@@ -0,0 +1,14 @@
263+ALTER TABLE repos ADD COLUMN user_id INTEGER;
264+
265+UPDATE repos SET user_id = (
266+  SELECT id FROM users WHERE admin = true ORDER BY id LIMIT 1
267+);
268+
269+ALTER TABLE repos
270+ALTER COLUMN user_id SET NOT NULL;
271+
272+ALTER TABLE repos
273+ADD CONSTRAINT user_id_fk
274+FOREIGN KEY(user_id) REFERENCES users(id)
275+ON DELETE CASCADE
276+ON UPDATE CASCADE;
277diff --git a/server/db/migrate/0004_repo_owner_sqlite.down.sql b/server/db/migrate/0004_repo_owner_sqlite.down.sql
278new file mode 100644
279index 0000000000000000000000000000000000000000..785e28f6188b03635cf9ba6ae0c84e118a0c81eb
280--- /dev/null
281+++ b/server/db/migrate/0004_repo_owner_sqlite.down.sql
282@@ -0,0 +1 @@
283+DROP TABLE IF EXISTS repos_old;
284diff --git a/server/db/migrate/0004_repo_owner_sqlite.up.sql b/server/db/migrate/0004_repo_owner_sqlite.up.sql
285new file mode 100644
286index 0000000000000000000000000000000000000000..453dbe85ca9f5fc76a7772629031a1eeb25eebeb
287--- /dev/null
288+++ b/server/db/migrate/0004_repo_owner_sqlite.up.sql
289@@ -0,0 +1,25 @@
290+ALTER TABLE repos RENAME TO repos_old;
291+
292+CREATE TABLE repos (
293+  id INTEGER PRIMARY KEY AUTOINCREMENT,
294+  name TEXT NOT NULL UNIQUE,
295+  project_name TEXT NOT NULL,
296+  description TEXT NOT NULL,
297+  private BOOLEAN NOT NULL,
298+  mirror BOOLEAN NOT NULL,
299+  hidden BOOLEAN NOT NULL,
300+  user_id INTEGER NOT NULL,
301+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
302+  updated_at DATETIME NOT NULL,
303+  CONSTRAINT user_id_fk
304+  FOREIGN KEY(user_id) REFERENCES users(id)
305+  ON DELETE CASCADE
306+  ON UPDATE CASCADE
307+);
308+
309+INSERT INTO repos (id, name, project_name, description, private, mirror, hidden, user_id, created_at, updated_at)
310+SELECT id, name, project_name, description, private, mirror, hidden, (
311+  SELECT id FROM users WHERE admin = true ORDER BY id LIMIT 1
312+), created_at, updated_at
313+FROM repos_old;
314+
315diff --git a/server/db/migrate/migrations.go b/server/db/migrate/migrations.go
316index 987a71105c0b51651a1d6151e790a16069aa1d21..482d61cad2b3e1e5a12e341264cbe43fc621d04a 100644
317--- a/server/db/migrate/migrations.go
318+++ b/server/db/migrate/migrations.go
319@@ -18,6 +18,7 @@ var migrations = []Migration{
320 	createTables,
321 	createLFSTables,
322 	passwordTokens,
323+	repoOwner,
324 }
325 
326 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {
327diff --git a/server/db/models/repo.go b/server/db/models/repo.go
328index 586a4f288941aa8298e1e3a527d6ec6d3cddc07b..88300bd3e3634822c7f39d928e1ad1bd474e74e9 100644
329--- a/server/db/models/repo.go
330+++ b/server/db/models/repo.go
331@@ -1,16 +1,20 @@
332 package models
333 
334-import "time"
335+import (
336+	"database/sql"
337+	"time"
338+)
339 
340 // Repo is a database model for a repository.
341 type Repo struct {
342-	ID          int64     `db:"id"`
343-	Name        string    `db:"name"`
344-	ProjectName string    `db:"project_name"`
345-	Description string    `db:"description"`
346-	Private     bool      `db:"private"`
347-	Mirror      bool      `db:"mirror"`
348-	Hidden      bool      `db:"hidden"`
349-	CreatedAt   time.Time `db:"created_at"`
350-	UpdatedAt   time.Time `db:"updated_at"`
351+	ID          int64         `db:"id"`
352+	Name        string        `db:"name"`
353+	ProjectName string        `db:"project_name"`
354+	Description string        `db:"description"`
355+	Private     bool          `db:"private"`
356+	Mirror      bool          `db:"mirror"`
357+	Hidden      bool          `db:"hidden"`
358+	UserID      sql.NullInt64 `db:"user_id"`
359+	CreatedAt   time.Time     `db:"created_at"`
360+	UpdatedAt   time.Time     `db:"updated_at"`
361 }
362diff --git a/server/git/lfs.go b/server/git/lfs.go
363index 1a1ead5a1ffb65cd18f084fc17a702c036e7e4b4..9e6851f1965a7051aeca7854870c4ec84aa9e711 100644
364--- a/server/git/lfs.go
365+++ b/server/git/lfs.go
366@@ -85,6 +85,7 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
367 		return err
368 	}
369 
370+	repoID := strconv.FormatInt(repo.ID(), 10)
371 	cfg := config.FromContext(ctx)
372 	processor := transfer.NewProcessor(handler, &lfsTransfer{
373 		ctx:     ctx,
374@@ -92,7 +93,7 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
375 		dbx:     db.FromContext(ctx),
376 		store:   store.FromContext(ctx),
377 		logger:  logger,
378-		storage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs")),
379+		storage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)),
380 		repo:    repo,
381 	})
382 
383@@ -132,7 +133,8 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer, _ map[string]
384 // Download implements transfer.Backend.
385 func (t *lfsTransfer) Download(oid string, _ map[string]string) (fs.File, error) {
386 	cfg := config.FromContext(t.ctx)
387-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
388+	repoID := strconv.FormatInt(t.repo.ID(), 10)
389+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
390 	pointer := transfer.Pointer{Oid: oid}
391 	return strg.Open(path.Join("objects", pointer.RelativePath()))
392 }
393diff --git a/server/proto/repo.go b/server/proto/repo.go
394index 2d54e9484caf3ec38269be0907a4af075b93d69d..ee6ccae29d174e00cbab536b7141073a80510618 100644
395--- a/server/proto/repo.go
396+++ b/server/proto/repo.go
397@@ -22,6 +22,9 @@ type Repository interface {
398 	IsMirror() bool
399 	// IsHidden returns whether the repository is hidden.
400 	IsHidden() bool
401+	// UserID returns the ID of the user who owns the repository.
402+	// It returns 0 if the repository is not owned by a user.
403+	UserID() int64
404 	// UpdatedAt returns the time the repository was last updated.
405 	// If the repository has never been updated, it returns the time it was created.
406 	UpdatedAt() time.Time
407@@ -36,4 +39,6 @@ type RepositoryOptions struct {
408 	ProjectName string
409 	Mirror      bool
410 	Hidden      bool
411+	LFS         bool
412+	LFSEndpoint string
413 }
414diff --git a/server/ssh/cmd/create.go b/server/ssh/cmd/create.go
415index 751c18e1438eb03b23d4649499378c61679cd906..7e301b3fd014bd3a8d821d7b34667b609c50edfb 100644
416--- a/server/ssh/cmd/create.go
417+++ b/server/ssh/cmd/create.go
418@@ -21,8 +21,9 @@ func createCommand() *cobra.Command {
419 		RunE: func(cmd *cobra.Command, args []string) error {
420 			ctx := cmd.Context()
421 			be := backend.FromContext(ctx)
422+			user := proto.UserFromContext(ctx)
423 			name := args[0]
424-			if _, err := be.CreateRepository(ctx, name, proto.RepositoryOptions{
425+			if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{
426 				Private:     private,
427 				Description: description,
428 				ProjectName: projectName,
429diff --git a/server/ssh/cmd/import.go b/server/ssh/cmd/import.go
430index 55f73e4f5df78dfb21f41655bba96f6f04a8ba02..b34b46f613389ed41a779060549e2ac760d8c4b9 100644
431--- a/server/ssh/cmd/import.go
432+++ b/server/ssh/cmd/import.go
433@@ -13,6 +13,8 @@ func importCommand() *cobra.Command {
434 	var projectName string
435 	var mirror bool
436 	var hidden bool
437+	var lfs bool
438+	var lfsEndpoint string
439 
440 	cmd := &cobra.Command{
441 		Use:               "import REPOSITORY REMOTE",
442@@ -22,14 +24,17 @@ func importCommand() *cobra.Command {
443 		RunE: func(cmd *cobra.Command, args []string) error {
444 			ctx := cmd.Context()
445 			be := backend.FromContext(ctx)
446+			user := proto.UserFromContext(ctx)
447 			name := args[0]
448 			remote := args[1]
449-			if _, err := be.ImportRepository(ctx, name, remote, proto.RepositoryOptions{
450+			if _, err := be.ImportRepository(ctx, name, user, remote, proto.RepositoryOptions{
451 				Private:     private,
452 				Description: description,
453 				ProjectName: projectName,
454 				Mirror:      mirror,
455 				Hidden:      hidden,
456+				LFS:         lfs,
457+				LFSEndpoint: lfsEndpoint,
458 			}); err != nil {
459 				return err
460 			}
461@@ -37,6 +42,8 @@ func importCommand() *cobra.Command {
462 		},
463 	}
464 
465+	cmd.Flags().BoolVarP(&lfs, "lfs", "", false, "pull Git LFS objects")
466+	cmd.Flags().StringVarP(&lfsEndpoint, "lfs-endpoint", "", "", "set the Git LFS endpoint")
467 	cmd.Flags().BoolVarP(&mirror, "mirror", "m", false, "mirror the repository")
468 	cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
469 	cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
470diff --git a/server/ssh/cmd/repo.go b/server/ssh/cmd/repo.go
471index 24f4b8c0622421b117346a349c1e2f38fb3de4d3..fc23844fb1494268d6f352091617cdca6f3d8c44 100644
472--- a/server/ssh/cmd/repo.go
473+++ b/server/ssh/cmd/repo.go
474@@ -5,6 +5,7 @@ import (
475 	"strings"
476 
477 	"github.com/charmbracelet/soft-serve/server/backend"
478+	"github.com/charmbracelet/soft-serve/server/proto"
479 	"github.com/spf13/cobra"
480 )
481 
482@@ -59,6 +60,14 @@ func repoCommand() *cobra.Command {
483 					return err
484 				}
485 
486+				var owner proto.User
487+				if rr.UserID() > 0 {
488+					owner, err = be.UserByID(ctx, rr.UserID())
489+					if err != nil {
490+						return err
491+					}
492+				}
493+
494 				branches, _ := r.Branches()
495 				tags, _ := r.Tags()
496 
497@@ -70,6 +79,9 @@ func repoCommand() *cobra.Command {
498 				cmd.Println("Private:", rr.IsPrivate())
499 				cmd.Println("Hidden:", rr.IsHidden())
500 				cmd.Println("Mirror:", rr.IsMirror())
501+				if owner != nil {
502+					cmd.Println(strings.TrimSpace(fmt.Sprint("Owner: ", owner.Username())))
503+				}
504 				cmd.Println("Default Branch:", head.Name().Short())
505 				if len(branches) > 0 {
506 					cmd.Println("Branches:")
507diff --git a/server/ssh/git.go b/server/ssh/git.go
508index eac3575913dcfc614142b7e6bed5d0e423d1c87e..b1a269a4999fe21181d327bf372f763701ca172b 100644
509--- a/server/ssh/git.go
510+++ b/server/ssh/git.go
511@@ -85,7 +85,7 @@ func handleGit(s ssh.Session) {
512 			return
513 		}
514 		if repo == nil {
515-			if _, err := be.CreateRepository(ctx, name, proto.RepositoryOptions{Private: false}); err != nil {
516+			if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {
517 				log.Errorf("failed to create repo: %s", err)
518 				sshFatal(s, err)
519 				return
520diff --git a/server/store/database/repo.go b/server/store/database/repo.go
521index 06ea92b492c32cfaf4c1020611daebf5ea7a8bbb..03c080444a035f0192f2ddfc103bf1eddd583c21 100644
522--- a/server/store/database/repo.go
523+++ b/server/store/database/repo.go
524@@ -14,12 +14,21 @@ type repoStore struct{}
525 var _ store.RepositoryStore = (*repoStore)(nil)
526 
527 // CreateRepo implements store.RepositoryStore.
528-func (*repoStore) CreateRepo(ctx context.Context, tx db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error {
529+func (*repoStore) CreateRepo(ctx context.Context, tx db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error {
530 	name = utils.SanitizeRepo(name)
531-	query := tx.Rebind(`INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at)
532-			VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`)
533-	_, err := tx.ExecContext(ctx, query,
534-		name, projectName, description, isPrivate, isMirror, isHidden)
535+	values := []interface{}{
536+		name, projectName, description, isPrivate, isMirror, isHidden,
537+	}
538+	query := `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at)
539+			VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
540+	if userID > 0 {
541+		query = `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at, user_id)
542+			VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?);`
543+		values = append(values, userID)
544+	}
545+
546+	query = tx.Rebind(query)
547+	_, err := tx.ExecContext(ctx, query, values...)
548 	return db.WrapError(err)
549 }
550 
551@@ -39,6 +48,14 @@ func (*repoStore) GetAllRepos(ctx context.Context, tx db.Handler) ([]models.Repo
552 	return repos, db.WrapError(err)
553 }
554 
555+// GetUserRepos implements store.RepositoryStore.
556+func (*repoStore) GetUserRepos(ctx context.Context, tx db.Handler, userID int64) ([]models.Repo, error) {
557+	var repos []models.Repo
558+	query := tx.Rebind("SELECT * FROM repos WHERE user_id = ?;")
559+	err := tx.SelectContext(ctx, &repos, query, userID)
560+	return repos, db.WrapError(err)
561+}
562+
563 // GetRepoByName implements store.RepositoryStore.
564 func (*repoStore) GetRepoByName(ctx context.Context, tx db.Handler, name string) (models.Repo, error) {
565 	var repo models.Repo
566diff --git a/server/store/repo.go b/server/store/repo.go
567index c64eb08ee491d46b173193908fb57e8a920f3598..01feece79cdfd608fda5ed77f04e8771fc4e46fe 100644
568--- a/server/store/repo.go
569+++ b/server/store/repo.go
570@@ -11,7 +11,8 @@ import (
571 type RepositoryStore interface {
572 	GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error)
573 	GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error)
574-	CreateRepo(ctx context.Context, h db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
575+	GetUserRepos(ctx context.Context, h db.Handler, userID int64) ([]models.Repo, error)
576+	CreateRepo(ctx context.Context, h db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
577 	DeleteRepoByName(ctx context.Context, h db.Handler, name string) error
578 	SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error
579 
580diff --git a/server/web/git.go b/server/web/git.go
581index bf2fbd6e37e9e7baad6b7890d0ab7c10b5fa0ff8..d2d640c5f730b0d4eec28c4cf09d6dcbc0e44f52 100644
582--- a/server/web/git.go
583+++ b/server/web/git.go
584@@ -262,7 +262,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
585 
586 			// Create the repo if it doesn't exist.
587 			if repo == nil {
588-				repo, err = be.CreateRepository(ctx, repoName, proto.RepositoryOptions{})
589+				repo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{})
590 				if err != nil {
591 					logger.Error("failed to create repository", "repo", repoName, "err", err)
592 					renderInternalServerError(w, r)
593diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go
594index b00a2661bcd006927492533fc1b3c277b5a59d31..d73f5a44208a8a28a1c4624e81130918d2353909 100644
595--- a/server/web/git_lfs.go
596+++ b/server/web/git_lfs.go
597@@ -89,7 +89,8 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
598 	dbx := db.FromContext(ctx)
599 	datastore := store.FromContext(ctx)
600 	// TODO: support S3 storage
601-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
602+	repoID := strconv.FormatInt(repo.ID(), 10)
603+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
604 
605 	baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git")
606 
607@@ -257,7 +258,8 @@ func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
608 	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
609 	datastore := store.FromContext(ctx)
610 	dbx := db.FromContext(ctx)
611-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
612+	repoID := strconv.FormatInt(repo.ID(), 10)
613+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
614 
615 	obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid)
616 	if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
617@@ -306,7 +308,9 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
618 	dbx := db.FromContext(ctx)
619 	datastore := store.FromContext(ctx)
620 	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
621-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
622+	repo := proto.RepositoryFromContext(ctx)
623+	repoID := strconv.FormatInt(repo.ID(), 10)
624+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
625 	name := mux.Vars(r)["repo"]
626 
627 	defer r.Body.Close() // nolint: errcheck
628@@ -393,7 +397,8 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
629 	cfg := config.FromContext(ctx)
630 	dbx := db.FromContext(ctx)
631 	datastore := store.FromContext(ctx)
632-	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
633+	repoID := strconv.FormatInt(repo.ID(), 10)
634+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
635 	if stat, err := strg.Stat(path.Join("objects", pointer.RelativePath())); err == nil {
636 		// Verify object is in the database.
637 		obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid)
638diff --git a/testscript/testdata/mirror.txtar b/testscript/testdata/mirror.txtar
639index 77b4bc281414c98620f0ba3b34cfc5e6447baa68..e1db9214d4f96288b5561b0ed8c41d413b5d66ff 100644
640--- a/testscript/testdata/mirror.txtar
641+++ b/testscript/testdata/mirror.txtar
642@@ -82,6 +82,7 @@ Description:
643 Private: false
644 Hidden: false
645 Mirror: true
646+Owner: admin
647 Default Branch: main
648 Branches:
649   - main
650@@ -92,6 +93,7 @@ Description: testing repo
651 Private: true
652 Hidden: true
653 Mirror: true
654+Owner: admin
655 Default Branch: main
656 Branches:
657   - main
658diff --git a/testscript/testdata/repo-create.txtar b/testscript/testdata/repo-create.txtar
659index 64849215c43d49b2ac9d420f29b3e6689aaf5b99..6787ee9d0b32828f5613bbf70adfe4b60dcbd66f 100644
660--- a/testscript/testdata/repo-create.txtar
661+++ b/testscript/testdata/repo-create.txtar
662@@ -90,6 +90,27 @@ soft repo branch delete repo1 master
663 soft repo branch list repo1
664 stdout branch1
665 
666+# create a new user
667+soft user create bar --key "$USER1_AUTHORIZED_KEY"
668+
669+# user create a repo
670+usoft repo create repo2 -d 'description' -H -p -n 'repo2'
671+usoft repo hidden repo2
672+stdout true
673+usoft repo private repo2
674+stdout true
675+! exists $DATA_PATH/repos/repo2.git/git-daemon-export-ok
676+usoft repo description repo2
677+stdout 'description'
678+readfile $DATA_PATH/repos/repo2.git/description 'description'
679+usoft repo project-name repo2
680+stdout 'repo2'
681+
682+# user delete a repo
683+usoft repo delete repo2
684+! exists $DATA_PATH/repos/repo2.git
685+
686+
687 -- readme.md --
688 # Project\nfoo
689 -- branch_list.1.txt --
690@@ -102,6 +123,7 @@ Description: description
691 Private: true
692 Hidden: true
693 Mirror: false
694+Owner: admin
695 Default Branch: master
696 Branches:
697   - master
698diff --git a/testscript/testdata/repo-import.txtar b/testscript/testdata/repo-import.txtar
699index 4d73a5a43ef7d34cc42f0a85fdba2a44a313753e..d85cdf3790187d2144f21087b39db2e07e9453dc 100644
700--- a/testscript/testdata/repo-import.txtar
701+++ b/testscript/testdata/repo-import.txtar
702@@ -25,6 +25,7 @@ Description: descriptive
703 Private: false
704 Hidden: false
705 Mirror: false
706+Owner: admin
707 Default Branch: main
708 Branches:
709   - main
710diff --git a/testscript/testdata/repo-perms.txtar b/testscript/testdata/repo-perms.txtar
711index bb3e423ac0be09389d08e2efdebe17e066646cf1..cf21151b55dde6e58e7853172aaad422bbb9e775 100644
712--- a/testscript/testdata/repo-perms.txtar
713+++ b/testscript/testdata/repo-perms.txtar
714@@ -82,6 +82,7 @@ Description: desc
715 Private: true
716 Hidden: false
717 Mirror: false
718+Owner: admin
719 Default Branch: master
720 Branches:
721   - master