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)
}