Go · 5016 bytes Raw Blame History
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 }
143