ajhahn.de
← eeco
Go 239 lines
package main

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/ajhahnde/eeco/internal/config"
	"github.com/ajhahnde/eeco/internal/hooks"
)

// runMigrate dispatches `eeco migrate v1 [--yes]`. v1 is the only supported
// target: it moves a legacy workspace (<repo>/.eeco) to the v1.0 per-user
// location (<repo>/<username>/.eeco). The grammar is tiny, so the two tokens
// (the `v1` target and the optional `--yes`/`-y`) are scanned by hand rather
// than through flag, which would stop parsing at the `v1` positional.
func runMigrate(args []string, stdout, stderr io.Writer) int {
	const usage = "usage: eeco migrate v1 [--yes]"
	assumeYes := false
	sub := ""
	for _, a := range args {
		switch a {
		case "--yes", "-y":
			assumeYes = true
		case "v1":
			sub = a
		default:
			fmt.Fprintf(stderr, "eeco migrate: unexpected argument %q\n", a)
			fmt.Fprintln(stderr, usage)
			return 2
		}
	}
	if sub != "v1" {
		fmt.Fprintln(stderr, usage)
		return 2
	}

	cfg, code := loadRepoConfig(stderr, "eeco migrate v1")
	if code != 0 {
		return code
	}

	code, err := migrateV1(cfg, initStdin, stdout, stderr, assumeYes)
	if err != nil {
		fmt.Fprintln(stderr, "eeco migrate v1:", err)
		return 1
	}
	return code
}

// migrateV1 relocates a legacy workspace at <repo>/.eeco to the v1.0 per-user
// location cfg.Workspace (<repo>/<username>/.eeco), corrects the hook ledger's
// recorded in-workspace paths, and rewrites .gitignore (drop the legacy
// /.eeco/ entry, add /<username>/). It is idempotent: a tree already on the
// v1.0 layout, or one with no workspace at all, is a clean no-op.
//
// The git hook scripts and the session-start settings command are deliberately
// left untouched: each invokes the eeco binary and re-resolves the workspace
// from the repo + owner at run time, so moving the directory is enough to
// relocate every hook. The only absolute paths that point inside the workspace
// are the session-start backups recorded in the ledger; those are rewritten in
// place. Returns the process exit code; a non-nil error is an unexpected I/O
// failure mid-migration.
func migrateV1(cfg *config.Config, in io.Reader, out, errw io.Writer, assumeYes bool) (int, error) {
	legacy := filepath.Join(cfg.RepoRoot, config.DefaultWorkspace)
	target := cfg.Workspace

	if legacy == target {
		// Only possible if the per-user dir resolved to the repo root, which
		// Load never does. Defensive: nothing to move.
		fmt.Fprintln(out, "workspace is already at the v1.0 location — nothing to migrate.")
		return 0, nil
	}

	legacyExists := isDir(legacy)
	targetExists := isDir(target)
	switch {
	case !legacyExists && targetExists:
		fmt.Fprintf(out, "already on the v1.0 layout — workspace at %s.\n", target)
		return 0, nil
	case !legacyExists && !targetExists:
		fmt.Fprintf(out, "no legacy workspace at %s — nothing to migrate.\n", legacy)
		return 0, nil
	case legacyExists && targetExists:
		fmt.Fprintf(errw, "both a legacy workspace (%s) and a v1.0 workspace (%s) exist.\n", legacy, target)
		fmt.Fprintln(errw, "resolve this by hand (keep one, remove the other) before migrating.")
		return 1, nil
	}

	fmt.Fprintln(out, "eeco migrate v1 will:")
	fmt.Fprintf(out, "  move    %s\n       -> %s\n", legacy, target)
	fmt.Fprintf(out, "  ignore  add /%s/ and drop /%s/ in .gitignore\n", cfg.Username, config.DefaultWorkspace)
	fmt.Fprintln(out, "  relink  correct recorded workspace paths in the hook ledger")
	fmt.Fprintln(out, "  rebake  rewrite enabled hook scripts with the current eeco binary path")

	if !assumeYes && !confirm(in, out, "proceed? [y/N]: ") {
		fmt.Fprintln(out, "aborted — no changes made.")
		return 1, nil
	}

	if err := os.MkdirAll(cfg.UserDir, 0o755); err != nil {
		return 1, fmt.Errorf("create %s: %w", cfg.UserDir, err)
	}
	if err := os.Rename(legacy, target); err != nil {
		return 1, fmt.Errorf("move workspace: %w", err)
	}
	if err := rewriteLedgerPaths(target, legacy); err != nil {
		return 1, fmt.Errorf("fix hook ledger: %w", err)
	}
	if err := migrateGitignore(cfg.RepoRoot, config.DefaultWorkspace, cfg.Username); err != nil {
		return 1, fmt.Errorf("update .gitignore: %w", err)
	}
	rebakeHooks(cfg, out, errw)

	fmt.Fprintf(out, "migrated workspace to %s\n", target)
	return 0, nil
}

// rebakeHooks re-renders every enabled hook script so its embedded eeco
// binary path matches what the running binary resolves today. The hook
// scripts bake an absolute binary path at install time, which goes stale
// after a `brew upgrade` (a moved cellar dir) or any other relocation;
// the workspace move is the natural moment to heal it. Each Refresh is a
// no-op when the hook is not enabled or already current. A refresh
// failure is a warning, never a migration failure — the move itself has
// already succeeded.
func rebakeHooks(cfg *config.Config, out, errw io.Writer) {
	steps := []struct {
		name string
		fn   func(*config.Config) (string, error)
	}{
		{"commit-msg", hooks.RefreshCommitMsg},
		{"pre-commit", hooks.RefreshPreCommit},
		{"post-merge", hooks.RefreshPostMerge},
		{"session-start", hooks.RefreshSessionStart},
	}
	for _, s := range steps {
		msg, err := s.fn(cfg)
		if err != nil {
			fmt.Fprintf(errw, "warning: refresh %s: %v\n", s.name, err)
			continue
		}
		if msg != "" {
			fmt.Fprintf(out, "  %s\n", msg)
		}
	}
}

// confirm prints prompt and returns true only on an explicit y/yes. EOF or any
// other input is a no, so a piped, non-interactive caller never migrates by
// accident.
func confirm(in io.Reader, out io.Writer, prompt string) bool {
	fmt.Fprint(out, prompt)
	s := bufio.NewScanner(in)
	if !s.Scan() {
		return false
	}
	switch strings.ToLower(strings.TrimSpace(s.Text())) {
	case "y", "yes":
		return true
	default:
		return false
	}
}

// rewriteLedgerPaths corrects the hook ledger after the workspace move. The
// ledger lives at <newWorkspace>/state/hooks.json; the only paths it records
// that point inside the workspace are the session-start backups, so replacing
// the old workspace prefix with the new one fixes them while leaving the
// repo-scoped .git/hooks/* paths and the external settings path untouched. A
// missing ledger (no hooks were ever wired) is a no-op.
func rewriteLedgerPaths(newWorkspace, oldWorkspace string) error {
	// hooks.json / state are mirrored from internal/hooks (ledgerName); a
	// migration tool intentionally hard-codes them rather than importing the
	// hooks package just for two constants.
	ledger := filepath.Join(newWorkspace, "state", "hooks.json")
	b, err := os.ReadFile(ledger)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil
		}
		return err
	}
	fixed := strings.ReplaceAll(string(b), oldWorkspace, newWorkspace)
	if fixed == string(b) {
		return nil
	}
	return os.WriteFile(ledger, []byte(fixed), 0o644)
}

// migrateGitignore drops every line equivalent to the legacy workspace ignore
// (`.eeco`, `.eeco/`, `/.eeco`, `/.eeco/`) and ensures `/<username>/` is
// present. Comments and unrelated entries are preserved; the file keeps a
// single trailing newline. A missing .gitignore is created with just the new
// entry.
func migrateGitignore(repoRoot, legacyName, username string) error {
	path := filepath.Join(repoRoot, ".gitignore")
	b, err := os.ReadFile(path)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return err
	}
	legacyEquiv := map[string]bool{
		legacyName: true, legacyName + "/": true,
		"/" + legacyName: true, "/" + legacyName + "/": true,
	}
	want := "/" + username + "/"
	wantEquiv := map[string]bool{
		username: true, username + "/": true,
		"/" + username: true, want: true,
	}

	var kept []string
	hasWant := false
	for _, raw := range strings.Split(string(b), "\n") {
		line := strings.TrimSpace(raw)
		if legacyEquiv[line] {
			continue
		}
		if wantEquiv[line] {
			hasWant = true
		}
		kept = append(kept, raw)
	}

	text := strings.TrimRight(strings.Join(kept, "\n"), "\n")
	if !hasWant {
		if text != "" {
			text += "\n"
		}
		text += want
	}
	text += "\n"
	return os.WriteFile(path, []byte(text), 0o644)
}