package main import ( "fmt" "os/exec" "strings" "time" "github.com/orchard9/rdev/internal/sdlc" "github.com/spf13/cobra" ) var mergeStrategy string var mergeCmd = &cobra.Command{ Use: "merge ", Short: "Merge a feature branch after all gates pass", Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { root := mustResolveRoot() slug := args[0] // Check merge readiness checklist, err := sdlc.MergeChecklist(root, slug) if err != nil { return err } if len(checklist) > 0 { if jsonOutput { if err := printJSON(map[string]any{ "error": "merge not ready", "checklist": checklist, }); err != nil { return err } } return fmt.Errorf("%w: %s", sdlc.ErrMergeNotReady, strings.Join(checklist, ", ")) } f, err := sdlc.LoadFeature(root, slug) if err != nil { return err } if f.Branch == "" { return fmt.Errorf("feature %s has no branch", slug) } manifest, err := sdlc.LoadBranch(root, f.Branch) if err != nil { return err } strategy := mergeStrategy if strategy == "" { strategy = "squash" } // Record original branch for rollback origBranchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") origBranchCmd.Dir = root origBranchOut, err := origBranchCmd.Output() if err != nil { return fmt.Errorf("get current branch: %w", err) } originalBranch := strings.TrimSpace(string(origBranchOut)) // Record HEAD for rollback headCmd := exec.Command("git", "rev-parse", "HEAD") headCmd.Dir = root headOut, err := headCmd.Output() if err != nil { return fmt.Errorf("get HEAD: %w", err) } originalHead := strings.TrimSpace(string(headOut)) // rollback reverts git state on failure rollback := func() { // Reset to original HEAD resetCmd := exec.Command("git", "reset", "--hard", originalHead) resetCmd.Dir = root _ = resetCmd.Run() // Checkout original branch if different if originalBranch != manifest.BaseBranch { checkoutOrigCmd := exec.Command("git", "checkout", originalBranch) checkoutOrigCmd.Dir = root _ = checkoutOrigCmd.Run() } } // Checkout main branch checkoutCmd := exec.Command("git", "checkout", manifest.BaseBranch) checkoutCmd.Dir = root if out, err := checkoutCmd.CombinedOutput(); err != nil { return fmt.Errorf("git checkout %s: %s: %w", manifest.BaseBranch, string(out), err) } // Record main branch HEAD for rollback after checkout mainHeadCmd := exec.Command("git", "rev-parse", "HEAD") mainHeadCmd.Dir = root mainHeadOut, err := mainHeadCmd.Output() if err != nil { rollback() return fmt.Errorf("get main HEAD: %w", err) } mainHead := strings.TrimSpace(string(mainHeadOut)) // Update rollback to use main branch HEAD rollbackAfterMerge := func() { resetCmd := exec.Command("git", "reset", "--hard", mainHead) resetCmd.Dir = root _ = resetCmd.Run() } // Determine merge ref: prefer local branch, fall back to origin/ // when running in worker pods where resetToMain leaves only remote refs. mergeRef := f.Branch checkBranch := exec.Command("git", "rev-parse", "--verify", f.Branch) checkBranch.Dir = root if err := checkBranch.Run(); err != nil { mergeRef = "origin/" + f.Branch } // Merge with -X theirs to auto-resolve .sdlc/ state conflicts. // Both main and feature branch modify .sdlc/state.yaml; the feature // branch version is more current and post-merge updates will overwrite. mergeArgs := []string{"merge", "-X", "theirs"} if strategy == "squash" { mergeArgs = append(mergeArgs, "--squash") } mergeArgs = append(mergeArgs, mergeRef) gitMerge := exec.Command("git", mergeArgs...) gitMerge.Dir = root if out, err := gitMerge.CombinedOutput(); err != nil { rollbackAfterMerge() return fmt.Errorf("git merge %s: %s: %w", f.Branch, string(out), err) } // For squash merges, create the commit if strategy == "squash" { commitMsg := fmt.Sprintf("feat: %s\n\nMerged feature %s via SDLC orchestration.", f.Title, slug) commitCmd := exec.Command("git", "commit", "-m", commitMsg) commitCmd.Dir = root if out, err := commitCmd.CombinedOutput(); err != nil { rollbackAfterMerge() return fmt.Errorf("git commit: %s: %w", string(out), err) } } // Update branch manifest - rollback git on failure now := time.Now().UTC() manifest.MergedAt = &now manifest.MergeStrategy = strategy if err := sdlc.SaveBranch(root, manifest); err != nil { rollbackAfterMerge() return fmt.Errorf("save branch manifest (git rolled back): %w", err) } // Transition feature to released - rollback git on failure if err := f.Transition(sdlc.PhaseReleased); err != nil { rollbackAfterMerge() return fmt.Errorf("transition feature (git rolled back): %w", err) } if err := f.Save(root); err != nil { rollbackAfterMerge() return fmt.Errorf("save feature (git rolled back): %w", err) } // Record action state, err := sdlc.LoadState(root) if err != nil { return err } state.RecordAction("MERGE_FEATURE", slug, "cli", "success") if err := state.Save(root); err != nil { return err } if jsonOutput { return printJSON(map[string]string{ "feature": slug, "branch": f.Branch, "strategy": strategy, "status": "merged", }) } fmt.Printf("Merged: %s -> %s (strategy: %s)\n", f.Branch, manifest.BaseBranch, strategy) fmt.Printf("Feature %s transitioned to released.\n", slug) return nil }, } func init() { mergeCmd.Flags().StringVar(&mergeStrategy, "strategy", "squash", "merge strategy: squash or merge") rootCmd.AddCommand(mergeCmd) }