git-module

git clone git://git.lin.moe/fork/git-module.git

  1package git
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"strconv"
  8	"strings"
  9	"time"
 10)
 11
 12// parseCommit parses commit information from the (uncompressed) raw data of the
 13// commit object. It assumes "\n\n" separates the header from the rest of the
 14// 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 := 0
 19loop:
 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, err
 32				}
 33				commit.Tree = &Tree{id: id}
 34			case "parent":
 35				// A commit can have one or more parents
 36				id, err := NewIDFromString(string(line[spacepos+1:]))
 37				if err != nil {
 38					return nil, err
 39				}
 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, err
 45				}
 46				commit.Author = sig
 47			case "committer":
 48				sig, err := parseSignature(line[spacepos+1:])
 49				if err != nil {
 50					return nil, err
 51				}
 52				commit.Committer = sig
 53			}
 54			nextline += eol + 1
 55		case eol == 0:
 56			commit.Message = string(data[nextline+1:])
 57			break loop
 58		default:
 59			break loop
 60		}
 61	}
 62	return commit, nil
 63}
 64
 65// CatFileCommitOptions contains optional arguments for verifying the objects.
 66//
 67// Docs: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt-lttypegt
 68type CatFileCommitOptions struct {
 69	CommandOptions
 70}
 71
 72// CatFileCommit returns the commit corresponding to the given revision of the
 73// 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 CatFileCommitOptions
 77	if len(opts) > 0 {
 78		opt = opts[0]
 79	}
 80
 81	cache, ok := r.cachedCommits.Get(rev)
 82	if ok {
 83		logf("Cached commit hit: %s", rev)
 84		return cache.(*Commit), nil
 85	}
 86
 87	commitID, err := r.RevParse(ctx, rev) //nolint
 88	if err != nil {
 89		return nil, err
 90	}
 91
 92	args := []string{"cat-file", "commit", commitID}
 93	stdout, err := exec(ctx, r.path, args, opt.Envs)
 94	if err != nil {
 95		return nil, err
 96	}
 97
 98	c, err := parseCommit(stdout)
 99	if err != nil {
100		return nil, err
101	}
102	c.repo = r
103	c.ID = MustIDFromString(commitID)
104
105	r.cachedCommits.Set(commitID, c)
106	return c, nil
107}
108
109// 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--t
112type CatFileTypeOptions struct {
113	CommandOptions
114}
115
116// 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 CatFileTypeOptions
119	if len(opts) > 0 {
120		opt = opts[0]
121	}
122
123	args := []string{"cat-file", "-t", rev}
124	typ, err := exec(ctx, r.path, args, opt.Envs)
125	if err != nil {
126		return "", err
127	}
128	typ = bytes.TrimSpace(typ)
129	return ObjectType(typ), nil
130}
131
132// BranchCommit returns the latest commit of given branch of the repository. The
133// 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}
137
138// TagCommit returns the latest commit of given tag of the repository. The tag
139// 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}
143
144// LogOptions contains optional arguments for listing commits.
145//
146// Docs: https://git-scm.com/docs/git-log
147type LogOptions struct {
148	// The maximum number of commits to output.
149	MaxCount int
150	// The number commits skipped before starting to show the commit output.
151	Skip int
152	// To only show commits since the time.
153	Since time.Time
154	// The regular expression to filter commits by their messages.
155	GrepPattern string
156	// Indicates whether to ignore letter case when match the regular expression.
157	RegexpIgnoreCase bool
158	// The relative path of the repository.
159	Path string
160	CommandOptions
161}
162
163func escapePath(path string) string {
164	if len(path) == 0 {
165		return path
166	}
167
168	// Path starts with ':' must be escaped.
169	if path[0] == ':' {
170		path = `\` + path
171	}
172	return path
173}
174
175// 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 LogOptions
179	if len(opts) > 0 {
180		opt = opts[0]
181	}
182
183	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	}
203
204	stdout, err := exec(ctx, r.path, args, opt.Envs)
205	if err != nil {
206		return nil, err
207	}
208	return r.parsePrettyFormatLogToList(ctx, stdout)
209}
210
211// CommitByRevisionOptions contains optional arguments for getting a commit.
212//
213// Docs: https://git-scm.com/docs/git-log
214type CommitByRevisionOptions struct {
215	// The relative path of the repository.
216	Path string
217	CommandOptions
218}
219
220// CommitByRevision returns a commit by given revision.
221func (r *Repository) CommitByRevision(ctx context.Context, rev string, opts ...CommitByRevisionOptions) (*Commit, error) {
222	var opt CommitByRevisionOptions
223	if len(opts) > 0 {
224		opt = opts[0]
225	}
226
227	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, ErrRevisionNotExist
235		}
236		return nil, err
237	} else if len(commits) == 0 {
238		return nil, ErrRevisionNotExist
239	}
240	return commits[0], nil
241}
242
243// CommitsByPageOptions contains optional arguments for getting paginated
244// commits.
245//
246// Docs: https://git-scm.com/docs/git-log
247type CommitsByPageOptions struct {
248	// The relative path of the repository.
249	Path string
250	CommandOptions
251}
252
253// CommitsByPage returns a paginated list of commits in the state of given
254// 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 CommitsByPageOptions
257	if len(opts) > 0 {
258		opt = opts[0]
259	}
260
261	return r.Log(ctx, rev, LogOptions{
262		MaxCount:       size,
263		Skip:           (page - 1) * size,
264		Path:           opt.Path,
265		CommandOptions: opt.CommandOptions,
266	})
267}
268
269// SearchCommitsOptions contains optional arguments for searching commits.
270//
271// Docs: https://git-scm.com/docs/git-log
272type SearchCommitsOptions struct {
273	// The maximum number of commits to output.
274	MaxCount int
275	// The relative path of the repository.
276	Path string
277	CommandOptions
278}
279
280// SearchCommits searches commit message with given pattern in the state of
281// 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 SearchCommitsOptions
284	if len(opts) > 0 {
285		opt = opts[0]
286	}
287
288	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}
296
297// CommitsSinceOptions contains optional arguments for listing commits since a
298// time.
299//
300// Docs: https://git-scm.com/docs/git-log
301type CommitsSinceOptions struct {
302	// The relative path of the repository.
303	Path string
304	CommandOptions
305}
306
307// CommitsSince returns a list of commits since given time. The returned list is
308// in reverse chronological order.
309func (r *Repository) CommitsSince(ctx context.Context, rev string, since time.Time, opts ...CommitsSinceOptions) ([]*Commit, error) {
310	var opt CommitsSinceOptions
311	if len(opts) > 0 {
312		opt = opts[0]
313	}
314
315	return r.Log(ctx, rev, LogOptions{
316		Since:          since,
317		Path:           opt.Path,
318		CommandOptions: opt.CommandOptions,
319	})
320}
321
322// DiffNameOnlyOptions contains optional arguments for listing changed files.
323//
324// Docs: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---name-only
325type DiffNameOnlyOptions struct {
326	// Indicates whether two commits should have a merge base.
327	NeedsMergeBase bool
328	// The relative path of the repository.
329	Path string
330	CommandOptions
331}
332
333// DiffNameOnly returns a list of changed files between base and head revisions of the
334// repository.
335func (r *Repository) DiffNameOnly(ctx context.Context, base, head string, opts ...DiffNameOnlyOptions) ([]string, error) {
336	var opt DiffNameOnlyOptions
337	if len(opts) > 0 {
338		opt = opts[0]
339	}
340
341	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	}
351
352	stdout, err := exec(ctx, r.path, args, opt.Envs)
353	if err != nil {
354		return nil, err
355	}
356
357	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			continue
362		}
363
364		names = append(names, string(lines[i]))
365	}
366	return names, nil
367}
368
369// RevListCountOptions contains optional arguments for counting commits.
370//
371// Docs: https://git-scm.com/docs/git-rev-list#Documentation/git-rev-list.txt---count
372type RevListCountOptions struct {
373	// The relative path of the repository.
374	Path string
375	CommandOptions
376}
377
378// RevListCount returns number of total commits up to given refspec of the
379// repository.
380func (r *Repository) RevListCount(ctx context.Context, refspecs []string, opts ...RevListCountOptions) (int64, error) {
381	var opt RevListCountOptions
382	if len(opts) > 0 {
383		opt = opts[0]
384	}
385
386	if len(refspecs) == 0 {
387		return 0, errors.New("must have at least one refspec")
388	}
389
390	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	}
396
397	stdout, err := exec(ctx, r.path, args, opt.Envs)
398	if err != nil {
399		return 0, err
400	}
401
402	return strconv.ParseInt(strings.TrimSpace(string(stdout)), 10, 64)
403}
404
405// RevListOptions contains optional arguments for listing commits.
406//
407// Docs: https://git-scm.com/docs/git-rev-list
408type RevListOptions struct {
409	// The relative path of the repository.
410	Path string
411	CommandOptions
412}
413
414// RevList returns a list of commits based on given refspecs in reverse
415// chronological order.
416func (r *Repository) RevList(ctx context.Context, refspecs []string, opts ...RevListOptions) ([]*Commit, error) {
417	var opt RevListOptions
418	if len(opts) > 0 {
419		opt = opts[0]
420	}
421
422	if len(refspecs) == 0 {
423		return nil, errors.New("must have at least one refspec")
424	}
425
426	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	}
432
433	stdout, err := exec(ctx, r.path, args, opt.Envs)
434	if err != nil {
435		return nil, err
436	}
437	return r.parsePrettyFormatLogToList(ctx, bytes.TrimSpace(stdout))
438}
439
440// LatestCommitTimeOptions contains optional arguments for getting the latest
441// commit time.
442type LatestCommitTimeOptions struct {
443	// To get the latest commit time of the branch. When not set, it checks all branches.
444	Branch string
445	CommandOptions
446}
447
448// 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 LatestCommitTimeOptions
451	if len(opts) > 0 {
452		opt = opts[0]
453	}
454
455	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	}
459
460	stdout, err := exec(ctx, r.path, args, opt.Envs)
461	if err != nil {
462		return time.Time{}, err
463	}
464	return time.Parse("2006-01-02 15:04:05 -0700", strings.TrimSpace(string(stdout)))
465}