rdev/cmd/sdlc/cmd_merge.go
jordan 6d52228d94
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(sdlc): auto-resolve merge conflicts in .sdlc/ state files
Both main and the feature branch modify .sdlc/state.yaml during the
SDLC lifecycle. Use -X theirs to auto-resolve conflicts in favor of the
feature branch, whose state is more current.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:01:18 -07:00

200 lines
5.5 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 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)
}