Go · 12796 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package fork_test
4
5 import (
6 "context"
7 "errors"
8 "io"
9 "log/slog"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "testing"
15
16 "github.com/jackc/pgx/v5/pgtype"
17 "github.com/jackc/pgx/v5/pgxpool"
18
19 "github.com/tenseleyFlow/shithub/internal/infra/storage"
20 "github.com/tenseleyFlow/shithub/internal/repos/fork"
21 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
22 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
23 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
24 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25 )
26
27 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
28 "AAAAAAAAAAAAAAAA$" +
29 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
30
31 type fx struct {
32 pool *pgxpool.Pool
33 deps fork.Deps
34 rfs *storage.RepoFS
35 root string
36 source reposdb.Repo
37 owner usersdb.User
38 other usersdb.User
39 }
40
41 // setup spins a fresh DB + on-disk repos root + a real bare source
42 // repo so the fork orchestrator can exercise CloneBareShared,
43 // ResolveRefOID, IsAncestor end-to-end.
44 func setup(t *testing.T) fx {
45 t.Helper()
46 pool := dbtest.NewTestDB(t)
47 ctx := context.Background()
48
49 root := t.TempDir()
50 rfs, err := storage.NewRepoFS(root)
51 if err != nil {
52 t.Fatalf("NewRepoFS: %v", err)
53 }
54
55 uq := usersdb.New()
56 owner, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
57 Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
58 })
59 if err != nil {
60 t.Fatalf("CreateUser owner: %v", err)
61 }
62 other, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
63 Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
64 })
65 if err != nil {
66 t.Fatalf("CreateUser other: %v", err)
67 }
68
69 source, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{
70 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
71 Name: "demo",
72 DefaultBranch: "trunk",
73 Visibility: reposdb.RepoVisibilityPublic,
74 })
75 if err != nil {
76 t.Fatalf("CreateRepo source: %v", err)
77 }
78 sourcePath, _ := rfs.RepoPath(owner.Username, source.Name)
79 if err := rfs.InitBare(ctx, sourcePath); err != nil {
80 t.Fatalf("InitBare source: %v", err)
81 }
82
83 return fx{
84 pool: pool,
85 deps: fork.Deps{
86 Pool: pool,
87 RepoFS: rfs,
88 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
89 },
90 rfs: rfs,
91 root: root,
92 source: source,
93 owner: owner,
94 other: other,
95 }
96 }
97
98 func gitCmd(args ...string) *exec.Cmd {
99 return exec.Command("git", args...) //nolint:gosec
100 }
101
102 // commitTo writes file=contents to a worktree of repoPath on branch
103 // and commits with msg. Returns the commit OID.
104 func commitTo(t *testing.T, repoPath, branch, msg, file, contents string) string {
105 t.Helper()
106 wt := t.TempDir()
107 addArgs := []string{"-C", repoPath, "worktree", "add"}
108 if _, err := gitCmd("-C", repoPath, "show-ref", "--verify", "refs/heads/"+branch).CombinedOutput(); err != nil {
109 addArgs = append(addArgs, "-b", branch, wt)
110 } else {
111 addArgs = append(addArgs, wt, branch)
112 }
113 if out, err := gitCmd(addArgs...).CombinedOutput(); err != nil {
114 t.Fatalf("worktree add %s: %v (%s)", branch, err, out)
115 }
116 defer func() {
117 _ = gitCmd("-C", repoPath, "worktree", "remove", "--force", wt).Run()
118 }()
119 if err := os.WriteFile(filepath.Join(wt, file), []byte(contents), 0o644); err != nil { //nolint:gosec
120 t.Fatalf("write %s: %v", file, err)
121 }
122 for _, a := range [][]string{
123 {"-C", wt, "config", "user.name", "Alice"},
124 {"-C", wt, "config", "user.email", "alice@example.com"},
125 {"-C", wt, "add", "."},
126 {"-C", wt, "commit", "-m", msg},
127 } {
128 if out, err := gitCmd(a...).CombinedOutput(); err != nil {
129 t.Fatalf("%v: %v (%s)", a, err, out)
130 }
131 }
132 out, err := gitCmd("-C", wt, "rev-parse", "HEAD").Output()
133 if err != nil {
134 t.Fatalf("rev-parse: %v", err)
135 }
136 return strings.TrimSpace(string(out))
137 }
138
139 // runForkClone simulates the worker job inline so tests can assert
140 // the post-clone state without spinning up the worker pool.
141 func (f fx) runForkClone(t *testing.T, sourceID, forkID int64) {
142 t.Helper()
143 ctx := context.Background()
144 rq := reposdb.New()
145 uq := usersdb.New()
146 src, _ := rq.GetRepoByID(ctx, f.pool, sourceID)
147 frk, _ := rq.GetRepoByID(ctx, f.pool, forkID)
148 srcOwner, _ := uq.GetUserByID(ctx, f.pool, src.OwnerUserID.Int64)
149 frkOwner, _ := uq.GetUserByID(ctx, f.pool, frk.OwnerUserID.Int64)
150 srcPath, _ := f.rfs.RepoPath(srcOwner.Username, src.Name)
151 frkPath, _ := f.rfs.RepoPath(frkOwner.Username, frk.Name)
152 if err := f.rfs.CloneBareShared(ctx, srcPath, frkPath); err != nil {
153 t.Fatalf("CloneBareShared: %v", err)
154 }
155 _ = rq.SetRepoInitStatus(ctx, f.pool, reposdb.SetRepoInitStatusParams{
156 ID: forkID, InitStatus: reposdb.RepoInitStatusInitialized,
157 })
158 }
159
160 func TestCreate_Basic(t *testing.T) {
161 f := setup(t)
162 res, err := fork.Create(context.Background(), f.deps, fork.CreateParams{
163 SourceRepoID: f.source.ID,
164 ActorUserID: f.other.ID,
165 TargetOwnerID: f.other.ID,
166 })
167 if err != nil {
168 t.Fatalf("Create: %v", err)
169 }
170 if res.Fork.InitStatus != reposdb.RepoInitStatusInitPending {
171 t.Errorf("init_status: got %s, want init_pending", res.Fork.InitStatus)
172 }
173 if !res.Fork.ForkOfRepoID.Valid || res.Fork.ForkOfRepoID.Int64 != f.source.ID {
174 t.Errorf("fork_of_repo_id: got %v, want %d", res.Fork.ForkOfRepoID, f.source.ID)
175 }
176 // Trigger should have bumped source's fork_count.
177 src, _ := reposdb.New().GetRepoByID(context.Background(), f.pool, f.source.ID)
178 if src.ForkCount != 1 {
179 t.Errorf("source.fork_count: got %d, want 1", src.ForkCount)
180 }
181 }
182
183 func TestCreate_VisibilityFloor_PrivateToPublic(t *testing.T) {
184 f := setup(t)
185 // Flip source to private.
186 _, err := f.pool.Exec(context.Background(),
187 "UPDATE repos SET visibility = 'private' WHERE id = $1", f.source.ID)
188 if err != nil {
189 t.Fatalf("flip private: %v", err)
190 }
191 _, err = fork.Create(context.Background(), f.deps, fork.CreateParams{
192 SourceRepoID: f.source.ID,
193 ActorUserID: f.other.ID,
194 TargetOwnerID: f.other.ID,
195 TargetVisibility: "public",
196 })
197 if !errors.Is(err, fork.ErrVisibilityFloor) {
198 t.Errorf("expected ErrVisibilityFloor, got %v", err)
199 }
200 }
201
202 func TestCreate_VisibilityFloor_PublicToPrivate_OK(t *testing.T) {
203 f := setup(t)
204 res, err := fork.Create(context.Background(), f.deps, fork.CreateParams{
205 SourceRepoID: f.source.ID,
206 ActorUserID: f.other.ID,
207 TargetOwnerID: f.other.ID,
208 TargetVisibility: "private",
209 })
210 if err != nil {
211 t.Fatalf("Create public→private: %v", err)
212 }
213 if res.Fork.Visibility != reposdb.RepoVisibilityPrivate {
214 t.Errorf("fork visibility: got %s, want private", res.Fork.Visibility)
215 }
216 }
217
218 func TestCreate_SelfForkSameName_Rejected(t *testing.T) {
219 f := setup(t)
220 _, err := fork.Create(context.Background(), f.deps, fork.CreateParams{
221 SourceRepoID: f.source.ID,
222 ActorUserID: f.owner.ID,
223 TargetOwnerID: f.owner.ID,
224 })
225 if !errors.Is(err, fork.ErrSelfForkSameName) {
226 t.Errorf("expected ErrSelfForkSameName, got %v", err)
227 }
228 }
229
230 func TestCreate_SelfForkRenamed_OK(t *testing.T) {
231 f := setup(t)
232 res, err := fork.Create(context.Background(), f.deps, fork.CreateParams{
233 SourceRepoID: f.source.ID,
234 ActorUserID: f.owner.ID,
235 TargetOwnerID: f.owner.ID,
236 TargetName: "demo-fork",
237 })
238 if err != nil {
239 t.Fatalf("self-fork renamed: %v", err)
240 }
241 if res.Fork.Name != "demo-fork" {
242 t.Errorf("fork name: got %s, want demo-fork", res.Fork.Name)
243 }
244 }
245
246 func TestCreate_TargetNameTaken(t *testing.T) {
247 f := setup(t)
248 // other already has a "demo" repo.
249 _, err := reposdb.New().CreateRepo(context.Background(), f.pool, reposdb.CreateRepoParams{
250 OwnerUserID: pgtype.Int8{Int64: f.other.ID, Valid: true},
251 Name: "demo",
252 DefaultBranch: "trunk",
253 Visibility: reposdb.RepoVisibilityPublic,
254 })
255 if err != nil {
256 t.Fatalf("CreateRepo collision: %v", err)
257 }
258 _, err = fork.Create(context.Background(), f.deps, fork.CreateParams{
259 SourceRepoID: f.source.ID,
260 ActorUserID: f.other.ID,
261 TargetOwnerID: f.other.ID,
262 })
263 if !errors.Is(err, fork.ErrTargetNameTaken) {
264 t.Errorf("expected ErrTargetNameTaken, got %v", err)
265 }
266 }
267
268 func TestCreate_SourceArchived(t *testing.T) {
269 f := setup(t)
270 _, err := f.pool.Exec(context.Background(),
271 "UPDATE repos SET is_archived = true WHERE id = $1", f.source.ID)
272 if err != nil {
273 t.Fatalf("archive: %v", err)
274 }
275 _, err = fork.Create(context.Background(), f.deps, fork.CreateParams{
276 SourceRepoID: f.source.ID,
277 ActorUserID: f.other.ID,
278 TargetOwnerID: f.other.ID,
279 })
280 if !errors.Is(err, fork.ErrSourceArchived) {
281 t.Errorf("expected ErrSourceArchived, got %v", err)
282 }
283 }
284
285 // TestForkCount_DecrementOnDelete confirms the fork_count trigger
286 // honors the AFTER DELETE path (the spec promises this and S16's
287 // hard-delete cascade depends on it).
288 func TestForkCount_DecrementOnDelete(t *testing.T) {
289 f := setup(t)
290 res, err := fork.Create(context.Background(), f.deps, fork.CreateParams{
291 SourceRepoID: f.source.ID,
292 ActorUserID: f.other.ID,
293 TargetOwnerID: f.other.ID,
294 })
295 if err != nil {
296 t.Fatalf("Create: %v", err)
297 }
298 if _, err := f.pool.Exec(context.Background(),
299 "DELETE FROM repos WHERE id = $1", res.Fork.ID); err != nil {
300 t.Fatalf("delete fork: %v", err)
301 }
302 src, _ := reposdb.New().GetRepoByID(context.Background(), f.pool, f.source.ID)
303 if src.ForkCount != 0 {
304 t.Errorf("source.fork_count after fork delete: got %d, want 0", src.ForkCount)
305 }
306 }
307
308 // --- Sync tests --------------------------------------------------------
309
310 func TestSync_FastForward(t *testing.T) {
311 f := setup(t)
312 ctx := context.Background()
313 sourcePath, _ := f.rfs.RepoPath(f.owner.Username, f.source.Name)
314 commitTo(t, sourcePath, "trunk", "init", "README.md", "v1\n")
315
316 res, err := fork.Create(ctx, f.deps, fork.CreateParams{
317 SourceRepoID: f.source.ID,
318 ActorUserID: f.other.ID,
319 TargetOwnerID: f.other.ID,
320 })
321 if err != nil {
322 t.Fatalf("Create: %v", err)
323 }
324 f.runForkClone(t, f.source.ID, res.Fork.ID)
325
326 // Source advances; fork is now strictly behind.
327 commitTo(t, sourcePath, "trunk", "v2", "README.md", "v2\n")
328
329 syncRes, err := fork.Sync(ctx, f.deps, f.other.ID, res.Fork.ID)
330 if err != nil {
331 t.Fatalf("Sync: %v", err)
332 }
333 forkPath, _ := f.rfs.RepoPath(f.other.Username, res.Fork.Name)
334 got, _ := repogit.ResolveRefOID(ctx, forkPath, "trunk")
335 if got != syncRes.NewOID {
336 t.Errorf("fork tip after sync: got %s, want %s", got, syncRes.NewOID)
337 }
338 }
339
340 func TestSync_UpToDate(t *testing.T) {
341 f := setup(t)
342 ctx := context.Background()
343 sourcePath, _ := f.rfs.RepoPath(f.owner.Username, f.source.Name)
344 commitTo(t, sourcePath, "trunk", "init", "README.md", "v1\n")
345
346 res, err := fork.Create(ctx, f.deps, fork.CreateParams{
347 SourceRepoID: f.source.ID,
348 ActorUserID: f.other.ID,
349 TargetOwnerID: f.other.ID,
350 })
351 if err != nil {
352 t.Fatalf("Create: %v", err)
353 }
354 f.runForkClone(t, f.source.ID, res.Fork.ID)
355
356 // Both sides at same OID: sync returns ErrSyncUpToDate.
357 _, err = fork.Sync(ctx, f.deps, f.other.ID, res.Fork.ID)
358 if !errors.Is(err, fork.ErrSyncUpToDate) {
359 t.Errorf("expected ErrSyncUpToDate, got %v", err)
360 }
361 }
362
363 func TestSync_Diverged(t *testing.T) {
364 f := setup(t)
365 ctx := context.Background()
366 sourcePath, _ := f.rfs.RepoPath(f.owner.Username, f.source.Name)
367 commitTo(t, sourcePath, "trunk", "init", "README.md", "v1\n")
368
369 res, err := fork.Create(ctx, f.deps, fork.CreateParams{
370 SourceRepoID: f.source.ID,
371 ActorUserID: f.other.ID,
372 TargetOwnerID: f.other.ID,
373 })
374 if err != nil {
375 t.Fatalf("Create: %v", err)
376 }
377 f.runForkClone(t, f.source.ID, res.Fork.ID)
378 forkPath, _ := f.rfs.RepoPath(f.other.Username, res.Fork.Name)
379
380 // Both sides advance independently — diverged.
381 commitTo(t, sourcePath, "trunk", "src v2", "README.md", "src-v2\n")
382 commitTo(t, forkPath, "trunk", "fork v2", "README.md", "fork-v2\n")
383
384 _, err = fork.Sync(ctx, f.deps, f.other.ID, res.Fork.ID)
385 if !errors.Is(err, fork.ErrSyncDiverged) {
386 t.Errorf("expected ErrSyncDiverged, got %v", err)
387 }
388 }
389
390 // --- Ahead/behind tests ------------------------------------------------
391
392 func TestAheadBehind_StrictlyBehind(t *testing.T) {
393 f := setup(t)
394 ctx := context.Background()
395 sourcePath, _ := f.rfs.RepoPath(f.owner.Username, f.source.Name)
396 commitTo(t, sourcePath, "trunk", "init", "README.md", "v1\n")
397
398 res, err := fork.Create(ctx, f.deps, fork.CreateParams{
399 SourceRepoID: f.source.ID,
400 ActorUserID: f.other.ID,
401 TargetOwnerID: f.other.ID,
402 })
403 if err != nil {
404 t.Fatalf("Create: %v", err)
405 }
406 f.runForkClone(t, f.source.ID, res.Fork.ID)
407 commitTo(t, sourcePath, "trunk", "v2", "README.md", "v2\n")
408 commitTo(t, sourcePath, "trunk", "v3", "README.md", "v3\n")
409
410 stats, err := fork.AheadBehind(ctx, f.deps, res.Fork.ID)
411 if err != nil {
412 t.Fatalf("AheadBehind: %v", err)
413 }
414 if !stats.Comparable || stats.Ahead != 0 || stats.Behind != 2 {
415 t.Errorf("stats: got %+v, want {Ahead:0 Behind:2 Comparable:true}", stats)
416 }
417 }
418