rdev/cmd/sdlc/cmd_branch.go
jordan f85fa181cf
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(sdlc): skip branch push when no origin remote exists
The fatal push+retry logic added in the git flow hardening broke tests
that use local-only git repos without an origin remote. Check for the
origin remote before attempting to push, preserving the fatal behavior
in production (where origin always exists) while allowing local/test
contexts to proceed without a remote.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 21:03:49 -07:00

230 lines
5.9 KiB
Go

package main
import (
"fmt"
"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)
}
}
// Only push if origin remote exists (skip in local-only/test contexts)
hasOrigin := exec.Command("git", "remote", "get-url", "origin")
hasOrigin.Dir = root
if hasOrigin.Run() == nil {
pushCmd := exec.Command("git", "push", "origin", "HEAD")
pushCmd.Dir = root
if _, err := pushCmd.CombinedOutput(); err != nil {
// Retry once — transient Gitea failures are common
time.Sleep(2 * time.Second)
retryCmd := exec.Command("git", "push", "origin", "HEAD")
retryCmd.Dir = root
if retryOut, retryErr := retryCmd.CombinedOutput(); retryErr != nil {
// Roll back the local commit so state doesn't diverge
rollbackCmd := exec.Command("git", "reset", "--soft", "HEAD~1")
rollbackCmd.Dir = root
_ = rollbackCmd.Run()
return fmt.Errorf("failed to push branch state to remote (branch not created): %s: %w",
strings.TrimSpace(string(retryOut)), retryErr)
}
}
}
// 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)
}