A failure after the first change left the application half-modified: a checkout moved into plugins/<vendor>/<name> but not registered, go.mod edited, submodules without summer.yaml entries (and the next install refused the leftover directory), or some submodules updated and others not. Both commands now snapshot go.mod, go.sum, go.work, go.work.sum, summer.yaml and .gitmodules (with its index entry) before applying; a failure restores them, removes the submodules the run added (index entry, submodule config, absorbed git dir, checkout; registered right after the move) and checks moved submodules out where they were. What cannot be undone is named in the error.
183 lines
5.6 KiB
Go
183 lines
5.6 KiB
Go
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/<vendor>/<name>
|
|
// (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
|
|
}
|