-
Notifications
You must be signed in to change notification settings - Fork 132
/
repo.go
384 lines (352 loc) · 10.9 KB
/
repo.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
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
package git
import (
"bytes"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/pkg/errors"
libExec "github.com/akuity/kargo/internal/exec"
)
// Repo is an interface for interacting with a git repository.
type Repo interface {
// AddAll stages pending changes for commit.
AddAll() error
// AddAllAndCommit is a convenience function that stages pending changes for
// commit to the current branch and then commits them using the provided
// commit message.
AddAllAndCommit(message string) error
// Clean cleans the working directory.
Clean() error
// Close cleans up file system resources used by this repository. This should
// always be called before a repository goes out of scope.
Close() error
// Checkout checks out the specified branch.
Checkout(branch string) error
// Commit commits staged changes to the current branch.
Commit(message string) error
// CreateChildBranch creates a new branch that is a child of the current
// branch.
CreateChildBranch(branch string) error
// CreateOrphanedBranch creates a new branch that shares no commit history
// with any other branch.
CreateOrphanedBranch(branch string) error
// HasDiffs returns a bool indicating whether the working directory currently
// contains any differences from what's already at the head of the current
// branch.
HasDiffs() (bool, error)
// LastCommitID returns the ID (sha) of the most recent commit to the current
// branch.
LastCommitID() (string, error)
// CommitMessage returns the text of the most recent commit message associated
// with the specified commit ID.
CommitMessage(id string) (string, error)
// CommitMessages returns a slice of commit messages starting with id1 and
// ending with id2. The results exclude id1, but include id2.
CommitMessages(id1, id2 string) ([]string, error)
// Push pushes from the current branch to a remote branch by the same name.
Push() error
// RemoteBranchExists returns a bool indicating if the specified branch exists
// in the remote repository.
RemoteBranchExists(branch string) (bool, error)
// ResetHard performs a hard reset.
ResetHard() error
// URL returns the remote URL of the repository.
URL() string
// HomeDir returns an absolute path to the home directory of the system user
// who has cloned this repo.
HomeDir() string
// WorkingDir returns an absolute path to the repository's working tree.
WorkingDir() string
}
// repo is an implementation of the Repo interface for interacting with a git
// repository.
type repo struct {
url string
homeDir string
dir string
currentBranch string
}
// Clone produces a local clone of the remote git repository at the specified
// URL and returns an implementation of the Repo interface that is stateful and
// NOT suitable for use across multiple goroutines. This function will also
// perform any setup that is required for successfully authenticating to the
// remote repository.
func Clone(url string, repoCreds *Credentials) (Repo, error) {
homeDir, err := os.MkdirTemp("", "")
if err != nil {
return nil, errors.Wrapf(
err,
"error creating home directory for repo %q",
url,
)
}
r := &repo{
url: url,
homeDir: homeDir,
dir: filepath.Join(homeDir, "repo"),
}
if repoCreds != nil {
if err = r.setupAuth(*repoCreds); err != nil {
return nil, err
}
}
return r, r.clone()
}
func (r *repo) AddAll() error {
_, err := libExec.Exec(r.buildCommand("add", "."))
return errors.Wrap(err, "error staging changes for commit")
}
func (r *repo) AddAllAndCommit(message string) error {
if err := r.AddAll(); err != nil {
return err
}
return r.Commit(message)
}
func (r *repo) Clean() error {
_, err := libExec.Exec(r.buildCommand("clean", "-fd"))
return errors.Wrapf(err, "error cleaning branch %q", r.currentBranch)
}
func (r *repo) clone() error {
r.currentBranch = "HEAD"
cmd := r.buildCommand("clone", "--no-tags", r.url, r.dir)
cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildCommand()
_, err := libExec.Exec(cmd)
return errors.Wrapf(
err,
"error cloning repo %q into %q",
r.url,
r.dir,
)
}
func (r *repo) Close() error {
return os.RemoveAll(r.homeDir)
}
func (r *repo) Checkout(branch string) error {
r.currentBranch = branch
_, err := libExec.Exec(r.buildCommand(
"checkout",
branch,
// The next line makes it crystal clear to git that we're checking out
// a branch. We need to do this because branch names can often resemble
// paths within the repo.
"--",
))
return errors.Wrapf(
err,
"error checking out branch %q from repo %q",
branch,
r.url,
)
}
func (r *repo) Commit(message string) error {
_, err := libExec.Exec(r.buildCommand("commit", "-m", message))
return errors.Wrapf(
err,
"error committing changes to branch %q",
r.currentBranch,
)
}
func (r *repo) CreateChildBranch(branch string) error {
r.currentBranch = branch
_, err := libExec.Exec(r.buildCommand(
"checkout",
"-b",
branch,
// The next line makes it crystal clear to git that we're checking out
// a branch. We need to do this because branch names can often resemble
// paths within the repo.
"--",
))
return errors.Wrapf(
err,
"error creating new branch %q for repo %q",
branch,
r.url,
)
}
func (r *repo) CreateOrphanedBranch(branch string) error {
r.currentBranch = branch
if _, err := libExec.Exec(r.buildCommand(
"switch",
"--orphan",
branch,
"--discard-changes",
)); err != nil {
return errors.Wrapf(
err,
"error creating orphaned branch %q for repo %q",
branch,
r.url,
)
}
return r.Clean()
}
func (r *repo) HasDiffs() (bool, error) {
resBytes, err := libExec.Exec(r.buildCommand("status", "-s"))
return len(resBytes) > 0,
errors.Wrapf(err, "error checking status of branch %q", r.currentBranch)
}
func (r *repo) LastCommitID() (string, error) {
shaBytes, err := libExec.Exec(r.buildCommand("rev-parse", "HEAD"))
return strings.TrimSpace(string(shaBytes)),
errors.Wrap(err, "error obtaining ID of last commit")
}
func (r *repo) CommitMessage(id string) (string, error) {
msgBytes, err := libExec.Exec(
r.buildCommand("log", "-n", "1", "--pretty=format:%s", id),
)
return string(msgBytes),
errors.Wrapf(err, "error obtaining commit message for commit %q", id)
}
func (r *repo) CommitMessages(id1, id2 string) ([]string, error) {
allMsgBytes, err := libExec.Exec(r.buildCommand(
"log",
"--pretty=oneline",
"--decorate-refs=",
"--decorate-refs-exclude=",
fmt.Sprintf("%s..%s", id1, id2),
))
if err != nil {
return nil, errors.Wrapf(
err,
"error obtaining commit messages between commits %q and %q",
id1,
id2,
)
}
msgsBytes := bytes.Split(allMsgBytes, []byte("\n"))
msgs := []string{}
for _, msgBytes := range msgsBytes {
msgStr := string(msgBytes)
// There's usually a trailing newline in the result. We could just discard
// the last line, but this feels more resilient against the admittedly
// remote possibility that that could change one day.
if strings.TrimSpace(msgStr) != "" {
msgs = append(msgs, string(msgBytes))
}
}
return msgs, nil
}
func (r *repo) Push() error {
_, err :=
libExec.Exec(r.buildCommand("push", "origin", r.currentBranch))
return errors.Wrapf(err, "error pushing branch %q", r.currentBranch)
}
func (r *repo) RemoteBranchExists(branch string) (bool, error) {
_, err := libExec.Exec(r.buildCommand(
"ls-remote",
"--heads",
"--exit-code", // Return 2 if not found
r.url,
branch,
))
if exitErr, ok := err.(*libExec.ExitError); ok && exitErr.ExitCode == 2 {
// Branch does not exist
return false, nil
}
return err == nil, errors.Wrapf(
err,
"error checking for existence of branch %q in remote repo %q",
branch,
r.url,
)
}
func (r *repo) ResetHard() error {
_, err :=
libExec.Exec(r.buildCommand("reset", "--hard"))
return errors.Wrap(err, "error resetting branch working tree")
}
func (r *repo) URL() string {
return r.url
}
func (r *repo) HomeDir() string {
return r.homeDir
}
func (r *repo) WorkingDir() string {
return r.dir
}
// SetupAuth configures the git CLI for authentication using either SSH or the
// "store" (username/password-based) credential helper.
func (r *repo) setupAuth(repoCreds Credentials) error {
// Configure the git client
cmd := r.buildCommand("config", "--global", "user.name", "Kargo")
cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildCommand()
if _, err := libExec.Exec(cmd); err != nil {
return errors.Wrapf(err, "error configuring git username")
}
cmd = r.buildCommand("config", "--global", "user.email", "kargo@akuity.io")
cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildCommand()
if _, err := libExec.Exec(cmd); err != nil {
return errors.Wrapf(err, "error configuring git user email address")
}
// If an SSH key was provided, use that.
if repoCreds.SSHPrivateKey != "" {
sshConfigPath := filepath.Join(r.homeDir, ".ssh", "config")
// nolint: lll
const sshConfig = "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile=/dev/null"
if err :=
os.WriteFile(sshConfigPath, []byte(sshConfig), 0600); err != nil {
return errors.Wrapf(err, "error writing SSH config to %q", sshConfigPath)
}
rsaKeyPath := filepath.Join(r.homeDir, ".ssh", "id_rsa")
if err := os.WriteFile(
rsaKeyPath,
[]byte(repoCreds.SSHPrivateKey),
0600,
); err != nil {
return errors.Wrapf(err, "error writing SSH key to %q", rsaKeyPath)
}
return nil // We're done
}
// If we get to here, we're authenticating using a password
// Set up the credential helper
cmd = r.buildCommand("config", "--global", "credential.helper", "store")
cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildCommand()
if _, err := libExec.Exec(cmd); err != nil {
return errors.Wrapf(err, "error configuring git credential helper")
}
credentialURL, err := url.Parse(r.url)
if err != nil {
return errors.Wrapf(err, "error parsing URL %q", r.url)
}
// Remove path and query string components from the URL
credentialURL.Path = ""
credentialURL.RawQuery = ""
// If the username is the empty string, we assume we're working with a git
// provider like GitHub that only requires the username to be non-empty. We
// arbitrarily set it to "git".
if repoCreds.Username == "" {
repoCreds.Username = "git"
}
// Augment the URL with user/pass information.
credentialURL.User = url.UserPassword(repoCreds.Username, repoCreds.Password)
// Write the augmented URL to the location used by the "stored" credential
// helper.
credentialsPath := filepath.Join(r.homeDir, ".git-credentials")
if err := os.WriteFile(
credentialsPath,
[]byte(credentialURL.String()),
0600,
); err != nil {
return errors.Wrapf(
err,
"error writing credentials to %q",
credentialsPath,
)
}
return nil
}
func (r *repo) buildCommand(arg ...string) *exec.Cmd {
cmd := exec.Command("git", arg...)
homeEnvVar := fmt.Sprintf("HOME=%s", r.homeDir)
if cmd.Env == nil {
cmd.Env = []string{homeEnvVar}
} else {
cmd.Env = append(cmd.Env, homeEnvVar)
}
cmd.Dir = r.dir
return cmd
}