1// Copyright 2015 The Gogs Authors. All rights reserved.
2// Copyright 2019 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
6package git
7
8import (
9	"bytes"
10	"io"
11	"strconv"
12	"strings"
13
14	"code.gitea.io/gitea/modules/setting"
15)
16
17// GetBranchCommitID returns last commit ID string of given branch.
18func (repo *Repository) GetBranchCommitID(name string) (string, error) {
19	return repo.GetRefCommitID(BranchPrefix + name)
20}
21
22// GetTagCommitID returns last commit ID string of given tag.
23func (repo *Repository) GetTagCommitID(name string) (string, error) {
24	return repo.GetRefCommitID(TagPrefix + name)
25}
26
27// GetCommit returns commit object of by ID string.
28func (repo *Repository) GetCommit(commitID string) (*Commit, error) {
29	id, err := repo.ConvertToSHA1(commitID)
30	if err != nil {
31		return nil, err
32	}
33
34	return repo.getCommit(id)
35}
36
37// GetBranchCommit returns the last commit of given branch.
38func (repo *Repository) GetBranchCommit(name string) (*Commit, error) {
39	commitID, err := repo.GetBranchCommitID(name)
40	if err != nil {
41		return nil, err
42	}
43	return repo.GetCommit(commitID)
44}
45
46// GetTagCommit get the commit of the specific tag via name
47func (repo *Repository) GetTagCommit(name string) (*Commit, error) {
48	commitID, err := repo.GetTagCommitID(name)
49	if err != nil {
50		return nil, err
51	}
52	return repo.GetCommit(commitID)
53}
54
55func (repo *Repository) getCommitByPathWithID(id SHA1, relpath string) (*Commit, error) {
56	// File name starts with ':' must be escaped.
57	if relpath[0] == ':' {
58		relpath = `\` + relpath
59	}
60
61	stdout, err := NewCommandContext(repo.Ctx, "log", "-1", prettyLogFormat, id.String(), "--", relpath).RunInDir(repo.Path)
62	if err != nil {
63		return nil, err
64	}
65
66	id, err = NewIDFromString(stdout)
67	if err != nil {
68		return nil, err
69	}
70
71	return repo.getCommit(id)
72}
73
74// GetCommitByPath returns the last commit of relative path.
75func (repo *Repository) GetCommitByPath(relpath string) (*Commit, error) {
76	stdout, err := NewCommandContext(repo.Ctx, "log", "-1", prettyLogFormat, "--", relpath).RunInDirBytes(repo.Path)
77	if err != nil {
78		return nil, err
79	}
80
81	commits, err := repo.parsePrettyFormatLogToList(stdout)
82	if err != nil {
83		return nil, err
84	}
85	return commits[0], nil
86}
87
88func (repo *Repository) commitsByRange(id SHA1, page, pageSize int) ([]*Commit, error) {
89	stdout, err := NewCommandContext(repo.Ctx, "log", id.String(), "--skip="+strconv.Itoa((page-1)*pageSize),
90		"--max-count="+strconv.Itoa(pageSize), prettyLogFormat).RunInDirBytes(repo.Path)
91
92	if err != nil {
93		return nil, err
94	}
95	return repo.parsePrettyFormatLogToList(stdout)
96}
97
98func (repo *Repository) searchCommits(id SHA1, opts SearchCommitsOptions) ([]*Commit, error) {
99	// create new git log command with limit of 100 commis
100	cmd := NewCommandContext(repo.Ctx, "log", id.String(), "-100", prettyLogFormat)
101	// ignore case
102	args := []string{"-i"}
103
104	// add authors if present in search query
105	if len(opts.Authors) > 0 {
106		for _, v := range opts.Authors {
107			args = append(args, "--author="+v)
108		}
109	}
110
111	// add committers if present in search query
112	if len(opts.Committers) > 0 {
113		for _, v := range opts.Committers {
114			args = append(args, "--committer="+v)
115		}
116	}
117
118	// add time constraints if present in search query
119	if len(opts.After) > 0 {
120		args = append(args, "--after="+opts.After)
121	}
122	if len(opts.Before) > 0 {
123		args = append(args, "--before="+opts.Before)
124	}
125
126	// pretend that all refs along with HEAD were listed on command line as <commis>
127	// https://git-scm.com/docs/git-log#Documentation/git-log.txt---all
128	// note this is done only for command created above
129	if opts.All {
130		cmd.AddArguments("--all")
131	}
132
133	// add remaining keywords from search string
134	// note this is done only for command created above
135	if len(opts.Keywords) > 0 {
136		for _, v := range opts.Keywords {
137			cmd.AddArguments("--grep=" + v)
138		}
139	}
140
141	// search for commits matching given constraints and keywords in commit msg
142	cmd.AddArguments(args...)
143	stdout, err := cmd.RunInDirBytes(repo.Path)
144	if err != nil {
145		return nil, err
146	}
147	if len(stdout) != 0 {
148		stdout = append(stdout, '\n')
149	}
150
151	// if there are any keywords (ie not committer:, author:, time:)
152	// then let's iterate over them
153	if len(opts.Keywords) > 0 {
154		for _, v := range opts.Keywords {
155			// ignore anything below 4 characters as too unspecific
156			if len(v) >= 4 {
157				// create new git log command with 1 commit limit
158				hashCmd := NewCommandContext(repo.Ctx, "log", "-1", prettyLogFormat)
159				// add previous arguments except for --grep and --all
160				hashCmd.AddArguments(args...)
161				// add keyword as <commit>
162				hashCmd.AddArguments(v)
163
164				// search with given constraints for commit matching sha hash of v
165				hashMatching, err := hashCmd.RunInDirBytes(repo.Path)
166				if err != nil || bytes.Contains(stdout, hashMatching) {
167					continue
168				}
169				stdout = append(stdout, hashMatching...)
170				stdout = append(stdout, '\n')
171			}
172		}
173	}
174
175	return repo.parsePrettyFormatLogToList(bytes.TrimSuffix(stdout, []byte{'\n'}))
176}
177
178func (repo *Repository) getFilesChanged(id1, id2 string) ([]string, error) {
179	stdout, err := NewCommandContext(repo.Ctx, "diff", "--name-only", id1, id2).RunInDirBytes(repo.Path)
180	if err != nil {
181		return nil, err
182	}
183	return strings.Split(string(stdout), "\n"), nil
184}
185
186// FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2
187// You must ensure that id1 and id2 are valid commit ids.
188func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bool, error) {
189	stdout, err := NewCommandContext(repo.Ctx, "diff", "--name-only", "-z", id1, id2, "--", filename).RunInDirBytes(repo.Path)
190	if err != nil {
191		return false, err
192	}
193	return len(strings.TrimSpace(string(stdout))) > 0, nil
194}
195
196// FileCommitsCount return the number of files at a revision
197func (repo *Repository) FileCommitsCount(revision, file string) (int64, error) {
198	return CommitsCountFiles(repo.Path, []string{revision}, []string{file})
199}
200
201// CommitsByFileAndRange return the commits according revision file and the page
202func (repo *Repository) CommitsByFileAndRange(revision, file string, page int) ([]*Commit, error) {
203	skip := (page - 1) * setting.Git.CommitsRangeSize
204
205	stdoutReader, stdoutWriter := io.Pipe()
206	defer func() {
207		_ = stdoutReader.Close()
208		_ = stdoutWriter.Close()
209	}()
210	go func() {
211		stderr := strings.Builder{}
212		err := NewCommandContext(repo.Ctx, "log", revision, "--follow",
213			"--max-count="+strconv.Itoa(setting.Git.CommitsRangeSize*page),
214			prettyLogFormat, "--", file).
215			RunInDirPipeline(repo.Path, stdoutWriter, &stderr)
216		if err != nil {
217			_ = stdoutWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
218		} else {
219			_ = stdoutWriter.Close()
220		}
221	}()
222
223	if skip > 0 {
224		_, err := io.CopyN(io.Discard, stdoutReader, int64(skip*41))
225		if err != nil {
226			if err == io.EOF {
227				return []*Commit{}, nil
228			}
229			_ = stdoutReader.CloseWithError(err)
230			return nil, err
231		}
232	}
233
234	stdout, err := io.ReadAll(stdoutReader)
235	if err != nil {
236		return nil, err
237	}
238	return repo.parsePrettyFormatLogToList(stdout)
239}
240
241// CommitsByFileAndRangeNoFollow return the commits according revision file and the page
242func (repo *Repository) CommitsByFileAndRangeNoFollow(revision, file string, page int) ([]*Commit, error) {
243	stdout, err := NewCommandContext(repo.Ctx, "log", revision, "--skip="+strconv.Itoa((page-1)*50),
244		"--max-count="+strconv.Itoa(setting.Git.CommitsRangeSize), prettyLogFormat, "--", file).RunInDirBytes(repo.Path)
245	if err != nil {
246		return nil, err
247	}
248	return repo.parsePrettyFormatLogToList(stdout)
249}
250
251// FilesCountBetween return the number of files changed between two commits
252func (repo *Repository) FilesCountBetween(startCommitID, endCommitID string) (int, error) {
253	stdout, err := NewCommandContext(repo.Ctx, "diff", "--name-only", startCommitID+"..."+endCommitID).RunInDir(repo.Path)
254	if err != nil && strings.Contains(err.Error(), "no merge base") {
255		// git >= 2.28 now returns an error if startCommitID and endCommitID have become unrelated.
256		// previously it would return the results of git diff --name-only startCommitID endCommitID so let's try that...
257		stdout, err = NewCommandContext(repo.Ctx, "diff", "--name-only", startCommitID, endCommitID).RunInDir(repo.Path)
258	}
259	if err != nil {
260		return 0, err
261	}
262	return len(strings.Split(stdout, "\n")) - 1, nil
263}
264
265// CommitsBetween returns a list that contains commits between [before, last).
266// If before is detached (removed by reset + push) it is not included.
267func (repo *Repository) CommitsBetween(last, before *Commit) ([]*Commit, error) {
268	var stdout []byte
269	var err error
270	if before == nil {
271		stdout, err = NewCommandContext(repo.Ctx, "rev-list", last.ID.String()).RunInDirBytes(repo.Path)
272	} else {
273		stdout, err = NewCommandContext(repo.Ctx, "rev-list", before.ID.String()+".."+last.ID.String()).RunInDirBytes(repo.Path)
274		if err != nil && strings.Contains(err.Error(), "no merge base") {
275			// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
276			// previously it would return the results of git rev-list before last so let's try that...
277			stdout, err = NewCommandContext(repo.Ctx, "rev-list", before.ID.String(), last.ID.String()).RunInDirBytes(repo.Path)
278		}
279	}
280	if err != nil {
281		return nil, err
282	}
283	return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
284}
285
286// CommitsBetweenLimit returns a list that contains at most limit commits skipping the first skip commits between [before, last)
287func (repo *Repository) CommitsBetweenLimit(last, before *Commit, limit, skip int) ([]*Commit, error) {
288	var stdout []byte
289	var err error
290	if before == nil {
291		stdout, err = NewCommandContext(repo.Ctx, "rev-list", "--max-count", strconv.Itoa(limit), "--skip", strconv.Itoa(skip), last.ID.String()).RunInDirBytes(repo.Path)
292	} else {
293		stdout, err = NewCommandContext(repo.Ctx, "rev-list", "--max-count", strconv.Itoa(limit), "--skip", strconv.Itoa(skip), before.ID.String()+".."+last.ID.String()).RunInDirBytes(repo.Path)
294		if err != nil && strings.Contains(err.Error(), "no merge base") {
295			// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
296			// previously it would return the results of git rev-list --max-count n before last so let's try that...
297			stdout, err = NewCommandContext(repo.Ctx, "rev-list", "--max-count", strconv.Itoa(limit), "--skip", strconv.Itoa(skip), before.ID.String(), last.ID.String()).RunInDirBytes(repo.Path)
298		}
299	}
300	if err != nil {
301		return nil, err
302	}
303	return repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
304}
305
306// CommitsBetweenIDs return commits between twoe commits
307func (repo *Repository) CommitsBetweenIDs(last, before string) ([]*Commit, error) {
308	lastCommit, err := repo.GetCommit(last)
309	if err != nil {
310		return nil, err
311	}
312	if before == "" {
313		return repo.CommitsBetween(lastCommit, nil)
314	}
315	beforeCommit, err := repo.GetCommit(before)
316	if err != nil {
317		return nil, err
318	}
319	return repo.CommitsBetween(lastCommit, beforeCommit)
320}
321
322// CommitsCountBetween return numbers of commits between two commits
323func (repo *Repository) CommitsCountBetween(start, end string) (int64, error) {
324	count, err := CommitsCountFiles(repo.Path, []string{start + ".." + end}, []string{})
325	if err != nil && strings.Contains(err.Error(), "no merge base") {
326		// future versions of git >= 2.28 are likely to return an error if before and last have become unrelated.
327		// previously it would return the results of git rev-list before last so let's try that...
328		return CommitsCountFiles(repo.Path, []string{start, end}, []string{})
329	}
330
331	return count, err
332}
333
334// commitsBefore the limit is depth, not total number of returned commits.
335func (repo *Repository) commitsBefore(id SHA1, limit int) ([]*Commit, error) {
336	cmd := NewCommandContext(repo.Ctx, "log")
337	if limit > 0 {
338		cmd.AddArguments("-"+strconv.Itoa(limit), prettyLogFormat, id.String())
339	} else {
340		cmd.AddArguments(prettyLogFormat, id.String())
341	}
342
343	stdout, err := cmd.RunInDirBytes(repo.Path)
344	if err != nil {
345		return nil, err
346	}
347
348	formattedLog, err := repo.parsePrettyFormatLogToList(bytes.TrimSpace(stdout))
349	if err != nil {
350		return nil, err
351	}
352
353	commits := make([]*Commit, 0, len(formattedLog))
354	for _, commit := range formattedLog {
355		branches, err := repo.getBranches(commit, 2)
356		if err != nil {
357			return nil, err
358		}
359
360		if len(branches) > 1 {
361			break
362		}
363
364		commits = append(commits, commit)
365	}
366
367	return commits, nil
368}
369
370func (repo *Repository) getCommitsBefore(id SHA1) ([]*Commit, error) {
371	return repo.commitsBefore(id, 0)
372}
373
374func (repo *Repository) getCommitsBeforeLimit(id SHA1, num int) ([]*Commit, error) {
375	return repo.commitsBefore(id, num)
376}
377
378func (repo *Repository) getBranches(commit *Commit, limit int) ([]string, error) {
379	if CheckGitVersionAtLeast("2.7.0") == nil {
380		stdout, err := NewCommandContext(repo.Ctx, "for-each-ref", "--count="+strconv.Itoa(limit), "--format=%(refname:strip=2)", "--contains", commit.ID.String(), BranchPrefix).RunInDir(repo.Path)
381		if err != nil {
382			return nil, err
383		}
384
385		branches := strings.Fields(stdout)
386		return branches, nil
387	}
388
389	stdout, err := NewCommandContext(repo.Ctx, "branch", "--contains", commit.ID.String()).RunInDir(repo.Path)
390	if err != nil {
391		return nil, err
392	}
393
394	refs := strings.Split(stdout, "\n")
395
396	var max int
397	if len(refs) > limit {
398		max = limit
399	} else {
400		max = len(refs) - 1
401	}
402
403	branches := make([]string, max)
404	for i, ref := range refs[:max] {
405		parts := strings.Fields(ref)
406
407		branches[i] = parts[len(parts)-1]
408	}
409	return branches, nil
410}
411
412// GetCommitsFromIDs get commits from commit IDs
413func (repo *Repository) GetCommitsFromIDs(commitIDs []string) []*Commit {
414	commits := make([]*Commit, 0, len(commitIDs))
415
416	for _, commitID := range commitIDs {
417		commit, err := repo.GetCommit(commitID)
418		if err == nil && commit != nil {
419			commits = append(commits, commit)
420		}
421	}
422
423	return commits
424}
425
426// IsCommitInBranch check if the commit is on the branch
427func (repo *Repository) IsCommitInBranch(commitID, branch string) (r bool, err error) {
428	stdout, err := NewCommandContext(repo.Ctx, "branch", "--contains", commitID, branch).RunInDir(repo.Path)
429	if err != nil {
430		return false, err
431	}
432	return len(stdout) > 0, err
433}
434