github.com/yourbase/yb@v0.7.1/cmd/yb/remote_build.go (about) 1 package main 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/base64" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "io/ioutil" 12 "net/http" 13 "net/url" 14 "os" 15 "path" 16 "path/filepath" 17 "strconv" 18 "strings" 19 "time" 20 21 ggit "gg-scm.io/pkg/git" 22 "github.com/gobwas/ws" 23 "github.com/gobwas/ws/wsutil" 24 "github.com/johnewart/archiver" 25 "github.com/spf13/cobra" 26 "github.com/ulikunitz/xz" 27 "github.com/yourbase/commons/http/headers" 28 "github.com/yourbase/yb" 29 "github.com/yourbase/yb/internal/config" 30 "gopkg.in/src-d/go-git.v4" 31 gitplumbing "gopkg.in/src-d/go-git.v4/plumbing" 32 "gopkg.in/src-d/go-git.v4/plumbing/object" 33 "zombiezen.com/go/log" 34 ) 35 36 type remoteCmd struct { 37 cfg config.Getter 38 target string 39 baseCommit string 40 branch string 41 patchData []byte 42 patchPath string 43 repoDir string 44 noAcceleration bool 45 disableCache bool 46 disableSkipper bool 47 dryRun bool 48 committed bool 49 publicRepo bool 50 backupWorktree bool 51 remotes []*url.URL 52 } 53 54 func newRemoteCmd(cfg config.Getter) *cobra.Command { 55 p := &remoteCmd{ 56 cfg: cfg, 57 } 58 c := &cobra.Command{ 59 Use: "remotebuild [options] [TARGET]", 60 Short: "Build a target remotely", 61 Long: `Builds a target using YourBase infrastructure. If no argument is given, ` + 62 `uses the target named "` + yb.DefaultTarget + `", if there is one.` + 63 "\n\n" + 64 `yb remotebuild will search for the .yourbase.yml file in the current ` + 65 `directory and its parent directories. The target's commands will be run ` + 66 `in the directory the .yourbase.yml file appears in.`, 67 Args: cobra.MaximumNArgs(1), 68 DisableFlagsInUseLine: true, 69 SilenceErrors: true, 70 SilenceUsage: true, 71 RunE: func(cmd *cobra.Command, args []string) error { 72 p.target = yb.DefaultTarget 73 if len(args) > 0 { 74 p.target = args[0] 75 } 76 return p.run(cmd.Context()) 77 }, 78 ValidArgsFunction: func(cc *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 79 if len(args) > 0 { 80 return nil, cobra.ShellCompDirectiveNoFileComp 81 } 82 return autocompleteTargetName(toComplete) 83 }, 84 } 85 c.Flags().StringVar(&p.baseCommit, "base-commit", "", "Base commit hash as common ancestor") 86 c.Flags().StringVar(&p.branch, "branch", "", "Branch name") 87 c.Flags().StringVar(&p.patchPath, "patch-path", "", "Path to save the patch") 88 c.Flags().BoolVar(&p.noAcceleration, "no-accel", false, "Disable acceleration") 89 c.Flags().BoolVar(&p.disableCache, "disable-cache", false, "Disable cache acceleration") 90 c.Flags().BoolVar(&p.disableSkipper, "disable-skipper", false, "Disable skipping steps acceleration") 91 c.Flags().BoolVarP(&p.dryRun, "dry-run", "n", false, "Pretend to remote build") 92 c.Flags().BoolVar(&p.committed, "committed", false, "Only remote build committed changes") 93 c.Flags().BoolVar(&p.backupWorktree, "backup-worktree", false, "Saves uncommitted work into a tarball") 94 return c 95 } 96 97 func (p *remoteCmd) run(ctx context.Context) error { 98 targetPackage, _, err := findPackage() 99 if err != nil { 100 return err 101 } 102 103 target := targetPackage.Targets[p.target] 104 if target == nil { 105 return fmt.Errorf("%s: no such target (found: %s)", p.target, strings.Join(listTargetNames(targetPackage.Targets), ", ")) 106 } 107 108 p.repoDir = targetPackage.Path 109 workRepo, err := git.PlainOpen(p.repoDir) 110 111 if err != nil { 112 return fmt.Errorf("opening repository %s: %w", p.repoDir, err) 113 } 114 115 g, err := ggit.New(ggit.Options{ 116 Dir: targetPackage.Path, 117 LogHook: func(ctx context.Context, args []string) { 118 log.Debugf(ctx, "running git %s", strings.Join(args, " ")) 119 }, 120 }) 121 if err != nil { 122 return err 123 } 124 125 // Show timing feedback and start tracking spent time 126 startTime := time.Now() 127 128 log.Infof(ctx, "Bootstrapping...") 129 130 list, err := workRepo.Remotes() 131 132 if err != nil { 133 return fmt.Errorf("getting remotes for %s: %w", p.repoDir, err) 134 } 135 136 var repoUrls []string 137 138 for _, r := range list { 139 c := r.Config() 140 repoUrls = append(repoUrls, c.URLs...) 141 } 142 143 project, err := p.fetchProject(ctx, repoUrls) 144 if err != nil { 145 return err 146 } 147 148 if project.Repository == "" { 149 projectURL, err := config.UIURL(p.cfg, fmt.Sprintf("%s/%s", project.OrgSlug, project.Label)) 150 if err != nil { 151 return err 152 } 153 154 return fmt.Errorf("empty repository for project %s. Please check your project settings at %s", project.Label, projectURL) 155 } 156 157 // First things first: 158 // 1. Define correct branch name 159 // 2. Define common ancestor commit 160 // 3. Generate patch file 161 // 3.1. Comparing every local commits with the one upstream 162 // 3.2. Comparing every unstaged/untracked changes with the one upstream 163 // 3.3. Save the patch and compress it 164 // 4. Submit build! 165 166 ancestorRef, commitCount, branch, err := fastFindAncestor(ctx, workRepo) 167 if err != nil { // Error 168 return err 169 } 170 p.branch = branch 171 p.baseCommit = ancestorRef.String() 172 173 head, err := workRepo.Head() 174 if err != nil { 175 return fmt.Errorf("couldn't find HEAD commit: %w", err) 176 } 177 headCommit, err := workRepo.CommitObject(head.Hash()) 178 if err != nil { 179 return fmt.Errorf("couldn't find HEAD commit: %w", err) 180 } 181 ancestorCommit, err := workRepo.CommitObject(ancestorRef) 182 if err != nil { 183 return fmt.Errorf("couldn't find merge-base commit: %w", err) 184 } 185 186 // Show feedback: end of bootstrap 187 endTime := time.Now() 188 bootTime := endTime.Sub(startTime) 189 log.Infof(ctx, "Bootstrap finished at %s, taking %s", endTime.Format(longTimeFormat), bootTime.Truncate(time.Millisecond)) 190 191 // Process patches 192 startTime = time.Now() 193 pGenerationChan := make(chan bool) 194 if p.committed && headCommit.Hash.String() != p.baseCommit { 195 log.Infof(ctx, "Generating patch for %d commits...", commitCount) 196 197 patch, err := ancestorCommit.Patch(headCommit) 198 if err != nil { 199 return fmt.Errorf("patch generation failed: %w", err) 200 } 201 // This is where the patch is actually generated see #278 202 go func(ch chan<- bool) { 203 log.Debugf(ctx, "Starting the actual patch generation...") 204 p.patchData = []byte(patch.String()) 205 log.Debugf(ctx, "Patch generation finished, only committed changes") 206 ch <- true 207 }(pGenerationChan) 208 } else if !p.committed { 209 // Apply changes that weren't committed yet 210 worktree, err := workRepo.Worktree() // current worktree 211 if err != nil { 212 return fmt.Errorf("couldn't get current worktree: %w", err) 213 } 214 215 log.Infof(ctx, "Generating patch for local changes...") 216 217 // Save files before committing. 218 log.Debugf(ctx, "Start backing up the worktree-save") 219 saver, err := newWorktreeSave(targetPackage.Path, ggit.Hash(headCommit.Hash), p.backupWorktree) 220 if err != nil { 221 return err 222 } 223 if err := p.traverseChanges(ctx, g, saver); err != nil { 224 return err 225 } 226 resetDone := false 227 if err := saver.save(ctx); err != nil { 228 return err 229 } 230 defer func() { 231 if !resetDone { 232 log.Debugf(ctx, "Reset failed, restoring...") 233 if err := saver.restore(ctx); err != nil { 234 log.Errorf(ctx, 235 "Unable to restore kept files at %s: %v\n"+ 236 " Please consider unarchiving that package", 237 saver.saveFilePath(), 238 err) 239 } 240 } 241 }() 242 243 log.Debugf(ctx, "Committing temporary changes") 244 latest, err := commitTempChanges(worktree, headCommit) 245 if err != nil { 246 return fmt.Errorf("commit to temporary cloned repository failed: %w", err) 247 } 248 249 tempCommit, err := workRepo.CommitObject(latest) 250 if err != nil { 251 return fmt.Errorf("can't find commit %q: %w", latest, err) 252 } 253 254 log.Debugf(ctx, "Starting the actual patch generation...") 255 patch, err := ancestorCommit.Patch(tempCommit) 256 if err != nil { 257 return fmt.Errorf("patch generation failed: %w", err) 258 } 259 260 // This is where the patch is actually generated see #278 261 p.patchData = []byte(patch.String()) 262 log.Debugf(ctx, "Actual patch generation finished") 263 264 log.Debugf(ctx, "Reseting worktree to previous state...") 265 // Reset back to HEAD 266 if err := worktree.Reset(&git.ResetOptions{ 267 Commit: headCommit.Hash, 268 }); err != nil { 269 log.Errorf(ctx, "Unable to reset temporary commit: %v\n Please try `git reset --hard HEAD~1`", err) 270 } else { 271 resetDone = true 272 } 273 log.Debugf(ctx, "Worktree reset done.") 274 275 } 276 277 // Show feedback: end of patch generation 278 endTime = time.Now() 279 patchTime := endTime.Sub(startTime) 280 log.Infof(ctx, "Patch finished at %s, taking %s", endTime.Format(longTimeFormat), patchTime.Truncate(time.Millisecond)) 281 if len(p.patchPath) > 0 && len(p.patchData) > 0 { 282 if err := p.savePatch(); err != nil { 283 log.Warnf(ctx, "Unable to save copy of generated patch: %v", err) 284 } 285 } 286 287 if p.dryRun { 288 log.Infof(ctx, "Dry run ended, build not submitted") 289 return nil 290 } 291 292 if err := p.submitBuild(ctx, project, target.Tags); err != nil { 293 return fmt.Errorf("unable to submit build: %w", err) 294 } 295 return nil 296 } 297 298 func commitTempChanges(w *git.Worktree, c *object.Commit) (latest gitplumbing.Hash, err error) { 299 if w == nil || c == nil { 300 err = fmt.Errorf("Needs a worktree and a commit object") 301 return 302 } 303 latest, err = w.Commit( 304 "YourBase remote build", 305 &git.CommitOptions{ 306 Author: &object.Signature{ 307 Name: c.Author.Name, 308 Email: c.Author.Email, 309 When: time.Now(), 310 }, 311 }, 312 ) 313 return 314 } 315 316 func (p *remoteCmd) traverseChanges(ctx context.Context, g *ggit.Git, saver *worktreeSave) error { 317 workTree, err := g.WorkTree(ctx) 318 if err != nil { 319 return fmt.Errorf("traverse changes: %w", err) 320 } 321 status, err := g.Status(ctx, ggit.StatusOptions{ 322 DisableRenames: true, 323 }) 324 if err != nil { 325 return fmt.Errorf("traverse changes: %w", err) 326 } 327 328 var addList []ggit.Pathspec 329 for _, ent := range status { 330 if ent.Code[1] == ' ' { 331 // If file is already staged, then skip. 332 continue 333 } 334 var err error 335 addList, err = findFilesToAdd(ctx, g, workTree, addList, ent.Name) 336 if err != nil { 337 return fmt.Errorf("traverse changes: %w", err) 338 } 339 340 if !ent.Code.IsMissing() { // No need to add deletion to the saver, right? 341 if err = saver.add(ctx, filepath.FromSlash(string(ent.Name))); err != nil { 342 return fmt.Errorf("traverse changes: %w", err) 343 } 344 } 345 } 346 347 err = g.Add(ctx, addList, ggit.AddOptions{ 348 IncludeIgnored: true, 349 }) 350 if err != nil { 351 return fmt.Errorf("traverse changes: %w", err) 352 } 353 return nil 354 } 355 356 // findFilesToAdd finds files to stage in Git, recursing into directories and 357 // ignoring any non-text files. 358 func findFilesToAdd(ctx context.Context, g *ggit.Git, workTree string, dst []ggit.Pathspec, file ggit.TopPath) ([]ggit.Pathspec, error) { 359 realPath := filepath.Join(workTree, filepath.FromSlash(string(file))) 360 fi, err := os.Stat(realPath) 361 if os.IsNotExist(err) { 362 return dst, nil 363 } 364 if err != nil { 365 return dst, fmt.Errorf("find files to git add: %w", err) 366 } 367 368 if !fi.IsDir() { 369 binary, err := isBinary(realPath) 370 if err != nil { 371 return dst, fmt.Errorf("find files to git add: %w", err) 372 } 373 log.Debugf(ctx, "%s is binary = %t", file, binary) 374 if binary { 375 log.Infof(ctx, "Skipping binary file %s", realPath) 376 return dst, nil 377 } 378 return append(dst, file.Pathspec()), nil 379 } 380 381 log.Debugf(ctx, "Added a dir, checking its contents: %s", file) 382 dir, err := ioutil.ReadDir(realPath) 383 if err != nil { 384 return dst, fmt.Errorf("find files to git add: %w", err) 385 } 386 for _, f := range dir { 387 var err error 388 dst, err = findFilesToAdd(ctx, g, workTree, dst, ggit.TopPath(path.Join(string(file), f.Name()))) 389 if err != nil { 390 return dst, err 391 } 392 } 393 return dst, nil 394 } 395 396 // isBinary returns whether a file contains a NUL byte near the beginning of the file. 397 func isBinary(filePath string) (bool, error) { 398 r, err := os.Open(filePath) 399 if err != nil { 400 return false, err 401 } 402 defer r.Close() 403 404 buf := make([]byte, 8000) 405 n, err := io.ReadFull(r, buf) 406 if err != nil { 407 // Ignore EOF, since it's fine for the file to be shorter than the buffer size. 408 // Otherwise, wrap the error. We don't fully stop the control flow here because 409 // we may still have read enough data to make a determination. 410 if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { 411 err = nil 412 } else { 413 err = fmt.Errorf("check for binary: %w", err) 414 } 415 } 416 for _, b := range buf[:n] { 417 if b == 0 { 418 return true, err 419 } 420 } 421 return false, err 422 } 423 424 func postToAPI(cfg config.Getter, path string, formData url.Values) (*http.Response, error) { 425 userToken, err := config.UserToken(cfg) 426 427 if err != nil { 428 return nil, fmt.Errorf("Couldn't get user token: %v", err) 429 } 430 431 apiURL, err := config.APIURL(cfg, path) 432 if err != nil { 433 return nil, fmt.Errorf("Couldn't determine API URL: %v", err) 434 } 435 req := &http.Request{ 436 Method: http.MethodPost, 437 URL: apiURL, 438 Header: http.Header{ 439 http.CanonicalHeaderKey("YB_API_TOKEN"): {userToken}, 440 headers.ContentType: {"application/x-www-form-urlencoded"}, 441 }, 442 GetBody: func() (io.ReadCloser, error) { 443 return ioutil.NopCloser(strings.NewReader(formData.Encode())), nil 444 }, 445 } 446 req.Body, _ = req.GetBody() 447 res, err := http.DefaultClient.Do(req) 448 if err != nil { 449 return nil, err 450 } 451 452 return res, nil 453 } 454 455 // buildIDFromLogURL returns the build ID in a build log WebSocket URL. 456 // 457 // TODO(ch2570): This should come from the API. 458 func buildIDFromLogURL(u *url.URL) (string, error) { 459 // Pattern is /builds/ID/progress 460 const prefix = "/builds/" 461 const suffix = "/progress" 462 if !strings.HasPrefix(u.Path, prefix) || !strings.HasSuffix(u.Path, suffix) { 463 return "", fmt.Errorf("build ID for %v: unrecognized path", u) 464 } 465 id := u.Path[len(prefix) : len(u.Path)-len(suffix)] 466 if strings.ContainsRune(id, '/') { 467 return "", fmt.Errorf("build ID for %v: unrecognized path", u) 468 } 469 return id, nil 470 } 471 472 // An apiProject is a YourBase project as returned by the API. 473 type apiProject struct { 474 ID int `json:"id"` 475 Label string `json:"label"` 476 Description string `json:"description"` 477 Repository string `json:"repository"` 478 OrgSlug string `json:"organization_slug"` 479 } 480 481 func (p *remoteCmd) fetchProject(ctx context.Context, urls []string) (*apiProject, error) { 482 v := url.Values{} 483 fmt.Println() 484 log.Infof(ctx, "URLs used to search: %s", urls) 485 486 for _, u := range urls { 487 rem, err := ggit.ParseURL(u) 488 if err != nil { 489 log.Warnf(ctx, "Invalid remote %s (%v), ignoring", u, err) 490 continue 491 } 492 // We only support GitHub by now 493 // TODO create something more generic 494 if rem.Host != "github.com" { 495 log.Warnf(ctx, "Ignoring remote %s (only github.com supported)", u) 496 continue 497 } 498 p.remotes = append(p.remotes, rem) 499 v.Add("urls[]", u) 500 } 501 resp, err := postToAPI(p.cfg, "search/projects", v) 502 if err != nil { 503 return nil, fmt.Errorf("Couldn't lookup project on api server: %v", err) 504 } 505 defer resp.Body.Close() 506 507 if resp.StatusCode != http.StatusOK { 508 log.Debugf(ctx, "Build server returned HTTP Status %d", resp.StatusCode) 509 switch resp.StatusCode { 510 case http.StatusNonAuthoritativeInfo: 511 p.publicRepo = true 512 case http.StatusUnauthorized: 513 return nil, fmt.Errorf("Unauthorized, authentication failed.\nPlease `yb login` again.") 514 case http.StatusPreconditionFailed, http.StatusNotFound: 515 return nil, fmt.Errorf("Please verify if this private repository has %s installed.", config.GitHubAppURL()) 516 default: 517 return nil, fmt.Errorf("This is us, not you, please try again in a few minutes.") 518 } 519 } 520 521 body, err := ioutil.ReadAll(resp.Body) 522 if err != nil { 523 return nil, err 524 } 525 project := new(apiProject) 526 err = json.Unmarshal(body, project) 527 if err != nil { 528 return nil, err 529 } 530 return project, nil 531 } 532 533 func (cmd *remoteCmd) savePatch() error { 534 535 err := ioutil.WriteFile(cmd.patchPath, cmd.patchData, 0644) 536 537 if err != nil { 538 return fmt.Errorf("Couldn't save a local patch file at: %s, because: %v", cmd.patchPath, err) 539 } 540 541 return nil 542 } 543 544 func (cmd *remoteCmd) submitBuild(ctx context.Context, project *apiProject, tagMap map[string]string) error { 545 546 startTime := time.Now() 547 548 userToken, err := config.UserToken(cmd.cfg) 549 if err != nil { 550 return err 551 } 552 553 patchBuffer := new(bytes.Buffer) 554 xzWriter, err := xz.NewWriter(patchBuffer) 555 if err != nil { 556 return fmt.Errorf("submit build: compress patch: %w", err) 557 } 558 if _, err := xzWriter.Write(cmd.patchData); err != nil { 559 return fmt.Errorf("submit build: compress patch: %w", err) 560 } 561 if err := xzWriter.Close(); err != nil { 562 return fmt.Errorf("submit build: compress patch: %w", err) 563 } 564 565 patchEncoded := base64.StdEncoding.EncodeToString(patchBuffer.Bytes()) 566 567 formData := url.Values{ 568 "project_id": {strconv.Itoa(project.ID)}, 569 "repository": {project.Repository}, 570 "api_key": {userToken}, 571 "target": {cmd.target}, 572 "patch_data": {patchEncoded}, 573 "commit": {cmd.baseCommit}, 574 "branch": {cmd.branch}, 575 } 576 577 tags := make([]string, 0) 578 for k, v := range tagMap { 579 tags = append(tags, fmt.Sprintf("%s:%s", k, v)) 580 } 581 582 for _, tag := range tags { 583 formData.Add("tags[]", tag) 584 } 585 586 if cmd.noAcceleration { 587 formData.Add("no-accel", "True") 588 } 589 590 if cmd.disableCache { 591 formData.Add("disable-cache", "True") 592 } 593 594 if cmd.disableSkipper { 595 formData.Add("disable-skipper", "True") 596 } 597 598 resp, err := postToAPI(cmd.cfg, "builds/cli", formData) 599 if err != nil { 600 return err 601 } 602 603 defer resp.Body.Close() 604 body, err := ioutil.ReadAll(resp.Body) 605 if err != nil { 606 return fmt.Errorf("Couldn't read response body: %s", err) 607 } 608 switch resp.StatusCode { 609 case 401: 610 return fmt.Errorf("Unauthorized, authentication failed.\nPlease `yb login` again.") 611 case 403: 612 if cmd.publicRepo { 613 return fmt.Errorf("This should not happen, please open a support inquery with YB") 614 } else { 615 return fmt.Errorf("Tried to build a private repository of a organization of which you're not part of.") 616 } 617 case 412: 618 // TODO Show helpful message with App URL to fix GH App installation issue 619 return fmt.Errorf("Please verify if this specific repo has %s installed", config.GitHubAppURL()) 620 case 500: 621 return fmt.Errorf("Internal server error") 622 } 623 //Process simple response from the API 624 body = bytes.ReplaceAll(body, []byte(`"`), nil) 625 if i := bytes.IndexByte(body, '\n'); i != -1 { 626 body = body[:i] 627 } 628 logURL, err := url.Parse(string(body)) 629 if err != nil { 630 return fmt.Errorf("server response: parse log URL: %w", err) 631 } 632 if logURL.Scheme != "ws" && logURL.Scheme != "wss" { 633 return fmt.Errorf("server response: parse log URL: unhandled scheme %q", logURL.Scheme) 634 } 635 // Construct UI URL to present to the user. 636 // Fine to proceed in the face of errors: this is displayed as a fallback if 637 // other things fail. 638 var uiURL *url.URL 639 if id, err := buildIDFromLogURL(logURL); err != nil { 640 log.Warnf(ctx, "Could not construct build link: %v", err) 641 } else { 642 uiURL, err = config.UIURL(cmd.cfg, "/"+project.OrgSlug+"/"+project.Label+"/builds/"+id) 643 if err != nil { 644 log.Warnf(ctx, "Could not construct build link: %v", err) 645 } 646 } 647 648 endTime := time.Now() 649 submitTime := endTime.Sub(startTime) 650 log.Infof(ctx, "Submission finished at %s, taking %s", endTime.Format(longTimeFormat), submitTime.Truncate(time.Millisecond)) 651 652 startTime = time.Now() 653 654 conn, _, _, err := ws.DefaultDialer.Dial(context.Background(), logURL.String()) 655 if err != nil { 656 return fmt.Errorf("Cannot connect: %v", err) 657 } 658 defer func() { 659 if err := conn.Close(); err != nil { 660 log.Debugf(ctx, "Cannot close: %v", err) 661 } 662 }() 663 664 buildSuccess := false 665 buildSetupFinished := false 666 667 for { 668 msg, control, err := wsutil.ReadServerData(conn) 669 if err != nil { 670 if err != io.EOF { 671 log.Debugf(ctx, "Unstable connection: %v", err) 672 } else { 673 if buildSuccess { 674 log.Infof(ctx, "Build Completed!") 675 } else { 676 log.Errorf(ctx, "Build failed or the connection was interrupted!") 677 } 678 if uiURL != nil { 679 log.Infof(ctx, "Build Log: %v", uiURL) 680 } 681 return nil 682 } 683 } else { 684 // TODO This depends on build agent output, try to structure this better 685 if control.IsData() && strings.Count(string(msg), "Streaming results from build") > 0 { 686 fmt.Println() 687 } else if control.IsData() && !buildSetupFinished && len(msg) > 0 { 688 buildSetupFinished = true 689 endTime := time.Now() 690 setupTime := endTime.Sub(startTime) 691 log.Infof(ctx, "Set up finished at %s, taking %s", endTime.Format(longTimeFormat), setupTime.Truncate(time.Millisecond)) 692 if cmd.publicRepo { 693 log.Infof(ctx, "Building a public repository: '%s'", project.Repository) 694 } 695 if uiURL != nil { 696 log.Infof(ctx, "Build Log: %v", uiURL) 697 } 698 } 699 if !buildSuccess { 700 buildSuccess = strings.Count(string(msg), "-- BUILD SUCCEEDED --") > 0 701 } 702 os.Stdout.Write(msg) 703 } 704 } 705 } 706 707 type worktreeSave struct { 708 path string 709 hash ggit.Hash 710 files []string 711 } 712 713 func newWorktreeSave(path string, hash ggit.Hash, enabled bool) (*worktreeSave, error) { 714 if !enabled { 715 return nil, nil 716 } 717 if _, err := os.Lstat(path); os.IsNotExist(err) { 718 return nil, fmt.Errorf("save worktree state: %w", err) 719 } 720 return &worktreeSave{ 721 path: path, 722 hash: hash, 723 }, nil 724 } 725 726 func (w *worktreeSave) hasFiles() bool { 727 return w != nil && len(w.files) > 0 728 } 729 730 func (w *worktreeSave) add(ctx context.Context, file string) error { 731 if w == nil { 732 return nil 733 } 734 fullPath := filepath.Join(w.path, file) 735 if _, err := os.Lstat(fullPath); os.IsNotExist(err) { 736 return fmt.Errorf("save worktree state: %w", err) 737 } 738 log.Debugf(ctx, "Saving %s to the tarball", file) 739 w.files = append(w.files, file) 740 return nil 741 } 742 743 func (w *worktreeSave) saveFilePath() string { 744 return filepath.Join(w.path, fmt.Sprintf(".yb-worktreesave-%v.tar", w.hash)) 745 } 746 747 func (w *worktreeSave) save(ctx context.Context) error { 748 if !w.hasFiles() { 749 return nil 750 } 751 log.Debugf(ctx, "Saving a tarball with all the worktree changes made") 752 tar := archiver.Tar{ 753 MkdirAll: true, 754 } 755 if err := tar.Archive(w.files, w.saveFilePath()); err != nil { 756 return fmt.Errorf("save worktree state: %w", err) 757 } 758 return nil 759 } 760 761 func (w *worktreeSave) restore(ctx context.Context) error { 762 if !w.hasFiles() { 763 return nil 764 } 765 log.Debugf(ctx, "Restoring the worktree tarball") 766 pkgFile := w.saveFilePath() 767 if _, err := os.Lstat(pkgFile); os.IsNotExist(err) { 768 return fmt.Errorf("restore worktree state: %w", err) 769 } 770 tar := archiver.Tar{OverwriteExisting: true} 771 if err := tar.Unarchive(pkgFile, w.path); err != nil { 772 return fmt.Errorf("restore worktree state: %w", err) 773 } 774 if err := os.Remove(pkgFile); err != nil { 775 log.Warnf(ctx, "Failed to clean up temporary worktree save: %v", err) 776 } 777 return nil 778 }