-
Notifications
You must be signed in to change notification settings - Fork 243
/
git.go
356 lines (328 loc) · 10.5 KB
/
git.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
// Copyright 2020-2024 Buf Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package git
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strings"
"github.com/bufbuild/buf/private/pkg/app"
"github.com/bufbuild/buf/private/pkg/command"
"github.com/bufbuild/buf/private/pkg/storage"
"github.com/bufbuild/buf/private/pkg/storage/storageos"
"github.com/bufbuild/buf/private/pkg/tracing"
"go.uber.org/zap"
)
const (
gitCommand = "git"
gitOriginRemote = "origin"
tagsPrefix = "refs/tags/"
headsPrefix = "refs/heads/"
)
var (
// ErrRemoteNotFound is returned from GetRemote when the specified remote name is not
// found in the current git checkout.
ErrRemoteNotFound = errors.New("git remote not found")
// ErrInvalidGitCheckout is returned from CheckDirectoryIsValidGitCheckout when the
// specified directory is not a valid git checkout.
ErrInvalidGitCheckout = errors.New("invalid git checkout")
)
// Name is a name identifiable by git.
type Name interface {
// If cloneBranch returns a non-empty string, any clones will be performed with --branch set to the value.
cloneBranch() string
// If checkout returns a non-empty string, a checkout of the value will be performed after cloning.
checkout() string
}
// NewBranchName returns a new Name for the branch.
func NewBranchName(branch string) Name {
return newBranch(branch)
}
// NewTagName returns a new Name for the tag.
func NewTagName(tag string) Name {
return newBranch(tag)
}
// NewRefName returns a new Name for the ref.
func NewRefName(ref string) Name {
return newRef(ref)
}
// NewRefNameWithBranch returns a new Name for the ref while setting branch as the clone target.
func NewRefNameWithBranch(ref string, branch string) Name {
return newRefWithBranch(ref, branch)
}
// Cloner clones git repositories to buckets.
type Cloner interface {
// CloneToBucket clones the repository to the bucket.
//
// The url must contain the scheme, including file:// if necessary.
// depth must be > 0.
CloneToBucket(
ctx context.Context,
envContainer app.EnvContainer,
url string,
depth uint32,
writeBucket storage.WriteBucket,
options CloneToBucketOptions,
) error
}
// CloneToBucketOptions are options for Clone.
type CloneToBucketOptions struct {
Mapper storage.Mapper
Name Name
RecurseSubmodules bool
}
// NewCloner returns a new Cloner.
func NewCloner(
logger *zap.Logger,
tracer tracing.Tracer,
storageosProvider storageos.Provider,
runner command.Runner,
options ClonerOptions,
) Cloner {
return newCloner(logger, tracer, storageosProvider, runner, options)
}
// ClonerOptions are options for a new Cloner.
type ClonerOptions struct {
HTTPSUsernameEnvKey string
HTTPSPasswordEnvKey string
SSHKeyFileEnvKey string
SSHKnownHostsFilesEnvKey string
}
// Lister lists files in git repositories.
type Lister interface {
// ListFilesAndUnstagedFiles lists all files checked into git except those that
// were deleted, and also lists unstaged files.
//
// This does not list unstaged deleted files
// This does not list unignored files that were not added.
// This ignores regular files.
//
// This is used for situations like license headers where we want all the
// potential git files during development.
//
// The returned paths will be unnormalized.
//
// This is the equivalent of doing:
//
// comm -23 \
// <(git ls-files --cached --modified --others --no-empty-directory --exclude-standard | sort -u | grep -v -e IGNORE_PATH1 -e IGNORE_PATH2) \
// <(git ls-files --deleted | sort -u)
ListFilesAndUnstagedFiles(
ctx context.Context,
envContainer app.EnvStdioContainer,
options ListFilesAndUnstagedFilesOptions,
) ([]string, error)
}
// NewLister returns a new Lister.
func NewLister(runner command.Runner) Lister {
return newLister(runner)
}
// ListFilesAndUnstagedFilesOptions are options for ListFilesAndUnstagedFiles.
type ListFilesAndUnstagedFilesOptions struct {
// IgnorePathRegexps are regexes of paths to ignore.
//
// These must be unnormalized in the manner of the local OS that the Lister
// is being applied to.
IgnorePathRegexps []*regexp.Regexp
}
// Remote represents a Git remote and provides associated metadata.
type Remote interface {
// Name of the remote (e.g. "origin")
Name() string
// HEADBranch is the name of the HEAD branch of the remote.
HEADBranch() string
// Hostname is the host name parsed from the remote URL. If the remote is an unknown
// kind, then this may be an empty string.
Hostname() string
// RepositoryPath is the path to the repository based on the remote URL. If the remote
// is an unknown kind, then this may be an empty string.
RepositoryPath() string
// SourceControlURL makes the best effort to construct a user-facing source control url
// given a commit sha string based on the remote source, and available hostname and
// repository path information.
//
// If the remote hostname contains bitbucket (e.g. bitbucket.mycompany.com or bitbucket.org),
// we construct the source control URL as:
//
// https://<hostname>/<repository-path>/commits/<git-commit-sha>
//
// If the remote hostname contains github (e.g. github.mycompany.com or github.com), we
// construct the source control URL as:
// https://<hostname>/repository-path>/commit/git-commit-sha>
//
// If the remote hostname contains gitlab (e.g. gitlab.mycompany.com or gitlab.com), we
// construct the source control URL as:
// https://<hostname>/repository-path>/commit/git-commit-sha>
//
// If the remote is unknown and/or no hostname/repository path information is available,
// this will return an empty string.
//
// This does not do any validation against the gitCommitSha provided.
SourceControlURL(gitCommitSha string) string
isRemote()
}
// GetRemote gets the Git remote based on the given remote name.
// In order to query the remote information, we need to pass in the env with appropriate
// permissions.
func GetRemote(
ctx context.Context,
runner command.Runner,
envContainer app.EnvContainer,
dir string,
name string,
) (Remote, error) {
return getRemote(ctx, runner, envContainer, dir, name)
}
// CheckDirectoryIsValidGitCheckout runs a simple git rev-parse. In the case where the
// directory is not a valid git checkout (e.g. the directory is not a git repository or
// the directory does not exist), this will return a 128. We handle that and return an
// ErrInvalidGitCheckout to the user.
func CheckDirectoryIsValidGitCheckout(
ctx context.Context,
runner command.Runner,
envContainer app.EnvContainer,
dir string,
) error {
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
if err := runner.Run(
ctx,
gitCommand,
command.RunWithArgs("rev-parse"),
command.RunWithStdout(stdout),
command.RunWithStderr(stderr),
command.RunWithDir(dir),
command.RunWithEnv(app.EnvironMap(envContainer)),
); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr.ProcessState.ExitCode() == 128 {
return fmt.Errorf("dir %s: %w", dir, ErrInvalidGitCheckout)
}
}
return err
}
return nil
}
// CheckForUncommittedGitChanges checks if there are any uncommitted and/or unchecked
// changes from git based on the given directory.
func CheckForUncommittedGitChanges(
ctx context.Context,
runner command.Runner,
dir string,
) ([]string, error) {
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
var modifiedFiles []string
// Unstaged changes
if err := runner.Run(
ctx,
gitCommand,
command.RunWithArgs("diff", "--name-only"),
command.RunWithStdout(stdout),
command.RunWithStderr(stderr),
command.RunWithDir(dir),
); err != nil {
return nil, err
}
modifiedFiles = append(modifiedFiles, getAllTrimmedLinesFromBuffer(stdout)...)
stdout = bytes.NewBuffer(nil)
stderr = bytes.NewBuffer(nil)
// Staged changes
if err := runner.Run(
ctx,
gitCommand,
command.RunWithArgs("diff", "--name-only", "--cached"),
command.RunWithStdout(stdout),
command.RunWithStderr(stderr),
command.RunWithDir(dir),
); err != nil {
return nil, err
}
modifiedFiles = append(modifiedFiles, getAllTrimmedLinesFromBuffer(stdout)...)
return modifiedFiles, nil
}
// GetCurrentHEADGitCommit returns the current HEAD commit based on the given directory.
func GetCurrentHEADGitCommit(
ctx context.Context,
runner command.Runner,
dir string,
) (string, error) {
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
if err := runner.Run(
ctx,
gitCommand,
command.RunWithArgs("rev-parse", "HEAD"),
command.RunWithStdout(stdout),
command.RunWithStderr(stderr),
command.RunWithDir(dir),
); err != nil {
return "", err
}
return strings.TrimSpace(stdout.String()), nil
}
// GetRefsForGitCommitAndRemote returns all refs pointing to a given commit based on the
// given remote for the given directory. Querying the remote for refs information requires
// passing the environment for permissions.
func GetRefsForGitCommitAndRemote(
ctx context.Context,
runner command.Runner,
envContainer app.EnvContainer,
dir string,
remote string,
gitCommitSha string,
) ([]string, error) {
stdout := bytes.NewBuffer(nil)
stderr := bytes.NewBuffer(nil)
if err := runner.Run(
ctx,
gitCommand,
command.RunWithArgs("ls-remote", "--heads", "--tags", "--refs", remote),
command.RunWithStdout(stdout),
command.RunWithStderr(stderr),
command.RunWithDir(dir),
command.RunWithEnv(app.EnvironMap(envContainer)),
); err != nil {
return nil, err
}
scanner := bufio.NewScanner(stdout)
var refs []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if ref, found := strings.CutPrefix(line, gitCommitSha); found {
ref = strings.TrimSpace(ref)
if tag, isTag := strings.CutPrefix(ref, tagsPrefix); isTag {
refs = append(refs, tag)
continue
}
if branch, isBranchHead := strings.CutPrefix(ref, headsPrefix); isBranchHead {
refs = append(refs, branch)
}
}
}
return refs, nil
}
func getAllTrimmedLinesFromBuffer(buffer *bytes.Buffer) []string {
scanner := bufio.NewScanner(buffer)
var lines []string
for scanner.Scan() {
lines = append(lines, strings.TrimSpace(scanner.Text()))
}
return lines
}