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