Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
5 fixes from stress test analysis: 1. CRITICAL: Add pull-before-push to claudebox GitOperations.CommitAndPush, matching the fix already in PodGitOperations (prevents push rejections when concurrent builds advance the remote). 2. HIGH: Extract ResetToMain into PodGitOperations as a shared public method. Wire into BuildExecutor after CloneRepo and update SDLCTaskExecutor to use the shared method. Prevents builds from running on wrong branch when worker pods are reused across tasks. 3. HIGH: Make branch create push failure fatal with retry+rollback in cmd/sdlc/cmd_branch.go. Prevents orphaned .sdlc/ state that causes merge failures after completing all 10 SDLC phases. 4. MEDIUM: Shell-escape token in credential helpers (both PodGitOperations and claudebox GitOperations) to prevent shell injection via tokens containing special characters. 5. MEDIUM: Add GitResetToMain to claudebox sidecar (git.go implementation, server.go endpoint, client.go HTTP method) and wire into HTTPSDLCTaskExecutor for the HTTP sidecar path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
5.7 KiB
Go
225 lines
5.7 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|