1// Copyright 2015 The Gogs Authors. 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
6package git
7
8import (
9	"bytes"
10	"context"
11	"fmt"
12	"io"
13	"net/url"
14	"os"
15	"path"
16	"path/filepath"
17	"strconv"
18	"strings"
19	"time"
20
21	"code.gitea.io/gitea/modules/proxy"
22)
23
24// GPGSettings represents the default GPG settings for this repository
25type GPGSettings struct {
26	Sign             bool
27	KeyID            string
28	Email            string
29	Name             string
30	PublicKeyContent string
31}
32
33const prettyLogFormat = `--pretty=format:%H`
34
35// GetAllCommitsCount returns count of all commits in repository
36func (repo *Repository) GetAllCommitsCount() (int64, error) {
37	return AllCommitsCount(repo.Path, false)
38}
39
40func (repo *Repository) parsePrettyFormatLogToList(logs []byte) ([]*Commit, error) {
41	var commits []*Commit
42	if len(logs) == 0 {
43		return commits, nil
44	}
45
46	parts := bytes.Split(logs, []byte{'\n'})
47
48	for _, commitID := range parts {
49		commit, err := repo.GetCommit(string(commitID))
50		if err != nil {
51			return nil, err
52		}
53		commits = append(commits, commit)
54	}
55
56	return commits, nil
57}
58
59// IsRepoURLAccessible checks if given repository URL is accessible.
60func IsRepoURLAccessible(url string) bool {
61	_, err := NewCommand("ls-remote", "-q", "-h", url, "HEAD").Run()
62	return err == nil
63}
64
65// InitRepository initializes a new Git repository.
66func InitRepository(repoPath string, bare bool) error {
67	err := os.MkdirAll(repoPath, os.ModePerm)
68	if err != nil {
69		return err
70	}
71
72	cmd := NewCommand("init")
73	if bare {
74		cmd.AddArguments("--bare")
75	}
76	_, err = cmd.RunInDir(repoPath)
77	return err
78}
79
80// IsEmpty Check if repository is empty.
81func (repo *Repository) IsEmpty() (bool, error) {
82	var errbuf, output strings.Builder
83	if err := NewCommandContext(repo.Ctx, "show-ref", "--head", "^HEAD$").RunWithContext(&RunContext{
84		Timeout: -1,
85		Dir:     repo.Path,
86		Stdout:  &output,
87		Stderr:  &errbuf,
88	}); err != nil {
89		if err.Error() == "exit status 1" && errbuf.String() == "" {
90			return true, nil
91		}
92		return true, fmt.Errorf("check empty: %v - %s", err, errbuf.String())
93	}
94
95	return strings.TrimSpace(output.String()) == "", nil
96}
97
98// CloneRepoOptions options when clone a repository
99type CloneRepoOptions struct {
100	Timeout       time.Duration
101	Mirror        bool
102	Bare          bool
103	Quiet         bool
104	Branch        string
105	Shared        bool
106	NoCheckout    bool
107	Depth         int
108	Filter        string
109	SkipTLSVerify bool
110}
111
112// Clone clones original repository to target path.
113func Clone(from, to string, opts CloneRepoOptions) error {
114	return CloneWithContext(DefaultContext, from, to, opts)
115}
116
117// CloneWithContext clones original repository to target path.
118func CloneWithContext(ctx context.Context, from, to string, opts CloneRepoOptions) error {
119	cargs := make([]string, len(GlobalCommandArgs))
120	copy(cargs, GlobalCommandArgs)
121	return CloneWithArgs(ctx, from, to, cargs, opts)
122}
123
124// CloneWithArgs original repository to target path.
125func CloneWithArgs(ctx context.Context, from, to string, args []string, opts CloneRepoOptions) (err error) {
126	toDir := path.Dir(to)
127	if err = os.MkdirAll(toDir, os.ModePerm); err != nil {
128		return err
129	}
130
131	cmd := NewCommandContextNoGlobals(ctx, args...).AddArguments("clone")
132	if opts.SkipTLSVerify {
133		cmd.AddArguments("-c", "http.sslVerify=false")
134	}
135	if opts.Mirror {
136		cmd.AddArguments("--mirror")
137	}
138	if opts.Bare {
139		cmd.AddArguments("--bare")
140	}
141	if opts.Quiet {
142		cmd.AddArguments("--quiet")
143	}
144	if opts.Shared {
145		cmd.AddArguments("-s")
146	}
147	if opts.NoCheckout {
148		cmd.AddArguments("--no-checkout")
149	}
150	if opts.Depth > 0 {
151		cmd.AddArguments("--depth", strconv.Itoa(opts.Depth))
152	}
153	if opts.Filter != "" {
154		cmd.AddArguments("--filter", opts.Filter)
155	}
156	if len(opts.Branch) > 0 {
157		cmd.AddArguments("-b", opts.Branch)
158	}
159	cmd.AddArguments("--", from, to)
160
161	if opts.Timeout <= 0 {
162		opts.Timeout = -1
163	}
164
165	var envs = os.Environ()
166	u, err := url.Parse(from)
167	if err == nil && (strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https")) {
168		if proxy.Match(u.Host) {
169			envs = append(envs, fmt.Sprintf("https_proxy=%s", proxy.GetProxyURL()))
170		}
171	}
172
173	var stderr = new(bytes.Buffer)
174	if err = cmd.RunWithContext(&RunContext{
175		Timeout: opts.Timeout,
176		Env:     envs,
177		Stdout:  io.Discard,
178		Stderr:  stderr,
179	}); err != nil {
180		return ConcatenateError(err, stderr.String())
181	}
182	return nil
183}
184
185// PullRemoteOptions options when pull from remote
186type PullRemoteOptions struct {
187	Timeout time.Duration
188	All     bool
189	Rebase  bool
190	Remote  string
191	Branch  string
192}
193
194// Pull pulls changes from remotes.
195func Pull(repoPath string, opts PullRemoteOptions) error {
196	cmd := NewCommand("pull")
197	if opts.Rebase {
198		cmd.AddArguments("--rebase")
199	}
200	if opts.All {
201		cmd.AddArguments("--all")
202	} else {
203		cmd.AddArguments("--", opts.Remote, opts.Branch)
204	}
205
206	if opts.Timeout <= 0 {
207		opts.Timeout = -1
208	}
209
210	_, err := cmd.RunInDirTimeout(opts.Timeout, repoPath)
211	return err
212}
213
214// PushOptions options when push to remote
215type PushOptions struct {
216	Remote  string
217	Branch  string
218	Force   bool
219	Mirror  bool
220	Env     []string
221	Timeout time.Duration
222}
223
224// Push pushs local commits to given remote branch.
225func Push(ctx context.Context, repoPath string, opts PushOptions) error {
226	cmd := NewCommandContext(ctx, "push")
227	if opts.Force {
228		cmd.AddArguments("-f")
229	}
230	if opts.Mirror {
231		cmd.AddArguments("--mirror")
232	}
233	cmd.AddArguments("--", opts.Remote)
234	if len(opts.Branch) > 0 {
235		cmd.AddArguments(opts.Branch)
236	}
237	var outbuf, errbuf strings.Builder
238
239	if opts.Timeout == 0 {
240		opts.Timeout = -1
241	}
242
243	err := cmd.RunInDirTimeoutEnvPipeline(opts.Env, opts.Timeout, repoPath, &outbuf, &errbuf)
244	if err != nil {
245		if strings.Contains(errbuf.String(), "non-fast-forward") {
246			return &ErrPushOutOfDate{
247				StdOut: outbuf.String(),
248				StdErr: errbuf.String(),
249				Err:    err,
250			}
251		} else if strings.Contains(errbuf.String(), "! [remote rejected]") {
252			err := &ErrPushRejected{
253				StdOut: outbuf.String(),
254				StdErr: errbuf.String(),
255				Err:    err,
256			}
257			err.GenerateMessage()
258			return err
259		} else if strings.Contains(errbuf.String(), "matches more than one") {
260			err := &ErrMoreThanOne{
261				StdOut: outbuf.String(),
262				StdErr: errbuf.String(),
263				Err:    err,
264			}
265			return err
266		}
267	}
268
269	if errbuf.Len() > 0 && err != nil {
270		return fmt.Errorf("%v - %s", err, errbuf.String())
271	}
272
273	return err
274}
275
276// CheckoutOptions options when heck out some branch
277type CheckoutOptions struct {
278	Timeout   time.Duration
279	Branch    string
280	OldBranch string
281}
282
283// Checkout checkouts a branch
284func Checkout(repoPath string, opts CheckoutOptions) error {
285	cmd := NewCommand("checkout")
286	if len(opts.OldBranch) > 0 {
287		cmd.AddArguments("-b")
288	}
289
290	if opts.Timeout <= 0 {
291		opts.Timeout = -1
292	}
293
294	cmd.AddArguments(opts.Branch)
295
296	if len(opts.OldBranch) > 0 {
297		cmd.AddArguments(opts.OldBranch)
298	}
299
300	_, err := cmd.RunInDirTimeout(opts.Timeout, repoPath)
301	return err
302}
303
304// ResetHEAD resets HEAD to given revision or head of branch.
305func ResetHEAD(repoPath string, hard bool, revision string) error {
306	cmd := NewCommand("reset")
307	if hard {
308		cmd.AddArguments("--hard")
309	}
310	_, err := cmd.AddArguments(revision).RunInDir(repoPath)
311	return err
312}
313
314// MoveFile moves a file to another file or directory.
315func MoveFile(repoPath, oldTreeName, newTreeName string) error {
316	_, err := NewCommand("mv").AddArguments(oldTreeName, newTreeName).RunInDir(repoPath)
317	return err
318}
319
320// CountObject represents repository count objects report
321type CountObject struct {
322	Count       int64
323	Size        int64
324	InPack      int64
325	Packs       int64
326	SizePack    int64
327	PrunePack   int64
328	Garbage     int64
329	SizeGarbage int64
330}
331
332const (
333	statCount        = "count: "
334	statSize         = "size: "
335	statInpack       = "in-pack: "
336	statPacks        = "packs: "
337	statSizePack     = "size-pack: "
338	statPrunePackage = "prune-package: "
339	statGarbage      = "garbage: "
340	statSizeGarbage  = "size-garbage: "
341)
342
343// CountObjects returns the results of git count-objects on the repoPath
344func CountObjects(repoPath string) (*CountObject, error) {
345	cmd := NewCommand("count-objects", "-v")
346	stdout, err := cmd.RunInDir(repoPath)
347	if err != nil {
348		return nil, err
349	}
350
351	return parseSize(stdout), nil
352}
353
354// parseSize parses the output from count-objects and return a CountObject
355func parseSize(objects string) *CountObject {
356	repoSize := new(CountObject)
357	for _, line := range strings.Split(objects, "\n") {
358		switch {
359		case strings.HasPrefix(line, statCount):
360			repoSize.Count, _ = strconv.ParseInt(line[7:], 10, 64)
361		case strings.HasPrefix(line, statSize):
362			repoSize.Size, _ = strconv.ParseInt(line[6:], 10, 64)
363			repoSize.Size *= 1024
364		case strings.HasPrefix(line, statInpack):
365			repoSize.InPack, _ = strconv.ParseInt(line[9:], 10, 64)
366		case strings.HasPrefix(line, statPacks):
367			repoSize.Packs, _ = strconv.ParseInt(line[7:], 10, 64)
368		case strings.HasPrefix(line, statSizePack):
369			repoSize.Count, _ = strconv.ParseInt(line[11:], 10, 64)
370			repoSize.Count *= 1024
371		case strings.HasPrefix(line, statPrunePackage):
372			repoSize.PrunePack, _ = strconv.ParseInt(line[16:], 10, 64)
373		case strings.HasPrefix(line, statGarbage):
374			repoSize.Garbage, _ = strconv.ParseInt(line[9:], 10, 64)
375		case strings.HasPrefix(line, statSizeGarbage):
376			repoSize.SizeGarbage, _ = strconv.ParseInt(line[14:], 10, 64)
377			repoSize.SizeGarbage *= 1024
378		}
379	}
380	return repoSize
381}
382
383// GetLatestCommitTime returns time for latest commit in repository (across all branches)
384func GetLatestCommitTime(repoPath string) (time.Time, error) {
385	cmd := NewCommand("for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)")
386	stdout, err := cmd.RunInDir(repoPath)
387	if err != nil {
388		return time.Time{}, err
389	}
390	commitTime := strings.TrimSpace(stdout)
391	return time.Parse(GitTimeLayout, commitTime)
392}
393
394// DivergeObject represents commit count diverging commits
395type DivergeObject struct {
396	Ahead  int
397	Behind int
398}
399
400func checkDivergence(repoPath, baseBranch, targetBranch string) (int, error) {
401	branches := fmt.Sprintf("%s..%s", baseBranch, targetBranch)
402	cmd := NewCommand("rev-list", "--count", branches)
403	stdout, err := cmd.RunInDir(repoPath)
404	if err != nil {
405		return -1, err
406	}
407	outInteger, errInteger := strconv.Atoi(strings.Trim(stdout, "\n"))
408	if errInteger != nil {
409		return -1, errInteger
410	}
411	return outInteger, nil
412}
413
414// GetDivergingCommits returns the number of commits a targetBranch is ahead or behind a baseBranch
415func GetDivergingCommits(repoPath, baseBranch, targetBranch string) (DivergeObject, error) {
416	// $(git rev-list --count master..feature) commits ahead of master
417	ahead, errorAhead := checkDivergence(repoPath, baseBranch, targetBranch)
418	if errorAhead != nil {
419		return DivergeObject{}, errorAhead
420	}
421
422	// $(git rev-list --count feature..master) commits behind master
423	behind, errorBehind := checkDivergence(repoPath, targetBranch, baseBranch)
424	if errorBehind != nil {
425		return DivergeObject{}, errorBehind
426	}
427
428	return DivergeObject{ahead, behind}, nil
429}
430
431// CreateBundle create bundle content to the target path
432func (repo *Repository) CreateBundle(ctx context.Context, commit string, out io.Writer) error {
433	tmp, err := os.MkdirTemp(os.TempDir(), "gitea-bundle")
434	if err != nil {
435		return err
436	}
437	defer os.RemoveAll(tmp)
438
439	env := append(os.Environ(), "GIT_OBJECT_DIRECTORY="+filepath.Join(repo.Path, "objects"))
440	_, err = NewCommandContext(ctx, "init", "--bare").RunInDirWithEnv(tmp, env)
441	if err != nil {
442		return err
443	}
444
445	_, err = NewCommandContext(ctx, "reset", "--soft", commit).RunInDirWithEnv(tmp, env)
446	if err != nil {
447		return err
448	}
449
450	_, err = NewCommandContext(ctx, "branch", "-m", "bundle").RunInDirWithEnv(tmp, env)
451	if err != nil {
452		return err
453	}
454
455	tmpFile := filepath.Join(tmp, "bundle")
456	_, err = NewCommandContext(ctx, "bundle", "create", tmpFile, "bundle", "HEAD").RunInDirWithEnv(tmp, env)
457	if err != nil {
458		return err
459	}
460
461	fi, err := os.Open(tmpFile)
462	if err != nil {
463		return err
464	}
465	defer fi.Close()
466
467	_, err = io.Copy(out, fi)
468	return err
469}
470