Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
230 lines
5.9 KiB
Go
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)
|
|
}
|