|
| 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 | +} |
0 commit comments