Files
gitea/modules/git/repo_commit.go
wxiaoguang 9dc04289aa refactor: make git package handle all git operations (#38543)
before: gitrepo vs git packages
after: git package fully handle all git operations

by the way, use `WithRepo(repo)` instead of `WithDir(repo.Path)` to hide
path details.

benefits:

1. remove all unnecessary wrappers, developers no need to struggle with
"which package should be used"
2. simplify code, RepositoryFacade can (will) be used everywhere, all
"path" details are (will be) hidden
2026-07-20 16:07:38 +00:00

460 lines
14 KiB
Go

// Copyright 2015 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bytes"
"context"
"io"
"strconv"
"strings"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
)
// GetBranchCommitID returns last commit ID string of given branch.
func (repo *Repository) GetBranchCommitID(ctx context.Context, name string) (string, error) {
return repo.GetRefCommitID(ctx, BranchPrefix+name)
}
// GetTagCommitID returns last commit ID string of given tag.
func (repo *Repository) GetTagCommitID(ctx context.Context, name string) (string, error) {
return repo.GetRefCommitID(ctx, TagPrefix+name)
}
// GetCommit returns a commit object of by the git ref.
func (repo *Repository) GetCommit(ctx context.Context, ref string) (*Commit, error) {
id, err := repo.ConvertToGitID(ctx, ref)
if err != nil {
return nil, err
}
return repo.getCommit(ctx, id)
}
// GetBranchCommit returns the last commit of given branch.
func (repo *Repository) GetBranchCommit(ctx context.Context, name string) (*Commit, error) {
return repo.GetCommit(ctx, RefNameFromBranch(name).String())
}
// GetTagCommit get the commit of the specific tag via name
func (repo *Repository) GetTagCommit(ctx context.Context, name string) (*Commit, error) {
return repo.GetCommit(ctx, RefNameFromTag(name).String())
}
const prettyLogFormat = `--pretty=format:%H`
func (repo *Repository) ShowPrettyFormatLogToList(ctx context.Context, revisionRange string) ([]*Commit, error) {
// avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git <command> [<revision>...] -- [<file>...]'
logs, _, err := gitcmd.NewCommand("log").AddArguments(prettyLogFormat).
AddDynamicArguments(revisionRange).AddArguments("--").WithRepo(repo).
RunStdBytes(ctx)
if err != nil {
return nil, err
}
return repo.parsePrettyFormatLogToList(ctx, logs)
}
func (repo *Repository) parsePrettyFormatLogToList(ctx context.Context, logs []byte) ([]*Commit, error) {
var commits []*Commit
if len(logs) == 0 {
return commits, nil
}
parts := bytes.SplitSeq(logs, []byte{'\n'})
for commitID := range parts {
commit, err := repo.GetCommit(ctx, string(commitID))
if err != nil {
return nil, err
}
commits = append(commits, commit)
}
return commits, nil
}
func (repo *Repository) getCommitByPathWithID(ctx context.Context, id ObjectID, relpath string) (*Commit, error) {
// File name starts with ':' must be escaped.
if strings.HasPrefix(relpath, ":") {
relpath = `\` + relpath
}
stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).
AddDynamicArguments(id.String()).
AddDashesAndList(relpath).
WithRepo(repo).
RunStdString(ctx)
if runErr != nil {
return nil, runErr
}
id, err := NewIDFromString(stdout)
if err != nil {
return nil, err
}
return repo.getCommit(ctx, id)
}
// GetCommitByPath returns the last commit of relative path.
func (repo *Repository) GetCommitByPath(ctx context.Context, relpath string) (*Commit, error) {
stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).
AddDashesAndList(relpath).
WithRepo(repo).
RunStdBytes(ctx)
if runErr != nil {
return nil, runErr
}
commits, err := repo.parsePrettyFormatLogToList(ctx, stdout)
if err != nil {
return nil, err
}
if len(commits) == 0 {
return nil, ErrNotExist{ID: relpath}
}
return commits[0], nil
}
// commitsByRangeWithTime returns the specific page commits before current revision, with not, since, until support
func (repo *Repository) commitsByRangeWithTime(ctx context.Context, id ObjectID, page, pageSize int, not, since, until string) ([]*Commit, error) {
cmd := gitcmd.NewCommand("log").
AddOptionFormat("--skip=%d", (page-1)*pageSize).
AddOptionFormat("--max-count=%d", pageSize).
AddArguments(prettyLogFormat).
AddDynamicArguments(id.String())
if not != "" {
cmd.AddOptionValues("--not", not)
}
if since != "" {
cmd.AddOptionFormat("--since=%s", since)
}
if until != "" {
cmd.AddOptionFormat("--until=%s", until)
}
stdout, _, err := cmd.WithRepo(repo).RunStdBytes(ctx)
if err != nil {
return nil, err
}
return repo.parsePrettyFormatLogToList(ctx, stdout)
}
func (repo *Repository) searchCommits(ctx context.Context, id ObjectID, opts SearchCommitsOptions) ([]*Commit, error) {
// add common arguments to git command
addCommonSearchArgs := func(c *gitcmd.Command) {
// ignore case
c.AddArguments("-i")
// add authors if present in search query
for _, v := range opts.Authors {
c.AddOptionFormat("--author=%s", v)
}
// add committers if present in search query
for _, v := range opts.Committers {
c.AddOptionFormat("--committer=%s", v)
}
// add time constraints if present in search query
if len(opts.After) > 0 {
c.AddOptionFormat("--after=%s", opts.After)
}
if len(opts.Before) > 0 {
c.AddOptionFormat("--before=%s", opts.Before)
}
}
// create new git log command with limit of 100 commits
cmd := gitcmd.NewCommand("log", "-100", prettyLogFormat).AddDynamicArguments(id.String())
// pretend that all refs along with HEAD were listed on command line as <commis>
// https://git-scm.com/docs/git-log#Documentation/git-log.txt---all
// note this is done only for command created above
if opts.All {
cmd.AddArguments("--all")
}
// interpret search string keywords as string instead of regex
cmd.AddArguments("--fixed-strings")
// add remaining keywords from search string
// note this is done only for command created above
for _, v := range opts.Keywords {
cmd.AddOptionFormat("--grep=%s", v)
}
// search for commits matching given constraints and keywords in commit msg
addCommonSearchArgs(cmd)
stdout, _, err := cmd.WithRepo(repo).RunStdBytes(ctx)
if err != nil {
return nil, err
}
if len(stdout) != 0 {
stdout = append(stdout, '\n')
}
// if there are any keywords (ie not committer:, author:, time:)
// then let's iterate over them
for _, v := range opts.Keywords {
// ignore anything not matching a valid sha pattern
if id.Type().IsValid(v) {
// create new git log command with 1 commit limit
hashCmd := gitcmd.NewCommand("log", "-1", prettyLogFormat)
// add previous arguments except for --grep and --all
addCommonSearchArgs(hashCmd)
// add keyword as <commit>
hashCmd.AddDynamicArguments(v)
// search with given constraints for commit matching sha hash of v
hashMatching, _, err := hashCmd.WithRepo(repo).RunStdBytes(ctx)
if err != nil || bytes.Contains(stdout, hashMatching) {
continue
}
stdout = append(stdout, hashMatching...)
stdout = append(stdout, '\n')
}
}
return repo.parsePrettyFormatLogToList(ctx, bytes.TrimSuffix(stdout, []byte{'\n'}))
}
// FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2
// You must ensure that id1 and id2 are valid commit ids.
func (repo *Repository) FileChangedBetweenCommits(ctx context.Context, filename, id1, id2 string) (bool, error) {
stdout, _, err := gitcmd.NewCommand("diff", "--name-only", "-z").
AddDynamicArguments(id1, id2).
AddDashesAndList(filename).
WithRepo(repo).
RunStdBytes(ctx)
if err != nil {
return false, err
}
return len(strings.TrimSpace(string(stdout))) > 0, nil
}
type CommitsByFileAndRangeOptions struct {
Revision string
File string
Not string
Page int
Since string
Until string
// when using FollowRename, there is no quick way to know the total count, so use hasMore to indicate if there are more commits to load
FollowRename bool
}
// CommitsByFileAndRange return the commits according revision file and the page
func (repo *Repository) CommitsByFileAndRange(ctx context.Context, opts CommitsByFileAndRangeOptions) (commits []*Commit, hasMore bool, _ error) {
limit := setting.Git.CommitsRangeSize
gitCmd := gitcmd.NewCommand("--no-pager", "log").
AddArguments("--pretty=tformat:%H").
AddOptionFormat("--max-count=%d", limit+1).
AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize)
if opts.FollowRename {
gitCmd.AddArguments("--follow")
}
if opts.Since != "" {
gitCmd.AddOptionFormat("--since=%s", opts.Since)
}
if opts.Until != "" {
gitCmd.AddOptionFormat("--until=%s", opts.Until)
}
gitCmd.AddDynamicArguments(opts.Revision)
if opts.Not != "" {
gitCmd.AddOptionValues("--not", opts.Not)
}
gitCmd.AddDashesAndList(opts.File)
stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := gitCmd.WithRepo(repo).
WithPipelineFunc(func(context gitcmd.Context) error {
objectFormat, err := repo.GetObjectFormat(ctx)
if err != nil {
return err
}
length := objectFormat.FullLength()
shaline := make([]byte, length+1)
for {
n, err := io.ReadFull(stdoutReader, shaline)
if err != nil || n < length {
if err == io.EOF {
err = nil
}
return err
}
objectID, err := NewIDFromString(string(shaline[0:length]))
if err != nil {
return err
}
commit, err := repo.getCommit(ctx, objectID)
if err != nil {
return err
}
commits = append(commits, commit)
}
}).
RunWithStderr(ctx)
hasMore = len(commits) > limit
if hasMore {
commits = commits[:limit]
}
return commits, hasMore, err
}
// CommitsBetween returns a list that contains commits between [after, before). After is the first item in the slice.
// If "before" and "after" are not related, it returns the all commits for the "after" commit.
func (repo *Repository) CommitsBetween(ctx context.Context, afterRef, beforeRef RefName, limit int, optSkip ...int) ([]*Commit, error) {
gitCmd := func() *gitcmd.Command {
cmd := gitcmd.NewCommand("rev-list").WithRepo(repo)
if limit >= 0 {
cmd.AddOptionValues("--max-count", strconv.Itoa(limit))
}
if len(optSkip) > 0 {
cmd.AddOptionValues("--skip", strconv.Itoa(optSkip[0]))
}
return cmd
}
var stdout []byte
var err error
if beforeRef == "" {
stdout, _, err = gitCmd().AddDynamicArguments(afterRef.String()).RunStdBytes(ctx)
} else {
stdout, _, err = gitCmd().AddDynamicArguments(beforeRef.String() + ".." + afterRef.String()).RunStdBytes(ctx)
if gitcmd.IsStderr(err, gitcmd.StderrNoMergeBase) {
// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
// if the beforeRef and afterRef are not related (no merge base), just get all commits pushed by afterRef
stdout, _, err = gitCmd().AddDynamicArguments(afterRef.String()).RunStdBytes(ctx)
}
}
if err != nil {
return nil, err
}
return repo.parsePrettyFormatLogToList(ctx, bytes.TrimSpace(stdout))
}
// commitsBefore the limit is depth, not total number of returned commits.
func (repo *Repository) commitsBefore(ctx context.Context, id ObjectID, limit int) ([]*Commit, error) {
cmd := gitcmd.NewCommand("log", prettyLogFormat)
if limit > 0 {
cmd.AddOptionFormat("-%d", limit)
}
cmd.AddDynamicArguments(id.String())
stdout, _, runErr := cmd.WithRepo(repo).RunStdBytes(ctx)
if runErr != nil {
return nil, runErr
}
formattedLog, err := repo.parsePrettyFormatLogToList(ctx, bytes.TrimSpace(stdout))
if err != nil {
return nil, err
}
commits := make([]*Commit, 0, len(formattedLog))
for _, commit := range formattedLog {
branches, err := repo.getBranches(ctx, nil, commit.ID.String(), 2)
if err != nil {
return nil, err
}
if len(branches) > 1 {
break
}
commits = append(commits, commit)
}
return commits, nil
}
func (repo *Repository) getCommitsBefore(ctx context.Context, id ObjectID) ([]*Commit, error) {
return repo.commitsBefore(ctx, id, 0)
}
func (repo *Repository) getCommitsBeforeLimit(ctx context.Context, id ObjectID, num int) ([]*Commit, error) {
return repo.commitsBefore(ctx, id, num)
}
func (repo *Repository) getBranches(ctx context.Context, env []string, commitID string, limit int) ([]string, error) {
stdout, _, err := gitcmd.NewCommand("for-each-ref", "--format=%(refname:strip=2)").
AddOptionFormat("--count=%d", limit).
AddOptionValues("--contains", commitID).
AddArguments(BranchPrefix).
WithEnv(env).
WithRepo(repo).
RunStdString(ctx)
if err != nil {
return nil, err
}
return strings.Fields(stdout), nil
}
// GetCommitsFromIDs get commits from commit IDs
func (repo *Repository) GetCommitsFromIDs(ctx context.Context, commitIDs []string) []*Commit {
commits := make([]*Commit, 0, len(commitIDs))
for _, commitID := range commitIDs {
commit, err := repo.GetCommit(ctx, commitID)
if err == nil && commit != nil {
commits = append(commits, commit)
}
}
return commits
}
// IsCommitInBranch check if the commit is on the branch
func (repo *Repository) IsCommitInBranch(ctx context.Context, commitID, branch string) (r bool, err error) {
stdout, _, err := gitcmd.NewCommand("branch", "--contains").
AddDynamicArguments(commitID, branch).
WithRepo(repo).
RunStdString(ctx)
if err != nil {
return false, err
}
return len(stdout) > 0, err
}
// GetCommitBranchStart returns the commit where the branch diverged
func (repo *Repository) GetCommitBranchStart(ctx context.Context, env []string, branch, endCommitID string) (string, error) {
cmd := gitcmd.NewCommand("log", prettyLogFormat)
cmd.AddDynamicArguments(endCommitID)
stdout, _, runErr := cmd.WithRepo(repo).
WithEnv(env).
RunStdBytes(ctx)
if runErr != nil {
return "", runErr
}
parts := bytes.SplitSeq(bytes.TrimSpace(stdout), []byte{'\n'})
// check the commits one by one until we find a commit contained by another branch,
// and we think this commit is the divergence point
for part := range parts {
commitID := string(part)
branches, err := repo.getBranches(ctx, env, commitID, 2)
if err != nil {
return "", err
}
for _, b := range branches {
if b != branch {
return commitID, nil
}
}
}
return "", nil
}