From 6b5d16183325113444541b761727e57ccb75e95d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 26 Apr 2026 17:26:39 -0400 Subject: [PATCH 1/3] feat: add guarded dirty migration repair --- CHANGELOG.md | 1 + cmd/workflow-migrate/main.go | 1 + internal/golangmigrate/driver.go | 95 ++++++++++++++ internal/golangmigrate/driver_test.go | 181 ++++++++++++++++++++++++++ pkg/cli/root.go | 57 ++++++++ pkg/cli/root_test.go | 95 ++++++++++++++ plugin.json | 1 + 7 files changed, 431 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b22804..25af5c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `workflow-migrate force ` for force-setting the recorded golang-migrate version after dirty or manual repair workflows. +- `workflow-migrate repair-dirty` for guarded dirty migration metadata repair. It requires an exact dirty-version match, an explicit force target, and typed confirmation before metadata is changed, with optional `--then-up` to continue applying pending migrations after repair. ## [0.3.1] - 2026-04-24 diff --git a/cmd/workflow-migrate/main.go b/cmd/workflow-migrate/main.go index ccf5622..a613363 100644 --- a/cmd/workflow-migrate/main.go +++ b/cmd/workflow-migrate/main.go @@ -9,6 +9,7 @@ // workflow-migrate status [flags] // workflow-migrate goto [flags] // workflow-migrate force [flags] +// workflow-migrate repair-dirty [flags] package main import ( diff --git a/internal/golangmigrate/driver.go b/internal/golangmigrate/driver.go index 13c27b5..7efe98c 100644 --- a/internal/golangmigrate/driver.go +++ b/internal/golangmigrate/driver.go @@ -192,6 +192,13 @@ type ForceOptions struct { AllowClean bool } +// RepairDirtyOptions controls a guarded metadata repair for a known dirty migration. +type RepairDirtyOptions struct { + ExpectedDirtyVersion string + ForceVersion string + ThenUp bool +} + // Force sets the recorded migration version without applying migration files. func (d *Driver) Force(_ context.Context, req interfaces.MigrationRequest, target string, opts ForceOptions) (interfaces.MigrationResult, error) { if err := req.Validate(); err != nil { @@ -237,6 +244,94 @@ func (d *Driver) Force(_ context.Context, req interfaces.MigrationRequest, targe }, nil } +// RepairDirty verifies the database is dirty at the exact expected version before forcing metadata. +func (d *Driver) RepairDirty(ctx context.Context, req interfaces.MigrationRequest, opts RepairDirtyOptions) (interfaces.MigrationResult, error) { + if err := req.Validate(); err != nil { + return interfaces.MigrationResult{}, err + } + start := time.Now() + + expected, err := parseExpectedDirtyVersion(opts.ExpectedDirtyVersion) + if err != nil { + return interfaces.MigrationResult{}, err + } + forceVersion, err := parseForceTarget(opts.ForceVersion) + if err != nil { + return interfaces.MigrationResult{}, err + } + if forceVersion > int(expected) { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty: force version %q must not be greater than expected dirty version %q", opts.ForceVersion, opts.ExpectedDirtyVersion) + } + + expectedExists, err := versionExists(req.Source.Dir, expected) + if err != nil { + return interfaces.MigrationResult{}, err + } + if !expectedExists { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty: expected dirty version %q does not exist in migration source", opts.ExpectedDirtyVersion) + } + if forceVersion > 0 { + forceExists, err := versionExists(req.Source.Dir, uint(forceVersion)) + if err != nil { + return interfaces.MigrationResult{}, err + } + if !forceExists { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty: force version %q does not exist in migration source", opts.ForceVersion) + } + } + + m, err := newMigrate(req) + if err != nil { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate: %w", err) + } + + current, dirty, err := m.Version() + if err != nil && !errors.Is(err, migrate.ErrNilVersion) { + _, _ = m.Close() + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty: version before repair: %w", err) + } + if !dirty { + _, _ = m.Close() + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty: database is clean; refusing metadata repair") + } + if current != expected { + _, _ = m.Close() + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty: database is dirty at version %d, expected %d", current, expected) + } + + if err := m.Force(forceVersion); err != nil { + _, _ = m.Close() + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty: force: %w", err) + } + _, _ = m.Close() + + if opts.ThenUp { + result, err := d.Up(ctx, req) + if err != nil { + st, statusErr := d.Status(ctx, req) + if statusErr != nil { + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty then-up failed after metadata was repaired to version %s; status after failure unavailable: %v; up error: %w", opts.ForceVersion, statusErr, err) + } + return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty then-up failed after metadata was repaired to version %s; current version %s dirty=%t: %w", opts.ForceVersion, st.Current, st.Dirty, err) + } + result.DurationMs = time.Since(start).Milliseconds() + return result, nil + } + + return interfaces.MigrationResult{ + Applied: nil, + DurationMs: time.Since(start).Milliseconds(), + }, nil +} + +func parseExpectedDirtyVersion(target string) (uint, error) { + version, err := strconv.ParseUint(target, 10, 0) + if err != nil || version == 0 { + return 0, fmt.Errorf("golang-migrate repair-dirty: invalid expected dirty version %q: must be a positive integer", target) + } + return uint(version), nil +} + func parseForceTarget(target string) (int, error) { version, err := strconv.Atoi(target) if err != nil || version == 0 || version < -1 { diff --git a/internal/golangmigrate/driver_test.go b/internal/golangmigrate/driver_test.go index 73f5deb..93b9c64 100644 --- a/internal/golangmigrate/driver_test.go +++ b/internal/golangmigrate/driver_test.go @@ -181,6 +181,187 @@ func TestDriver_ForceRejectsInvalidTarget(t *testing.T) { } } +func TestDriver_RepairDirtyGuardsExpectedVersion(t *testing.T) { + if testing.Short() { + t.Skip("skipping: requires postgres (set up with testharness)") + } + h, err := testharness.New() + if err != nil { + t.Skipf("skipping: no postgres available: %v", err) + } + defer h.Close(t) + + dir := t.TempDir() + writeSQL(t, dir, "000001_users.up.sql", "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL);") + writeSQL(t, dir, "000001_users.down.sql", "DROP TABLE IF EXISTS users;") + writeSQL(t, dir, "000002_posts.up.sql", "CREATE TABLE posts (id SERIAL PRIMARY KEY, title TEXT NOT NULL);") + writeSQL(t, dir, "000002_posts.down.sql", "DROP TABLE IF EXISTS posts;") + writeSQL(t, dir, "000003_comments.up.sql", "CREATE TABLE comments (id SERIAL PRIMARY KEY, body TEXT NOT NULL);") + writeSQL(t, dir, "000003_comments.down.sql", "DROP TABLE IF EXISTS comments;") + + ctx := context.Background() + d := golangmigrate.New() + req := interfaces.MigrationRequest{ + DSN: h.DSN(), + Source: interfaces.MigrationSource{ + Dir: dir, + }, + } + + if _, err := d.Goto(ctx, req, "1"); err != nil { + t.Fatalf("Goto(1) error: %v", err) + } + + _, err = d.RepairDirty(ctx, req, golangmigrate.RepairDirtyOptions{ + ExpectedDirtyVersion: "2", + ForceVersion: "1", + }) + if err == nil { + t.Fatal("RepairDirty() clean error = nil; want refusal") + } + if !strings.Contains(err.Error(), "database is clean") { + t.Fatalf("RepairDirty() clean error = %v; want clean refusal", err) + } + + markDirty(t, h.DSN(), 2) + + _, err = d.RepairDirty(ctx, req, golangmigrate.RepairDirtyOptions{ + ExpectedDirtyVersion: "3", + ForceVersion: "1", + }) + if err == nil { + t.Fatal("RepairDirty() wrong-version error = nil; want refusal") + } + if !strings.Contains(err.Error(), "dirty at version 2, expected 3") { + t.Fatalf("RepairDirty() wrong-version error = %v; want exact-version refusal", err) + } + + _, err = d.RepairDirty(ctx, req, golangmigrate.RepairDirtyOptions{ + ExpectedDirtyVersion: "2", + ForceVersion: "3", + }) + if err == nil { + t.Fatal("RepairDirty() forward-force error = nil; want refusal") + } + if !strings.Contains(err.Error(), "must not be greater than expected dirty version") { + t.Fatalf("RepairDirty() forward-force error = %v; want forward-force refusal", err) + } + + result, err := d.RepairDirty(ctx, req, golangmigrate.RepairDirtyOptions{ + ExpectedDirtyVersion: "2", + ForceVersion: "1", + }) + if err != nil { + t.Fatalf("RepairDirty() error: %v", err) + } + if len(result.Applied) != 0 { + t.Fatalf("RepairDirty() Applied = %v; metadata repair must not report applied migrations", result.Applied) + } + st, err := d.Status(ctx, req) + if err != nil { + t.Fatalf("Status() after RepairDirty error: %v", err) + } + if st.Current != "1" || st.Dirty { + t.Fatalf("Status() after RepairDirty = current %q dirty %v; want current 1 clean", st.Current, st.Dirty) + } +} + +func TestDriver_RepairDirtyThenUp(t *testing.T) { + if testing.Short() { + t.Skip("skipping: requires postgres (set up with testharness)") + } + h, err := testharness.New() + if err != nil { + t.Skipf("skipping: no postgres available: %v", err) + } + defer h.Close(t) + + dir := t.TempDir() + writeSQL(t, dir, "000001_users.up.sql", "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL);") + writeSQL(t, dir, "000001_users.down.sql", "DROP TABLE IF EXISTS users;") + writeSQL(t, dir, "000002_posts.up.sql", "CREATE TABLE posts (id SERIAL PRIMARY KEY, title TEXT NOT NULL);") + writeSQL(t, dir, "000002_posts.down.sql", "DROP TABLE IF EXISTS posts;") + + ctx := context.Background() + d := golangmigrate.New() + req := interfaces.MigrationRequest{ + DSN: h.DSN(), + Source: interfaces.MigrationSource{ + Dir: dir, + }, + } + if _, err := d.Goto(ctx, req, "1"); err != nil { + t.Fatalf("Goto(1) error: %v", err) + } + markDirty(t, h.DSN(), 2) + + result, err := d.RepairDirty(ctx, req, golangmigrate.RepairDirtyOptions{ + ExpectedDirtyVersion: "2", + ForceVersion: "1", + ThenUp: true, + }) + if err != nil { + t.Fatalf("RepairDirty(ThenUp) error: %v", err) + } + if len(result.Applied) != 1 || result.Applied[0] != "2" { + t.Fatalf("RepairDirty(ThenUp) Applied = %v; want [2]", result.Applied) + } + st, err := d.Status(ctx, req) + if err != nil { + t.Fatalf("Status() after RepairDirty(ThenUp) error: %v", err) + } + if st.Current != "2" || st.Dirty { + t.Fatalf("Status() after RepairDirty(ThenUp) = current %q dirty %v; want current 2 clean", st.Current, st.Dirty) + } +} + +func TestDriver_RepairDirtyThenUpReportsPartialRepairOnUpFailure(t *testing.T) { + if testing.Short() { + t.Skip("skipping: requires postgres (set up with testharness)") + } + h, err := testharness.New() + if err != nil { + t.Skipf("skipping: no postgres available: %v", err) + } + defer h.Close(t) + + dir := t.TempDir() + writeSQL(t, dir, "000001_users.up.sql", "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL);") + writeSQL(t, dir, "000001_users.down.sql", "DROP TABLE IF EXISTS users;") + writeSQL(t, dir, "000002_posts.up.sql", "CREATE TABLE posts (id SERIAL PRIMARY KEY, title TEXT NOT NULL);") + writeSQL(t, dir, "000002_posts.down.sql", "DROP TABLE IF EXISTS posts;") + writeSQL(t, dir, "000003_broken.up.sql", "THIS IS NOT SQL;") + writeSQL(t, dir, "000003_broken.down.sql", "SELECT 1;") + + ctx := context.Background() + d := golangmigrate.New() + req := interfaces.MigrationRequest{ + DSN: h.DSN(), + Source: interfaces.MigrationSource{ + Dir: dir, + }, + } + if _, err := d.Goto(ctx, req, "1"); err != nil { + t.Fatalf("Goto(1) error: %v", err) + } + markDirty(t, h.DSN(), 2) + + _, err = d.RepairDirty(ctx, req, golangmigrate.RepairDirtyOptions{ + ExpectedDirtyVersion: "2", + ForceVersion: "1", + ThenUp: true, + }) + if err == nil { + t.Fatal("RepairDirty(ThenUp) error = nil; want broken migration failure") + } + if !strings.Contains(err.Error(), "then-up failed after metadata was repaired to version 1") { + t.Fatalf("RepairDirty(ThenUp) error = %v; want partial-repair message", err) + } + if !strings.Contains(err.Error(), "current version 3 dirty=true") { + t.Fatalf("RepairDirty(ThenUp) error = %v; want final dirty status", err) + } +} + func markDirty(t *testing.T, dsn string, version int) { t.Helper() db, err := sql.Open("pgx", dsn) diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 002ee90..606b75d 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -48,6 +48,7 @@ func NewRoot() *cobra.Command { newStatusCmd(), newGotoCmd(), newForceCmd(), + newRepairDirtyCmd(), newLintCmd(), newTestCmd(), newTenantEnsureCmd(), @@ -203,6 +204,10 @@ type forceDriver interface { Force(ctx context.Context, req interfaces.MigrationRequest, target string, opts golangmigrate.ForceOptions) (interfaces.MigrationResult, error) } +type repairDirtyDriver interface { + RepairDirty(ctx context.Context, req interfaces.MigrationRequest, opts golangmigrate.RepairDirtyOptions) (interfaces.MigrationResult, error) +} + func newForceCmd() *cobra.Command { cmd := &cobra.Command{ Use: "force ", @@ -243,6 +248,58 @@ func newForceCmd() *cobra.Command { return cmd } +func newRepairDirtyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "repair-dirty", + Short: "Repair a known dirty golang-migrate metadata version", + RunE: func(cmd *cobra.Command, _ []string) error { + confirmation, _ := cmd.Flags().GetString("confirm-force") + if confirmation != "FORCE_MIGRATION_METADATA" { + return fmt.Errorf("repair-dirty mutates migration metadata without applying SQL; pass --confirm-force FORCE_MIGRATION_METADATA to continue") + } + expected, _ := cmd.Flags().GetString("expected-dirty-version") + forceVersion, _ := cmd.Flags().GetString("force-version") + thenUp, _ := cmd.Flags().GetBool("then-up") + + d, req, err := buildDriverAndRequest(cmd) + if err != nil { + return err + } + repairer, ok := d.(repairDirtyDriver) + if !ok { + return fmt.Errorf("driver %q does not support repair-dirty", d.Name()) + } + + result, err := repairer.RepairDirty(context.Background(), req, golangmigrate.RepairDirtyOptions{ + ExpectedDirtyVersion: expected, + ForceVersion: forceVersion, + ThenUp: thenUp, + }) + if err != nil { + return fmt.Errorf("migrate repair-dirty: %w", err) + } + if thenUp { + if len(result.Applied) == 0 { + fmt.Printf("Repaired dirty metadata at version %s to %s; no pending migrations. Duration: %dms\n", expected, forceVersion, result.DurationMs) + return nil + } + fmt.Printf("Repaired dirty metadata at version %s to %s; applied %d migration(s): %v. Duration: %dms\n", expected, forceVersion, len(result.Applied), result.Applied, result.DurationMs) + return nil + } + fmt.Printf("Repaired dirty metadata at version %s to %s; no migrations applied. Duration: %dms\n", expected, forceVersion, result.DurationMs) + return nil + }, + } + sharedFlags(cmd) + cmd.Flags().String("expected-dirty-version", "", "Required dirty version currently recorded in migration metadata") + cmd.Flags().String("force-version", "", "Version to force-set after the dirty version guard passes") + cmd.Flags().String("confirm-force", "", "Typed confirmation required: FORCE_MIGRATION_METADATA") + cmd.Flags().Bool("then-up", false, "Run pending migrations after successful metadata repair") + _ = cmd.MarkFlagRequired("expected-dirty-version") + _ = cmd.MarkFlagRequired("force-version") + return cmd +} + func splitForceArgs(args []string) (string, []string, error) { var target string flagArgs := make([]string, 0, len(args)) diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index 6f3e2c3..7def9b3 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -17,6 +17,18 @@ func TestRootIncludesForceCommand(t *testing.T) { } } +func TestRootIncludesRepairDirtyCommand(t *testing.T) { + root := NewRoot() + + cmd, _, err := root.Find([]string{"repair-dirty"}) + if err != nil { + t.Fatalf("Find(repair-dirty) error: %v", err) + } + if cmd == nil || cmd.Name() != "repair-dirty" { + t.Fatalf("Find(repair-dirty) = %v; want repair-dirty command", cmd) + } +} + func TestForceCommandRequiresTypedConfirmation(t *testing.T) { root := NewRoot() root.SetArgs([]string{ @@ -35,6 +47,89 @@ func TestForceCommandRequiresTypedConfirmation(t *testing.T) { } } +func TestRepairDirtyCommandRequiresTypedConfirmation(t *testing.T) { + root := NewRoot() + root.SetArgs([]string{ + "repair-dirty", + "--expected-dirty-version", "2", + "--force-version", "1", + "--source-dir", t.TempDir(), + "--dsn", "postgres://user:pass@example.invalid/db", + }) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil; want confirmation error") + } + if !strings.Contains(err.Error(), "--confirm-force FORCE_MIGRATION_METADATA") { + t.Fatalf("Execute() error = %v; want confirmation error", err) + } +} + +func TestRepairDirtyCommandRejectsInvalidVersionsBeforeConnecting(t *testing.T) { + tests := []struct { + name string + expected string + forceVersion string + wantErrorContains string + }{ + { + name: "expected", + expected: "not-a-version", + forceVersion: "1", + wantErrorContains: "invalid expected dirty version", + }, + { + name: "force", + expected: "2", + forceVersion: "not-a-version", + wantErrorContains: "invalid target version", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := NewRoot() + root.SetArgs([]string{ + "repair-dirty", + "--expected-dirty-version", tt.expected, + "--force-version", tt.forceVersion, + "--source-dir", t.TempDir(), + "--dsn", "postgres://user:pass@example.invalid/db", + "--confirm-force", "FORCE_MIGRATION_METADATA", + }) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil; want invalid version error") + } + if !strings.Contains(err.Error(), tt.wantErrorContains) { + t.Fatalf("Execute() error = %v; want %q", err, tt.wantErrorContains) + } + }) + } +} + +func TestRepairDirtyCommandRejectsUnsupportedDrivers(t *testing.T) { + root := NewRoot() + root.SetArgs([]string{ + "repair-dirty", + "--driver", "goose", + "--expected-dirty-version", "2", + "--force-version", "1", + "--source-dir", t.TempDir(), + "--dsn", "postgres://user:pass@example.invalid/db", + "--confirm-force", "FORCE_MIGRATION_METADATA", + }) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil; want unsupported driver error") + } + if !strings.Contains(err.Error(), `driver "goose" does not support repair-dirty`) { + t.Fatalf("Execute() error = %v; want unsupported driver error", err) + } +} + func TestForceCommandRejectsInvalidVersionWithConfirmation(t *testing.T) { root := NewRoot() root.SetArgs([]string{ diff --git a/plugin.json b/plugin.json index 6a27e7b..510539a 100644 --- a/plugin.json +++ b/plugin.json @@ -35,6 +35,7 @@ {"name": "status", "description": "Show migration status"}, {"name": "goto", "description": "Migrate to a specific version"}, {"name": "force", "description": "Force-set the recorded migration version"}, + {"name": "repair-dirty", "description": "Repair a known dirty migration metadata state"}, {"name": "lint", "description": "Static analysis for migration files"}, {"name": "test", "description": "Run full-cycle and checkpoint migration tests"}, {"name": "tenant-ensure", "description": "Ensure a tenant schema exists in the database"} From 287128645f83891eb4d07406eb2ee614261ade71 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 26 Apr 2026 17:31:58 -0400 Subject: [PATCH 2/3] fix: avoid narrowing repair version comparison --- internal/golangmigrate/driver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/golangmigrate/driver.go b/internal/golangmigrate/driver.go index 7efe98c..b22e608 100644 --- a/internal/golangmigrate/driver.go +++ b/internal/golangmigrate/driver.go @@ -259,7 +259,7 @@ func (d *Driver) RepairDirty(ctx context.Context, req interfaces.MigrationReques if err != nil { return interfaces.MigrationResult{}, err } - if forceVersion > int(expected) { + if forceVersion > 0 && uint(forceVersion) > expected { return interfaces.MigrationResult{}, fmt.Errorf("golang-migrate repair-dirty: force version %q must not be greater than expected dirty version %q", opts.ForceVersion, opts.ExpectedDirtyVersion) } From c894f9028b2126d356ea62900c6486819a95e89d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 26 Apr 2026 17:35:17 -0400 Subject: [PATCH 3/3] fix: clarify repair dirty force parse errors --- internal/golangmigrate/driver.go | 8 ++++---- pkg/cli/root_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/golangmigrate/driver.go b/internal/golangmigrate/driver.go index b22e608..a44408b 100644 --- a/internal/golangmigrate/driver.go +++ b/internal/golangmigrate/driver.go @@ -206,7 +206,7 @@ func (d *Driver) Force(_ context.Context, req interfaces.MigrationRequest, targe } start := time.Now() - version, err := parseForceTarget(target) + version, err := parseForceTarget(target, "force") if err != nil { return interfaces.MigrationResult{}, err } @@ -255,7 +255,7 @@ func (d *Driver) RepairDirty(ctx context.Context, req interfaces.MigrationReques if err != nil { return interfaces.MigrationResult{}, err } - forceVersion, err := parseForceTarget(opts.ForceVersion) + forceVersion, err := parseForceTarget(opts.ForceVersion, "repair-dirty") if err != nil { return interfaces.MigrationResult{}, err } @@ -332,10 +332,10 @@ func parseExpectedDirtyVersion(target string) (uint, error) { return uint(version), nil } -func parseForceTarget(target string) (int, error) { +func parseForceTarget(target, command string) (int, error) { version, err := strconv.Atoi(target) if err != nil || version == 0 || version < -1 { - return 0, fmt.Errorf("golang-migrate force: invalid target version %q: must be -1 or a positive integer", target) + return 0, fmt.Errorf("golang-migrate %s: invalid target version %q: must be -1 or a positive integer", command, target) } return version, nil } diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index 7def9b3..0b0bbc1 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -83,7 +83,7 @@ func TestRepairDirtyCommandRejectsInvalidVersionsBeforeConnecting(t *testing.T) name: "force", expected: "2", forceVersion: "not-a-version", - wantErrorContains: "invalid target version", + wantErrorContains: "golang-migrate repair-dirty: invalid target version", }, } for _, tt := range tests {