github.com/gitbundle/modules@v0.0.0-20231025071548-85b91c5c3b01/git/commit.go (about)

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