Go · 5104 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package lifecycle
4
5 import (
6 "context"
7 "fmt"
8 "os"
9 "strings"
10
11 "github.com/jackc/pgx/v5/pgconn"
12 "github.com/jackc/pgx/v5/pgtype"
13
14 "github.com/tenseleyFlow/shithub/internal/auth/audit"
15 "github.com/tenseleyFlow/shithub/internal/repos"
16 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17 )
18
19 // RenameParams is what a same-owner rename takes.
20 type RenameParams struct {
21 ActorUserID int64 // who is initiating; recorded in audit
22 RepoID int64
23 OwnerUserID int64 // current owner — also gives us the FS path
24 OwnerName string // current owner username (for FS path)
25 OldName string // current repo name
26 NewName string // requested name
27 }
28
29 // Rename performs a same-owner rename: validate → tx (insert redirect,
30 // update repos.name) → FS Move → audit. On FS failure the DB tx is
31 // reverted via a compensating UPDATE so the persistent state is
32 // consistent. The caller is responsible for policy.Can; we don't
33 // re-check here.
34 func Rename(ctx context.Context, deps Deps, p RenameParams) error {
35 newName := strings.ToLower(strings.TrimSpace(p.NewName))
36 if newName == strings.ToLower(p.OldName) {
37 return ErrSameName
38 }
39 if err := repos.ValidateName(newName); err != nil {
40 return fmt.Errorf("%w: %v", ErrInvalidName, err)
41 }
42
43 // Rate limit: count recent renames (= redirect rows for this repo).
44 rq := reposdb.New()
45 count, err := rq.CountRecentRedirectsForRepo(ctx, deps.Pool, p.RepoID)
46 if err != nil {
47 return fmt.Errorf("count redirects: %w", err)
48 }
49 if int(count) >= renameRateLimitMax {
50 return ErrRenameRateLimited
51 }
52
53 tx, err := deps.Pool.Begin(ctx)
54 if err != nil {
55 return fmt.Errorf("begin: %w", err)
56 }
57 committed := false
58 defer func() {
59 if !committed {
60 _ = tx.Rollback(ctx)
61 }
62 }()
63
64 // 1. Insert redirect row before mutating the repo so a unique-name
65 // collision rolls back both with a single ROLLBACK.
66 if err := rq.InsertRepoRedirect(ctx, tx, reposdb.InsertRepoRedirectParams{
67 OldOwnerUserID: pgtype.Int8{Int64: p.OwnerUserID, Valid: true},
68 OldOwnerOrgID: pgtype.Int8{Valid: false},
69 OldName: p.OldName,
70 RepoID: p.RepoID,
71 }); err != nil {
72 return fmt.Errorf("insert redirect: %w", err)
73 }
74
75 // 2. Update the name. Unique-violation here means the new name is
76 // taken on this owner.
77 if err := rq.RenameRepo(ctx, tx, reposdb.RenameRepoParams{ID: p.RepoID, Name: newName}); err != nil {
78 var pgErr *pgconn.PgError
79 if errAs(err, &pgErr) && pgErr.Code == "23505" {
80 return ErrNameTaken
81 }
82 return fmt.Errorf("rename repo: %w", err)
83 }
84
85 if err := tx.Commit(ctx); err != nil {
86 return fmt.Errorf("commit: %w", err)
87 }
88 committed = true
89
90 // 3. FS rename. On failure, run the compensating SQL so DB matches
91 // disk again. The compensating tx is best-effort logged; if it
92 // also fails the operator must reconcile by hand (rare path).
93 oldPath, e1 := deps.RepoFS.RepoPath(p.OwnerName, p.OldName)
94 newPath, e2 := deps.RepoFS.RepoPath(p.OwnerName, newName)
95 if e1 != nil || e2 != nil {
96 compensateRename(ctx, deps, p.RepoID, p.OldName, p.OwnerUserID, newName)
97 return fmt.Errorf("repo path resolution: e1=%v e2=%v", e1, e2)
98 }
99 if _, err := os.Stat(oldPath); err == nil {
100 if err := deps.RepoFS.Move(oldPath, newPath); err != nil {
101 compensateRename(ctx, deps, p.RepoID, p.OldName, p.OwnerUserID, newName)
102 return fmt.Errorf("fs move: %w", err)
103 }
104 }
105 // (oldPath missing is acceptable — the on-disk repo may not exist
106 // yet for a freshly-created repo whose initial commit hasn't been
107 // pushed by the user. The bare-repo init is creation-time; rename
108 // just needs to handle the present-or-absent case symmetrically.)
109
110 if deps.Audit != nil {
111 _ = deps.Audit.Record(ctx, deps.Pool, p.ActorUserID,
112 audit.ActionRepoRenamed, audit.TargetRepo, p.RepoID,
113 map[string]any{"old_name": p.OldName, "new_name": newName})
114 }
115 return nil
116 }
117
118 // compensateRename undoes the redirect+name change after an FS failure.
119 // Logged at warn so the operator notices but the request still returns
120 // the original error (the caller sees the FS error, which is the user-
121 // visible truth).
122 func compensateRename(ctx context.Context, deps Deps, repoID int64, oldName string, ownerUserID int64, newName string) {
123 rq := reposdb.New()
124 if err := rq.RenameRepo(ctx, deps.Pool, reposdb.RenameRepoParams{ID: repoID, Name: oldName}); err != nil {
125 if deps.Logger != nil {
126 deps.Logger.WarnContext(ctx, "rename: compensating UPDATE failed", "repo_id", repoID, "error", err)
127 }
128 }
129 // Drop the redirect row we just wrote — it now points at a name
130 // that's about to come back to its old self.
131 if err := rq.DeleteRedirectByUserOwnerOldName(ctx, deps.Pool, reposdb.DeleteRedirectByUserOwnerOldNameParams{
132 RepoID: repoID,
133 OldOwnerUserID: pgtype.Int8{Int64: ownerUserID, Valid: ownerUserID != 0},
134 OldName: oldName,
135 }); err != nil && deps.Logger != nil {
136 deps.Logger.WarnContext(ctx, "rename: compensating redirect-delete failed", "repo_id", repoID, "error", err)
137 }
138 _ = newName // kept in signature for symmetry / future logging
139 }
140