Skip to content

Commit 8598356

Browse files
zeripathtechknowlogickdelvh
authored
Refactor and tidy-up the merge/update branch code (#22568)
The merge and update branch code was previously a little tangled and had some very long functions. The functions were not very clear in their reasoning and there were deficiencies in their logging and at least one bug in the handling of LFS for update by rebase. This PR substantially refactors this code and splits things out to into separate functions. It also attempts to tidy up the calls by wrapping things in "context"s. There are also attempts to improve logging when there are errors. Signed-off-by: Andrew Thornton <[email protected]> --------- Signed-off-by: Andrew Thornton <[email protected]> Co-authored-by: techknowlogick <[email protected]> Co-authored-by: delvh <[email protected]>
1 parent a2f4446 commit 8598356

File tree

12 files changed

+757
-607
lines changed

12 files changed

+757
-607
lines changed

modules/git/pipeline/revlist.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ func RevListObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.
4242
defer revListWriter.Close()
4343
stderr := new(bytes.Buffer)
4444
var errbuf strings.Builder
45-
cmd := git.NewCommand(ctx, "rev-list", "--objects").AddDynamicArguments(headSHA).AddArguments("--not").AddDynamicArguments(baseSHA)
45+
cmd := git.NewCommand(ctx, "rev-list", "--objects").AddDynamicArguments(headSHA)
46+
if baseSHA != "" {
47+
cmd = cmd.AddArguments("--not").AddDynamicArguments(baseSHA)
48+
}
4649
if err := cmd.Run(&git.RunOpts{
4750
Dir: tmpBasePath,
4851
Stdout: revListWriter,

services/pull/lfs.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func createLFSMetaObjectsFromCatFileBatch(catFileBatchReader *io.PipeReader, wg
116116
}
117117

118118
// Then we need to check that this pointer is in the db
119-
if _, err := git_model.GetLFSMetaObjectByOid(db.DefaultContext, pr.HeadRepo.ID, pointer.Oid); err != nil {
119+
if _, err := git_model.GetLFSMetaObjectByOid(db.DefaultContext, pr.HeadRepoID, pointer.Oid); err != nil {
120120
if err == git_model.ErrLFSObjectNotExist {
121121
log.Warn("During merge of: %d in %-v, there is a pointer to LFS Oid: %s which although present in the LFS store is not associated with the head repo %-v", pr.Index, pr.BaseRepo, pointer.Oid, pr.HeadRepo)
122122
continue

services/pull/merge.go

+65-430
Large diffs are not rendered by default.

services/pull/merge_merge.go

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package pull
5+
6+
import (
7+
repo_model "code.gitea.io/gitea/models/repo"
8+
"code.gitea.io/gitea/modules/git"
9+
"code.gitea.io/gitea/modules/log"
10+
)
11+
12+
// doMergeStyleMerge merges the tracking into the current HEAD - which is assumed to tbe staging branch (equal to the pr.BaseBranch)
13+
func doMergeStyleMerge(ctx *mergeContext, message string) error {
14+
cmd := git.NewCommand(ctx, "merge", "--no-ff", "--no-commit").AddDynamicArguments(trackingBranch)
15+
if err := runMergeCommand(ctx, repo_model.MergeStyleMerge, cmd); err != nil {
16+
log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err)
17+
return err
18+
}
19+
20+
if err := commitAndSignNoAuthor(ctx, message); err != nil {
21+
log.Error("%-v Unable to make final commit: %v", ctx.pr, err)
22+
return err
23+
}
24+
return nil
25+
}

services/pull/merge_prepare.go

+297
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package pull
5+
6+
import (
7+
"bufio"
8+
"bytes"
9+
"context"
10+
"fmt"
11+
"io"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
"time"
16+
17+
"code.gitea.io/gitea/models"
18+
issues_model "code.gitea.io/gitea/models/issues"
19+
repo_model "code.gitea.io/gitea/models/repo"
20+
user_model "code.gitea.io/gitea/models/user"
21+
"code.gitea.io/gitea/modules/git"
22+
"code.gitea.io/gitea/modules/log"
23+
asymkey_service "code.gitea.io/gitea/services/asymkey"
24+
)
25+
26+
type mergeContext struct {
27+
*prContext
28+
doer *user_model.User
29+
sig *git.Signature
30+
committer *git.Signature
31+
signArg git.TrustedCmdArgs
32+
env []string
33+
}
34+
35+
func (ctx *mergeContext) RunOpts() *git.RunOpts {
36+
ctx.outbuf.Reset()
37+
ctx.errbuf.Reset()
38+
return &git.RunOpts{
39+
Env: ctx.env,
40+
Dir: ctx.tmpBasePath,
41+
Stdout: ctx.outbuf,
42+
Stderr: ctx.errbuf,
43+
}
44+
}
45+
46+
func createTemporaryRepoForMerge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, expectedHeadCommitID string) (mergeCtx *mergeContext, cancel context.CancelFunc, err error) {
47+
// Clone base repo.
48+
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
49+
if err != nil {
50+
log.Error("createTemporaryRepoForPR: %v", err)
51+
return nil, cancel, err
52+
}
53+
54+
mergeCtx = &mergeContext{
55+
prContext: prCtx,
56+
doer: doer,
57+
}
58+
59+
if expectedHeadCommitID != "" {
60+
trackingCommitID, _, err := git.NewCommand(ctx, "show-ref", "--hash").AddDynamicArguments(git.BranchPrefix + trackingBranch).RunStdString(&git.RunOpts{Dir: mergeCtx.tmpBasePath})
61+
if err != nil {
62+
defer cancel()
63+
log.Error("failed to get sha of head branch in %-v: show-ref[%s] --hash refs/heads/tracking: %v", mergeCtx.pr, mergeCtx.tmpBasePath, err)
64+
return nil, nil, fmt.Errorf("unable to get sha of head branch in %v %w", pr, err)
65+
}
66+
if strings.TrimSpace(trackingCommitID) != expectedHeadCommitID {
67+
defer cancel()
68+
return nil, nil, models.ErrSHADoesNotMatch{
69+
GivenSHA: expectedHeadCommitID,
70+
CurrentSHA: trackingCommitID,
71+
}
72+
}
73+
}
74+
75+
mergeCtx.outbuf.Reset()
76+
mergeCtx.errbuf.Reset()
77+
if err := prepareTemporaryRepoForMerge(mergeCtx); err != nil {
78+
defer cancel()
79+
return nil, nil, err
80+
}
81+
82+
mergeCtx.sig = doer.NewGitSig()
83+
mergeCtx.committer = mergeCtx.sig
84+
85+
// Determine if we should sign
86+
sign, keyID, signer, _ := asymkey_service.SignMerge(ctx, mergeCtx.pr, mergeCtx.doer, mergeCtx.tmpBasePath, "HEAD", trackingBranch)
87+
if sign {
88+
mergeCtx.signArg = git.ToTrustedCmdArgs([]string{"-S" + keyID})
89+
if pr.BaseRepo.GetTrustModel() == repo_model.CommitterTrustModel || pr.BaseRepo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
90+
mergeCtx.committer = signer
91+
}
92+
} else {
93+
mergeCtx.signArg = git.ToTrustedCmdArgs([]string{"--no-gpg-sign"})
94+
}
95+
96+
commitTimeStr := time.Now().Format(time.RFC3339)
97+
98+
// Because this may call hooks we should pass in the environment
99+
mergeCtx.env = append(os.Environ(),
100+
"GIT_AUTHOR_NAME="+mergeCtx.sig.Name,
101+
"GIT_AUTHOR_EMAIL="+mergeCtx.sig.Email,
102+
"GIT_AUTHOR_DATE="+commitTimeStr,
103+
"GIT_COMMITTER_NAME="+mergeCtx.committer.Name,
104+
"GIT_COMMITTER_EMAIL="+mergeCtx.committer.Email,
105+
"GIT_COMMITTER_DATE="+commitTimeStr,
106+
)
107+
108+
return mergeCtx, cancel, nil
109+
}
110+
111+
// prepareTemporaryRepoForMerge takes a repository that has been created using createTemporaryRepo
112+
// it then sets up the sparse-checkout and other things
113+
func prepareTemporaryRepoForMerge(ctx *mergeContext) error {
114+
infoPath := filepath.Join(ctx.tmpBasePath, ".git", "info")
115+
if err := os.MkdirAll(infoPath, 0o700); err != nil {
116+
log.Error("%-v Unable to create .git/info in %s: %v", ctx.pr, ctx.tmpBasePath, err)
117+
return fmt.Errorf("Unable to create .git/info in tmpBasePath: %w", err)
118+
}
119+
120+
// Enable sparse-checkout
121+
// Here we use the .git/info/sparse-checkout file as described in the git documentation
122+
sparseCheckoutListFile, err := os.OpenFile(filepath.Join(infoPath, "sparse-checkout"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
123+
if err != nil {
124+
log.Error("%-v Unable to write .git/info/sparse-checkout file in %s: %v", ctx.pr, ctx.tmpBasePath, err)
125+
return fmt.Errorf("Unable to write .git/info/sparse-checkout file in tmpBasePath: %w", err)
126+
}
127+
defer sparseCheckoutListFile.Close() // we will close it earlier but we need to ensure it is closed if there is an error
128+
129+
if err := getDiffTree(ctx, ctx.tmpBasePath, baseBranch, trackingBranch, sparseCheckoutListFile); err != nil {
130+
log.Error("%-v getDiffTree(%s, %s, %s): %v", ctx.pr, ctx.tmpBasePath, baseBranch, trackingBranch, err)
131+
return fmt.Errorf("getDiffTree: %w", err)
132+
}
133+
134+
if err := sparseCheckoutListFile.Close(); err != nil {
135+
log.Error("%-v Unable to close .git/info/sparse-checkout file in %s: %v", ctx.pr, ctx.tmpBasePath, err)
136+
return fmt.Errorf("Unable to close .git/info/sparse-checkout file in tmpBasePath: %w", err)
137+
}
138+
139+
gitConfigCommand := func() *git.Command {
140+
return git.NewCommand(ctx, "config", "--local")
141+
}
142+
143+
setConfig := func(key, value string) error {
144+
if err := gitConfigCommand().AddArguments(git.ToTrustedCmdArgs([]string{key, value})...).
145+
Run(ctx.RunOpts()); err != nil {
146+
if value == "" {
147+
value = "<>"
148+
}
149+
log.Error("git config [%s -> %s ]: %v\n%s\n%s", key, value, err, ctx.outbuf.String(), ctx.errbuf.String())
150+
return fmt.Errorf("git config [%s -> %s ]: %w\n%s\n%s", key, value, err, ctx.outbuf.String(), ctx.errbuf.String())
151+
}
152+
ctx.outbuf.Reset()
153+
ctx.errbuf.Reset()
154+
155+
return nil
156+
}
157+
158+
// Switch off LFS process (set required, clean and smudge here also)
159+
if err := setConfig("filter.lfs.process", ""); err != nil {
160+
return err
161+
}
162+
163+
if err := setConfig("filter.lfs.required", "false"); err != nil {
164+
return err
165+
}
166+
167+
if err := setConfig("filter.lfs.clean", ""); err != nil {
168+
return err
169+
}
170+
171+
if err := setConfig("filter.lfs.smudge", ""); err != nil {
172+
return err
173+
}
174+
175+
if err := setConfig("core.sparseCheckout", "true"); err != nil {
176+
return err
177+
}
178+
179+
// Read base branch index
180+
if err := git.NewCommand(ctx, "read-tree", "HEAD").
181+
Run(ctx.RunOpts()); err != nil {
182+
log.Error("git read-tree HEAD: %v\n%s\n%s", err, ctx.outbuf.String(), ctx.errbuf.String())
183+
return fmt.Errorf("Unable to read base branch in to the index: %w\n%s\n%s", err, ctx.outbuf.String(), ctx.errbuf.String())
184+
}
185+
ctx.outbuf.Reset()
186+
ctx.errbuf.Reset()
187+
188+
return nil
189+
}
190+
191+
// getDiffTree returns a string containing all the files that were changed between headBranch and baseBranch
192+
// the filenames are escaped so as to fit the format required for .git/info/sparse-checkout
193+
func getDiffTree(ctx context.Context, repoPath, baseBranch, headBranch string, out io.Writer) error {
194+
diffOutReader, diffOutWriter, err := os.Pipe()
195+
if err != nil {
196+
log.Error("Unable to create os.Pipe for %s", repoPath)
197+
return err
198+
}
199+
defer func() {
200+
_ = diffOutReader.Close()
201+
_ = diffOutWriter.Close()
202+
}()
203+
204+
scanNullTerminatedStrings := func(data []byte, atEOF bool) (advance int, token []byte, err error) {
205+
if atEOF && len(data) == 0 {
206+
return 0, nil, nil
207+
}
208+
if i := bytes.IndexByte(data, '\x00'); i >= 0 {
209+
return i + 1, data[0:i], nil
210+
}
211+
if atEOF {
212+
return len(data), data, nil
213+
}
214+
return 0, nil, nil
215+
}
216+
217+
err = git.NewCommand(ctx, "diff-tree", "--no-commit-id", "--name-only", "-r", "-r", "-z", "--root").AddDynamicArguments(baseBranch, headBranch).
218+
Run(&git.RunOpts{
219+
Dir: repoPath,
220+
Stdout: diffOutWriter,
221+
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
222+
// Close the writer end of the pipe to begin processing
223+
_ = diffOutWriter.Close()
224+
defer func() {
225+
// Close the reader on return to terminate the git command if necessary
226+
_ = diffOutReader.Close()
227+
}()
228+
229+
// Now scan the output from the command
230+
scanner := bufio.NewScanner(diffOutReader)
231+
scanner.Split(scanNullTerminatedStrings)
232+
for scanner.Scan() {
233+
filepath := scanner.Text()
234+
// escape '*', '?', '[', spaces and '!' prefix
235+
filepath = escapedSymbols.ReplaceAllString(filepath, `\$1`)
236+
// no necessary to escape the first '#' symbol because the first symbol is '/'
237+
fmt.Fprintf(out, "/%s\n", filepath)
238+
}
239+
return scanner.Err()
240+
},
241+
})
242+
return err
243+
}
244+
245+
// rebaseTrackingOnToBase checks out the tracking branch as staging and rebases it on to the base branch
246+
// if there is a conflict it will return a models.ErrRebaseConflicts
247+
func rebaseTrackingOnToBase(ctx *mergeContext, mergeStyle repo_model.MergeStyle) error {
248+
// Checkout head branch
249+
if err := git.NewCommand(ctx, "checkout", "-b").AddDynamicArguments(stagingBranch, trackingBranch).
250+
Run(ctx.RunOpts()); err != nil {
251+
return fmt.Errorf("unable to git checkout tracking as staging in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
252+
}
253+
ctx.outbuf.Reset()
254+
ctx.errbuf.Reset()
255+
256+
// Rebase before merging
257+
if err := git.NewCommand(ctx, "rebase").AddDynamicArguments(baseBranch).
258+
Run(ctx.RunOpts()); err != nil {
259+
// Rebase will leave a REBASE_HEAD file in .git if there is a conflict
260+
if _, statErr := os.Stat(filepath.Join(ctx.tmpBasePath, ".git", "REBASE_HEAD")); statErr == nil {
261+
var commitSha string
262+
ok := false
263+
failingCommitPaths := []string{
264+
filepath.Join(ctx.tmpBasePath, ".git", "rebase-apply", "original-commit"), // Git < 2.26
265+
filepath.Join(ctx.tmpBasePath, ".git", "rebase-merge", "stopped-sha"), // Git >= 2.26
266+
}
267+
for _, failingCommitPath := range failingCommitPaths {
268+
if _, statErr := os.Stat(failingCommitPath); statErr == nil {
269+
commitShaBytes, readErr := os.ReadFile(failingCommitPath)
270+
if readErr != nil {
271+
// Abandon this attempt to handle the error
272+
return fmt.Errorf("unable to git rebase staging on to base in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
273+
}
274+
commitSha = strings.TrimSpace(string(commitShaBytes))
275+
ok = true
276+
break
277+
}
278+
}
279+
if !ok {
280+
log.Error("Unable to determine failing commit sha for failing rebase in temp repo for %-v. Cannot cast as models.ErrRebaseConflicts.", ctx.pr)
281+
return fmt.Errorf("unable to git rebase staging on to base in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
282+
}
283+
log.Debug("Conflict when rebasing staging on to base in %-v at %s: %v\n%s\n%s", ctx.pr, commitSha, err, ctx.outbuf.String(), ctx.errbuf.String())
284+
return models.ErrRebaseConflicts{
285+
CommitSHA: commitSha,
286+
Style: mergeStyle,
287+
StdOut: ctx.outbuf.String(),
288+
StdErr: ctx.errbuf.String(),
289+
Err: err,
290+
}
291+
}
292+
return fmt.Errorf("unable to git rebase staging on to base in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
293+
}
294+
ctx.outbuf.Reset()
295+
ctx.errbuf.Reset()
296+
return nil
297+
}

services/pull/merge_rebase.go

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package pull
5+
6+
import (
7+
"fmt"
8+
9+
repo_model "code.gitea.io/gitea/models/repo"
10+
"code.gitea.io/gitea/modules/git"
11+
"code.gitea.io/gitea/modules/log"
12+
)
13+
14+
// doMergeStyleRebase rebaases the tracking branch on the base branch as the current HEAD with or with a merge commit to the original pr branch
15+
func doMergeStyleRebase(ctx *mergeContext, mergeStyle repo_model.MergeStyle, message string) error {
16+
if err := rebaseTrackingOnToBase(ctx, mergeStyle); err != nil {
17+
return err
18+
}
19+
20+
// Checkout base branch again
21+
if err := git.NewCommand(ctx, "checkout").AddDynamicArguments(baseBranch).
22+
Run(ctx.RunOpts()); err != nil {
23+
log.Error("git checkout base prior to merge post staging rebase %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
24+
return fmt.Errorf("git checkout base prior to merge post staging rebase %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
25+
}
26+
ctx.outbuf.Reset()
27+
ctx.errbuf.Reset()
28+
29+
cmd := git.NewCommand(ctx, "merge")
30+
if mergeStyle == repo_model.MergeStyleRebase {
31+
cmd.AddArguments("--ff-only")
32+
} else {
33+
cmd.AddArguments("--no-ff", "--no-commit")
34+
}
35+
cmd.AddDynamicArguments(stagingBranch)
36+
37+
// Prepare merge with commit
38+
if err := runMergeCommand(ctx, mergeStyle, cmd); err != nil {
39+
log.Error("Unable to merge staging into base: %v", err)
40+
return err
41+
}
42+
if mergeStyle == repo_model.MergeStyleRebaseMerge {
43+
if err := commitAndSignNoAuthor(ctx, message); err != nil {
44+
log.Error("Unable to make final commit: %v", err)
45+
return err
46+
}
47+
}
48+
49+
return nil
50+
}

0 commit comments

Comments
 (0)