rdev/cmd/sdlc/cmd_merge.go
jordan 8b5842682d
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(sdlc): use remote ref for merge when local branch doesn't exist
After resetToMain in the executor, only remote refs exist for feature
branches. The merge command now checks if the local branch exists and
falls back to origin/<branch> when it doesn't.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:17:10 -07:00

198 lines
5.3 KiB
Go

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 <slug>",
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/<branch>
// 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
mergeArgs := []string{"merge"}
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)
}