code.gitea.io/gitea@v1.19.3/modules/git/commit.go (about)

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