Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/content-service/pkg/git/git.go
2500 views
1
// Copyright (c) 2020 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package git
6
7
import (
8
"bytes"
9
"context"
10
"fmt"
11
"io"
12
"os"
13
"os/exec"
14
"path/filepath"
15
"strings"
16
"time"
17
18
"github.com/opentracing/opentracing-go"
19
"golang.org/x/xerrors"
20
21
"github.com/gitpod-io/gitpod/common-go/log"
22
"github.com/gitpod-io/gitpod/common-go/tracing"
23
csapi "github.com/gitpod-io/gitpod/content-service/api"
24
)
25
26
var (
27
// errNoCommitsYet is a substring of a Git error if we have no commits yet in a working copy
28
errNoCommitsYet = "does not have any commits yet"
29
)
30
31
// IsWorkingCopy determines whether a path is a valid Git working copy/repo
32
func IsWorkingCopy(location string) bool {
33
gitFolder := filepath.Join(location, ".git")
34
if stat, err := os.Stat(gitFolder); err == nil {
35
return stat.IsDir()
36
}
37
38
return false
39
}
40
41
// AuthMethod is the means of authentication used during clone
42
type AuthMethod string
43
44
// AuthProvider provides authentication to access a Git repository
45
type AuthProvider func() (username string, password string, err error)
46
47
const (
48
// NoAuth disables authentication during clone
49
NoAuth AuthMethod = ""
50
51
// BasicAuth uses HTTP basic auth during clone (fails if repo is cloned through http)
52
BasicAuth AuthMethod = "basic-auth"
53
)
54
55
// CachingAuthProvider caches the first non-erroneous response of the delegate auth provider
56
func CachingAuthProvider(d AuthProvider) AuthProvider {
57
var (
58
cu, cpwd string
59
cached bool
60
)
61
return func() (username string, password string, err error) {
62
if cached {
63
return cu, cpwd, nil
64
}
65
66
username, password, err = d()
67
if err != nil {
68
return
69
}
70
71
cu = username
72
cpwd = password
73
cached = true
74
return
75
}
76
}
77
78
// Client is a Git configuration based on which we can execute git
79
type Client struct {
80
// AuthProvider provides authentication to access a Git repository
81
AuthProvider AuthProvider
82
// AuthMethod is the method by which we authenticate
83
AuthMethod AuthMethod
84
85
// Location is the path in the filesystem where we'll work in (the CWD of the Git executable)
86
Location string
87
88
// Config values to be set on clone provided through `.gitpod.yml`
89
Config map[string]string
90
91
// RemoteURI is the Git WS remote origin
92
RemoteURI string
93
94
// UpstreamCloneURI is the fork upstream of a repository
95
UpstreamRemoteURI string
96
97
// if true will run git command as gitpod user (should be executed as root that has access to sudo in this case)
98
RunAsGitpodUser bool
99
100
// FullClone indicates whether we should do a full checkout or a shallow clone
101
FullClone bool
102
}
103
104
// Status describes the status of a Git repo/working copy akin to "git status"
105
type Status struct {
106
porcelainStatus
107
UnpushedCommits []string
108
LatestCommit string
109
}
110
111
const (
112
// maxPendingChanges is the limit beyond which we no longer report pending changes.
113
// For example, if a workspace has then 150 untracked files, we'll report the first
114
// 100 followed by "... and 50 more".
115
//
116
// We do this to keep the load on our infrastructure light and because beyond this number
117
// the changes are irrelevant anyways.
118
maxPendingChanges = 100
119
)
120
121
// ToAPI produces an API response from the Git status
122
func (s *Status) ToAPI() *csapi.GitStatus {
123
limit := func(entries []string) []string {
124
if len(entries) > maxPendingChanges {
125
return append(entries[0:maxPendingChanges], fmt.Sprintf("... and %d more", len(entries)-maxPendingChanges))
126
}
127
128
return entries
129
}
130
return &csapi.GitStatus{
131
Branch: s.BranchHead,
132
LatestCommit: s.LatestCommit,
133
UncommitedFiles: limit(s.UncommitedFiles),
134
TotalUncommitedFiles: int64(len(s.UncommitedFiles)),
135
UntrackedFiles: limit(s.UntrackedFiles),
136
TotalUntrackedFiles: int64(len(s.UntrackedFiles)),
137
UnpushedCommits: limit(s.UnpushedCommits),
138
TotalUnpushedCommits: int64(len(s.UnpushedCommits)),
139
}
140
}
141
142
// OpFailedError is returned by GitWithOutput if the operation fails
143
// e.g. returns with a non-zero exit code.
144
type OpFailedError struct {
145
Subcommand string
146
Args []string
147
ExecErr error
148
Output string
149
}
150
151
func (e OpFailedError) Error() string {
152
return fmt.Sprintf("git %s %s failed (%v): %v", e.Subcommand, strings.Join(e.Args, " "), e.ExecErr, e.Output)
153
}
154
155
// GitWithOutput starts git and returns the stdout of the process. This function returns once git is started,
156
// not after it finishd. Once the returned reader returned io.EOF, the command is finished.
157
func (c *Client) GitWithOutput(ctx context.Context, ignoreErr *string, subcommand string, args ...string) (out []byte, err error) {
158
//nolint:staticcheck,ineffassign
159
span, ctx := opentracing.StartSpanFromContext(ctx, fmt.Sprintf("git.%s", subcommand))
160
defer func() {
161
if err != nil && ignoreErr != nil && strings.Contains(err.Error(), *ignoreErr) {
162
tracing.FinishSpan(span, nil)
163
} else {
164
tracing.FinishSpan(span, &err)
165
}
166
}()
167
168
fullArgs := make([]string, 0)
169
env := make([]string, 0)
170
if c.AuthMethod == BasicAuth {
171
if c.AuthProvider == nil {
172
return nil, xerrors.Errorf("basic-auth method requires an auth provider")
173
}
174
175
fullArgs = append(fullArgs, "-c", "credential.helper=/bin/sh -c \"echo username=$GIT_AUTH_USER; echo password=$GIT_AUTH_PASSWORD\"")
176
177
user, pwd, err := c.AuthProvider()
178
if err != nil {
179
return nil, err
180
}
181
env = append(env, fmt.Sprintf("GIT_AUTH_USER=%s", user))
182
env = append(env, fmt.Sprintf("GIT_AUTH_PASSWORD=%s", pwd))
183
}
184
185
env = append(env, "HOME=/home/gitpod")
186
187
fullArgs = append(fullArgs, subcommand)
188
fullArgs = append(fullArgs, args...)
189
190
env = append(env, fmt.Sprintf("PATH=%s", os.Getenv("PATH")))
191
if os.Getenv("http_proxy") != "" {
192
env = append(env, fmt.Sprintf("http_proxy=%s", os.Getenv("http_proxy")))
193
}
194
if os.Getenv("https_proxy") != "" {
195
env = append(env, fmt.Sprintf("https_proxy=%s", os.Getenv("https_proxy")))
196
}
197
if v := os.Getenv("GIT_SSL_CAPATH"); v != "" {
198
env = append(env, fmt.Sprintf("GIT_SSL_CAPATH=%s", v))
199
}
200
201
if v := os.Getenv("GIT_SSL_CAINFO"); v != "" {
202
env = append(env, fmt.Sprintf("GIT_SSL_CAINFO=%s", v))
203
}
204
205
span.LogKV("args", fullArgs)
206
207
cmdName := "git"
208
if c.RunAsGitpodUser {
209
cmdName = "sudo"
210
fullArgs = append([]string{"-u", "gitpod", "git"}, fullArgs...)
211
}
212
cmd := exec.Command(cmdName, fullArgs...)
213
cmd.Dir = c.Location
214
cmd.Env = env
215
216
res, err := cmd.CombinedOutput()
217
if err != nil {
218
if strings.Contains(err.Error(), "no child process") {
219
return res, nil
220
}
221
222
return nil, OpFailedError{
223
Args: args,
224
ExecErr: err,
225
Output: string(res),
226
Subcommand: subcommand,
227
}
228
}
229
230
return res, nil
231
}
232
233
// Git executes git using the client configuration
234
func (c *Client) Git(ctx context.Context, subcommand string, args ...string) (err error) {
235
_, err = c.GitWithOutput(ctx, nil, subcommand, args...)
236
if err != nil {
237
return err
238
}
239
return nil
240
}
241
242
// GitStatusFromFiles same as Status but reads git output from preexisting files that were generated by prestop hook
243
func GitStatusFromFiles(ctx context.Context, loc string) (res *Status, err error) {
244
gitout, err := os.ReadFile(filepath.Join(loc, "git_status.txt"))
245
if err != nil {
246
return nil, err
247
}
248
porcelain, err := parsePorcelain(bytes.NewReader(gitout))
249
if err != nil {
250
return nil, err
251
}
252
253
unpushedCommits := make([]string, 0)
254
gitout, err = os.ReadFile(filepath.Join(loc, "git_log_1.txt"))
255
if err != nil && !strings.Contains(err.Error(), errNoCommitsYet) {
256
return nil, err
257
}
258
if gitout != nil {
259
out, err := io.ReadAll(bytes.NewReader(gitout))
260
if err != nil {
261
return nil, xerrors.Errorf("cannot determine unpushed commits: %w", err)
262
}
263
for _, l := range strings.Split(string(out), "\n") {
264
tl := strings.TrimSpace(l)
265
if tl != "" {
266
unpushedCommits = append(unpushedCommits, tl)
267
}
268
}
269
}
270
if len(unpushedCommits) == 0 {
271
unpushedCommits = nil
272
}
273
274
latestCommit := ""
275
gitout, err = os.ReadFile(filepath.Join(loc, "git_log_2.txt"))
276
if err != nil && !strings.Contains(err.Error(), errNoCommitsYet) {
277
return nil, err
278
}
279
if len(gitout) > 0 {
280
latestCommit = strings.TrimSpace(string(gitout))
281
}
282
283
return &Status{
284
porcelainStatus: *porcelain,
285
UnpushedCommits: unpushedCommits,
286
LatestCommit: latestCommit,
287
}, nil
288
}
289
290
// StatusOption configures the behavior of git status
291
type StatusOption func(*statusOptions)
292
293
type statusOptions struct {
294
disableOptionalLocks bool
295
}
296
297
// WithDisableOptionalLocks disables optional locks during git status
298
func WithDisableOptionalLocks(disable bool) StatusOption {
299
return func(o *statusOptions) {
300
o.disableOptionalLocks = disable
301
}
302
}
303
304
// Status runs git status
305
func (c *Client) Status(ctx context.Context, opts ...StatusOption) (res *Status, err error) {
306
options := &statusOptions{}
307
for _, opt := range opts {
308
opt(options)
309
}
310
311
args := []string{"status", "--porcelain=v2", "--branch", "-uall"}
312
if options.disableOptionalLocks {
313
args = append([]string{"--no-optional-locks"}, args...)
314
}
315
gitout, err := c.GitWithOutput(ctx, nil, args[0], args[1:]...)
316
if err != nil {
317
return nil, err
318
}
319
porcelain, err := parsePorcelain(bytes.NewReader(gitout))
320
if err != nil {
321
return nil, err
322
}
323
324
unpushedCommits := make([]string, 0)
325
gitout, err = c.GitWithOutput(ctx, &errNoCommitsYet, "log", "--pretty=%h: %s", "--branches", "--not", "--remotes")
326
if err != nil && !strings.Contains(err.Error(), errNoCommitsYet) {
327
return nil, err
328
}
329
if gitout != nil {
330
out, err := io.ReadAll(bytes.NewReader(gitout))
331
if err != nil {
332
return nil, xerrors.Errorf("cannot determine unpushed commits: %w", err)
333
}
334
for _, l := range strings.Split(string(out), "\n") {
335
tl := strings.TrimSpace(l)
336
if tl != "" {
337
unpushedCommits = append(unpushedCommits, tl)
338
}
339
}
340
}
341
if len(unpushedCommits) == 0 {
342
unpushedCommits = nil
343
}
344
345
latestCommit := ""
346
gitout, err = c.GitWithOutput(ctx, &errNoCommitsYet, "log", "--pretty=%H", "-n", "1")
347
if err != nil && !strings.Contains(err.Error(), errNoCommitsYet) {
348
return nil, err
349
}
350
if len(gitout) > 0 {
351
latestCommit = strings.TrimSpace(string(gitout))
352
}
353
354
return &Status{
355
porcelainStatus: *porcelain,
356
UnpushedCommits: unpushedCommits,
357
LatestCommit: latestCommit,
358
}, nil
359
}
360
361
// Clone runs git clone
362
func (c *Client) Clone(ctx context.Context) (err error) {
363
err = os.MkdirAll(c.Location, 0775)
364
if err != nil {
365
log.WithError(err).Error("cannot create clone location")
366
}
367
368
now := time.Now()
369
370
defer func() {
371
log.WithField("duration", time.Since(now).String()).WithField("FullClone", c.FullClone).Info("clone repository took")
372
}()
373
374
args := []string{"--depth=1", "--shallow-submodules", c.RemoteURI}
375
376
if c.FullClone {
377
args = []string{c.RemoteURI}
378
}
379
380
for key, value := range c.Config {
381
args = append(args, "--config")
382
args = append(args, strings.TrimSpace(key)+"="+strings.TrimSpace(value))
383
}
384
385
// TODO: remove workaround once https://gitlab.com/gitlab-org/gitaly/-/issues/4248 is fixed
386
if strings.Contains(c.RemoteURI, "gitlab.com") {
387
args = append(args, "--config")
388
args = append(args, "http.version=HTTP/1.1")
389
}
390
391
args = append(args, ".")
392
393
return c.Git(ctx, "clone", args...)
394
}
395
396
// UpdateRemote performs a git fetch on the upstream remote URI
397
func (c *Client) UpdateRemote(ctx context.Context) (err error) {
398
//nolint:staticcheck,ineffassign
399
span, ctx := opentracing.StartSpanFromContext(ctx, "updateRemote")
400
span.SetTag("upstreamRemoteURI", c.UpstreamRemoteURI)
401
defer tracing.FinishSpan(span, &err)
402
403
// fetch upstream
404
if c.UpstreamRemoteURI != "" {
405
if err := c.Git(ctx, "remote", "add", "upstream", c.UpstreamRemoteURI); err != nil {
406
return err
407
}
408
// fetch
409
if err := c.Git(ctx, "fetch", "upstream"); err != nil {
410
return err
411
}
412
}
413
414
return nil
415
}
416
417
// UpdateSubmodules updates a repositories submodules
418
func (c *Client) UpdateSubmodules(ctx context.Context) (err error) {
419
//nolint:staticcheck,ineffassign
420
span, ctx := opentracing.StartSpanFromContext(ctx, "updateSubmodules")
421
defer tracing.FinishSpan(span, &err)
422
423
// checkout submodules
424
// git submodule update --init --recursive
425
if err := c.Git(ctx, "submodule", "update", "--init", "--recursive"); err != nil {
426
return err
427
}
428
return nil
429
}
430
431