tenseleyflow/shithub / 969e627

Browse files

S11: repo create orchestration — validate, rate-limit, tx-insert, init bare, optional initial commit, audit

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
969e627515fb52a21499c1df782e10d5ca3fb3f0
Parents
04743f1
Tree
652e9c9

6 changed files

StatusFile+-
M internal/auth/audit/audit.go 2 0
A internal/repos/create.go 281 0
A internal/repos/create_test.go 232 0
M internal/repos/git/plumbing.go 0 1
M internal/repos/git/plumbing_test.go 13 5
M internal/repos/validate.go 11 11
internal/auth/audit/audit.gomodified
@@ -47,6 +47,7 @@ const (
47
 	ActionUsernameChanged      Action = "username_changed"
47
 	ActionUsernameChanged      Action = "username_changed"
48
 	ActionAccountDeleted       Action = "account_deleted"
48
 	ActionAccountDeleted       Action = "account_deleted"
49
 	ActionAccountRestored      Action = "account_restored"
49
 	ActionAccountRestored      Action = "account_restored"
50
+	ActionRepoCreated          Action = "repo_created"
50
 )
51
 )
51
 
52
 
52
 // Target is a typed target-type constant.
53
 // Target is a typed target-type constant.
@@ -54,6 +55,7 @@ type Target string
54
 
55
 
55
 const (
56
 const (
56
 	TargetUser Target = "user"
57
 	TargetUser Target = "user"
58
+	TargetRepo Target = "repo"
57
 )
59
 )
58
 
60
 
59
 // Recorder writes audit rows. Bound to the sqlc queries handle.
61
 // Recorder writes audit rows. Bound to the sqlc queries handle.
internal/repos/create.goadded
@@ -0,0 +1,281 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repos
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+	"log/slog"
10
+	"os"
11
+	"strings"
12
+	"time"
13
+
14
+	"github.com/jackc/pgx/v5/pgconn"
15
+	"github.com/jackc/pgx/v5/pgtype"
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+
18
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
19
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
20
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
21
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
22
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
23
+	"github.com/tenseleyFlow/shithub/internal/repos/templates"
24
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25
+)
26
+
27
+// CreateRateLimit caps how many repos one user can create per hour.
28
+// The spec calls for 10/hour; the value is exported so the operator can
29
+// override via the throttle plumbing if needed.
30
+const (
31
+	CreateRateLimitMax    = 10
32
+	CreateRateLimitWindow = time.Hour
33
+)
34
+
35
+// Deps wires the repo orchestrator. Inject from the web layer; no
36
+// global state.
37
+type Deps struct {
38
+	Pool    *pgxpool.Pool
39
+	RepoFS  *storage.RepoFS
40
+	Audit   *audit.Recorder
41
+	Limiter *throttle.Limiter
42
+	Logger  *slog.Logger
43
+	Now     func() time.Time
44
+}
45
+
46
+// Params describes one repo-create request as it arrives from the
47
+// handler, normalized but not yet validated against the DB.
48
+type Params struct {
49
+	OwnerUserID   int64
50
+	OwnerUsername string
51
+
52
+	Name        string // already lowercased + trimmed
53
+	Description string
54
+	Visibility  string // "public" | "private"
55
+
56
+	InitReadme   bool
57
+	LicenseKey   string // "" = none
58
+	GitignoreKey string // "" = none
59
+
60
+	// Optional override for the initial commit timestamp; tests pin this
61
+	// for determinism. Production callers leave it zero and let
62
+	// orchestrator default to deps.Now().
63
+	InitialCommitWhen time.Time
64
+}
65
+
66
+// Result is what Create returns on success.
67
+type Result struct {
68
+	Repo             reposdb.Repo
69
+	InitialCommitOID string // "" when InitReadme/License/Gitignore were all unset
70
+	DiskPath         string // bare-repo on-disk path
71
+}
72
+
73
+// Create validates, rate-limits, inserts the DB row, initializes the
74
+// bare repo on disk, optionally builds the initial commit, audit-logs,
75
+// and returns. On post-DB failure the tx rolls back and the partial
76
+// repo dir is best-effort removed.
77
+func Create(ctx context.Context, deps Deps, p Params) (Result, error) {
78
+	if deps.Pool == nil || deps.RepoFS == nil || deps.Audit == nil || deps.Limiter == nil {
79
+		return Result{}, errors.New("repos: Deps missing required field")
80
+	}
81
+	now := deps.Now
82
+	if now == nil {
83
+		now = time.Now
84
+	}
85
+
86
+	if err := ValidateName(p.Name); err != nil {
87
+		return Result{}, err
88
+	}
89
+	if err := ValidateDescription(p.Description); err != nil {
90
+		return Result{}, err
91
+	}
92
+	if p.Visibility != "public" && p.Visibility != "private" {
93
+		return Result{}, fmt.Errorf("repos: visibility must be public or private (got %q)", p.Visibility)
94
+	}
95
+	if p.LicenseKey != "" && !templates.HasLicense(p.LicenseKey) {
96
+		return Result{}, fmt.Errorf("%w: %s", ErrUnknownLicense, p.LicenseKey)
97
+	}
98
+	if p.GitignoreKey != "" && !templates.HasGitignore(p.GitignoreKey) {
99
+		return Result{}, fmt.Errorf("%w: %s", ErrUnknownGitignore, p.GitignoreKey)
100
+	}
101
+
102
+	// Rate-limit per owning user.
103
+	if err := deps.Limiter.Hit(ctx, deps.Pool, throttle.Limit{
104
+		Scope:      "repo_create",
105
+		Identifier: fmt.Sprintf("user:%d", p.OwnerUserID),
106
+		Max:        CreateRateLimitMax,
107
+		Window:     CreateRateLimitWindow,
108
+	}); err != nil {
109
+		return Result{}, err
110
+	}
111
+
112
+	// Resolve author identity for the initial commit (only needed when we
113
+	// actually build one; resolved up-front so an unverified email
114
+	// rejects the create cleanly even if the user didn't tick init).
115
+	authorName, authorEmail, err := resolveAuthor(ctx, deps.Pool, p.OwnerUserID)
116
+	wantInit := p.InitReadme || p.LicenseKey != "" || p.GitignoreKey != ""
117
+	if wantInit && err != nil {
118
+		return Result{}, err
119
+	}
120
+
121
+	// Pre-compute disk path from RepoFS. Doing this before the tx avoids
122
+	// inserting a DB row for a name that fails the path-validation
123
+	// whitelist (which mostly mirrors our own ValidateName, but
124
+	// defense-in-depth never hurts).
125
+	diskPath, err := deps.RepoFS.RepoPath(p.OwnerUsername, p.Name)
126
+	if err != nil {
127
+		return Result{}, fmt.Errorf("%w: %v", ErrInvalidName, err)
128
+	}
129
+
130
+	tx, err := deps.Pool.Begin(ctx)
131
+	if err != nil {
132
+		return Result{}, fmt.Errorf("repos: begin tx: %w", err)
133
+	}
134
+	committed := false
135
+	defer func() {
136
+		if !committed {
137
+			_ = tx.Rollback(ctx)
138
+		}
139
+	}()
140
+
141
+	q := reposdb.New()
142
+	row, err := q.CreateRepo(ctx, tx, reposdb.CreateRepoParams{
143
+		OwnerUserID:     pgtype.Int8{Int64: p.OwnerUserID, Valid: true},
144
+		OwnerOrgID:      pgtype.Int8{Valid: false},
145
+		Name:            p.Name,
146
+		Description:     p.Description,
147
+		Visibility:      reposdb.RepoVisibility(p.Visibility),
148
+		DefaultBranch:   "trunk",
149
+		LicenseKey:      pgtype.Text{String: p.LicenseKey, Valid: p.LicenseKey != ""},
150
+		PrimaryLanguage: pgtype.Text{Valid: false},
151
+	})
152
+	if err != nil {
153
+		if isUniqueViolation(err) {
154
+			return Result{}, ErrTaken
155
+		}
156
+		return Result{}, fmt.Errorf("repos: insert: %w", err)
157
+	}
158
+
159
+	// FS init AFTER DB insert. If this fails the deferred Rollback
160
+	// reverses the row; we also best-effort RemoveAll the directory in
161
+	// case it got partially created.
162
+	if err := deps.RepoFS.InitBare(ctx, diskPath); err != nil {
163
+		_ = os.RemoveAll(diskPath)
164
+		return Result{}, fmt.Errorf("repos: init bare: %w", err)
165
+	}
166
+
167
+	var commitOID string
168
+	if wantInit {
169
+		commitWhen := p.InitialCommitWhen
170
+		if commitWhen.IsZero() {
171
+			commitWhen = now()
172
+		}
173
+		oid, err := buildInitialCommit(ctx, repogit.InitialCommit{
174
+			GitDir:      diskPath,
175
+			AuthorName:  authorName,
176
+			AuthorEmail: authorEmail,
177
+			Branch:      "trunk",
178
+			When:        commitWhen,
179
+			Files:       initFiles(p, authorName, commitWhen.Year()),
180
+		})
181
+		if err != nil {
182
+			_ = os.RemoveAll(diskPath)
183
+			return Result{}, fmt.Errorf("repos: initial commit: %w", err)
184
+		}
185
+		commitOID = oid
186
+	}
187
+
188
+	if err := tx.Commit(ctx); err != nil {
189
+		_ = os.RemoveAll(diskPath)
190
+		return Result{}, fmt.Errorf("repos: commit tx: %w", err)
191
+	}
192
+	committed = true
193
+
194
+	if err := deps.Audit.Record(ctx, deps.Pool, p.OwnerUserID,
195
+		audit.ActionRepoCreated, audit.TargetRepo, row.ID, map[string]any{
196
+			"name":       p.Name,
197
+			"visibility": p.Visibility,
198
+			"init":       wantInit,
199
+			"license":    p.LicenseKey,
200
+			"gitignore":  p.GitignoreKey,
201
+		}); err != nil {
202
+		if deps.Logger != nil {
203
+			deps.Logger.WarnContext(ctx, "repos: audit", "error", err)
204
+		}
205
+	}
206
+
207
+	return Result{Repo: row, InitialCommitOID: commitOID, DiskPath: diskPath}, nil
208
+}
209
+
210
+// initFiles assembles the FileEntry slice for the initial commit based
211
+// on which init checkboxes the user ticked.
212
+func initFiles(p Params, author string, year int) []repogit.FileEntry {
213
+	var files []repogit.FileEntry
214
+	if p.InitReadme {
215
+		files = append(files, repogit.FileEntry{
216
+			Path: "README.md",
217
+			Body: []byte(templates.ReadmeText(p.Name, p.Description)),
218
+		})
219
+	}
220
+	if p.LicenseKey != "" {
221
+		body, err := templates.LicenseText(p.LicenseKey, year, author)
222
+		if err == nil {
223
+			files = append(files, repogit.FileEntry{
224
+				Path: "LICENSE",
225
+				Body: []byte(body),
226
+			})
227
+		}
228
+	}
229
+	if p.GitignoreKey != "" {
230
+		body, err := templates.GitignoreText(p.GitignoreKey)
231
+		if err == nil {
232
+			files = append(files, repogit.FileEntry{
233
+				Path: ".gitignore",
234
+				Body: []byte(body),
235
+			})
236
+		}
237
+	}
238
+	return files
239
+}
240
+
241
+// buildInitialCommit is a thin pass-through so tests can swap it (post-MVP).
242
+var buildInitialCommit = func(ctx context.Context, ic repogit.InitialCommit) (string, error) {
243
+	return ic.Build(ctx)
244
+}
245
+
246
+// resolveAuthor reads the user's display name + verified primary email.
247
+// Returns ErrNoVerifiedEmail if the user has no primary email or the
248
+// primary isn't verified.
249
+func resolveAuthor(ctx context.Context, pool *pgxpool.Pool, userID int64) (name, addr string, err error) {
250
+	uq := usersdb.New()
251
+	user, err := uq.GetUserByID(ctx, pool, userID)
252
+	if err != nil {
253
+		return "", "", fmt.Errorf("repos: load user: %w", err)
254
+	}
255
+	if !user.PrimaryEmailID.Valid {
256
+		return "", "", ErrNoVerifiedEmail
257
+	}
258
+	em, err := uq.GetUserEmailByID(ctx, pool, user.PrimaryEmailID.Int64)
259
+	if err != nil {
260
+		return "", "", fmt.Errorf("repos: load primary email: %w", err)
261
+	}
262
+	if !em.Verified {
263
+		return "", "", ErrNoVerifiedEmail
264
+	}
265
+	display := strings.TrimSpace(user.DisplayName)
266
+	if display == "" {
267
+		display = user.Username
268
+	}
269
+	return display, string(em.Email), nil
270
+}
271
+
272
+// isUniqueViolation matches Postgres SQLSTATE 23505. Used to surface
273
+// the friendly "name taken" error from the unique-by-owner-and-name
274
+// indexes when the pre-check raced.
275
+func isUniqueViolation(err error) bool {
276
+	var pgErr *pgconn.PgError
277
+	if errors.As(err, &pgErr) {
278
+		return pgErr.Code == "23505"
279
+	}
280
+	return false
281
+}
internal/repos/create_test.goadded
@@ -0,0 +1,232 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repos_test
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"io"
9
+	"log/slog"
10
+	"os/exec"
11
+	"path/filepath"
12
+	"strings"
13
+	"testing"
14
+	"time"
15
+
16
+	"github.com/jackc/pgx/v5/pgtype"
17
+	"github.com/jackc/pgx/v5/pgxpool"
18
+
19
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
20
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
21
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
22
+	"github.com/tenseleyFlow/shithub/internal/repos"
23
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
24
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25
+)
26
+
27
+// gitCmd wraps exec.Command with the single G204 suppression for this
28
+// file — every git invocation runs against a t.TempDir path.
29
+func gitCmd(args ...string) *exec.Cmd {
30
+	//nolint:gosec // G204 false positive: callers feed t.TempDir paths and fixed flags.
31
+	return exec.Command("git", args...)
32
+}
33
+
34
+// fixtureHash is a static PHC test fixture (zero salt, zero key) — not a credential.
35
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
36
+	"AAAAAAAAAAAAAAAA$" +
37
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
38
+
39
+// setupCreateEnv constructs Deps + a verified-email user against a
40
+// fresh test DB.
41
+func setupCreateEnv(t *testing.T) (*pgxpool.Pool, repos.Deps, int64, string, string) {
42
+	t.Helper()
43
+	pool := dbtest.NewTestDB(t)
44
+
45
+	root := t.TempDir()
46
+	rfs, err := storage.NewRepoFS(root)
47
+	if err != nil {
48
+		t.Fatalf("NewRepoFS: %v", err)
49
+	}
50
+
51
+	deps := repos.Deps{
52
+		Pool:    pool,
53
+		RepoFS:  rfs,
54
+		Audit:   audit.NewRecorder(),
55
+		Limiter: throttle.NewLimiter(),
56
+		Logger:  slog.New(slog.NewTextHandler(io.Discard, nil)),
57
+	}
58
+
59
+	uq := usersdb.New()
60
+	user, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
61
+		Username: "alice", DisplayName: "Alice Anderson", PasswordHash: fixtureHash,
62
+	})
63
+	if err != nil {
64
+		t.Fatalf("CreateUser: %v", err)
65
+	}
66
+	em, err := uq.CreateUserEmail(context.Background(), pool, usersdb.CreateUserEmailParams{
67
+		UserID: user.ID, Email: "alice@example.com", IsPrimary: true, Verified: true,
68
+	})
69
+	if err != nil {
70
+		t.Fatalf("CreateUserEmail: %v", err)
71
+	}
72
+	if err := uq.LinkUserPrimaryEmail(context.Background(), pool, usersdb.LinkUserPrimaryEmailParams{
73
+		ID: user.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
74
+	}); err != nil {
75
+		t.Fatalf("LinkUserPrimaryEmail: %v", err)
76
+	}
77
+	return pool, deps, user.ID, user.Username, root
78
+}
79
+
80
+func TestCreate_EmptyRepo(t *testing.T) {
81
+	t.Parallel()
82
+	_, deps, uid, uname, root := setupCreateEnv(t)
83
+	res, err := repos.Create(context.Background(), deps, repos.Params{
84
+		OwnerUserID:   uid,
85
+		OwnerUsername: uname,
86
+		Name:          "empty-repo",
87
+		Visibility:    "public",
88
+	})
89
+	if err != nil {
90
+		t.Fatalf("Create: %v", err)
91
+	}
92
+	if res.InitialCommitOID != "" {
93
+		t.Errorf("expected no initial commit; got %q", res.InitialCommitOID)
94
+	}
95
+	if !strings.HasPrefix(res.DiskPath, root) {
96
+		t.Errorf("DiskPath %q not under root %q", res.DiskPath, root)
97
+	}
98
+
99
+	// HEAD must be a symbolic ref to refs/heads/trunk (unborn branch).
100
+	out, err := gitCmd("-C", res.DiskPath, "symbolic-ref", "HEAD").CombinedOutput()
101
+	if err != nil {
102
+		t.Fatalf("symbolic-ref: %v\n%s", err, out)
103
+	}
104
+	if got := strings.TrimSpace(string(out)); got != "refs/heads/trunk" {
105
+		t.Fatalf("HEAD = %q, want refs/heads/trunk", got)
106
+	}
107
+
108
+	// Zero commits.
109
+	out, _ = gitCmd("-C", res.DiskPath, "rev-list", "--all", "--count").CombinedOutput()
110
+	if got := strings.TrimSpace(string(out)); got != "0" {
111
+		t.Fatalf("rev-list count = %q, want 0", got)
112
+	}
113
+}
114
+
115
+func TestCreate_WithReadmeLicenseGitignore(t *testing.T) {
116
+	t.Parallel()
117
+	_, deps, uid, uname, _ := setupCreateEnv(t)
118
+	res, err := repos.Create(context.Background(), deps, repos.Params{
119
+		OwnerUserID:       uid,
120
+		OwnerUsername:     uname,
121
+		Name:              "init-repo",
122
+		Description:       "hello world",
123
+		Visibility:        "public",
124
+		InitReadme:        true,
125
+		LicenseKey:        "MIT",
126
+		GitignoreKey:      "Go",
127
+		InitialCommitWhen: time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC),
128
+	})
129
+	if err != nil {
130
+		t.Fatalf("Create: %v", err)
131
+	}
132
+	if res.InitialCommitOID == "" {
133
+		t.Fatal("expected an initial commit")
134
+	}
135
+
136
+	// Single commit, three files, expected names.
137
+	out, _ := gitCmd("-C", res.DiskPath, "rev-list", "--count", "trunk").CombinedOutput()
138
+	if got := strings.TrimSpace(string(out)); got != "1" {
139
+		t.Fatalf("rev-list count = %q, want 1", got)
140
+	}
141
+	out, _ = gitCmd("-C", res.DiskPath, "ls-tree", "--name-only", "trunk").CombinedOutput()
142
+	got := strings.TrimSpace(string(out))
143
+	for _, want := range []string{"README.md", "LICENSE", ".gitignore"} {
144
+		if !strings.Contains(got, want) {
145
+			t.Errorf("missing %q in tree: %q", want, got)
146
+		}
147
+	}
148
+	// Author identity is alice's verified primary email.
149
+	out, _ = gitCmd("-C", res.DiskPath, "log", "-1", "--format=%an <%ae>", "trunk").CombinedOutput()
150
+	if want := "Alice Anderson <alice@example.com>"; strings.TrimSpace(string(out)) != want {
151
+		t.Errorf("author = %q, want %q", strings.TrimSpace(string(out)), want)
152
+	}
153
+	// LICENSE has the year substituted.
154
+	out, _ = gitCmd("-C", res.DiskPath, "show", "trunk:LICENSE").CombinedOutput()
155
+	if !strings.Contains(string(out), "2026") {
156
+		t.Errorf("LICENSE missing year 2026; got first 200 chars: %s", string(out)[:200])
157
+	}
158
+}
159
+
160
+func TestCreate_RejectsDuplicate(t *testing.T) {
161
+	t.Parallel()
162
+	_, deps, uid, uname, _ := setupCreateEnv(t)
163
+	if _, err := repos.Create(context.Background(), deps, repos.Params{
164
+		OwnerUserID: uid, OwnerUsername: uname, Name: "dup", Visibility: "public",
165
+	}); err != nil {
166
+		t.Fatalf("first create: %v", err)
167
+	}
168
+	_, err := repos.Create(context.Background(), deps, repos.Params{
169
+		OwnerUserID: uid, OwnerUsername: uname, Name: "dup", Visibility: "public",
170
+	})
171
+	if !errors.Is(err, repos.ErrTaken) {
172
+		t.Fatalf("second create: err = %v, want ErrTaken", err)
173
+	}
174
+}
175
+
176
+func TestCreate_RejectsReservedName(t *testing.T) {
177
+	t.Parallel()
178
+	_, deps, uid, uname, _ := setupCreateEnv(t)
179
+	_, err := repos.Create(context.Background(), deps, repos.Params{
180
+		OwnerUserID: uid, OwnerUsername: uname, Name: "head", Visibility: "public",
181
+	})
182
+	if !errors.Is(err, repos.ErrReservedName) {
183
+		t.Fatalf("err = %v, want ErrReservedName", err)
184
+	}
185
+}
186
+
187
+func TestCreate_RefusesWithoutVerifiedEmail(t *testing.T) {
188
+	t.Parallel()
189
+	pool := dbtest.NewTestDB(t)
190
+	root := t.TempDir()
191
+	rfs, _ := storage.NewRepoFS(root)
192
+	deps := repos.Deps{
193
+		Pool: pool, RepoFS: rfs,
194
+		Audit:   audit.NewRecorder(),
195
+		Limiter: throttle.NewLimiter(),
196
+	}
197
+	uq := usersdb.New()
198
+	user, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
199
+		Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
200
+	})
201
+	if err != nil {
202
+		t.Fatalf("CreateUser: %v", err)
203
+	}
204
+	// User exists but has NO verified primary email.
205
+	_, err = repos.Create(context.Background(), deps, repos.Params{
206
+		OwnerUserID: user.ID, OwnerUsername: user.Username,
207
+		Name: "needs-email", Visibility: "public",
208
+		InitReadme: true,
209
+	})
210
+	if !errors.Is(err, repos.ErrNoVerifiedEmail) {
211
+		t.Fatalf("err = %v, want ErrNoVerifiedEmail", err)
212
+	}
213
+}
214
+
215
+func TestCreate_PrivateVisibilityPersists(t *testing.T) {
216
+	t.Parallel()
217
+	_, deps, uid, uname, _ := setupCreateEnv(t)
218
+	res, err := repos.Create(context.Background(), deps, repos.Params{
219
+		OwnerUserID: uid, OwnerUsername: uname,
220
+		Name: "secret", Visibility: "private",
221
+	})
222
+	if err != nil {
223
+		t.Fatalf("Create: %v", err)
224
+	}
225
+	if string(res.Repo.Visibility) != "private" {
226
+		t.Errorf("Visibility = %q, want private", res.Repo.Visibility)
227
+	}
228
+	// Sharded path layout sanity check (shard is first 2 chars of OWNER).
229
+	if want := filepath.Join("al", "alice", "secret.git"); !strings.HasSuffix(res.DiskPath, want) {
230
+		t.Errorf("DiskPath %q does not end with %q", res.DiskPath, want)
231
+	}
232
+}
internal/repos/git/plumbing.gomodified
@@ -190,4 +190,3 @@ func wrapExecErr(err error) error {
190
 	}
190
 	}
191
 	return err
191
 	return err
192
 }
192
 }
193
-
internal/repos/git/plumbing_test.gomodified
@@ -13,6 +13,14 @@ import (
13
 	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
13
 	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
14
 )
14
 )
15
 
15
 
16
+// gitCmd is a thin wrapper that owns the single G204 suppression for
17
+// the whole test file. Every test call goes through here; the bare-repo
18
+// path is always a t.TempDir, never user input.
19
+func gitCmd(args ...string) *exec.Cmd {
20
+	//nolint:gosec // G204 false positive: callers feed t.TempDir paths and fixed flags.
21
+	return exec.Command("git", args...)
22
+}
23
+
16
 // initBare creates a bare repo at dir/<name>.git and returns the path.
24
 // initBare creates a bare repo at dir/<name>.git and returns the path.
17
 // Tests that need a bare repo to operate against use this rather than
25
 // Tests that need a bare repo to operate against use this rather than
18
 // reaching into the storage package (one less inter-package dependency
26
 // reaching into the storage package (one less inter-package dependency
@@ -21,7 +29,7 @@ func initBare(t *testing.T) string {
21
 	t.Helper()
29
 	t.Helper()
22
 	root := t.TempDir()
30
 	root := t.TempDir()
23
 	gitDir := filepath.Join(root, "x.git")
31
 	gitDir := filepath.Join(root, "x.git")
24
-	if out, err := exec.Command("git", "init", "--bare", "--initial-branch=trunk", gitDir).CombinedOutput(); err != nil {
32
+	if out, err := gitCmd("init", "--bare", "--initial-branch=trunk", gitDir).CombinedOutput(); err != nil {
25
 		t.Fatalf("git init: %v\n%s", err, out)
33
 		t.Fatalf("git init: %v\n%s", err, out)
26
 	}
34
 	}
27
 	return gitDir
35
 	return gitDir
@@ -54,7 +62,7 @@ func TestInitialCommit_BuildSingleCommitWithFiles(t *testing.T) {
54
 	}
62
 	}
55
 
63
 
56
 	// HEAD must now resolve to the commit via refs/heads/trunk.
64
 	// HEAD must now resolve to the commit via refs/heads/trunk.
57
-	out, err := exec.Command("git", "-C", gitDir, "rev-parse", "refs/heads/trunk").CombinedOutput()
65
+	out, err := gitCmd("-C", gitDir, "rev-parse", "refs/heads/trunk").CombinedOutput()
58
 	if err != nil {
66
 	if err != nil {
59
 		t.Fatalf("rev-parse: %v\n%s", err, out)
67
 		t.Fatalf("rev-parse: %v\n%s", err, out)
60
 	}
68
 	}
@@ -63,7 +71,7 @@ func TestInitialCommit_BuildSingleCommitWithFiles(t *testing.T) {
63
 	}
71
 	}
64
 
72
 
65
 	// Single commit, no parent.
73
 	// Single commit, no parent.
66
-	out, err = exec.Command("git", "-C", gitDir, "rev-list", "--count", "trunk").CombinedOutput()
74
+	out, err = gitCmd("-C", gitDir, "rev-list", "--count", "trunk").CombinedOutput()
67
 	if err != nil {
75
 	if err != nil {
68
 		t.Fatalf("rev-list: %v\n%s", err, out)
76
 		t.Fatalf("rev-list: %v\n%s", err, out)
69
 	}
77
 	}
@@ -72,7 +80,7 @@ func TestInitialCommit_BuildSingleCommitWithFiles(t *testing.T) {
72
 	}
80
 	}
73
 
81
 
74
 	// Tree contents are the three files at the right paths.
82
 	// Tree contents are the three files at the right paths.
75
-	out, err = exec.Command("git", "-C", gitDir, "ls-tree", "--name-only", "trunk").CombinedOutput()
83
+	out, err = gitCmd("-C", gitDir, "ls-tree", "--name-only", "trunk").CombinedOutput()
76
 	if err != nil {
84
 	if err != nil {
77
 		t.Fatalf("ls-tree: %v\n%s", err, out)
85
 		t.Fatalf("ls-tree: %v\n%s", err, out)
78
 	}
86
 	}
@@ -89,7 +97,7 @@ func TestInitialCommit_BuildSingleCommitWithFiles(t *testing.T) {
89
 	}
97
 	}
90
 
98
 
91
 	// Author identity is what we passed in.
99
 	// Author identity is what we passed in.
92
-	out, err = exec.Command("git", "-C", gitDir, "log", "-1", "--format=%an <%ae>", "trunk").CombinedOutput()
100
+	out, err = gitCmd("-C", gitDir, "log", "-1", "--format=%an <%ae>", "trunk").CombinedOutput()
93
 	if err != nil {
101
 	if err != nil {
94
 		t.Fatalf("log: %v\n%s", err, out)
102
 		t.Fatalf("log: %v\n%s", err, out)
95
 	}
103
 	}
internal/repos/validate.gomodified
@@ -27,18 +27,18 @@ var nameRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9_])?$`)
27
 // The list is kept short on purpose; it's all-lowercase because we
27
 // The list is kept short on purpose; it's all-lowercase because we
28
 // always lowercase the name before comparing.
28
 // always lowercase the name before comparing.
29
 var reservedRepoNames = map[string]struct{}{
29
 var reservedRepoNames = map[string]struct{}{
30
-	".git":         {},
30
+	".git":           {},
31
-	".gitignore":   {},
31
+	".gitignore":     {},
32
-	".gitmodules":  {},
32
+	".gitmodules":    {},
33
 	".gitattributes": {},
33
 	".gitattributes": {},
34
-	".well-known":  {},
34
+	".well-known":    {},
35
-	".github":      {},
35
+	".github":        {},
36
-	"head":         {},
36
+	"head":           {},
37
-	"refs":         {},
37
+	"refs":           {},
38
-	"objects":      {},
38
+	"objects":        {},
39
-	"info":         {},
39
+	"info":           {},
40
-	"hooks":        {},
40
+	"hooks":          {},
41
-	"branches":     {},
41
+	"branches":       {},
42
 }
42
 }
43
 
43
 
44
 // IsReservedRepoName reports whether name (case-insensitive) is on the
44
 // IsReservedRepoName reports whether name (case-insensitive) is on the