Skip to content

Commit 5bdf805

Browse files
wolfogrelunny
andauthored
Sync branches to DB immediately when handle git hook calling (#29493)
Unlike other async processing in the queue, we should sync branches to the DB immediately when handling git hook calling. If it fails, users can see the error message in the output of the git command. It can avoid potential inconsistency issues, and help #29494. --------- Co-authored-by: Lunny Xiao <[email protected]>
1 parent a2b0fb1 commit 5bdf805

File tree

5 files changed

+283
-45
lines changed

5 files changed

+283
-45
lines changed

models/git/branch.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ func GetBranch(ctx context.Context, repoID int64, branchName string) (*Branch, e
158158
return &branch, nil
159159
}
160160

161+
func GetBranches(ctx context.Context, repoID int64, branchNames []string) ([]*Branch, error) {
162+
branches := make([]*Branch, 0, len(branchNames))
163+
return branches, db.GetEngine(ctx).Where("repo_id=?", repoID).In("name", branchNames).Find(&branches)
164+
}
165+
161166
func AddBranches(ctx context.Context, branches []*Branch) error {
162167
for _, branch := range branches {
163168
if _, err := db.GetEngine(ctx).Insert(branch); err != nil {

routers/private/hook_post_receive.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88
"net/http"
99
"strconv"
1010

11+
git_model "code.gitea.io/gitea/models/git"
1112
issues_model "code.gitea.io/gitea/models/issues"
1213
repo_model "code.gitea.io/gitea/models/repo"
1314
"code.gitea.io/gitea/modules/git"
15+
"code.gitea.io/gitea/modules/gitrepo"
1416
"code.gitea.io/gitea/modules/log"
1517
"code.gitea.io/gitea/modules/private"
1618
repo_module "code.gitea.io/gitea/modules/repository"
@@ -27,14 +29,19 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
2729

2830
// We don't rely on RepoAssignment here because:
2931
// a) we don't need the git repo in this function
32+
// OUT OF DATE: we do need the git repo to sync the branch to the db now.
3033
// b) our update function will likely change the repository in the db so we will need to refresh it
3134
// c) we don't always need the repo
3235

3336
ownerName := ctx.Params(":owner")
3437
repoName := ctx.Params(":repo")
3538

3639
// defer getting the repository at this point - as we should only retrieve it if we're going to call update
37-
var repo *repo_model.Repository
40+
var (
41+
repo *repo_model.Repository
42+
gitRepo *git.Repository
43+
)
44+
defer gitRepo.Close() // it's safe to call Close on a nil pointer
3845

3946
updates := make([]*repo_module.PushUpdateOptions, 0, len(opts.OldCommitIDs))
4047
wasEmpty := false
@@ -87,6 +94,63 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
8794
})
8895
return
8996
}
97+
98+
branchesToSync := make([]*repo_module.PushUpdateOptions, 0, len(updates))
99+
for _, update := range updates {
100+
if !update.RefFullName.IsBranch() {
101+
continue
102+
}
103+
if repo == nil {
104+
repo = loadRepository(ctx, ownerName, repoName)
105+
if ctx.Written() {
106+
return
107+
}
108+
wasEmpty = repo.IsEmpty
109+
}
110+
111+
if update.IsDelRef() {
112+
if err := git_model.AddDeletedBranch(ctx, repo.ID, update.RefFullName.BranchName(), update.PusherID); err != nil {
113+
log.Error("Failed to add deleted branch: %s/%s Error: %v", ownerName, repoName, err)
114+
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
115+
Err: fmt.Sprintf("Failed to add deleted branch: %s/%s Error: %v", ownerName, repoName, err),
116+
})
117+
return
118+
}
119+
} else {
120+
branchesToSync = append(branchesToSync, update)
121+
}
122+
}
123+
if len(branchesToSync) > 0 {
124+
if gitRepo == nil {
125+
var err error
126+
gitRepo, err = gitrepo.OpenRepository(ctx, repo)
127+
if err != nil {
128+
log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
129+
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
130+
Err: fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err),
131+
})
132+
return
133+
}
134+
}
135+
136+
var (
137+
branchNames = make([]string, 0, len(branchesToSync))
138+
commitIDs = make([]string, 0, len(branchesToSync))
139+
)
140+
for _, update := range branchesToSync {
141+
branchNames = append(branchNames, update.RefFullName.BranchName())
142+
commitIDs = append(commitIDs, update.NewCommitID)
143+
}
144+
145+
if err := repo_service.SyncBranchesToDB(ctx, repo.ID, opts.UserID, branchNames, commitIDs, func(commitID string) (*git.Commit, error) {
146+
return gitRepo.GetCommit(commitID)
147+
}); err != nil {
148+
ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
149+
Err: fmt.Sprintf("Failed to sync branch to DB in repository: %s/%s Error: %v", ownerName, repoName, err),
150+
})
151+
return
152+
}
153+
}
90154
}
91155

92156
// Handle Push Options

services/repository/branch.go

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -221,44 +221,91 @@ func checkBranchName(ctx context.Context, repo *repo_model.Repository, name stri
221221
return err
222222
}
223223

224-
// syncBranchToDB sync the branch information in the database. It will try to update the branch first,
225-
// if updated success with affect records > 0, then all are done. Because that means the branch has been in the database.
226-
// If no record is affected, that means the branch does not exist in database. So there are two possibilities.
227-
// One is this is a new branch, then we just need to insert the record. Another is the branches haven't been synced,
228-
// then we need to sync all the branches into database.
229-
func syncBranchToDB(ctx context.Context, repoID, pusherID int64, branchName string, commit *git.Commit) error {
230-
cnt, err := git_model.UpdateBranch(ctx, repoID, pusherID, branchName, commit)
231-
if err != nil {
232-
return fmt.Errorf("git_model.UpdateBranch %d:%s failed: %v", repoID, branchName, err)
233-
}
234-
if cnt > 0 { // This means branch does exist, so it's a normal update. It also means the branch has been synced.
235-
return nil
236-
}
224+
// SyncBranchesToDB sync the branch information in the database.
225+
// It will check whether the branches of the repository have never been synced before.
226+
// If so, it will sync all branches of the repository.
227+
// Otherwise, it will sync the branches that need to be updated.
228+
func SyncBranchesToDB(ctx context.Context, repoID, pusherID int64, branchNames, commitIDs []string, getCommit func(commitID string) (*git.Commit, error)) error {
229+
// Some designs that make the code look strange but are made for performance optimization purposes:
230+
// 1. Sync branches in a batch to reduce the number of DB queries.
231+
// 2. Lazy load commit information since it may be not necessary.
232+
// 3. Exit early if synced all branches of git repo when there's no branch in DB.
233+
// 4. Check the branches in DB if they are already synced.
234+
//
235+
// If the user pushes many branches at once, the Git hook will call the internal API in batches, rather than all at once.
236+
// See https://github.com/go-gitea/gitea/blob/cb52b17f92e2d2293f7c003649743464492bca48/cmd/hook.go#L27
237+
// For the first batch, it will hit optimization 3.
238+
// For other batches, it will hit optimization 4.
239+
240+
if len(branchNames) != len(commitIDs) {
241+
return fmt.Errorf("branchNames and commitIDs length not match")
242+
}
243+
244+
return db.WithTx(ctx, func(ctx context.Context) error {
245+
branches, err := git_model.GetBranches(ctx, repoID, branchNames)
246+
if err != nil {
247+
return fmt.Errorf("git_model.GetBranches: %v", err)
248+
}
237249

238-
// if user haven't visit UI but directly push to a branch after upgrading from 1.20 -> 1.21,
239-
// we cannot simply insert the branch but need to check we have branches or not
240-
hasBranch, err := db.Exist[git_model.Branch](ctx, git_model.FindBranchOptions{
241-
RepoID: repoID,
242-
IsDeletedBranch: optional.Some(false),
243-
}.ToConds())
244-
if err != nil {
245-
return err
246-
}
247-
if !hasBranch {
248-
if _, err = repo_module.SyncRepoBranches(ctx, repoID, pusherID); err != nil {
249-
return fmt.Errorf("repo_module.SyncRepoBranches %d:%s failed: %v", repoID, branchName, err)
250+
if len(branches) == 0 {
251+
// if user haven't visit UI but directly push to a branch after upgrading from 1.20 -> 1.21,
252+
// we cannot simply insert the branch but need to check we have branches or not
253+
hasBranch, err := db.Exist[git_model.Branch](ctx, git_model.FindBranchOptions{
254+
RepoID: repoID,
255+
IsDeletedBranch: optional.Some(false),
256+
}.ToConds())
257+
if err != nil {
258+
return err
259+
}
260+
if !hasBranch {
261+
if _, err = repo_module.SyncRepoBranches(ctx, repoID, pusherID); err != nil {
262+
return fmt.Errorf("repo_module.SyncRepoBranches %d failed: %v", repoID, err)
263+
}
264+
return nil
265+
}
250266
}
251-
return nil
252-
}
253267

254-
// if database have branches but not this branch, it means this is a new branch
255-
return db.Insert(ctx, &git_model.Branch{
256-
RepoID: repoID,
257-
Name: branchName,
258-
CommitID: commit.ID.String(),
259-
CommitMessage: commit.Summary(),
260-
PusherID: pusherID,
261-
CommitTime: timeutil.TimeStamp(commit.Committer.When.Unix()),
268+
branchMap := make(map[string]*git_model.Branch, len(branches))
269+
for _, branch := range branches {
270+
branchMap[branch.Name] = branch
271+
}
272+
273+
newBranches := make([]*git_model.Branch, 0, len(branchNames))
274+
275+
for i, branchName := range branchNames {
276+
commitID := commitIDs[i]
277+
branch, exist := branchMap[branchName]
278+
if exist && branch.CommitID == commitID {
279+
continue
280+
}
281+
282+
commit, err := getCommit(branchName)
283+
if err != nil {
284+
return fmt.Errorf("get commit of %s failed: %v", branchName, err)
285+
}
286+
287+
if exist {
288+
if _, err := git_model.UpdateBranch(ctx, repoID, pusherID, branchName, commit); err != nil {
289+
return fmt.Errorf("git_model.UpdateBranch %d:%s failed: %v", repoID, branchName, err)
290+
}
291+
return nil
292+
}
293+
294+
// if database have branches but not this branch, it means this is a new branch
295+
newBranches = append(newBranches, &git_model.Branch{
296+
RepoID: repoID,
297+
Name: branchName,
298+
CommitID: commit.ID.String(),
299+
CommitMessage: commit.Summary(),
300+
PusherID: pusherID,
301+
CommitTime: timeutil.TimeStamp(commit.Committer.When.Unix()),
302+
})
303+
}
304+
305+
if len(newBranches) > 0 {
306+
return db.Insert(ctx, newBranches)
307+
}
308+
return nil
262309
})
263310
}
264311

services/repository/push.go

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"time"
1212

1313
"code.gitea.io/gitea/models/db"
14-
git_model "code.gitea.io/gitea/models/git"
1514
repo_model "code.gitea.io/gitea/models/repo"
1615
user_model "code.gitea.io/gitea/models/user"
1716
"code.gitea.io/gitea/modules/cache"
@@ -259,10 +258,6 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
259258
commits.Commits = commits.Commits[:setting.UI.FeedMaxCommitNum]
260259
}
261260

262-
if err = syncBranchToDB(ctx, repo.ID, opts.PusherID, branch, newCommit); err != nil {
263-
return fmt.Errorf("git_model.UpdateBranch %s:%s failed: %v", repo.FullName(), branch, err)
264-
}
265-
266261
notify_service.PushCommits(ctx, pusher, repo, opts, commits)
267262

268263
// Cache for big repository
@@ -275,10 +270,6 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
275270
// close all related pulls
276271
log.Error("close related pull request failed: %v", err)
277272
}
278-
279-
if err := git_model.AddDeletedBranch(ctx, repo.ID, branch, pusher.ID); err != nil {
280-
return fmt.Errorf("AddDeletedBranch %s:%s failed: %v", repo.FullName(), branch, err)
281-
}
282273
}
283274

284275
// Even if user delete a branch on a repository which he didn't watch, he will be watch that.

tests/integration/git_push_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package integration
5+
6+
import (
7+
"fmt"
8+
"net/url"
9+
"testing"
10+
11+
"code.gitea.io/gitea/models/db"
12+
git_model "code.gitea.io/gitea/models/git"
13+
"code.gitea.io/gitea/models/unittest"
14+
user_model "code.gitea.io/gitea/models/user"
15+
"code.gitea.io/gitea/modules/git"
16+
repo_service "code.gitea.io/gitea/services/repository"
17+
18+
"github.com/stretchr/testify/assert"
19+
"github.com/stretchr/testify/require"
20+
)
21+
22+
func TestGitPush(t *testing.T) {
23+
onGiteaRun(t, testGitPush)
24+
}
25+
26+
func testGitPush(t *testing.T, u *url.URL) {
27+
t.Run("Push branches at once", func(t *testing.T) {
28+
runTestGitPush(t, u, func(t *testing.T, gitPath string) (pushed, deleted []string) {
29+
for i := 0; i < 100; i++ {
30+
branchName := fmt.Sprintf("branch-%d", i)
31+
pushed = append(pushed, branchName)
32+
doGitCreateBranch(gitPath, branchName)(t)
33+
}
34+
pushed = append(pushed, "master")
35+
doGitPushTestRepository(gitPath, "origin", "--all")(t)
36+
return pushed, deleted
37+
})
38+
})
39+
40+
t.Run("Push branches one by one", func(t *testing.T) {
41+
runTestGitPush(t, u, func(t *testing.T, gitPath string) (pushed, deleted []string) {
42+
for i := 0; i < 100; i++ {
43+
branchName := fmt.Sprintf("branch-%d", i)
44+
doGitCreateBranch(gitPath, branchName)(t)
45+
doGitPushTestRepository(gitPath, "origin", branchName)(t)
46+
pushed = append(pushed, branchName)
47+
}
48+
return pushed, deleted
49+
})
50+
})
51+
52+
t.Run("Delete branches", func(t *testing.T) {
53+
runTestGitPush(t, u, func(t *testing.T, gitPath string) (pushed, deleted []string) {
54+
doGitPushTestRepository(gitPath, "origin", "master")(t) // make sure master is the default branch instead of a branch we are going to delete
55+
pushed = append(pushed, "master")
56+
57+
for i := 0; i < 100; i++ {
58+
branchName := fmt.Sprintf("branch-%d", i)
59+
pushed = append(pushed, branchName)
60+
doGitCreateBranch(gitPath, branchName)(t)
61+
}
62+
doGitPushTestRepository(gitPath, "origin", "--all")(t)
63+
64+
for i := 0; i < 10; i++ {
65+
branchName := fmt.Sprintf("branch-%d", i)
66+
doGitPushTestRepository(gitPath, "origin", "--delete", branchName)(t)
67+
deleted = append(deleted, branchName)
68+
}
69+
return pushed, deleted
70+
})
71+
})
72+
}
73+
74+
func runTestGitPush(t *testing.T, u *url.URL, gitOperation func(t *testing.T, gitPath string) (pushed, deleted []string)) {
75+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
76+
repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
77+
Name: "repo-to-push",
78+
Description: "test git push",
79+
AutoInit: false,
80+
DefaultBranch: "main",
81+
IsPrivate: false,
82+
})
83+
require.NoError(t, err)
84+
require.NotEmpty(t, repo)
85+
86+
gitPath := t.TempDir()
87+
88+
doGitInitTestRepository(gitPath)(t)
89+
90+
oldPath := u.Path
91+
oldUser := u.User
92+
defer func() {
93+
u.Path = oldPath
94+
u.User = oldUser
95+
}()
96+
u.Path = repo.FullName() + ".git"
97+
u.User = url.UserPassword(user.LowerName, userPassword)
98+
99+
doGitAddRemote(gitPath, "origin", u)(t)
100+
101+
gitRepo, err := git.OpenRepository(git.DefaultContext, gitPath)
102+
require.NoError(t, err)
103+
defer gitRepo.Close()
104+
105+
pushedBranches, deletedBranches := gitOperation(t, gitPath)
106+
107+
dbBranches := make([]*git_model.Branch, 0)
108+
require.NoError(t, db.GetEngine(db.DefaultContext).Where("repo_id=?", repo.ID).Find(&dbBranches))
109+
assert.Equalf(t, len(pushedBranches), len(dbBranches), "mismatched number of branches in db")
110+
dbBranchesMap := make(map[string]*git_model.Branch, len(dbBranches))
111+
for _, branch := range dbBranches {
112+
dbBranchesMap[branch.Name] = branch
113+
}
114+
115+
deletedBranchesMap := make(map[string]bool, len(deletedBranches))
116+
for _, branchName := range deletedBranches {
117+
deletedBranchesMap[branchName] = true
118+
}
119+
120+
for _, branchName := range pushedBranches {
121+
branch, ok := dbBranchesMap[branchName]
122+
deleted := deletedBranchesMap[branchName]
123+
assert.True(t, ok, "branch %s not found in database", branchName)
124+
assert.Equal(t, deleted, branch.IsDeleted, "IsDeleted of %s is %v, but it's expected to be %v", branchName, branch.IsDeleted, deleted)
125+
commitID, err := gitRepo.GetBranchCommitID(branchName)
126+
require.NoError(t, err)
127+
assert.Equal(t, commitID, branch.CommitID)
128+
}
129+
130+
require.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repo.ID))
131+
}

0 commit comments

Comments
 (0)