tenseleyflow/shithub / 3b9dc8e

Browse files

S27: fork orchestrator (create, sync, ahead/behind, visibility)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
3b9dc8e845d905e2a6c90619e9976039191dbe15
Parents
0544eb7
Tree
a934442

8 changed files

StatusFile+-
M internal/auth/audit/audit.go 2 0
A internal/repos/fork/ahead_behind.go 104 0
A internal/repos/fork/create.go 119 0
A internal/repos/fork/fork.go 46 0
A internal/repos/fork/fork_test.go 417 0
A internal/repos/fork/helpers.go 27 0
A internal/repos/fork/sync.go 142 0
A internal/repos/fork/visibility.go 29 0
internal/auth/audit/audit.gomodified
@@ -69,6 +69,8 @@ const (
6969
 	ActionStarDeleted            Action = "star_deleted"
7070
 	ActionWatchSet               Action = "watch_set"
7171
 	ActionWatchUnset             Action = "watch_unset"
72
+	ActionRepoForked             Action = "repo_forked"
73
+	ActionRepoForkSynced         Action = "repo_fork_synced"
7274
 )
7375
 
7476
 // Target is a typed target-type constant.
internal/repos/fork/ahead_behind.goadded
@@ -0,0 +1,104 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package fork
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+
10
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
11
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
12
+)
13
+
14
+// AheadBehindStats describes how the fork's default branch relates
15
+// to the source's default branch. Both numbers are commit counts:
16
+// `Ahead` = commits in fork not in source; `Behind` = commits in
17
+// source not in fork.
18
+//
19
+// `Comparable` is false when either side's default ref doesn't
20
+// exist (e.g. an empty fork before its first push, or a source
21
+// that's never been initialized). The UI renders "—" in that case.
22
+type AheadBehindStats struct {
23
+	Ahead      int
24
+	Behind     int
25
+	Comparable bool
26
+}
27
+
28
+// AheadBehind computes the stats by reading both default OIDs,
29
+// then running `rev-list --left-right --count` inside the fork's
30
+// bare repo. Because forks share object alternates with their
31
+// source, the fork can resolve OIDs from the source without an
32
+// explicit fetch — they're already reachable through the
33
+// alternates link.
34
+//
35
+// This is the floor implementation. S36's perf-pass sprint adds
36
+// caching keyed on `(fork_repo_id, fork_default_oid,
37
+// upstream_default_oid)` so the rev-list shells out only on push
38
+// (the deferral pointer is already in S36's spec).
39
+func AheadBehind(ctx context.Context, deps Deps, forkRepoID int64) (AheadBehindStats, error) {
40
+	rq := reposdb.New()
41
+	fork, err := rq.GetRepoByID(ctx, deps.Pool, forkRepoID)
42
+	if err != nil {
43
+		return AheadBehindStats{}, fmt.Errorf("ahead-behind: load fork: %w", err)
44
+	}
45
+	if !fork.ForkOfRepoID.Valid {
46
+		return AheadBehindStats{}, ErrSourceNotFound
47
+	}
48
+	source, err := rq.GetRepoByID(ctx, deps.Pool, fork.ForkOfRepoID.Int64)
49
+	if err != nil {
50
+		return AheadBehindStats{}, ErrSourceNotFound
51
+	}
52
+	if source.DeletedAt.Valid {
53
+		return AheadBehindStats{}, ErrSourceDeleted
54
+	}
55
+
56
+	forkOwner, err := ownerUsername(ctx, deps, fork)
57
+	if err != nil {
58
+		return AheadBehindStats{}, err
59
+	}
60
+	sourceOwner, err := ownerUsername(ctx, deps, source)
61
+	if err != nil {
62
+		return AheadBehindStats{}, err
63
+	}
64
+	forkPath, err := deps.RepoFS.RepoPath(forkOwner, fork.Name)
65
+	if err != nil {
66
+		return AheadBehindStats{}, err
67
+	}
68
+	sourcePath, err := deps.RepoFS.RepoPath(sourceOwner, source.Name)
69
+	if err != nil {
70
+		return AheadBehindStats{}, err
71
+	}
72
+
73
+	// Resolve both default OIDs. Either side empty → not comparable.
74
+	forkOID, err := repogit.ResolveRefOID(ctx, forkPath, fork.DefaultBranch)
75
+	if err != nil {
76
+		if errors.Is(err, repogit.ErrRefNotFound) {
77
+			return AheadBehindStats{Comparable: false}, nil
78
+		}
79
+		return AheadBehindStats{}, fmt.Errorf("ahead-behind: resolve fork: %w", err)
80
+	}
81
+	sourceOID, err := repogit.ResolveRefOID(ctx, sourcePath, source.DefaultBranch)
82
+	if err != nil {
83
+		if errors.Is(err, repogit.ErrRefNotFound) {
84
+			return AheadBehindStats{Comparable: false}, nil
85
+		}
86
+		return AheadBehindStats{}, fmt.Errorf("ahead-behind: resolve source: %w", err)
87
+	}
88
+	if forkOID == sourceOID {
89
+		return AheadBehindStats{Comparable: true}, nil
90
+	}
91
+
92
+	// Run the count inside the fork's repo. The fork's alternates
93
+	// give it visibility into source's objects, so passing
94
+	// `<forkOID>...<sourceOID>` resolves both ends.
95
+	ahead, behind, err := repogit.AheadBehind(ctx, forkPath, sourceOID, forkOID)
96
+	if err != nil {
97
+		return AheadBehindStats{}, fmt.Errorf("ahead-behind: rev-list: %w", err)
98
+	}
99
+	// repogit.AheadBehind's argument order is (base, head): ahead
100
+	// = "commits in head not in base"; we asked it for forkOID's
101
+	// ahead-of sourceOID. So the returned ahead/behind are already
102
+	// from the fork's perspective.
103
+	return AheadBehindStats{Ahead: ahead, Behind: behind, Comparable: true}, nil
104
+}
internal/repos/fork/create.goadded
@@ -0,0 +1,119 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package fork
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+	"strings"
10
+
11
+	"github.com/jackc/pgx/v5"
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16
+)
17
+
18
+// CreateParams describes a fork-create request.
19
+type CreateParams struct {
20
+	SourceRepoID  int64
21
+	ActorUserID   int64
22
+	TargetOwnerID int64 // user id; org id support lands with S31
23
+	// TargetName is optional. Empty defaults to the source repo's
24
+	// name; non-empty must pass the same name validator as repo
25
+	// create (the lookup against the existing rows surfaces a
26
+	// taken-name error if needed).
27
+	TargetName string
28
+	// TargetVisibility is optional. Empty defaults to source
29
+	// visibility; non-empty must pass `allowedTargetVisibility`.
30
+	// (Public source can fork to private; private source is
31
+	// pinned to private.)
32
+	TargetVisibility string
33
+}
34
+
35
+// CreateResult is the inserted fork shell. The on-disk clone hasn't
36
+// run yet; init_status is `init_pending`. The caller is expected to
37
+// enqueue the `repo:fork_clone` worker job whose payload is
38
+// {SourceRepoID, ForkRepoID}.
39
+type CreateResult struct {
40
+	Fork   reposdb.Repo
41
+	Source reposdb.Repo
42
+}
43
+
44
+// Create writes the fork's `repos` row, applies the fork_count
45
+// trigger, and emits the audit row. The on-disk clone is the
46
+// caller's responsibility to enqueue (the web handler does this
47
+// right after Create returns).
48
+//
49
+// Authorization (visibility on source, login, fork:create policy)
50
+// is the caller's responsibility — this orchestrator trusts the
51
+// handler's policy gate. Visibility floor + same-owner-name guards
52
+// are enforced here because they're domain rules, not auth rules.
53
+func Create(ctx context.Context, deps Deps, p CreateParams) (CreateResult, error) {
54
+	if p.ActorUserID == 0 {
55
+		return CreateResult{}, ErrNotLoggedIn
56
+	}
57
+	rq := reposdb.New()
58
+
59
+	source, err := rq.GetRepoByID(ctx, deps.Pool, p.SourceRepoID)
60
+	if err != nil {
61
+		if errors.Is(err, pgx.ErrNoRows) {
62
+			return CreateResult{}, ErrSourceNotFound
63
+		}
64
+		return CreateResult{}, fmt.Errorf("fork: load source: %w", err)
65
+	}
66
+	if source.DeletedAt.Valid {
67
+		return CreateResult{}, ErrSourceDeleted
68
+	}
69
+	if source.IsArchived {
70
+		return CreateResult{}, ErrSourceArchived
71
+	}
72
+
73
+	targetName := strings.TrimSpace(p.TargetName)
74
+	if targetName == "" {
75
+		targetName = source.Name
76
+	}
77
+	// Same-owner-name shortcut: if the target owner == source owner
78
+	// AND the name didn't change, this is a no-op fork onto itself.
79
+	// Users CAN fork their own repos but must rename.
80
+	if source.OwnerUserID.Valid && source.OwnerUserID.Int64 == p.TargetOwnerID && targetName == source.Name {
81
+		return CreateResult{}, ErrSelfForkSameName
82
+	}
83
+
84
+	visibility, ok := allowedTargetVisibility(string(source.Visibility), p.TargetVisibility)
85
+	if !ok {
86
+		return CreateResult{}, ErrVisibilityFloor
87
+	}
88
+
89
+	// Check name availability against the target owner.
90
+	exists, err := rq.ExistsRepoForOwnerUser(ctx, deps.Pool, reposdb.ExistsRepoForOwnerUserParams{
91
+		OwnerUserID: pgtype.Int8{Int64: p.TargetOwnerID, Valid: true},
92
+		Name:        targetName,
93
+	})
94
+	if err != nil {
95
+		return CreateResult{}, fmt.Errorf("fork: name check: %w", err)
96
+	}
97
+	if exists {
98
+		return CreateResult{}, ErrTargetNameTaken
99
+	}
100
+
101
+	row, err := rq.CreateForkRepo(ctx, deps.Pool, reposdb.CreateForkRepoParams{
102
+		OwnerUserID:   pgtype.Int8{Int64: p.TargetOwnerID, Valid: true},
103
+		Name:          targetName,
104
+		Description:   source.Description,
105
+		Visibility:    reposdb.RepoVisibility(visibility),
106
+		DefaultBranch: source.DefaultBranch,
107
+		ForkOfRepoID:  pgtype.Int8{Int64: source.ID, Valid: true},
108
+	})
109
+	if err != nil {
110
+		return CreateResult{}, fmt.Errorf("fork: insert row: %w", err)
111
+	}
112
+
113
+	if deps.Audit != nil {
114
+		_ = deps.Audit.Record(ctx, deps.Pool, p.ActorUserID,
115
+			audit.ActionRepoForked, audit.TargetRepo, row.ID,
116
+			map[string]any{"source_repo_id": source.ID})
117
+	}
118
+	return CreateResult{Fork: row, Source: source}, nil
119
+}
internal/repos/fork/fork.goadded
@@ -0,0 +1,46 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package fork owns S27's fork orchestration. The DB row for a fork
4
+// is created synchronously in Create; the on-disk `git clone --bare
5
+// --shared` runs out-of-band as a `repo:fork_clone` worker job so
6
+// fork creation returns fast even for large source repos.
7
+//
8
+// Sync (fast-forward fork's default branch from upstream) is the
9
+// other entrypoint here. Cross-fork PR support extends `internal/
10
+// pulls` and lives there; this package is just the fork lifecycle.
11
+package fork
12
+
13
+import (
14
+	"errors"
15
+	"log/slog"
16
+
17
+	"github.com/jackc/pgx/v5/pgxpool"
18
+
19
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
20
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
21
+)
22
+
23
+// Deps wires this package against the rest of the runtime.
24
+type Deps struct {
25
+	Pool   *pgxpool.Pool
26
+	RepoFS *storage.RepoFS
27
+	Audit  *audit.Recorder
28
+	Logger *slog.Logger
29
+}
30
+
31
+// Errors surfaced to handlers.
32
+var (
33
+	ErrNotLoggedIn          = errors.New("fork: login required")
34
+	ErrSourceNotFound       = errors.New("fork: source repo not found")
35
+	ErrSourceNotVisible     = errors.New("fork: source repo not visible to actor")
36
+	ErrTargetNameTaken      = errors.New("fork: target name already exists for owner")
37
+	ErrVisibilityFloor      = errors.New("fork: target visibility cannot exceed source visibility")
38
+	ErrSelfForkSameName     = errors.New("fork: forking into the same owner requires a different name")
39
+	ErrSourceArchived       = errors.New("fork: source repo is archived")
40
+	ErrSourceDeleted        = errors.New("fork: source repo is deleted")
41
+	ErrSyncDiverged         = errors.New("fork: fork has diverged from upstream; sync via your client")
42
+	ErrSyncUpToDate         = errors.New("fork: already up to date")
43
+	ErrSyncDefaultsMissing  = errors.New("fork: source or fork default branch is empty")
44
+	ErrSyncRefRaced         = errors.New("fork: ref changed concurrently; retry")
45
+	ErrForkNotInitialized   = errors.New("fork: fork is still being prepared")
46
+)
internal/repos/fork/fork_test.goadded
@@ -0,0 +1,417 @@
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
+}
internal/repos/fork/helpers.goadded
@@ -0,0 +1,27 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package fork
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+
9
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
10
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
11
+)
12
+
13
+// ownerUsername resolves the username string for a repo owner. Only
14
+// user-owned repos are supported today; org-owned repos surface
15
+// when S31 lands. Returns an error rather than guessing when the
16
+// repo isn't user-owned so the caller fails loudly during the
17
+// transition rather than silently misroute.
18
+func ownerUsername(ctx context.Context, deps Deps, repo reposdb.Repo) (string, error) {
19
+	if !repo.OwnerUserID.Valid {
20
+		return "", fmt.Errorf("fork: repo %d has no user owner (org-owned repos arrive in S31)", repo.ID)
21
+	}
22
+	u, err := usersdb.New().GetUserByID(ctx, deps.Pool, repo.OwnerUserID.Int64)
23
+	if err != nil {
24
+		return "", fmt.Errorf("fork: load owner: %w", err)
25
+	}
26
+	return u.Username, nil
27
+}
internal/repos/fork/sync.goadded
@@ -0,0 +1,142 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package fork
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
14
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
15
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16
+)
17
+
18
+// SyncResult describes what Sync did. Reasons cover the four
19
+// outcomes the spec enumerates: fast-forwarded, already up to date,
20
+// diverged (refused), or default-branch-missing (typically a freshly
21
+// initialized fork or upstream that's never been pushed to).
22
+type SyncResult struct {
23
+	OldOID string
24
+	NewOID string
25
+}
26
+
27
+// Sync fast-forwards the fork's default branch to the upstream's.
28
+// Refuses on diverged history (as the spec mandates — anything else
29
+// belongs in the user's client). The CAS via UpdateRefCAS catches
30
+// concurrent pushes to the fork: if a push lands between our read
31
+// and our update, we return ErrSyncRefRaced and the caller can ask
32
+// the user to retry.
33
+//
34
+// The handler MUST authorize via policy.Can(ActionRepoWrite) on the
35
+// fork before calling — Sync mutates the fork's refs and is a write
36
+// action. Read access on the source is implied by ownership of the
37
+// fork (you forked it; you saw it then).
38
+func Sync(ctx context.Context, deps Deps, actorUserID, forkRepoID int64) (SyncResult, error) {
39
+	rq := reposdb.New()
40
+	fork, err := rq.GetRepoByID(ctx, deps.Pool, forkRepoID)
41
+	if err != nil {
42
+		if errors.Is(err, pgx.ErrNoRows) {
43
+			return SyncResult{}, ErrSourceNotFound
44
+		}
45
+		return SyncResult{}, fmt.Errorf("sync: load fork: %w", err)
46
+	}
47
+	if fork.InitStatus != reposdb.RepoInitStatusInitialized {
48
+		return SyncResult{}, ErrForkNotInitialized
49
+	}
50
+	if !fork.ForkOfRepoID.Valid {
51
+		return SyncResult{}, ErrSourceNotFound
52
+	}
53
+	source, err := rq.GetRepoByID(ctx, deps.Pool, fork.ForkOfRepoID.Int64)
54
+	if err != nil {
55
+		// Source was hard-deleted (orphaned fork). Sync isn't
56
+		// applicable; surface as not-found.
57
+		return SyncResult{}, ErrSourceNotFound
58
+	}
59
+	if source.DeletedAt.Valid {
60
+		return SyncResult{}, ErrSourceDeleted
61
+	}
62
+
63
+	forkOwner, err := ownerUsername(ctx, deps, fork)
64
+	if err != nil {
65
+		return SyncResult{}, err
66
+	}
67
+	sourceOwner, err := ownerUsername(ctx, deps, source)
68
+	if err != nil {
69
+		return SyncResult{}, err
70
+	}
71
+	forkPath, err := deps.RepoFS.RepoPath(forkOwner, fork.Name)
72
+	if err != nil {
73
+		return SyncResult{}, err
74
+	}
75
+	sourcePath, err := deps.RepoFS.RepoPath(sourceOwner, source.Name)
76
+	if err != nil {
77
+		return SyncResult{}, err
78
+	}
79
+
80
+	// Fork and source must agree on the default branch name. If they
81
+	// don't, the fast-forward gate doesn't have an obvious answer and
82
+	// we refuse — the user's client can sync arbitrary refs if they
83
+	// really want to.
84
+	branch := fork.DefaultBranch
85
+	if branch == "" || source.DefaultBranch == "" {
86
+		return SyncResult{}, ErrSyncDefaultsMissing
87
+	}
88
+
89
+	upstreamOID, err := repogit.ResolveRefOID(ctx, sourcePath, branch)
90
+	if err != nil {
91
+		return SyncResult{}, fmt.Errorf("sync: resolve upstream %s: %w", branch, err)
92
+	}
93
+	forkOID, err := repogit.ResolveRefOID(ctx, forkPath, branch)
94
+	if err != nil {
95
+		// Fork branch doesn't exist (an empty fork) — fall through
96
+		// to the create-ref path below by passing the zero OID. git
97
+		// update-ref accepts the literal 40-zero string as "must not
98
+		// exist yet" semantics.
99
+		forkOID = "0000000000000000000000000000000000000000"
100
+	}
101
+	if forkOID == upstreamOID {
102
+		return SyncResult{}, ErrSyncUpToDate
103
+	}
104
+	if forkOID != "0000000000000000000000000000000000000000" {
105
+		ancestor, err := repogit.IsAncestor(ctx, forkPath, forkOID, upstreamOID)
106
+		if err != nil {
107
+			return SyncResult{}, fmt.Errorf("sync: ancestor check: %w", err)
108
+		}
109
+		if !ancestor {
110
+			// fork has commits upstream doesn't — diverged.
111
+			return SyncResult{}, ErrSyncDiverged
112
+		}
113
+	}
114
+
115
+	// CAS update. The fork's bare repo holds the alternates pointing
116
+	// at source's objects, so the upstream OID is reachable from the
117
+	// fork's perspective without an explicit fetch.
118
+	ref := "refs/heads/" + branch
119
+	if err := repogit.UpdateRefCAS(ctx, forkPath, ref, upstreamOID, forkOID); err != nil {
120
+		if errors.Is(err, repogit.ErrRefRaced) {
121
+			return SyncResult{}, ErrSyncRefRaced
122
+		}
123
+		return SyncResult{}, fmt.Errorf("sync: update-ref: %w", err)
124
+	}
125
+
126
+	// Update the cached default_branch_oid on the fork so the home
127
+	// view reflects the new tip without waiting for a push:process
128
+	// tick (update-ref bypasses the post-receive hook here, same
129
+	// reason as the merge handler's fix in the audit-remediation
130
+	// sprint).
131
+	_ = rq.UpdateRepoDefaultBranchOID(ctx, deps.Pool, reposdb.UpdateRepoDefaultBranchOIDParams{
132
+		ID:               fork.ID,
133
+		DefaultBranchOid: pgtype.Text{String: upstreamOID, Valid: true},
134
+	})
135
+
136
+	if deps.Audit != nil {
137
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
138
+			audit.ActionRepoForkSynced, audit.TargetRepo, fork.ID,
139
+			map[string]any{"old_oid": forkOID, "new_oid": upstreamOID, "branch": branch})
140
+	}
141
+	return SyncResult{OldOID: forkOID, NewOID: upstreamOID}, nil
142
+}
internal/repos/fork/visibility.goadded
@@ -0,0 +1,29 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package fork
4
+
5
+// allowedTargetVisibility enforces the visibility floor: a fork's
6
+// visibility must be ≤ source's. Forking public → private is fine
7
+// (the user is just choosing to keep their copy private); forking
8
+// private → public would expose previously-private content and is
9
+// always rejected.
10
+//
11
+// Returns "" + false when the proposed shape isn't allowed.
12
+func allowedTargetVisibility(source, target string) (string, bool) {
13
+	switch source {
14
+	case "public":
15
+		// Any target visibility is allowed; default to public if blank.
16
+		if target == "" {
17
+			return "public", true
18
+		}
19
+		return target, target == "public" || target == "private"
20
+	case "private":
21
+		// Forking a private repo never expands its reach; target must
22
+		// stay private. Empty defaults to private.
23
+		if target == "" || target == "private" {
24
+			return "private", true
25
+		}
26
+		return "", false
27
+	}
28
+	return "", false
29
+}