1package git23import (4 "bytes"5 "context"6 "errors"7 "strconv"8 "strings"9 "time"10)1112// parseCommit parses commit information from the (uncompressed) raw data of the13// commit object. It assumes "\n\n" separates the header from the rest of the14// message.15func parseCommit(data []byte) (*Commit, error) {16 commit := new(Commit)17 // we now have the contents of the commit object. Let's investigate.18 nextline := 019loop:20 for {21 eol := bytes.IndexByte(data[nextline:], '\n')22 switch {23 case eol > 0:24 line := data[nextline : nextline+eol]25 spacepos := bytes.IndexByte(line, ' ')26 reftype := line[:spacepos]27 switch string(reftype) {28 case "tree", "object":29 id, err := NewIDFromString(string(line[spacepos+1:]))30 if err != nil {31 return nil, err32 }33 commit.Tree = &Tree{id: id}34 case "parent":35 // A commit can have one or more parents36 id, err := NewIDFromString(string(line[spacepos+1:]))37 if err != nil {38 return nil, err39 }40 commit.parents = append(commit.parents, id)41 case "author", "tagger":42 sig, err := parseSignature(line[spacepos+1:])43 if err != nil {44 return nil, err45 }46 commit.Author = sig47 case "committer":48 sig, err := parseSignature(line[spacepos+1:])49 if err != nil {50 return nil, err51 }52 commit.Committer = sig53 }54 nextline += eol + 155 case eol == 0:56 commit.Message = string(data[nextline+1:])57 break loop58 default:59 break loop60 }61 }62 return commit, nil63}6465// CatFileCommitOptions contains optional arguments for verifying the objects.66//67// Docs: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt-lttypegt68type CatFileCommitOptions struct {69 CommandOptions70}7172// CatFileCommit returns the commit corresponding to the given revision of the73// repository. The revision could be a commit ID or full refspec (e.g.74// "refs/heads/master").75func (r *Repository) CatFileCommit(ctx context.Context, rev string, opts ...CatFileCommitOptions) (*Commit, error) {76 var opt CatFileCommitOptions77 if len(opts) > 0 {78 opt = opts[0]79 }8081 cache, ok := r.cachedCommits.Get(rev)82 if ok {83 logf("Cached commit hit: %s", rev)84 return cache.(*Commit), nil85 }8687 commitID, err := r.RevParse(ctx, rev) //nolint88 if err != nil {89 return nil, err90 }9192 args := []string{"cat-file", "commit", commitID}93 stdout, err := exec(ctx, r.path, args, opt.Envs)94 if err != nil {95 return nil, err96 }9798 c, err := parseCommit(stdout)99 if err != nil {100 return nil, err101 }102 c.repo = r103 c.ID = MustIDFromString(commitID)104105 r.cachedCommits.Set(commitID, c)106 return c, nil107}108109// CatFileTypeOptions contains optional arguments for showing the object type.110//111// Docs: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt--t112type CatFileTypeOptions struct {113 CommandOptions114}115116// CatFileType returns the object type of given revision of the repository.117func (r *Repository) CatFileType(ctx context.Context, rev string, opts ...CatFileTypeOptions) (ObjectType, error) {118 var opt CatFileTypeOptions119 if len(opts) > 0 {120 opt = opts[0]121 }122123 args := []string{"cat-file", "-t", rev}124 typ, err := exec(ctx, r.path, args, opt.Envs)125 if err != nil {126 return "", err127 }128 typ = bytes.TrimSpace(typ)129 return ObjectType(typ), nil130}131132// BranchCommit returns the latest commit of given branch of the repository. The133// branch must be given in short name e.g. "master".134func (r *Repository) BranchCommit(ctx context.Context, branch string, opts ...CatFileCommitOptions) (*Commit, error) {135 return r.CatFileCommit(ctx, RefsHeads+branch, opts...)136}137138// TagCommit returns the latest commit of given tag of the repository. The tag139// must be given in short name e.g. "v1.0.0".140func (r *Repository) TagCommit(ctx context.Context, tag string, opts ...CatFileCommitOptions) (*Commit, error) {141 return r.CatFileCommit(ctx, RefsTags+tag, opts...)142}143144// LogOptions contains optional arguments for listing commits.145//146// Docs: https://git-scm.com/docs/git-log147type LogOptions struct {148 // The maximum number of commits to output.149 MaxCount int150 // The number commits skipped before starting to show the commit output.151 Skip int152 // To only show commits since the time.153 Since time.Time154 // The regular expression to filter commits by their messages.155 GrepPattern string156 // Indicates whether to ignore letter case when match the regular expression.157 RegexpIgnoreCase bool158 // The relative path of the repository.159 Path string160 CommandOptions161}162163func escapePath(path string) string {164 if len(path) == 0 {165 return path166 }167168 // Path starts with ':' must be escaped.169 if path[0] == ':' {170 path = `\` + path171 }172 return path173}174175// Log returns a list of commits in the state of given revision of the repository.176// The returned list is in reverse chronological order.177func (r *Repository) Log(ctx context.Context, rev string, opts ...LogOptions) ([]*Commit, error) {178 var opt LogOptions179 if len(opts) > 0 {180 opt = opts[0]181 }182183 args := []string{"log", "--pretty=" + LogFormatHashOnly}184 if opt.MaxCount > 0 {185 args = append(args, "--max-count="+strconv.Itoa(opt.MaxCount))186 }187 if opt.Skip > 0 {188 args = append(args, "--skip="+strconv.Itoa(opt.Skip))189 }190 if !opt.Since.IsZero() {191 args = append(args, "--since="+opt.Since.Format(time.RFC3339))192 }193 if opt.GrepPattern != "" {194 args = append(args, "--grep="+opt.GrepPattern)195 }196 if opt.RegexpIgnoreCase {197 args = append(args, "--regexp-ignore-case")198 }199 args = append(args, "--end-of-options", rev, "--")200 if opt.Path != "" {201 args = append(args, escapePath(opt.Path))202 }203204 stdout, err := exec(ctx, r.path, args, opt.Envs)205 if err != nil {206 return nil, err207 }208 return r.parsePrettyFormatLogToList(ctx, stdout)209}210211// CommitByRevisionOptions contains optional arguments for getting a commit.212//213// Docs: https://git-scm.com/docs/git-log214type CommitByRevisionOptions struct {215 // The relative path of the repository.216 Path string217 CommandOptions218}219220// CommitByRevision returns a commit by given revision.221func (r *Repository) CommitByRevision(ctx context.Context, rev string, opts ...CommitByRevisionOptions) (*Commit, error) {222 var opt CommitByRevisionOptions223 if len(opts) > 0 {224 opt = opts[0]225 }226227 commits, err := r.Log(ctx, rev, LogOptions{228 MaxCount: 1,229 Path: opt.Path,230 CommandOptions: opt.CommandOptions,231 })232 if err != nil {233 if strings.Contains(err.Error(), "bad revision") {234 return nil, ErrRevisionNotExist235 }236 return nil, err237 } else if len(commits) == 0 {238 return nil, ErrRevisionNotExist239 }240 return commits[0], nil241}242243// CommitsByPageOptions contains optional arguments for getting paginated244// commits.245//246// Docs: https://git-scm.com/docs/git-log247type CommitsByPageOptions struct {248 // The relative path of the repository.249 Path string250 CommandOptions251}252253// CommitsByPage returns a paginated list of commits in the state of given254// revision. The pagination starts from the newest to the oldest commit.255func (r *Repository) CommitsByPage(ctx context.Context, rev string, page, size int, opts ...CommitsByPageOptions) ([]*Commit, error) {256 var opt CommitsByPageOptions257 if len(opts) > 0 {258 opt = opts[0]259 }260261 return r.Log(ctx, rev, LogOptions{262 MaxCount: size,263 Skip: (page - 1) * size,264 Path: opt.Path,265 CommandOptions: opt.CommandOptions,266 })267}268269// SearchCommitsOptions contains optional arguments for searching commits.270//271// Docs: https://git-scm.com/docs/git-log272type SearchCommitsOptions struct {273 // The maximum number of commits to output.274 MaxCount int275 // The relative path of the repository.276 Path string277 CommandOptions278}279280// SearchCommits searches commit message with given pattern in the state of281// given revision. The returned list is in reverse chronological order.282func (r *Repository) SearchCommits(ctx context.Context, rev, pattern string, opts ...SearchCommitsOptions) ([]*Commit, error) {283 var opt SearchCommitsOptions284 if len(opts) > 0 {285 opt = opts[0]286 }287288 return r.Log(ctx, rev, LogOptions{289 MaxCount: opt.MaxCount,290 GrepPattern: pattern,291 RegexpIgnoreCase: true,292 Path: opt.Path,293 CommandOptions: opt.CommandOptions,294 })295}296297// CommitsSinceOptions contains optional arguments for listing commits since a298// time.299//300// Docs: https://git-scm.com/docs/git-log301type CommitsSinceOptions struct {302 // The relative path of the repository.303 Path string304 CommandOptions305}306307// CommitsSince returns a list of commits since given time. The returned list is308// in reverse chronological order.309func (r *Repository) CommitsSince(ctx context.Context, rev string, since time.Time, opts ...CommitsSinceOptions) ([]*Commit, error) {310 var opt CommitsSinceOptions311 if len(opts) > 0 {312 opt = opts[0]313 }314315 return r.Log(ctx, rev, LogOptions{316 Since: since,317 Path: opt.Path,318 CommandOptions: opt.CommandOptions,319 })320}321322// DiffNameOnlyOptions contains optional arguments for listing changed files.323//324// Docs: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---name-only325type DiffNameOnlyOptions struct {326 // Indicates whether two commits should have a merge base.327 NeedsMergeBase bool328 // The relative path of the repository.329 Path string330 CommandOptions331}332333// DiffNameOnly returns a list of changed files between base and head revisions of the334// repository.335func (r *Repository) DiffNameOnly(ctx context.Context, base, head string, opts ...DiffNameOnlyOptions) ([]string, error) {336 var opt DiffNameOnlyOptions337 if len(opts) > 0 {338 opt = opts[0]339 }340341 args := []string{"diff", "--name-only", "--end-of-options"}342 if opt.NeedsMergeBase {343 args = append(args, base+"..."+head)344 } else {345 args = append(args, base, head)346 }347 args = append(args, "--")348 if opt.Path != "" {349 args = append(args, escapePath(opt.Path))350 }351352 stdout, err := exec(ctx, r.path, args, opt.Envs)353 if err != nil {354 return nil, err355 }356357 lines := bytes.Split(stdout, []byte("\n"))358 names := make([]string, 0, len(lines)-1)359 for i := range lines {360 if len(lines[i]) == 0 {361 continue362 }363364 names = append(names, string(lines[i]))365 }366 return names, nil367}368369// RevListCountOptions contains optional arguments for counting commits.370//371// Docs: https://git-scm.com/docs/git-rev-list#Documentation/git-rev-list.txt---count372type RevListCountOptions struct {373 // The relative path of the repository.374 Path string375 CommandOptions376}377378// RevListCount returns number of total commits up to given refspec of the379// repository.380func (r *Repository) RevListCount(ctx context.Context, refspecs []string, opts ...RevListCountOptions) (int64, error) {381 var opt RevListCountOptions382 if len(opts) > 0 {383 opt = opts[0]384 }385386 if len(refspecs) == 0 {387 return 0, errors.New("must have at least one refspec")388 }389390 args := []string{"rev-list", "--count", "--end-of-options"}391 args = append(args, refspecs...)392 args = append(args, "--")393 if opt.Path != "" {394 args = append(args, escapePath(opt.Path))395 }396397 stdout, err := exec(ctx, r.path, args, opt.Envs)398 if err != nil {399 return 0, err400 }401402 return strconv.ParseInt(strings.TrimSpace(string(stdout)), 10, 64)403}404405// RevListOptions contains optional arguments for listing commits.406//407// Docs: https://git-scm.com/docs/git-rev-list408type RevListOptions struct {409 // The relative path of the repository.410 Path string411 CommandOptions412}413414// RevList returns a list of commits based on given refspecs in reverse415// chronological order.416func (r *Repository) RevList(ctx context.Context, refspecs []string, opts ...RevListOptions) ([]*Commit, error) {417 var opt RevListOptions418 if len(opts) > 0 {419 opt = opts[0]420 }421422 if len(refspecs) == 0 {423 return nil, errors.New("must have at least one refspec")424 }425426 args := []string{"rev-list", "--end-of-options"}427 args = append(args, refspecs...)428 args = append(args, "--")429 if opt.Path != "" {430 args = append(args, escapePath(opt.Path))431 }432433 stdout, err := exec(ctx, r.path, args, opt.Envs)434 if err != nil {435 return nil, err436 }437 return r.parsePrettyFormatLogToList(ctx, bytes.TrimSpace(stdout))438}439440// LatestCommitTimeOptions contains optional arguments for getting the latest441// commit time.442type LatestCommitTimeOptions struct {443 // To get the latest commit time of the branch. When not set, it checks all branches.444 Branch string445 CommandOptions446}447448// LatestCommitTime returns the time of latest commit of the repository.449func (r *Repository) LatestCommitTime(ctx context.Context, opts ...LatestCommitTimeOptions) (time.Time, error) {450 var opt LatestCommitTimeOptions451 if len(opts) > 0 {452 opt = opts[0]453 }454455 args := []string{"for-each-ref", "--count=1", "--sort=-committerdate", "--format=%(committerdate:iso8601)", "--end-of-options"}456 if opt.Branch != "" {457 args = append(args, RefsHeads+opt.Branch)458 }459460 stdout, err := exec(ctx, r.path, args, opt.Envs)461 if err != nil {462 return time.Time{}, err463 }464 return time.Parse("2006-01-02 15:04:05 -0700", strings.TrimSpace(string(stdout)))465}