diff --git a/README.md b/README.md index 6eeeadb..70da815 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,36 @@ go vet ./... go test -race -count=1 ./... ``` +## Rewrite Orchestration (git-fcuk flow) + +The `git` package now includes bounded multi-pass rewrite orchestration via +`RunRewriteScenario`, designed for flows that must: + +1. Detect whether intervention is needed. +2. Intervene (run one rewrite pass). +3. Verify the repo is clean. +4. Rerun from detect until clean or attempts are exhausted. + +Use it for safety-first rewrite loops where you need deterministic stop +conditions and pass-by-pass telemetry (`RewriteScenarioResult.Passes`). + +### Validate locally + +```bash +# Targeted rewrite orchestration tests +go test -count=1 ./git -run RunRewriteScenario + +# Full module sanity +go test -count=1 ./... +``` + +Expected outcome: + +- Targeted run: `ok github.com/git-fire/git-harness/git ...` +- Full run: all packages return `ok` with no failing tests +- If a rewrite loop never reaches clean within `MaxAttempts`, callers receive + `ErrRewriteAttemptsExceeded` + ## Stability `v0.x` releases may change APIs; pin a minor or patch version in consumers. diff --git a/git/rewrite_scenario.go b/git/rewrite_scenario.go new file mode 100644 index 0000000..7c8c3a1 --- /dev/null +++ b/git/rewrite_scenario.go @@ -0,0 +1,137 @@ +package git + +import ( + "context" + "errors" + "fmt" +) + +// ErrRewriteAttemptsExceeded indicates a rewrite run remained dirty after all +// configured attempts were used. +var ErrRewriteAttemptsExceeded = errors.New("rewrite scenario exceeded maximum attempts") + +// RewriteScenarioOptions configures detect -> intervene -> verify orchestration. +// +// The flow is: +// 1. Detect whether rewrite intervention is required. +// 2. Intervene when required. +// 3. Verify whether the repo is now clean. +// 4. Repeat until clean or attempts are exhausted. +type RewriteScenarioOptions struct { + // MaxAttempts bounds total detect passes. Must be >= 1. + MaxAttempts int + + // Detect reports whether intervention is needed for the attempt. + // Return true when the repository still needs rewrite intervention. + Detect func(ctx context.Context, attempt int) (needsIntervention bool, err error) + + // Intervene performs one rewrite intervention pass for the attempt. + Intervene func(ctx context.Context, attempt int) error + + // Verify checks whether the repository is clean after intervention. + // Return true when no further rewrite intervention is needed. + Verify func(ctx context.Context, attempt int) (clean bool, err error) +} + +// RewriteAttempt captures one detect pass in the orchestration loop. +type RewriteAttempt struct { + Attempt int // 1-based pass number + NeedsIntervention bool // Detect outcome + Intervened bool // Whether Intervene ran + CleanAfterVerify bool // Verify outcome when Intervene ran +} + +// RewriteScenarioResult captures pass-by-pass outcomes. +type RewriteScenarioResult struct { + Clean bool + Attempts int + Passes []RewriteAttempt +} + +// RunRewriteScenario executes bounded detect -> intervene -> verify passes until +// the repository is clean or attempts are exhausted. +func RunRewriteScenario(ctx context.Context, opts RewriteScenarioOptions) (RewriteScenarioResult, error) { + result := RewriteScenarioResult{} + + if ctx == nil { + ctx = context.Background() + } + if opts.MaxAttempts < 1 { + return result, fmt.Errorf("max attempts must be >= 1") + } + if opts.Detect == nil { + return result, fmt.Errorf("detect callback is required") + } + if opts.Intervene == nil { + return result, fmt.Errorf("intervene callback is required") + } + if opts.Verify == nil { + return result, fmt.Errorf("verify callback is required") + } + + for attempt := 1; attempt <= opts.MaxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + result.Attempts = attempt - 1 + return result, fmt.Errorf("rewrite scenario cancelled before attempt %d: %w", attempt, err) + } + + pass := RewriteAttempt{Attempt: attempt} + + needsIntervention, err := opts.Detect(ctx, attempt) + if err != nil { + result.Attempts = attempt + result.Passes = append(result.Passes, pass) + return result, fmt.Errorf("detect failed on attempt %d: %w", attempt, err) + } + pass.NeedsIntervention = needsIntervention + + if !needsIntervention { + result.Attempts = attempt + result.Clean = true + result.Passes = append(result.Passes, pass) + return result, nil + } + + if err := ctx.Err(); err != nil { + result.Attempts = attempt + result.Passes = append(result.Passes, pass) + return result, fmt.Errorf("rewrite scenario cancelled before intervene on attempt %d: %w", attempt, err) + } + + if err := opts.Intervene(ctx, attempt); err != nil { + result.Attempts = attempt + result.Passes = append(result.Passes, pass) + return result, fmt.Errorf("intervene failed on attempt %d: %w", attempt, err) + } + pass.Intervened = true + + if err := ctx.Err(); err != nil { + result.Attempts = attempt + result.Passes = append(result.Passes, pass) + return result, fmt.Errorf("rewrite scenario cancelled before verify on attempt %d: %w", attempt, err) + } + + clean, err := opts.Verify(ctx, attempt) + if err != nil { + result.Attempts = attempt + result.Passes = append(result.Passes, pass) + return result, fmt.Errorf("verify failed on attempt %d: %w", attempt, err) + } + pass.CleanAfterVerify = clean + result.Passes = append(result.Passes, pass) + + if clean { + result.Attempts = attempt + result.Clean = true + return result, nil + } + + if err := ctx.Err(); err != nil { + result.Attempts = attempt + return result, fmt.Errorf("rewrite scenario cancelled after verify on attempt %d: %w", attempt, err) + } + } + + result.Attempts = opts.MaxAttempts + return result, fmt.Errorf("%w: attempts=%d", ErrRewriteAttemptsExceeded, opts.MaxAttempts) +} diff --git a/git/rewrite_scenario_test.go b/git/rewrite_scenario_test.go new file mode 100644 index 0000000..2ad34fa --- /dev/null +++ b/git/rewrite_scenario_test.go @@ -0,0 +1,453 @@ +package git + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestRunRewriteScenario_CleanOnFirstDetect(t *testing.T) { + detectCalls := 0 + interveneCalls := 0 + verifyCalls := 0 + + result, err := RunRewriteScenario(context.Background(), RewriteScenarioOptions{ + MaxAttempts: 3, + Detect: func(_ context.Context, attempt int) (bool, error) { + detectCalls++ + if attempt != 1 { + t.Fatalf("attempt = %d, want 1", attempt) + } + return false, nil + }, + Intervene: func(_ context.Context, _ int) error { + interveneCalls++ + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + verifyCalls++ + return true, nil + }, + }) + if err != nil { + t.Fatalf("RunRewriteScenario() error = %v", err) + } + + if !result.Clean { + t.Fatalf("result.Clean = false, want true") + } + if result.Attempts != 1 { + t.Fatalf("result.Attempts = %d, want 1", result.Attempts) + } + if len(result.Passes) != 1 { + t.Fatalf("len(result.Passes) = %d, want 1", len(result.Passes)) + } + pass := result.Passes[0] + if pass.NeedsIntervention { + t.Fatalf("pass.NeedsIntervention = true, want false") + } + if pass.Intervened { + t.Fatalf("pass.Intervened = true, want false") + } + if pass.CleanAfterVerify { + t.Fatalf("pass.CleanAfterVerify = true, want false when verify not run") + } + if detectCalls != 1 || interveneCalls != 0 || verifyCalls != 0 { + t.Fatalf("calls = detect:%d intervene:%d verify:%d, want 1/0/0", detectCalls, interveneCalls, verifyCalls) + } +} + +func TestRunRewriteScenario_MultiPassUntilVerifyClean(t *testing.T) { + detectCalls := 0 + interveneCalls := 0 + verifyCalls := 0 + + result, err := RunRewriteScenario(context.Background(), RewriteScenarioOptions{ + MaxAttempts: 4, + Detect: func(_ context.Context, _ int) (bool, error) { + detectCalls++ + return true, nil + }, + Intervene: func(_ context.Context, _ int) error { + interveneCalls++ + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + verifyCalls++ + return verifyCalls == 2, nil + }, + }) + if err != nil { + t.Fatalf("RunRewriteScenario() error = %v", err) + } + + if !result.Clean { + t.Fatalf("result.Clean = false, want true") + } + if result.Attempts != 2 { + t.Fatalf("result.Attempts = %d, want 2", result.Attempts) + } + if len(result.Passes) != 2 { + t.Fatalf("len(result.Passes) = %d, want 2", len(result.Passes)) + } + if result.Passes[0].CleanAfterVerify { + t.Fatalf("pass 1 clean after verify = true, want false") + } + if !result.Passes[1].CleanAfterVerify { + t.Fatalf("pass 2 clean after verify = false, want true") + } + if detectCalls != 2 || interveneCalls != 2 || verifyCalls != 2 { + t.Fatalf("calls = detect:%d intervene:%d verify:%d, want 2/2/2", detectCalls, interveneCalls, verifyCalls) + } +} + +func TestRunRewriteScenario_RerunDetectUntilClean(t *testing.T) { + detectCalls := 0 + interveneCalls := 0 + verifyCalls := 0 + + result, err := RunRewriteScenario(context.Background(), RewriteScenarioOptions{ + MaxAttempts: 3, + Detect: func(_ context.Context, _ int) (bool, error) { + detectCalls++ + return detectCalls == 1, nil + }, + Intervene: func(_ context.Context, _ int) error { + interveneCalls++ + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + verifyCalls++ + return false, nil + }, + }) + if err != nil { + t.Fatalf("RunRewriteScenario() error = %v", err) + } + + if !result.Clean { + t.Fatalf("result.Clean = false, want true") + } + if result.Attempts != 2 { + t.Fatalf("result.Attempts = %d, want 2", result.Attempts) + } + if len(result.Passes) != 2 { + t.Fatalf("len(result.Passes) = %d, want 2", len(result.Passes)) + } + if !result.Passes[0].NeedsIntervention || !result.Passes[0].Intervened { + t.Fatalf("pass 1 should include intervention, got %+v", result.Passes[0]) + } + if result.Passes[1].NeedsIntervention || result.Passes[1].Intervened { + t.Fatalf("pass 2 should be clean detect with no intervention, got %+v", result.Passes[1]) + } + if detectCalls != 2 || interveneCalls != 1 || verifyCalls != 1 { + t.Fatalf("calls = detect:%d intervene:%d verify:%d, want 2/1/1", detectCalls, interveneCalls, verifyCalls) + } +} + +func TestRunRewriteScenario_ExceedsMaxAttempts(t *testing.T) { + result, err := RunRewriteScenario(context.Background(), RewriteScenarioOptions{ + MaxAttempts: 3, + Detect: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + Intervene: func(_ context.Context, _ int) error { + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + return false, nil + }, + }) + if err == nil { + t.Fatal("RunRewriteScenario() error = nil, want max-attempts error") + } + if !errors.Is(err, ErrRewriteAttemptsExceeded) { + t.Fatalf("error = %v, want ErrRewriteAttemptsExceeded", err) + } + if result.Clean { + t.Fatalf("result.Clean = true, want false") + } + if result.Attempts != 3 { + t.Fatalf("result.Attempts = %d, want 3", result.Attempts) + } + if len(result.Passes) != 3 { + t.Fatalf("len(result.Passes) = %d, want 3", len(result.Passes)) + } +} + +func TestRunRewriteScenario_CallbackErrors(t *testing.T) { + t.Run("detect error", func(t *testing.T) { + result, err := RunRewriteScenario(context.Background(), RewriteScenarioOptions{ + MaxAttempts: 2, + Detect: func(_ context.Context, _ int) (bool, error) { + return false, errors.New("boom-detect") + }, + Intervene: func(_ context.Context, _ int) error { + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "detect failed") { + t.Fatalf("error = %v, want detect failure", err) + } + if result.Attempts != 1 { + t.Fatalf("result.Attempts = %d, want 1", result.Attempts) + } + }) + + t.Run("intervene error", func(t *testing.T) { + result, err := RunRewriteScenario(context.Background(), RewriteScenarioOptions{ + MaxAttempts: 2, + Detect: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + Intervene: func(_ context.Context, _ int) error { + return errors.New("boom-intervene") + }, + Verify: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "intervene failed") { + t.Fatalf("error = %v, want intervene failure", err) + } + if result.Attempts != 1 { + t.Fatalf("result.Attempts = %d, want 1", result.Attempts) + } + if len(result.Passes) != 1 || !result.Passes[0].NeedsIntervention { + t.Fatalf("passes = %+v, want one pass with intervention required", result.Passes) + } + }) + + t.Run("verify error", func(t *testing.T) { + result, err := RunRewriteScenario(context.Background(), RewriteScenarioOptions{ + MaxAttempts: 2, + Detect: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + Intervene: func(_ context.Context, _ int) error { + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + return false, errors.New("boom-verify") + }, + }) + if err == nil || !strings.Contains(err.Error(), "verify failed") { + t.Fatalf("error = %v, want verify failure", err) + } + if result.Attempts != 1 { + t.Fatalf("result.Attempts = %d, want 1", result.Attempts) + } + if len(result.Passes) != 1 || !result.Passes[0].Intervened { + t.Fatalf("passes = %+v, want one pass with intervene=true", result.Passes) + } + }) +} + +func TestRunRewriteScenario_InvalidConfiguration(t *testing.T) { + _, err := RunRewriteScenario(context.Background(), RewriteScenarioOptions{ + MaxAttempts: 0, + Detect: func(_ context.Context, _ int) (bool, error) { + return false, nil + }, + Intervene: func(_ context.Context, _ int) error { + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "max attempts") { + t.Fatalf("error = %v, want max-attempts validation error", err) + } + + _, err = RunRewriteScenario(context.Background(), RewriteScenarioOptions{ + MaxAttempts: 1, + Intervene: func(_ context.Context, _ int) error { + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "detect callback") { + t.Fatalf("error = %v, want detect callback validation error", err) + } +} + +func TestRunRewriteScenario_CancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := RunRewriteScenario(ctx, RewriteScenarioOptions{ + MaxAttempts: 2, + Detect: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + Intervene: func(_ context.Context, _ int) error { + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + return false, nil + }, + }) + if err == nil { + t.Fatal("RunRewriteScenario() error = nil, want cancellation error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("RunRewriteScenario() error = %v, want context.Canceled", err) + } + if result.Attempts != 0 { + t.Fatalf("result.Attempts = %d, want 0", result.Attempts) + } +} + +func TestRunRewriteScenario_NilContextFallback(t *testing.T) { + detectCalls := 0 + result, err := RunRewriteScenario(nil, RewriteScenarioOptions{ + MaxAttempts: 1, + Detect: func(_ context.Context, attempt int) (bool, error) { + detectCalls++ + if attempt != 1 { + t.Fatalf("attempt = %d, want 1", attempt) + } + return false, nil + }, + Intervene: func(_ context.Context, _ int) error { + t.Fatal("Intervene should not run when detect reports clean") + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + t.Fatal("Verify should not run when detect reports clean") + return true, nil + }, + }) + if err != nil { + t.Fatalf("RunRewriteScenario() error = %v", err) + } + if !result.Clean { + t.Fatalf("result.Clean = false, want true") + } + if detectCalls != 1 { + t.Fatalf("detectCalls = %d, want 1", detectCalls) + } +} + +func TestRunRewriteScenario_CancelledAfterDetectSkipsIntervene(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + interveneCalls := 0 + verifyCalls := 0 + + result, err := RunRewriteScenario(ctx, RewriteScenarioOptions{ + MaxAttempts: 2, + Detect: func(_ context.Context, _ int) (bool, error) { + cancel() + return true, nil + }, + Intervene: func(_ context.Context, _ int) error { + interveneCalls++ + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + verifyCalls++ + return true, nil + }, + }) + if err == nil { + t.Fatal("RunRewriteScenario() error = nil, want cancellation error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("RunRewriteScenario() error = %v, want context.Canceled", err) + } + if !strings.Contains(err.Error(), "cancelled before intervene") { + t.Fatalf("error = %v, want message about intervene phase", err) + } + if result.Attempts != 1 { + t.Fatalf("result.Attempts = %d, want 1", result.Attempts) + } + if len(result.Passes) != 1 || !result.Passes[0].NeedsIntervention { + t.Fatalf("passes = %+v, want one pass with detect outcome recorded", result.Passes) + } + if interveneCalls != 0 || verifyCalls != 0 { + t.Fatalf("calls = intervene:%d verify:%d, want 0/0", interveneCalls, verifyCalls) + } +} + +func TestRunRewriteScenario_CancelledAfterInterveneSkipsVerify(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + verifyCalls := 0 + + result, err := RunRewriteScenario(ctx, RewriteScenarioOptions{ + MaxAttempts: 2, + Detect: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + Intervene: func(_ context.Context, _ int) error { + cancel() + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + verifyCalls++ + return true, nil + }, + }) + if err == nil { + t.Fatal("RunRewriteScenario() error = nil, want cancellation error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("RunRewriteScenario() error = %v, want context.Canceled", err) + } + if !strings.Contains(err.Error(), "cancelled before verify") { + t.Fatalf("error = %v, want message about verify phase", err) + } + if result.Attempts != 1 { + t.Fatalf("result.Attempts = %d, want 1", result.Attempts) + } + if len(result.Passes) != 1 || !result.Passes[0].Intervened { + t.Fatalf("passes = %+v, want one pass with intervene=true", result.Passes) + } + if verifyCalls != 0 { + t.Fatalf("verifyCalls = %d, want 0", verifyCalls) + } +} + +func TestRunRewriteScenario_CancelledInVerifyOnLastAttempt(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + result, err := RunRewriteScenario(ctx, RewriteScenarioOptions{ + MaxAttempts: 1, + Detect: func(_ context.Context, _ int) (bool, error) { + return true, nil + }, + Intervene: func(_ context.Context, _ int) error { + return nil + }, + Verify: func(_ context.Context, _ int) (bool, error) { + cancel() + return false, nil + }, + }) + if err == nil { + t.Fatal("RunRewriteScenario() error = nil, want cancellation error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("RunRewriteScenario() error = %v, want context.Canceled", err) + } + if !strings.Contains(err.Error(), "cancelled after verify") { + t.Fatalf("error = %v, want message about post-verify cancellation", err) + } + if result.Attempts != 1 { + t.Fatalf("result.Attempts = %d, want 1", result.Attempts) + } + if len(result.Passes) != 1 || !result.Passes[0].Intervened || result.Passes[0].CleanAfterVerify { + t.Fatalf("passes = %+v, want one pass with verify outcome recorded", result.Passes) + } + if errors.Is(err, ErrRewriteAttemptsExceeded) { + t.Fatal("error should not be ErrRewriteAttemptsExceeded when context was cancelled") + } +}