-
Notifications
You must be signed in to change notification settings - Fork 370
Expand file tree
/
Copy pathcheckout.go
More file actions
1256 lines (1094 loc) · 45.9 KB
/
Copy pathcheckout.go
File metadata and controls
1256 lines (1094 loc) · 45.9 KB
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
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package job
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/buildkite/agent/v3/internal/experiments"
"github.com/buildkite/agent/v3/internal/osutil"
"github.com/buildkite/agent/v3/internal/self"
"github.com/buildkite/agent/v3/internal/shell"
"github.com/buildkite/agent/v3/tracetools"
"github.com/buildkite/roko"
"github.com/buildkite/shellwords"
)
// configureGitCredentialHelper sets up the agent to use a git credential helper that calls the Buildkite Agent API
// asking for a Github App token to use when cloning. This feature is turned on serverside
func (e *Executor) configureGitCredentialHelper(ctx context.Context) error {
// credential.useHttpPath is a git config setting that tells git to tell the credential helper the full URL of the repo
// this means that we can pass the repo being cloned up to the BK API, which can then choose (or not, if it's not permitted)
// to return a token for that repo.
//
// This is important for the case where a user clones multiple repos in a step - ie, if we always crammed
// os.Getenv("BUILDKITE_REPO") into credential helper, we'd only ever get a token for the repo that the step is running
// in, and not for any other repos that the step might clone.
err := e.shell.Command("git", "config", "--global", "credential.useHttpPath", "true").Run(ctx, shell.ShowPrompt(false))
if err != nil {
return fmt.Errorf("enabling git credential.useHttpPath: %w", err)
}
helper := fmt.Sprintf(`%s git-credentials-helper`, self.Path(ctx))
err = e.shell.Command("git", "config", "--global", "credential.helper", helper).Run(ctx, shell.ShowPrompt(false))
if err != nil {
return fmt.Errorf("configuring git credential.helper: %w", err)
}
return nil
}
// Disables SSH keyscan and configures git to use HTTPS instead of SSH for github.
// We may later expand this for other SCMs.
func (e *Executor) configureHTTPSInsteadOfSSH(ctx context.Context) error {
return e.shell.Command(
"git", "config", "--global", "url.https://github.com/.insteadOf", "git@github.com:",
).Run(ctx, shell.ShowPrompt(false))
}
func (e *Executor) removeCheckoutDir() error {
checkoutPath, _ := e.shell.Env.Get("BUILDKITE_BUILD_CHECKOUT_PATH")
if e.checkoutRoot != nil {
_ = e.checkoutRoot.Close()
e.checkoutRoot = nil
}
// on windows, sometimes removing large dirs can fail for various reasons
// for instance having files open
// see https://github.com/golang/go/issues/20841
for range 10 {
e.shell.Commentf("Removing %s", checkoutPath)
if err := os.RemoveAll(checkoutPath); err != nil {
e.shell.Errorf("Failed to remove \"%s\" (%s)", checkoutPath, err)
} else {
if _, err := os.Stat(checkoutPath); os.IsNotExist(err) {
return nil
} else {
e.shell.Errorf("Failed to remove %s", checkoutPath)
}
}
e.shell.Commentf("Waiting 10 seconds")
<-time.After(time.Second * 10)
}
return fmt.Errorf("failed to remove %s", checkoutPath)
}
// createCheckoutDir checks for the existence of a directory at
// $BUILDKITE_BUILD_CHECKOUT_PATH, and creates it if it does not exist.
// It opens the checkout directory as an [os.Root], saved to e.checkoutRoot.
// It then changes e.shell's working directory to the checkout directory.
func (e *Executor) createCheckoutDir() error {
checkoutPath, _ := e.shell.Env.Get("BUILDKITE_BUILD_CHECKOUT_PATH")
if !osutil.FileExists(checkoutPath) {
e.shell.Commentf("Creating %q", checkoutPath)
// Actual file permissions will be reduced by umask, and won't be 0o777 unless the user has manually changed the umask to 000
if err := os.MkdirAll(checkoutPath, 0o777); err != nil {
return err
}
}
if err := e.refreshCheckoutRoot(); err != nil {
return err
}
if e.shell.Getwd() != checkoutPath {
if err := e.shell.Chdir(checkoutPath); err != nil {
return err
}
}
return nil
}
// refreshCheckoutRoot refreshes e.checkoutRoot
func (e *Executor) refreshCheckoutRoot() error {
checkoutPath, _ := e.shell.Env.Get("BUILDKITE_BUILD_CHECKOUT_PATH")
if e.checkoutRoot != nil {
if err := e.checkoutRoot.Close(); err != nil {
// While it's unlikely, it's not a blocking error
e.shell.Warningf("unable to close existing checkoutRoot during refreshCheckoutRoot: %w", err)
}
}
root, err := os.OpenRoot(checkoutPath)
if err != nil {
return fmt.Errorf("opening checkout path as root: %w", err)
}
// This cleanup is largely ornamental, since the executor pointer only
// becomes unreachable when the bootstrap exits.
runtime.AddCleanup(e, func(r *os.Root) { _ = r.Close() }, root)
e.checkoutRoot = root
return nil
}
// CheckoutPhase creates the build directory and makes sure we're running the
// build at the right commit.
func (e *Executor) CheckoutPhase(ctx context.Context) error {
span, ctx := tracetools.StartSpanFromContext(ctx, "checkout", e.TracingBackend)
var err error
defer func() { span.FinishWithError(err) }()
if err = e.executeGlobalHook(ctx, "pre-checkout"); err != nil {
return err
}
if err = e.executePluginHook(ctx, "pre-checkout", e.pluginCheckouts); err != nil {
return err
}
// Remove the checkout directory if BUILDKITE_CLEAN_CHECKOUT is present
if e.CleanCheckout {
e.shell.Headerf("Cleaning pipeline checkout")
if err = e.removeCheckoutDir(); err != nil {
return err
}
}
e.shell.Headerf("Preparing working directory")
// If we have a blank repository then use a temp dir for builds
if e.Repository == "" {
var buildDir string
buildDir, err = os.MkdirTemp("", "buildkite-job-"+e.JobID)
if err != nil {
return err
}
e.shell.Env.Set("BUILDKITE_BUILD_CHECKOUT_PATH", buildDir)
// Track the directory so we can remove it at the end of the job
e.cleanupDirs = append(e.cleanupDirs, buildDir)
}
// Make sure the build directory exists
if err := e.createCheckoutDir(); err != nil {
return err
}
if err := e.checkout(ctx); err != nil {
return err
}
err = e.sendCommitToBuildkite(ctx)
if err != nil {
e.shell.OptionalWarningf("git-commit-resolution-failed", "Couldn't send commit information to Buildkite: %v", err)
}
// Store the current value of BUILDKITE_BUILD_CHECKOUT_PATH, so we can detect if
// one of the post-checkout hooks changed it.
previousCheckoutPath, exists := e.shell.Env.Get("BUILDKITE_BUILD_CHECKOUT_PATH")
if !exists {
e.shell.Printf("Could not determine previous checkout path from BUILDKITE_BUILD_CHECKOUT_PATH")
}
// Run post-checkout hooks
if err := e.executeGlobalHook(ctx, "post-checkout"); err != nil {
return err
}
if err := e.executeLocalHook(ctx, "post-checkout"); err != nil {
return err
}
if err := e.executePluginHook(ctx, "post-checkout", e.pluginCheckouts); err != nil {
return err
}
// Capture the new checkout path so we can see if it's changed.
newCheckoutPath, _ := e.shell.Env.Get("BUILDKITE_BUILD_CHECKOUT_PATH")
// If the working directory has been changed by a hook, log and switch to it
if previousCheckoutPath != "" && previousCheckoutPath != newCheckoutPath {
e.shell.Headerf("A post-checkout hook has changed the working directory to \"%s\"", newCheckoutPath)
if err := e.shell.Chdir(newCheckoutPath); err != nil {
return err
}
}
return nil
}
// checkout runs checkout hook or default checkout logic
func (e *Executor) checkout(ctx context.Context) error {
if e.SkipCheckout {
e.shell.Commentf("Skipping checkout, BUILDKITE_SKIP_CHECKOUT is set")
return nil
}
// There can only be one checkout hook, either plugin or global, in that order
switch {
case e.hasPluginHook("checkout"):
if err := e.executePluginHook(ctx, "checkout", e.pluginCheckouts); err != nil {
return err
}
case e.hasGlobalHook("checkout"):
if err := e.executeGlobalHook(ctx, "checkout"); err != nil {
return err
}
default:
if e.Repository == "" {
e.shell.Commentf("Skipping checkout, BUILDKITE_REPO is empty")
break
}
maxAttempts := e.CheckoutAttempts
if maxAttempts <= 0 {
maxAttempts = 6
}
if err := roko.NewRetrier(
roko.WithMaxAttempts(maxAttempts),
roko.WithStrategy(roko.Exponential(2*time.Second, 0)),
roko.WithJitter(),
).DoWithContext(ctx, func(r *roko.Retrier) error {
err := e.defaultCheckoutPhase(ctx)
if err == nil {
return nil
}
var errLockTimeout ErrTimedOutAcquiringLock
var errGit *gitError
switch {
case shell.IsExitError(err) && shell.ExitCode(err) == -1:
e.shell.Warningf("Checkout was interrupted by a signal")
r.Break()
case errors.As(err, &errLockTimeout):
e.shell.Warningf("Checkout could not acquire the %s lock before timing out", errLockTimeout.Name)
r.Break()
// 94 chosen by fair die roll
return &shell.ExitError{Code: 94, Err: err}
case errors.Is(err, context.Canceled):
e.shell.Warningf("Checkout was cancelled")
r.Break()
case errors.Is(ctx.Err(), context.Canceled):
e.shell.Warningf("Checkout was cancelled due to context cancellation")
r.Break()
case errors.As(err, &errGit):
if errGit.WasRetried {
// This error has already been retried, so don't retry it again
// Also don't print the retrier information, as it will be confusing -- it'll say "Attempt 1/3" but
// we won't actually be retrying it
e.shell.Warningf("Checkout failed! %s", err)
r.Break()
} else {
e.shell.Warningf("Checkout failed! %s (%s)", err, r)
}
switch errGit.Type {
case gitErrorClean, gitErrorCleanSubmodules, gitErrorClone,
gitErrorCheckoutRetryClean, gitErrorFetchRetryClean,
gitErrorFetchBadObject:
// Checkout can fail because of corrupted files in the checkout which can leave the agent in a state where it
// keeps failing. This removes the checkout dir, which means the next checkout will be a lot slower (clone vs
// fetch), but hopefully will allow the agent to self-heal
if err := e.removeCheckoutDir(); err != nil {
e.shell.Warningf("Failed to remove checkout dir while cleaning up after a checkout error: %v", err)
}
// Now make sure the build directory exists again before we try to checkout again, or proceed and run hooks
// which presume the checkout dir exists
if err := e.createCheckoutDir(); err != nil {
return err
}
default:
// Otherwise, don't clean the checkout dir
return err
}
default:
e.shell.Warningf("Checkout failed! %s (%s)", err, r)
// If it's some kind of error that we don't know about, clean the checkout dir just to be safe
if err := e.removeCheckoutDir(); err != nil {
e.shell.Warningf("Failed to remove checkout dir while cleaning up after a checkout error: %v", err)
}
// Now make sure the build directory exists again before we try to checkout again, or proceed and run hooks
// which presume the checkout dir exists
if err := e.createCheckoutDir(); err != nil {
return err
}
}
return err
}); err != nil {
return err
}
}
// After everything, we need to refresh checkout root.
// This is because checkout hook might re-create the checkout root folder entirely, deprecating e.checkoutRoot.
if err := e.refreshCheckoutRoot(); err != nil {
return err
}
return nil
}
func hasGitSubmodules(sh *shell.Shell) bool {
return osutil.FileExists(filepath.Join(sh.Getwd(), ".gitmodules"))
}
func hasGitCommit(ctx context.Context, sh *shell.Shell, gitDir, commit string) bool {
// Resolve commit to an actual commit object
output, err := sh.Command("git", "--git-dir", gitDir, "rev-parse", commit+"^{commit}").RunAndCaptureStdout(ctx, shell.ShowStderr(false))
if err != nil {
return false
}
// Filter out commitish things like HEAD et al
if strings.TrimSpace(output) != commit {
return false
}
// Otherwise it's a commit in the repo
return true
}
// updateGitMirror clones a new git mirror (git clone --mirror ...), or updates
// an existing git mirror to ensure relevant refs are available. It returns a
// directory path that a checkout can use for the --reference flag. If clean
// checkouts are enabled, dir will be a path to a snapshot of the mirror,
// otherwise it will be the mirror.
//
// For efficiency reasons, updating an existing mirror is done by fetching
// specific refspecs rather than using `git remote update` to fetch everything
// (see https://github.com/buildkite/agent/pull/1112).
func (e *Executor) updateGitMirror(ctx context.Context, repository string) (dir string, finalErr error) {
// Create a unique directory for the repository mirror
mirrorDir := filepath.Join(e.GitMirrorsPath, dirForRepository(repository))
isMainRepository := repository == e.Repository
// Create the mirrors path if it doesn't exist
if baseDir := filepath.Dir(mirrorDir); !osutil.FileExists(baseDir) {
e.shell.Commentf("Creating \"%s\"", baseDir)
// Actual file permissions will be reduced by umask, and won't be 0o777 unless the user has manually changed the umask to 000
if err := os.MkdirAll(baseDir, 0o777); err != nil {
return "", err
}
}
if err := e.shell.Chdir(e.GitMirrorsPath); err != nil {
return "", fmt.Errorf("failed to change directory to %q: %w", e.GitMirrorsPath, err)
}
lockTimeout := time.Second * time.Duration(e.GitMirrorsLockTimeout)
if e.Debug {
e.shell.Commentf("Acquiring mirror repository clone lock")
}
// Lock the mirror dir to prevent concurrent clones
cloneCtx, canc := context.WithTimeout(ctx, lockTimeout)
defer canc()
mirrorCloneLock, err := e.shell.LockFile(cloneCtx, mirrorDir+".clonelock")
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", ErrTimedOutAcquiringLock{Name: "clone", Err: err}
}
return "", fmt.Errorf("unable to acquire clone lock: %w", err)
}
defer mirrorCloneLock.Unlock() //nolint:errcheck // Best-effort cleanup - primary unlock checked below.
// If we don't have a mirror, we need to clone it
if !osutil.FileExists(mirrorDir) {
e.shell.Commentf("Cloning a mirror of the repository to %q", mirrorDir)
flags := []string{"--mirror"} // note --mirror implies --bare
mirrorFlags, err := shellwords.Split(e.GitCloneMirrorFlags)
if err != nil {
e.shell.Errorf("Invalid --git-clone-mirror-flags %q (%s)", e.GitCloneMirrorFlags, err)
return "", err
}
flags = append(flags, mirrorFlags...)
if err := gitClone(ctx, e.shell, flags, repository, mirrorDir); err != nil {
e.shell.Commentf("Removing mirror dir %q due to failed clone", mirrorDir)
if err := os.RemoveAll(mirrorDir); err != nil {
e.shell.Errorf("Failed to remove %q (%s)", mirrorDir, err)
}
return "", err
}
return e.snapshotMirror(ctx, repository, mirrorDir)
}
// If it exists, immediately release the clone lock.
if err := mirrorCloneLock.Unlock(); err != nil {
return "", fmt.Errorf("unable to release clone lock: %w", err)
}
if e.Debug {
e.shell.Commentf("Acquiring mirror repository update lock")
}
// Lock the mirror dir to prevent concurrent updates
updateCtx, canc := context.WithTimeout(ctx, lockTimeout)
defer canc()
mirrorUpdateLock, err := e.shell.LockFile(updateCtx, mirrorDir+".updatelock")
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", ErrTimedOutAcquiringLock{Name: "update", Err: err}
}
return "", fmt.Errorf("unable to acquire update lock: %w", err)
}
defer func() {
if err := mirrorUpdateLock.Unlock(); err != nil {
finalErr = errors.Join(finalErr, fmt.Errorf("unable to release update lock: %w", err))
}
}()
if isMainRepository {
// Check again after we get a lock, in case the other process has already updated
if hasGitCommit(ctx, e.shell, mirrorDir, e.Commit) {
e.shell.Commentf("Commit %q exists in mirror", e.Commit)
return e.snapshotMirror(ctx, repository, mirrorDir)
}
}
e.shell.Commentf("Updating existing repository mirror to find commit %s", e.Commit)
// Update the origin of the repository so we can gracefully handle
// repository renames.
urlChanged, err := e.updateRemoteURL(ctx, mirrorDir, repository)
if err != nil {
return "", fmt.Errorf("setting remote URL: %w", err)
}
if isMainRepository {
var refspecs []string
var retry bool
switch {
case e.RefSpec != "":
// If a custom refspec is provided, use it instead of the branch
e.shell.Commentf("Fetching and mirroring custom refspec %s", e.RefSpec)
refspecs = []string{e.RefSpec}
case e.PullRequest != "false" && strings.Contains(e.PipelineProvider, "github"):
e.shell.Commentf("Fetching and mirroring pull request head from GitHub. This will be retried if it fails, as the pull request head might not be available yet — GitHub creates them asynchronously")
var refspec string
if e.PullRequestUsingMergeRefspec {
refspec = fmt.Sprintf("refs/pull/%s/merge", e.PullRequest)
} else {
refspec = fmt.Sprintf("refs/pull/%s/head", e.PullRequest)
}
refspecs = []string{refspec}
retry = true
default:
// Fetch the build branch from the upstream repository into the mirror.
refspecs = []string{e.Branch}
}
// Fetch the refspecs from the upstream repository into the mirror.
if err := gitFetch(ctx, gitFetchArgs{
Shell: e.shell,
GitFlags: fmt.Sprintf("--git-dir=%s", mirrorDir),
Repository: "origin",
RefSpecs: refspecs,
Retry: retry,
}); err != nil {
return "", err
}
} else { // not the main repo.
// This is a mirror of a submodule.
// Update without specifying particular ref, since we don't know which
// ref is needed for the main build.
// (If it doesn't contain the needed ref, then the build would fail on
// a clean host or with a clean checkout.)
// TODO: Investigate getting the ref from the main repo and passing
// that in here.
cmd := e.shell.Command("git", "--git-dir", mirrorDir, "fetch", "origin")
if err := cmd.Run(ctx); err != nil {
return "", err
}
}
if urlChanged {
// Let's opportunistically fsck and gc.
// 1. In case of remote URL confusion (bug introduced in #1959), and
// 2. There's possibly some object churn when remotes are renamed.
if err := e.shell.Command("git", "--git-dir", mirrorDir, "fsck").Run(ctx); err != nil {
e.shell.Warningf("Couldn't run git fsck: %v", err)
}
if err := e.shell.Command("git", "--git-dir", mirrorDir, "gc").Run(ctx); err != nil {
e.shell.Warningf("Couldn't run git gc: %v", err)
}
}
return e.snapshotMirror(ctx, repository, mirrorDir)
}
// snapshotMirror creates a snapshot of the mirror. It returns the directory for
// the rest of the checkout to use as --reference, which will be the path to a
// snapshot, unless clean checkout is disabled, in which case it will simply
// return mirrorDir.
//
// This "snapshot" is a clone of the *mirror* in a nearby directory, but on a
// filesystem that supports hardlinks (most modern filesystems), the git objects
// in this clone will be hardlinks into the mirror - quick to create and taking
// up negligible extra space. Doing this ensures that any object changes in the
// mirror (due to, say, git gc) won't corrupt the downstream reference clone
// (the checkout). When the mirror updates, it may write new object files and
// unlink old object files, but the snapshot continues to have access to the old
// files via its hardlinks. (At that point, the snapshot takes up more space.)
//
// Like the (clean) checkout, the snapshot only needs to exist as long as the
// current job, but we specifically remove the snapshot at the end of the job,
// since the filesystem containing mirrors (typically) persists across jobs -
// leaving them will let them accumulate and drift from the mirror, taking up
// space. (In contrast, the checkout may cease to exist if the agent is
// ephemeral, so cleaning up the checkout at the end of the job might or might
// not be wasted effort.) Ephemeral agents can flag that they don't keep
// their checkouts by just enabling clean checkout.
//
// Why no snapshots if clean checkout is disabled:
// Disabling clean checkout is how checkouts are reused (for efficiency), so
// the checkout can exist with its dependency on the snapshot forever, so
// we can't delete the snapshot (otherwise we will corrupt the checkout), so
// the mirror volume will gradually fill up with snapshots.
// And while the mirror is updated with new objects in each job, the snapshots
// are not updated, so the non-clean checkout will probably redundantly fetch
// its update from the remote, so we would need extra logic to disable the
// mirror update unless the checkout turns out to be a fresh clone.
// It's not impossible (perhaps we need a process to age-out existing checkouts)
// but something to think about, and when we have a good implementation enable
// it for more cases.
//
// Why no snapshots if the command phase isn't included:
// The cleanup mechanism happens when this instance of the executor tears down,
// not when the last executor among many tears down. In a split-phase setup
// (such as in agent-stack-k8s) where one container runs the checkout phase and
// another runs the command phase, the snapshot would be deleted after the
// checkout phase, which could break many git operations in the command phase.
// Presently we have no way to pass cleanup instructions between containers,
// which would enable this case.
func (e *Executor) snapshotMirror(ctx context.Context, repository, mirrorDir string) (string, error) {
if !e.CleanCheckout || !e.includePhase("command") {
return mirrorDir, nil
}
snapshotBaseDir := filepath.Join(e.GitMirrorsPath, "snapshots")
// Create the snapshots base dir if it doesn't exist
if !osutil.FileExists(snapshotBaseDir) {
e.shell.Commentf("Creating %q", snapshotBaseDir)
// See comment above about umask
if err := os.MkdirAll(snapshotBaseDir, 0o777); err != nil {
return "", fmt.Errorf("creating base directory for snapshots: %w", err)
}
}
// Create a unique directory for this snapshot.
// MkdirTemp ensures the new dir won't collide with other agents.
snapshotDir, err := os.MkdirTemp(snapshotBaseDir, dirForRepository(repository))
if err != nil {
return "", fmt.Errorf("creating snapshot directory: %w", err)
}
if err := os.Chmod(snapshotDir, 0o777&^osutil.Umask); err != nil {
return "", fmt.Errorf("changing permissions on snapshot directory: %w", err)
}
// Automatically remove it during teardown
e.cleanupDirs = append(e.cleanupDirs, snapshotDir)
// Finally, clone the snapshot. Yes, it's a --mirror of a --mirror.
e.shell.Commentf("Creating mirror snapshot in %q", snapshotDir)
if err := gitClone(ctx, e.shell, []string{"--mirror"}, mirrorDir, snapshotDir); err != nil {
return "", err
}
return snapshotDir, nil
}
type ErrTimedOutAcquiringLock struct {
Name string
Err error
}
func (e ErrTimedOutAcquiringLock) Error() string {
return fmt.Sprintf("timed out acquiring %s lock: %v", e.Name, e.Err)
}
func (e ErrTimedOutAcquiringLock) Unwrap() error { return e.Err }
// updateRemoteURL updates the URL for 'origin' and reports whether the
// URL changed from something else. If gitDir == "", it assumes the
// local repo is in the current directory, otherwise it includes --git-dir.
// If the remote has changed, it logs some extra information.
func (e *Executor) updateRemoteURL(ctx context.Context, gitDir, repository string) (bool, error) {
// Update the origin of the repository so we can gracefully handle
// repository renames.
// First check what the existing remote is, for both logging and debugging
// purposes.
// Check if there are multiple URLs configured (e.g., via git remote set-url --add).
args := []string{"config", "--get-all", "remote.origin.url"}
if gitDir != "" {
args = append([]string{"--git-dir", gitDir}, args...)
}
allURLs, err := e.shell.Command("git", args...).RunAndCaptureStdout(ctx)
if err != nil {
return false, err
}
var gotURL string
urls := strings.Split(strings.TrimSpace(allURLs), "\n")
if len(urls) > 1 {
// Multiple URLs configured - fall back to git remote get-url which
// handles this correctly (returns primary fetch URL).
args = []string{"remote", "get-url", "origin"}
if gitDir != "" {
args = append([]string{"--git-dir", gitDir}, args...)
}
gotURL, err = e.shell.Command("git", args...).RunAndCaptureStdout(ctx)
if err != nil {
return false, err
}
} else {
// Single URL - use config output directly to avoid insteadOf transformation.
gotURL = urls[0]
}
if gotURL == repository {
// No need to update anything
return false, nil
}
gd := gitDir
if gd == "" {
gd = e.shell.Getwd()
}
e.shell.Commentf("Remote URL for git directory %s has changed (%s -> %s)!", gd, gotURL, repository)
e.shell.Commentf("This is usually because the repository has been renamed.")
e.shell.Commentf("If this is unexpected, you may see failures.")
args = []string{"remote", "set-url", "origin", repository}
if gitDir != "" {
args = append([]string{"--git-dir", gitDir}, args...)
}
return true, e.shell.Command("git", args...).Run(ctx)
}
func (e *Executor) getOrUpdateMirrorDir(ctx context.Context, repository string) (string, error) {
var mirrorDir string
// Skip updating the Git mirror before using it?
if e.GitMirrorsSkipUpdate {
mirrorDir = filepath.Join(e.GitMirrorsPath, dirForRepository(repository))
e.shell.Commentf("Skipping update and using existing mirror for repository %s at %s.", repository, mirrorDir)
// Check if specified mirrorDir exists, otherwise the clone will fail.
if !osutil.FileExists(mirrorDir) {
// Fall back to a clean clone, rather than failing the clone and therefore the build
e.shell.Commentf("No existing mirror found for repository %s at %s.", repository, mirrorDir)
mirrorDir = ""
}
// If git mirror updates are skipped, we assume there's no change
// to the mirror objects, so no need for snapshotting.
return mirrorDir, nil
}
return e.updateGitMirror(ctx, repository)
}
// fetchSource fetches the git source for the job. If GitSkipFetchExistingCommits is
// enabled and the commit already exists locally, the fetch is skipped entirely.
func (e *Executor) fetchSource(ctx context.Context) error {
// If configured, skip the fetch when the commit already exists locally.
// This is useful when a pre-populated git mirror is used with --reference,
// as the commit objects are already reachable and fetching is redundant.
if e.GitSkipFetchExistingCommits && e.Commit != "HEAD" &&
hasGitCommit(ctx, e.shell, ".git", e.Commit) {
e.shell.Commentf("Commit %q already exists locally, skipping fetch", e.Commit)
return nil
}
gitFetchFlags := e.GitFetchFlags
switch {
case e.RefSpec != "":
// If a refspec is provided then use it instead.
// For example, `refs/not/a/head`
e.shell.Commentf("Fetch and checkout custom refspec")
if err := gitFetch(ctx, gitFetchArgs{
Shell: e.shell,
GitFetchFlags: gitFetchFlags,
Repository: "origin",
RefSpecs: []string{e.RefSpec},
}); err != nil {
return fmt.Errorf("fetching refspec %q: %w", e.RefSpec, err)
}
case e.PullRequest != "false" && strings.Contains(e.PipelineProvider, "github"):
var refspec string
var retry bool
if e.PullRequestUsingMergeRefspec {
// Merge refspecs represents a speculative merge of the PR branch against the base branch.
// Checking out this refspec enables testing the result of the merge before it happens.
// If a merge conflict exists, this refspec won't be created and the fetch will fail. In this
// case we want the job to fail earlier, rather than retrying the fetch (which adds ~2-3 mins job run time before failing)
// Note: An outer retry loop will still retry the failed checkout 3 times before failing.
e.shell.Commentf("Fetch and checkout pull request merge commit from GitHub")
retry = false
refspec = fmt.Sprintf("refs/pull/%s/merge", e.PullRequest)
} else {
// GitHub has a special ref which lets us fetch a pull request head, whether
// or not it's a current head in this repository or a fork. See:
// https://help.github.com/articles/checking-out-pull-requests-locally/#modifying-an-inactive-pull-request-locally
e.shell.Commentf("Fetch and checkout pull request head from GitHub")
retry = true
refspec = fmt.Sprintf("refs/pull/%s/head", e.PullRequest)
}
refspecs := []string{refspec}
if e.Commit == "HEAD" {
// If we don't know the commit, we don't want to fetch with a fallback (otherwise FETCH_HEAD
// will resolve during a fallback to the alphabetically earliest branch/tag - rather than the
// correct commit for this build)
if err := gitFetch(ctx, gitFetchArgs{
Shell: e.shell,
GitFetchFlags: gitFetchFlags,
Repository: "origin",
Retry: retry,
RefSpecs: refspecs,
}); err != nil {
return fmt.Errorf("fetching PR refspec %q: %w", refspecs, err)
}
} else {
// If we know the commit, also fetch it directly. The commit might not be in the history of `refspec` if there
// have been force pushes to the pull request, so this ensures we have it.
// Note: this is the typical case e.Commit != HEAD.
refspecs = append(refspecs, e.Commit)
// We aim to eliminate network round-trip as much as possible so we use a single git fetch here.
if err := gitFetchWithFallback(ctx, e.shell, gitFetchFlags, refspecs...); err != nil {
return fmt.Errorf("fetching PR refspec %q: %w", refspecs, err)
}
}
gitFetchHead, _ := e.shell.Command("git", "rev-parse", "FETCH_HEAD").RunAndCaptureStdout(ctx)
e.shell.Commentf("FETCH_HEAD is now `%s`", gitFetchHead)
case e.Commit == "HEAD":
// If the commit is "HEAD" then we can't do a commit-specific fetch and will
// need to fetch the remote head and checkout the fetched head explicitly.
e.shell.Commentf("Fetch and checkout remote branch HEAD commit")
if err := gitFetch(ctx, gitFetchArgs{
Shell: e.shell,
GitFetchFlags: gitFetchFlags,
Repository: "origin",
RefSpecs: []string{e.Branch},
}); err != nil {
return fmt.Errorf("fetching branch %q: %w", e.Branch, err)
}
default:
// Otherwise fetch and checkout the commit directly.
if err := gitFetchWithFallback(ctx, e.shell, gitFetchFlags, e.Commit); err != nil {
return err
}
}
return nil
}
// defaultCheckoutPhase is called by the CheckoutPhase if no global or plugin checkout
// hook exists. It performs the default checkout on the Repository provided in the config
func (e *Executor) defaultCheckoutPhase(ctx context.Context) error {
span, _ := tracetools.StartSpanFromContext(ctx, "repo-checkout", e.TracingBackend)
span.AddAttributes(map[string]string{
"checkout.repo_name": e.Repository,
"checkout.refspec": e.RefSpec,
"checkout.commit": e.Commit,
})
var err error
defer func() { span.FinishWithError(err) }()
if e.SSHKeyscan {
addRepositoryHostToSSHKnownHosts(ctx, e.shell, e.Repository)
}
var mirrorDir string
// If we can, get a mirror of the git repository to use for reference later
if e.GitMirrorsPath != "" && e.Repository != "" {
span.AddAttributes(map[string]string{"checkout.is_using_git_mirrors": "true"})
mirrorDir, err = e.getOrUpdateMirrorDir(ctx, e.Repository)
if err != nil {
return fmt.Errorf("getting/updating git mirror: %w", err)
}
e.shell.Env.Set("BUILDKITE_REPO_MIRROR", mirrorDir)
}
// Make sure the build directory exists and that we change directory into it
if err := e.createCheckoutDir(); err != nil {
return fmt.Errorf("creating checkout dir: %w", err)
}
// On mirrors and dissociation:
//
// --reference makes the clone reuse objects from the mirror, using the
// .git/objects/info/alternates file. On its own, it won't copy the objects
// from the mirror, just refer to them. This becomes a problem if they
// disappear, which happens during routine normal use of the mirror.
//
// --dissociate makes copies of the objects from the mirror, which makes the
// clone robust against that failure, at the expense of disk space and extra
// work up front.
//
// --dissociate is safer, so it's what we want, but it can be disabled. It
// is important even when CleanCheckout is enabled, because auto-maintenance
// can happen on the mirror at any time!
// Does the git directory exist?
existingGitDir := filepath.Join(e.shell.Getwd(), ".git")
if osutil.FileExists(existingGitDir) {
// Ensure the origin matches the configured repo, so we can
// gracefully handle repository renames.
if _, err := e.updateRemoteURL(ctx, "", e.Repository); err != nil {
return fmt.Errorf("setting origin: %w", err)
}
if mirrorDir != "" {
switch e.GitMirrorCheckoutMode {
case "dissociate":
// If the existing repo is still relying on the reference, then
// "dissociate" it (git repack, and delete the alternates file).
if err := e.dissociateIfNeeded(ctx, existingGitDir); err != nil {
return fmt.Errorf("dissociating existing reference clone: %w", err)
}
case "reference":
// If the existing repo does not have a reference to the mirror,
// create one. Existing objects don't need cleaning up.
if err := e.reassociateIfNeeded(ctx, existingGitDir, mirrorDir); err != nil {
return fmt.Errorf("reassociating existing clone: %w", err)
}
}
}
} else { // the .git directory does not already exist
// Compute the clone flags. For mirrors we need --reference, and usually
// --dissociate.
gitCloneFlags, err := shellwords.Split(e.GitCloneFlags)
if err != nil {
return fmt.Errorf("splitting --git-clone-flags %q: %w", e.GitCloneFlags, err)
}
if mirrorDir != "" {
gitCloneFlags = append(gitCloneFlags, "--reference", mirrorDir)
if e.GitMirrorCheckoutMode == "dissociate" {
gitCloneFlags = append(gitCloneFlags, "--dissociate")
}
}
// Do the clone.
if err := gitClone(ctx, e.shell, gitCloneFlags, e.Repository, "."); err != nil {
return fmt.Errorf("cloning git repository: %w", err)
}
}
// Fail fast before any git work if git-lfs is required but missing.
if e.GitLFSEnabled {
if _, err := exec.LookPath("git-lfs"); err != nil {
return fmt.Errorf("BUILDKITE_GIT_LFS_ENABLED=true but git-lfs binary is not found on PATH: %w", err)
}
}
// Git clean prior to checkout, we do this even if submodules have been
// disabled to ensure previous submodules are cleaned up
if hasGitSubmodules(e.shell) {
if err := gitCleanSubmodules(ctx, e.shell, e.GitCleanFlags); err != nil {
return fmt.Errorf("cleaning git submodules: %w", err)
}
}
if err := gitClean(ctx, e.shell, e.GitCleanFlags); err != nil {
return fmt.Errorf("cleaning git repository: %w", err)
}
// Install LFS filter before fetch so the filter is registered before any
// network operation, following the conventional git-lfs setup order.
if e.GitLFSEnabled {
e.shell.Commentf("Installing Git LFS filter")
if err := e.shell.Command("git", "lfs", "install", "--local").Run(ctx); err != nil {
return fmt.Errorf("installing git lfs filter: %w", err)
}
// Force-set GIT_LFS_SKIP_SMUDGE=1 so checkout writes pointer files to
// disk rather than downloading objects inline. Intentionally not
// restored — git lfs checkout materialises files from cache without
// triggering the smudge filter.
e.shell.Env.Set("GIT_LFS_SKIP_SMUDGE", "1")
}
if err := e.fetchSource(ctx); err != nil {
return err
}
gitCheckoutFlags := e.GitCheckoutFlags
if e.Commit == "HEAD" {
if err := gitCheckout(ctx, e.shell, gitCheckoutFlags, "FETCH_HEAD"); err != nil {
return fmt.Errorf("checking out FETCH_HEAD: %w", err)
}
} else {
if err := gitCheckout(ctx, e.shell, gitCheckoutFlags, e.Commit); err != nil {
return fmt.Errorf("checking out commit %q: %w", e.Commit, err)
}
}
gitSubmodules := false
if hasGitSubmodules(e.shell) {
if e.GitSubmodules {
e.shell.Commentf("Git submodules detected")
gitSubmodules = true
} else {
e.shell.OptionalWarningf("submodules-disabled", "This repository has submodules, but submodules are disabled")
}
}
if gitSubmodules {
// `submodule sync` will ensure the .git/config
// matches the .gitmodules file. The command
// is only available in git version 1.8.1, so
// if the call fails, continue the job
// script, and show an informative error.
if err := e.shell.Command("git", "submodule", "sync", "--recursive").Run(ctx); err != nil {
gitVersionOutput, _ := e.shell.Command("git", "--version").RunAndCaptureStdout(ctx)
e.shell.Warningf("Failed to recursively sync git submodules. This is most likely because you have an older version of git installed (" + gitVersionOutput + ") and you need version 1.8.1 and above. If you're using submodules, it's highly recommended you upgrade if you can.")
}
args := []string{}
for _, config := range e.GitSubmoduleCloneConfig {
// -c foo=bar is valid, -c foo= is valid, -c foo is valid, but...
// -c (nothing) is invalid.
// This could happen because the env var was set to an empty value.
if config == "" {
continue
}
args = append(args, "-c", config)
}
// Checking for submodule repositories
submoduleRepos, err := gitEnumerateSubmoduleURLs(ctx, e.shell)
if err != nil {
e.shell.Warningf("Failed to enumerate git submodules: %v", err)
} else {
mirrorSubmodules := e.GitMirrorsPath != ""
for _, repository := range submoduleRepos {
// submodules might need their fingerprints verified too
if e.SSHKeyscan {
addRepositoryHostToSSHKnownHosts(ctx, e.shell, repository)
}
if !mirrorSubmodules {
continue