rdev/cmd/sdlc/cmd_branch.go
jordan 6ec2a4fea3
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(sdlc): persist branch metadata on main before feature branch creation
The `sdlc merge` command reads the Branch field from the feature manifest
on main, but `sdlc branch create` was only committing that state to the
feature branch (via the executor's CommitAndPush). This caused merge to
fail with "feature has no branch".

Two changes:
1. cmd/sdlc/cmd_branch.go: commit .sdlc/ state to main before
   `git checkout -b`, ensuring Branch metadata is on main where merge
   reads it.
2. internal/worker/sdlc_executor.go: reset workspace to main
   (`git fetch && git checkout main && git reset --hard origin/main`)
   before each SDLC task, preventing cross-task branch contamination
   from commands that switch branches.

Also updates foundary cookbook with architect fallback pattern and
on_error: continue for steps that may fail during early lifecycle.

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

218 lines
5.4 KiB
Go

package main
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var branchCmd = &cobra.Command{
Use: "branch",
Short: "Manage feature branches",
}
var branchCreateCmd = &cobra.Command{
Use: "create <slug>",
Short: "Create a feature branch",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug := args[0]
cfg, err := sdlc.LoadConfig(root)
if err != nil {
return err
}
// Create the branch manifest
manifest, err := sdlc.CreateBranch(root, slug, cfg)
if err != nil {
return err
}
// Commit .sdlc/ state to main BEFORE creating the feature branch.
// This ensures the Branch field in the feature manifest is persisted
// on main, which is required by `sdlc merge` (reads from main).
addCmd := exec.Command("git", "add", ".sdlc/")
addCmd.Dir = root
if out, err := addCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git add .sdlc/: %s: %w", string(out), err)
}
commitCmd := exec.Command("git", "commit", "-m", fmt.Sprintf("sdlc: create branch for %s", slug))
commitCmd.Dir = root
if out, err := commitCmd.CombinedOutput(); err != nil {
// If nothing to commit (already clean), that's fine — continue
if !strings.Contains(string(out), "nothing to commit") {
return fmt.Errorf("git commit .sdlc/: %s: %w", string(out), err)
}
}
pushCmd := exec.Command("git", "push", "origin", "HEAD")
pushCmd.Dir = root
if out, err := pushCmd.CombinedOutput(); err != nil {
// Push failure is non-fatal. In executor contexts, origin exists
// (from git clone) and this succeeds. In local/test contexts without
// a remote, the commit is still persisted locally on main.
fmt.Fprintf(os.Stderr, "warning: failed to push .sdlc/ state: %s\n", strings.TrimSpace(string(out)))
}
// Create the git branch
branchName := manifest.Name
gitCmd := exec.Command("git", "checkout", "-b", branchName)
gitCmd.Dir = root
if out, err := gitCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git checkout -b %s: %s: %w", branchName, string(out), err)
}
// Record the action in state
state, err := sdlc.LoadState(root)
if err != nil {
return err
}
state.RecordAction("CREATE_BRANCH", slug, "cli", "success")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
return printJSON(manifest)
}
fmt.Printf("Created branch: %s (from %s)\n", branchName, manifest.BaseBranch)
return nil
},
}
var branchStatusCmd = &cobra.Command{
Use: "status <slug>",
Short: "Show branch status and merge checklist",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug := args[0]
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
if f.Branch == "" {
if jsonOutput {
return printJSON(map[string]string{"error": "no branch associated with feature"})
}
fmt.Printf("Feature %s has no branch.\n", slug)
return nil
}
manifest, err := sdlc.LoadBranch(root, f.Branch)
if err != nil {
return err
}
checklist, err := sdlc.MergeChecklist(root, slug)
if err != nil {
return err
}
if jsonOutput {
return printJSON(map[string]any{
"branch": manifest,
"checklist": checklist,
"ready": len(checklist) == 0,
})
}
fmt.Printf("Branch: %s\n", manifest.Name)
fmt.Printf("Feature: %s\n", manifest.Feature)
fmt.Printf("Base: %s\n", manifest.BaseBranch)
fmt.Printf("Created: %s\n", manifest.CreatedAt.Format(time.RFC3339))
if manifest.LastSyncAt != nil {
fmt.Printf("Last sync: %s\n", manifest.LastSyncAt.Format(time.RFC3339))
}
fmt.Println()
if len(checklist) == 0 {
fmt.Println("Merge status: READY")
} else {
fmt.Println("Merge status: NOT READY")
fmt.Println("Unmet gates:")
for _, gate := range checklist {
fmt.Printf(" - %s\n", gate)
}
}
return nil
},
}
var branchSyncCmd = &cobra.Command{
Use: "sync <slug>",
Short: "Sync feature branch with base branch",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug := args[0]
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
}
// Fetch and rebase
fetchCmd := exec.Command("git", "fetch", "origin")
fetchCmd.Dir = root
if out, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch: %s: %w", string(out), err)
}
rebaseCmd := exec.Command("git", "rebase", "origin/"+manifest.BaseBranch)
rebaseCmd.Dir = root
if out, err := rebaseCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git rebase origin/%s: %s: %w", manifest.BaseBranch, string(out), err)
}
// Update last sync time
now := time.Now().UTC()
manifest.LastSyncAt = &now
if err := sdlc.SaveBranch(root, manifest); err != nil {
return err
}
if jsonOutput {
return printJSON(map[string]string{
"feature": slug,
"branch": manifest.Name,
"synced": "true",
"synced_at": now.Format(time.RFC3339),
})
}
fmt.Printf("Synced %s with origin/%s\n", manifest.Name, manifest.BaseBranch)
return nil
},
}
func init() {
branchCmd.AddCommand(
branchCreateCmd,
branchStatusCmd,
branchSyncCmd,
)
rootCmd.AddCommand(branchCmd)
}