code.gitea.io/gitea@v1.21.7/services/pull/merge_prepare.go (about) 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 signKeyID string // empty for no-sign, non-empty to sign 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.signKeyID = keyID 89 if pr.BaseRepo.GetTrustModel() == repo_model.CommitterTrustModel || pr.BaseRepo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel { 90 mergeCtx.committer = signer 91 } 92 } 93 94 commitTimeStr := time.Now().Format(time.RFC3339) 95 96 // Because this may call hooks we should pass in the environment 97 mergeCtx.env = append(os.Environ(), 98 "GIT_AUTHOR_NAME="+mergeCtx.sig.Name, 99 "GIT_AUTHOR_EMAIL="+mergeCtx.sig.Email, 100 "GIT_AUTHOR_DATE="+commitTimeStr, 101 "GIT_COMMITTER_NAME="+mergeCtx.committer.Name, 102 "GIT_COMMITTER_EMAIL="+mergeCtx.committer.Email, 103 "GIT_COMMITTER_DATE="+commitTimeStr, 104 ) 105 106 return mergeCtx, cancel, nil 107 } 108 109 // prepareTemporaryRepoForMerge takes a repository that has been created using createTemporaryRepo 110 // it then sets up the sparse-checkout and other things 111 func prepareTemporaryRepoForMerge(ctx *mergeContext) error { 112 infoPath := filepath.Join(ctx.tmpBasePath, ".git", "info") 113 if err := os.MkdirAll(infoPath, 0o700); err != nil { 114 log.Error("%-v Unable to create .git/info in %s: %v", ctx.pr, ctx.tmpBasePath, err) 115 return fmt.Errorf("Unable to create .git/info in tmpBasePath: %w", err) 116 } 117 118 // Enable sparse-checkout 119 // Here we use the .git/info/sparse-checkout file as described in the git documentation 120 sparseCheckoutListFile, err := os.OpenFile(filepath.Join(infoPath, "sparse-checkout"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) 121 if err != nil { 122 log.Error("%-v Unable to write .git/info/sparse-checkout file in %s: %v", ctx.pr, ctx.tmpBasePath, err) 123 return fmt.Errorf("Unable to write .git/info/sparse-checkout file in tmpBasePath: %w", err) 124 } 125 defer sparseCheckoutListFile.Close() // we will close it earlier but we need to ensure it is closed if there is an error 126 127 if err := getDiffTree(ctx, ctx.tmpBasePath, baseBranch, trackingBranch, sparseCheckoutListFile); err != nil { 128 log.Error("%-v getDiffTree(%s, %s, %s): %v", ctx.pr, ctx.tmpBasePath, baseBranch, trackingBranch, err) 129 return fmt.Errorf("getDiffTree: %w", err) 130 } 131 132 if err := sparseCheckoutListFile.Close(); err != nil { 133 log.Error("%-v Unable to close .git/info/sparse-checkout file in %s: %v", ctx.pr, ctx.tmpBasePath, err) 134 return fmt.Errorf("Unable to close .git/info/sparse-checkout file in tmpBasePath: %w", err) 135 } 136 137 setConfig := func(key, value string) error { 138 if err := git.NewCommand(ctx, "config", "--local").AddDynamicArguments(key, value). 139 Run(ctx.RunOpts()); err != nil { 140 log.Error("git config [%s -> %q]: %v\n%s\n%s", key, value, err, ctx.outbuf.String(), ctx.errbuf.String()) 141 return fmt.Errorf("git config [%s -> %q]: %w\n%s\n%s", key, value, err, ctx.outbuf.String(), ctx.errbuf.String()) 142 } 143 ctx.outbuf.Reset() 144 ctx.errbuf.Reset() 145 146 return nil 147 } 148 149 // Switch off LFS process (set required, clean and smudge here also) 150 if err := setConfig("filter.lfs.process", ""); err != nil { 151 return err 152 } 153 154 if err := setConfig("filter.lfs.required", "false"); err != nil { 155 return err 156 } 157 158 if err := setConfig("filter.lfs.clean", ""); err != nil { 159 return err 160 } 161 162 if err := setConfig("filter.lfs.smudge", ""); err != nil { 163 return err 164 } 165 166 if err := setConfig("core.sparseCheckout", "true"); err != nil { 167 return err 168 } 169 170 // Read base branch index 171 if err := git.NewCommand(ctx, "read-tree", "HEAD"). 172 Run(ctx.RunOpts()); err != nil { 173 log.Error("git read-tree HEAD: %v\n%s\n%s", err, ctx.outbuf.String(), ctx.errbuf.String()) 174 return fmt.Errorf("Unable to read base branch in to the index: %w\n%s\n%s", err, ctx.outbuf.String(), ctx.errbuf.String()) 175 } 176 ctx.outbuf.Reset() 177 ctx.errbuf.Reset() 178 179 return nil 180 } 181 182 // getDiffTree returns a string containing all the files that were changed between headBranch and baseBranch 183 // the filenames are escaped so as to fit the format required for .git/info/sparse-checkout 184 func getDiffTree(ctx context.Context, repoPath, baseBranch, headBranch string, out io.Writer) error { 185 diffOutReader, diffOutWriter, err := os.Pipe() 186 if err != nil { 187 log.Error("Unable to create os.Pipe for %s", repoPath) 188 return err 189 } 190 defer func() { 191 _ = diffOutReader.Close() 192 _ = diffOutWriter.Close() 193 }() 194 195 scanNullTerminatedStrings := func(data []byte, atEOF bool) (advance int, token []byte, err error) { 196 if atEOF && len(data) == 0 { 197 return 0, nil, nil 198 } 199 if i := bytes.IndexByte(data, '\x00'); i >= 0 { 200 return i + 1, data[0:i], nil 201 } 202 if atEOF { 203 return len(data), data, nil 204 } 205 return 0, nil, nil 206 } 207 208 err = git.NewCommand(ctx, "diff-tree", "--no-commit-id", "--name-only", "-r", "-r", "-z", "--root").AddDynamicArguments(baseBranch, headBranch). 209 Run(&git.RunOpts{ 210 Dir: repoPath, 211 Stdout: diffOutWriter, 212 PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { 213 // Close the writer end of the pipe to begin processing 214 _ = diffOutWriter.Close() 215 defer func() { 216 // Close the reader on return to terminate the git command if necessary 217 _ = diffOutReader.Close() 218 }() 219 220 // Now scan the output from the command 221 scanner := bufio.NewScanner(diffOutReader) 222 scanner.Split(scanNullTerminatedStrings) 223 for scanner.Scan() { 224 filepath := scanner.Text() 225 // escape '*', '?', '[', spaces and '!' prefix 226 filepath = escapedSymbols.ReplaceAllString(filepath, `\$1`) 227 // no necessary to escape the first '#' symbol because the first symbol is '/' 228 fmt.Fprintf(out, "/%s\n", filepath) 229 } 230 return scanner.Err() 231 }, 232 }) 233 return err 234 } 235 236 // rebaseTrackingOnToBase checks out the tracking branch as staging and rebases it on to the base branch 237 // if there is a conflict it will return a models.ErrRebaseConflicts 238 func rebaseTrackingOnToBase(ctx *mergeContext, mergeStyle repo_model.MergeStyle) error { 239 // Checkout head branch 240 if err := git.NewCommand(ctx, "checkout", "-b").AddDynamicArguments(stagingBranch, trackingBranch). 241 Run(ctx.RunOpts()); err != nil { 242 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()) 243 } 244 ctx.outbuf.Reset() 245 ctx.errbuf.Reset() 246 247 // Rebase before merging 248 if err := git.NewCommand(ctx, "rebase").AddDynamicArguments(baseBranch). 249 Run(ctx.RunOpts()); err != nil { 250 // Rebase will leave a REBASE_HEAD file in .git if there is a conflict 251 if _, statErr := os.Stat(filepath.Join(ctx.tmpBasePath, ".git", "REBASE_HEAD")); statErr == nil { 252 var commitSha string 253 ok := false 254 failingCommitPaths := []string{ 255 filepath.Join(ctx.tmpBasePath, ".git", "rebase-apply", "original-commit"), // Git < 2.26 256 filepath.Join(ctx.tmpBasePath, ".git", "rebase-merge", "stopped-sha"), // Git >= 2.26 257 } 258 for _, failingCommitPath := range failingCommitPaths { 259 if _, statErr := os.Stat(failingCommitPath); statErr == nil { 260 commitShaBytes, readErr := os.ReadFile(failingCommitPath) 261 if readErr != nil { 262 // Abandon this attempt to handle the error 263 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()) 264 } 265 commitSha = strings.TrimSpace(string(commitShaBytes)) 266 ok = true 267 break 268 } 269 } 270 if !ok { 271 log.Error("Unable to determine failing commit sha for failing rebase in temp repo for %-v. Cannot cast as models.ErrRebaseConflicts.", ctx.pr) 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 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()) 275 return models.ErrRebaseConflicts{ 276 CommitSHA: commitSha, 277 Style: mergeStyle, 278 StdOut: ctx.outbuf.String(), 279 StdErr: ctx.errbuf.String(), 280 Err: err, 281 } 282 } 283 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()) 284 } 285 ctx.outbuf.Reset() 286 ctx.errbuf.Reset() 287 return nil 288 }