package build import ( "context" "errors" "fmt" "io" "os" "path/filepath" "strings" ) // appSnapshot keeps what plugin:install and plugin:update change in an // application, so a run that fails half-way puts it back (WR-02): the // files they edit (go.mod, go.sum, go.work, go.work.sum, summer.yaml and // .gitmodules, with the .gitmodules index entry), the submodules a run // added and the checkouts it moved. type appSnapshot struct { appDir string // files maps a path to its content before the run; nil means the // file did not exist. files map[string][]byte // top is the git work tree of the application ("" outside git) and // gitmodulesIndex the `git ls-files -s` entry of .gitmodules there. top string gitmodulesIndex string added []addedSubmodule moved []movedCheckout } // addedSubmodule is a checkout a run moved to plugins// // (and then added as a submodule). type addedSubmodule struct { dir string } // movedCheckout is a submodule a run checked out at another ref; ref is // where it was before: a branch name, or a commit when detached. type movedCheckout struct { dir, ref string } // snapshotApp records the files of appDir that a run may edit. func snapshotApp(ctx context.Context, appDir string) (*appSnapshot, error) { s := &appSnapshot{appDir: appDir, files: map[string][]byte{}} paths := []string{ filepath.Join(appDir, "go.mod"), filepath.Join(appDir, "go.sum"), filepath.Join(appDir, manifestName), } gw := findGoWork(appDir) if gw == "" { gw = filepath.Join(appDir, "go.work") } paths = append(paths, gw, gw+".sum") if out, err := gitOutput(ctx, appDir, "rev-parse", "--show-toplevel"); err == nil { s.top = strings.TrimSpace(string(out)) paths = append(paths, filepath.Join(s.top, ".gitmodules")) if idx, err := gitOutput(ctx, s.top, "ls-files", "-s", "--", ".gitmodules"); err == nil { s.gitmodulesIndex = strings.TrimSpace(string(idx)) } } for _, p := range paths { raw, err := os.ReadFile(p) switch { case errors.Is(err, os.ErrNotExist): s.files[p] = nil case err != nil: return nil, fmt.Errorf("build: read %s: %w", p, err) default: s.files[p] = raw } } return s, nil } // addedCheckout records a checkout moved into the application; call it // right after the move, before anything else can fail. func (s *appSnapshot) addedCheckout(dir string) { if s != nil { s.added = append(s.added, addedSubmodule{dir: dir}) } } // movingCheckout records where the submodule in dir is before it is // checked out elsewhere. func (s *appSnapshot) movingCheckout(ctx context.Context, dir string) error { if s == nil { return nil } ref := "" if out, err := gitOutput(ctx, dir, "symbolic-ref", "--quiet", "--short", "HEAD"); err == nil { ref = strings.TrimSpace(string(out)) } else if out, err := gitOutput(ctx, dir, "rev-parse", "--verify", "HEAD"); err == nil { ref = strings.TrimSpace(string(out)) } else { return fmt.Errorf("build: read the checkout of %s: %w", dir, err) } s.moved = append(s.moved, movedCheckout{dir: dir, ref: ref}) return nil } // restore puts the application back as the snapshot found it and prints // what it did. What it could not undo is returned as one error naming // the commands that finish the job. func (s *appSnapshot) restore(ctx context.Context, out io.Writer) error { if s == nil { return nil } // Undo even when the run was cancelled. ctx = context.WithoutCancel(ctx) var left []string for i := len(s.moved) - 1; i >= 0; i-- { m := s.moved[i] if _, err := runCLIGit(ctx, m.dir, false, "checkout", "--quiet", m.ref); err != nil { left = append(left, fmt.Sprintf("git -C %s checkout %s", m.dir, m.ref)) } } for i := len(s.added) - 1; i >= 0; i-- { a := s.added[i] if s.top != "" { rel, err := filepath.Rel(s.top, a.dir) if err == nil { rel = filepath.ToSlash(rel) _, _ = gitOutput(ctx, s.top, "update-index", "--force-remove", "--", rel) _, _ = gitOutput(ctx, s.top, "config", "--remove-section", "submodule."+rel) if gitDir, err := gitOutput(ctx, s.top, "rev-parse", "--path-format=absolute", "--git-common-dir"); err == nil { modules := filepath.Join(strings.TrimSpace(string(gitDir)), "modules") if target := filepath.Join(modules, filepath.FromSlash(rel)); underRoot(modules, target) && target != modules { _ = os.RemoveAll(target) } } } } if underRoot(s.appDir, a.dir) { if err := os.RemoveAll(a.dir); err != nil { left = append(left, "rm -r "+a.dir) } } } for p, raw := range s.files { var err error if raw == nil { err = os.Remove(p) if errors.Is(err, os.ErrNotExist) { err = nil } } else { err = os.WriteFile(p, raw, 0o644) } if err != nil { left = append(left, "restore "+p+" from git") } } if s.top != "" { if s.gitmodulesIndex == "" { _, _ = gitOutput(ctx, s.top, "rm", "--cached", "--quiet", "--ignore-unmatch", "--", ".gitmodules") } else if mode, rest, ok := strings.Cut(s.gitmodulesIndex, " "); ok { if sha, _, ok := strings.Cut(rest, " "); ok { if _, err := gitOutput(ctx, s.top, "update-index", "--cacheinfo", mode+","+sha+",.gitmodules"); err != nil { left = append(left, "git -C "+s.top+" add .gitmodules") } } } } if len(left) > 0 { return fmt.Errorf("build: could not undo every change; finish with: %s", strings.Join(left, "; ")) } if out != nil { fmt.Fprintln(out, "Undid the changes of this run.") } return nil } // undo restores the snapshot after err and returns err, with the cleanup // that could not be done attached. func (s *appSnapshot) undo(ctx context.Context, out io.Writer, err error) error { if rerr := s.restore(ctx, out); rerr != nil { return fmt.Errorf("%w\n%v", err, rerr) } return err }