1// Copyright 2015 The Gogs Authors. All rights reserved.
2// Copyright 2018 The Gitea Authors. All rights reserved.
3// SPDX-License-Identifier: MIT
4
5package git
6
7import (
8 "bufio"
9 "bytes"
10 "context"
11 "errors"
12 "fmt"
13 "io"
14 "os/exec"
15 "strconv"
16 "strings"
17
18 "forgejo.org/modules/log"
19 "forgejo.org/modules/util"
20
21 "github.com/go-git/go-git/v5/config"
22)
23
24// Commit represents a git commit.
25type Commit struct {
26 Tree
27 ID ObjectID // The ID of this commit object
28 Author *Signature
29 Committer *Signature
30 CommitMessage string
31 Signature *ObjectSignature
32
33 Parents []ObjectID // ID strings
34 submoduleCache *ObjectCache
35}
36
37// Message returns the commit message. Same as retrieving CommitMessage directly.
38func (c *Commit) Message() string {
39 return c.CommitMessage
40}
41
42// Summary returns first line of commit message.
43// The string is forced to be valid UTF8
44func (c *Commit) Summary() string {
45 return strings.ToValidUTF8(strings.Split(strings.TrimSpace(c.CommitMessage), "\n")[0], "?")
46}
47
48// ParentID returns oid of n-th parent (0-based index).
49// It returns nil if no such parent exists.
50func (c *Commit) ParentID(n int) (ObjectID, error) {
51 if n >= len(c.Parents) {
52 return nil, ErrNotExist{"", ""}
53 }
54 return c.Parents[n], nil
55}
56
57// Parent returns n-th parent (0-based index) of the commit.
58func (c *Commit) Parent(n int) (*Commit, error) {
59 id, err := c.ParentID(n)
60 if err != nil {
61 return nil, err
62 }
63 parent, err := c.repo.getCommit(id)
64 if err != nil {
65 return nil, err
66 }
67 return parent, nil
68}
69
70// ParentCount returns number of parents of the commit.
71// 0 if this is the root commit, otherwise 1,2, etc.
72func (c *Commit) ParentCount() int {
73 return len(c.Parents)
74}
75
76// GetCommitByPath return the commit of relative path object.
77func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) {
78 if c.repo.LastCommitCache != nil {
79 return c.repo.LastCommitCache.GetCommitByPath(c.ID.String(), relpath)
80 }
81 return c.repo.getCommitByPathWithID(c.ID, relpath)
82}
83
84// AddChanges marks local changes to be ready for commit.
85func AddChanges(repoPath string, all bool, files ...string) error {
86 return AddChangesWithArgs(repoPath, globalCommandArgs, all, files...)
87}
88
89// AddChangesWithArgs marks local changes to be ready for commit.
90func AddChangesWithArgs(repoPath string, globalArgs TrustedCmdArgs, all bool, files ...string) error {
91 cmd := NewCommandContextNoGlobals(DefaultContext, globalArgs...).AddArguments("add")
92 if all {
93 cmd.AddArguments("--all")
94 }
95 cmd.AddDashesAndList(files...)
96 _, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
97 return err
98}
99
100// CommitChangesOptions the options when a commit created
101type CommitChangesOptions struct {
102 Committer *Signature
103 Author *Signature
104 Message string
105}
106
107// CommitChanges commits local changes with given committer, author and message.
108// If author is nil, it will be the same as committer.
109func CommitChanges(repoPath string, opts CommitChangesOptions) error {
110 cargs := make(TrustedCmdArgs, len(globalCommandArgs))
111 copy(cargs, globalCommandArgs)
112 return CommitChangesWithArgs(repoPath, cargs, opts)
113}
114
115// CommitChangesWithArgs commits local changes with given committer, author and message.
116// If author is nil, it will be the same as committer.
117func CommitChangesWithArgs(repoPath string, args TrustedCmdArgs, opts CommitChangesOptions) error {
118 cmd := NewCommandContextNoGlobals(DefaultContext, args...)
119 if opts.Committer != nil {
120 cmd.AddOptionValues("-c", "user.name="+opts.Committer.Name)
121 cmd.AddOptionValues("-c", "user.email="+opts.Committer.Email)
122 }
123 cmd.AddArguments("commit")
124
125 if opts.Author == nil {
126 opts.Author = opts.Committer
127 }
128 if opts.Author != nil {
129 cmd.AddOptionFormat("--author='%s <%s>'", opts.Author.Name, opts.Author.Email)
130 }
131 cmd.AddOptionFormat("--message=%s", opts.Message)
132
133 _, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
134 // No stderr but exit status 1 means nothing to commit.
135 if err != nil && err.Error() == "exit status 1" {
136 return nil
137 }
138 return err
139}
140
141// AllCommitsCount returns count of all commits in repository
142func AllCommitsCount(ctx context.Context, repoPath string, hidePRRefs bool, files ...string) (int64, error) {
143 cmd := NewCommand(ctx, "rev-list")
144 if hidePRRefs {
145 cmd.AddArguments("--exclude=" + PullPrefix + "*")
146 }
147 cmd.AddArguments("--all", "--count")
148 if len(files) > 0 {
149 cmd.AddDashesAndList(files...)
150 }
151
152 stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath})
153 if err != nil {
154 return 0, err
155 }
156
157 return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
158}
159
160// CommitsCountOptions the options when counting commits
161type CommitsCountOptions struct {
162 RepoPath string
163 Not string
164 Revision []string
165 RelPath []string
166}
167
168// CommitsCount returns number of total commits of until given revision.
169func CommitsCount(ctx context.Context, opts CommitsCountOptions) (int64, error) {
170 cmd := NewCommand(ctx, "rev-list", "--count")
171
172 cmd.AddDynamicArguments(opts.Revision...)
173
174 if opts.Not != "" {
175 cmd.AddOptionValues("--not", opts.Not)
176 }
177
178 if len(opts.RelPath) > 0 {
179 cmd.AddDashesAndList(opts.RelPath...)
180 }
181
182 stdout, _, err := cmd.RunStdString(&RunOpts{Dir: opts.RepoPath})
183 if err != nil {
184 return 0, err
185 }
186
187 return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
188}
189
190// CommitsCount returns number of total commits of until current revision.
191func (c *Commit) CommitsCount() (int64, error) {
192 return CommitsCount(c.repo.Ctx, CommitsCountOptions{
193 RepoPath: c.repo.Path,
194 Revision: []string{c.ID.String()},
195 })
196}
197
198// CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize
199func (c *Commit) CommitsByRange(page, pageSize int, not string) ([]*Commit, error) {
200 return c.repo.commitsByRange(c.ID, page, pageSize, not)
201}
202
203// CommitsBefore returns all the commits before current revision
204func (c *Commit) CommitsBefore() ([]*Commit, error) {
205 return c.repo.getCommitsBefore(c.ID)
206}
207
208// HasPreviousCommit returns true if a given commitHash is contained in commit's parents
209func (c *Commit) HasPreviousCommit(objectID ObjectID) (bool, error) {
210 this := c.ID.String()
211 that := objectID.String()
212
213 if this == that {
214 return false, nil
215 }
216
217 _, _, err := NewCommand(c.repo.Ctx, "merge-base", "--is-ancestor").AddDynamicArguments(that, this).RunStdString(&RunOpts{Dir: c.repo.Path})
218 if err == nil {
219 return true, nil
220 }
221 var exitError *exec.ExitError
222 if errors.As(err, &exitError) {
223 if exitError.ExitCode() == 1 && len(exitError.Stderr) == 0 {
224 return false, nil
225 }
226 }
227 return false, err
228}
229
230// IsForcePush returns true if a push from oldCommitHash to this is a force push
231func (c *Commit) IsForcePush(oldCommitID string) (bool, error) {
232 objectFormat, err := c.repo.GetObjectFormat()
233 if err != nil {
234 return false, err
235 }
236 if oldCommitID == objectFormat.EmptyObjectID().String() {
237 return false, nil
238 }
239
240 oldCommit, err := c.repo.GetCommit(oldCommitID)
241 if err != nil {
242 return false, err
243 }
244 hasPreviousCommit, err := c.HasPreviousCommit(oldCommit.ID)
245 return !hasPreviousCommit, err
246}
247
248// CommitsBeforeLimit returns num commits before current revision
249func (c *Commit) CommitsBeforeLimit(num int) ([]*Commit, error) {
250 return c.repo.getCommitsBeforeLimit(c.ID, num)
251}
252
253// CommitsBeforeUntil returns the commits between commitID to current revision
254func (c *Commit) CommitsBeforeUntil(commitID string) ([]*Commit, error) {
255 endCommit, err := c.repo.GetCommit(commitID)
256 if err != nil {
257 return nil, err
258 }
259 return c.repo.CommitsBetween(c, endCommit)
260}
261
262// SearchCommitsOptions specify the parameters for SearchCommits
263type SearchCommitsOptions struct {
264 Keywords []string
265 Authors, Committers []string
266 After, Before string
267 All bool
268}
269
270// NewSearchCommitsOptions construct a SearchCommitsOption from a space-delimited search string
271func NewSearchCommitsOptions(searchString string, forAllRefs bool) SearchCommitsOptions {
272 var keywords, authors, committers []string
273 var after, before string
274
275 fields := strings.Fields(searchString)
276 for _, k := range fields {
277 switch {
278 case strings.HasPrefix(k, "author:"):
279 authors = append(authors, strings.TrimPrefix(k, "author:"))
280 case strings.HasPrefix(k, "committer:"):
281 committers = append(committers, strings.TrimPrefix(k, "committer:"))
282 case strings.HasPrefix(k, "after:"):
283 after = strings.TrimPrefix(k, "after:")
284 case strings.HasPrefix(k, "before:"):
285 before = strings.TrimPrefix(k, "before:")
286 default:
287 keywords = append(keywords, k)
288 }
289 }
290
291 return SearchCommitsOptions{
292 Keywords: keywords,
293 Authors: authors,
294 Committers: committers,
295 After: after,
296 Before: before,
297 All: forAllRefs,
298 }
299}
300
301// SearchCommits returns the commits match the keyword before current revision
302func (c *Commit) SearchCommits(opts SearchCommitsOptions) ([]*Commit, error) {
303 return c.repo.searchCommits(c.ID, opts)
304}
305
306// GetFilesChangedSinceCommit get all changed file names between pastCommit to current revision
307func (c *Commit) GetFilesChangedSinceCommit(pastCommit string) ([]string, error) {
308 return c.repo.GetFilesChangedBetween(pastCommit, c.ID.String())
309}
310
311// FileChangedSinceCommit Returns true if the file given has changed since the past commit
312// YOU MUST ENSURE THAT pastCommit is a valid commit ID.
313func (c *Commit) FileChangedSinceCommit(filename, pastCommit string) (bool, error) {
314 return c.repo.FileChangedBetweenCommits(filename, pastCommit, c.ID.String())
315}
316
317// HasFile returns true if the file given exists on this commit
318// This does only mean it's there - it does not mean the file was changed during the commit.
319func (c *Commit) HasFile(filename string) (bool, error) {
320 _, err := c.GetBlobByPath(filename)
321 if err != nil {
322 return false, err
323 }
324 return true, nil
325}
326
327// GetFileContent reads a file content as a string or returns false if this was not possible
328func (c *Commit) GetFileContent(filename string, limit int) (string, error) {
329 entry, err := c.GetTreeEntryByPath(filename)
330 if err != nil {
331 return "", err
332 }
333
334 r, err := entry.Blob().DataAsync()
335 if err != nil {
336 return "", err
337 }
338 defer r.Close()
339
340 if limit > 0 {
341 bs := make([]byte, limit)
342 n, err := util.ReadAtMost(r, bs)
343 if err != nil {
344 return "", err
345 }
346 return string(bs[:n]), nil
347 }
348
349 bytes, err := io.ReadAll(r)
350 if err != nil {
351 return "", err
352 }
353 return string(bytes), nil
354}
355
356// GetSubModules get all the sub modules of current revision git tree
357func (c *Commit) GetSubModules() (*ObjectCache, error) {
358 if c.submoduleCache != nil {
359 return c.submoduleCache, nil
360 }
361
362 entry, err := c.GetTreeEntryByPath(".gitmodules")
363 if err != nil {
364 if _, ok := err.(ErrNotExist); ok {
365 return nil, nil
366 }
367 return nil, err
368 }
369
370 content, err := entry.Blob().GetBlobContent(10 * 1024)
371 if err != nil {
372 return nil, err
373 }
374
375 c.submoduleCache, err = parseSubmoduleContent([]byte(content))
376 if err != nil {
377 return nil, err
378 }
379 return c.submoduleCache, nil
380}
381
382func parseSubmoduleContent(bs []byte) (*ObjectCache, error) {
383 cfg := config.NewModules()
384 if err := cfg.Unmarshal(bs); err != nil {
385 return nil, err
386 }
387 submoduleCache := newObjectCache()
388 if len(cfg.Submodules) == 0 {
389 return nil, fmt.Errorf("no submodules found")
390 }
391 for _, subModule := range cfg.Submodules {
392 submoduleCache.Set(subModule.Path, subModule.URL)
393 }
394
395 return submoduleCache, nil
396}
397
398// GetSubModule returns the URL to the submodule according entryname
399func (c *Commit) GetSubModule(entryname string) (string, error) {
400 modules, err := c.GetSubModules()
401 if err != nil {
402 return "", err
403 }
404
405 if modules != nil {
406 module, has := modules.Get(entryname)
407 if has {
408 return module.(string), nil
409 }
410 }
411 return "", nil
412}
413
414// GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only')
415func (c *Commit) GetBranchName() (string, error) {
416 cmd := NewCommand(c.repo.Ctx, "name-rev")
417 if CheckGitVersionAtLeast("2.13.0") == nil {
418 cmd.AddArguments("--exclude", "refs/tags/*")
419 }
420 cmd.AddArguments("--name-only", "--no-undefined").AddDynamicArguments(c.ID.String())
421 data, _, err := cmd.RunStdString(&RunOpts{Dir: c.repo.Path})
422 if err != nil {
423 // handle special case where git can not describe commit
424 if strings.Contains(err.Error(), "cannot describe") {
425 return "", nil
426 }
427
428 return "", err
429 }
430
431 // name-rev commitID output will be "master" or "master~12"
432 return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil
433}
434
435// GetAllBranches returns a slice with all branches that contains this commit
436func (c *Commit) GetAllBranches() ([]string, error) {
437 return c.repo.getBranches(c, -1)
438}
439
440// CommitFileStatus represents status of files in a commit.
441type CommitFileStatus struct {
442 Added []string
443 Removed []string
444 Modified []string
445}
446
447// NewCommitFileStatus creates a CommitFileStatus
448func NewCommitFileStatus() *CommitFileStatus {
449 return &CommitFileStatus{
450 []string{}, []string{}, []string{},
451 }
452}
453
454func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) {
455 rd := bufio.NewReader(stdout)
456 peek, err := rd.Peek(1)
457 if err != nil {
458 if err != io.EOF {
459 log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
460 }
461 return
462 }
463 if peek[0] == '\n' || peek[0] == '\x00' {
464 _, _ = rd.Discard(1)
465 }
466 for {
467 modifier, err := rd.ReadString('\x00')
468 if err != nil {
469 if err != io.EOF {
470 log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
471 }
472 return
473 }
474 file, err := rd.ReadString('\x00')
475 if err != nil {
476 if err != io.EOF {
477 log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
478 }
479 return
480 }
481 file = file[:len(file)-1]
482 switch modifier[0] {
483 case 'A':
484 fileStatus.Added = append(fileStatus.Added, file)
485 case 'D':
486 fileStatus.Removed = append(fileStatus.Removed, file)
487 case 'M':
488 fileStatus.Modified = append(fileStatus.Modified, file)
489 }
490 }
491}
492
493// GetCommitFileStatus returns file status of commit in given repository.
494func GetCommitFileStatus(ctx context.Context, repoPath, commitID string) (*CommitFileStatus, error) {
495 stdout, w := io.Pipe()
496 done := make(chan struct{})
497 fileStatus := NewCommitFileStatus()
498 go func() {
499 parseCommitFileStatus(fileStatus, stdout)
500 close(done)
501 }()
502
503 stderr := new(bytes.Buffer)
504 err := NewCommand(ctx, "log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1").AddDynamicArguments(commitID).Run(&RunOpts{
505 Dir: repoPath,
506 Stdout: w,
507 Stderr: stderr,
508 })
509 w.Close() // Close writer to exit parsing goroutine
510 if err != nil {
511 return nil, ConcatenateError(err, stderr.String())
512 }
513
514 <-done
515 return fileStatus, nil
516}
517
518func parseCommitRenames(renames *[][2]string, stdout io.Reader) {
519 rd := bufio.NewReader(stdout)
520 for {
521 // Skip (R || three digits || NULL byte)
522 _, err := rd.Discard(5)
523 if err != nil {
524 if err != io.EOF {
525 log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
526 }
527 return
528 }
529 oldFileName, err := rd.ReadString('\x00')
530 if err != nil {
531 if err != io.EOF {
532 log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
533 }
534 return
535 }
536 newFileName, err := rd.ReadString('\x00')
537 if err != nil {
538 if err != io.EOF {
539 log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err)
540 }
541 return
542 }
543 oldFileName = strings.TrimSuffix(oldFileName, "\x00")
544 newFileName = strings.TrimSuffix(newFileName, "\x00")
545 *renames = append(*renames, [2]string{oldFileName, newFileName})
546 }
547}
548
549// GetCommitFileRenames returns the renames that the commit contains.
550func GetCommitFileRenames(ctx context.Context, repoPath, commitID string) ([][2]string, error) {
551 renames := [][2]string{}
552 stdout, w := io.Pipe()
553 done := make(chan struct{})
554 go func() {
555 parseCommitRenames(&renames, stdout)
556 close(done)
557 }()
558
559 stderr := new(bytes.Buffer)
560 err := NewCommand(ctx, "show", "--name-status", "--pretty=format:", "-z", "--diff-filter=R").AddDynamicArguments(commitID).Run(&RunOpts{
561 Dir: repoPath,
562 Stdout: w,
563 Stderr: stderr,
564 })
565 w.Close() // Close writer to exit parsing goroutine
566 if err != nil {
567 return nil, ConcatenateError(err, stderr.String())
568 }
569
570 <-done
571 return renames, nil
572}
573
574// GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository.
575func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) {
576 commitID, _, err := NewCommand(ctx, "rev-parse").AddDynamicArguments(shortID).RunStdString(&RunOpts{Dir: repoPath})
577 if err != nil {
578 if strings.Contains(err.Error(), "exit status 128") {
579 return "", ErrNotExist{shortID, ""}
580 }
581 return "", err
582 }
583 return strings.TrimSpace(commitID), nil
584}
585
586// GetRepositoryDefaultPublicGPGKey returns the default public key for this commit
587func (c *Commit) GetRepositoryDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) {
588 if c.repo == nil {
589 return nil, nil
590 }
591 return c.repo.GetDefaultPublicGPGKey(forceUpdate)
592}