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 ", 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 ", 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 ", 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) }