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>
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package worker
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/orchard9/rdev/internal/logging"
|
|
)
|
|
|
|
// PodGitOperations provides git operations that run inside a Kubernetes pod
|
|
// via kubectl exec. This ensures git commands execute in the same environment
|
|
// where the code agent runs.
|
|
type PodGitOperations struct {
|
|
namespace string
|
|
giteaToken string
|
|
gitUser string
|
|
gitEmail string
|
|
}
|
|
|
|
// PodGitOperationsConfig configures pod git operations.
|
|
type PodGitOperationsConfig struct {
|
|
// Namespace is the Kubernetes namespace for kubectl exec.
|
|
Namespace string
|
|
|
|
// GiteaToken is the token for HTTPS push authentication.
|
|
GiteaToken string
|
|
|
|
// GitUser is the git commit author name.
|
|
GitUser string
|
|
|
|
// GitEmail is the git commit author email.
|
|
GitEmail string
|
|
}
|
|
|
|
// NewPodGitOperations creates a new pod git operations helper.
|
|
func NewPodGitOperations(cfg PodGitOperationsConfig) *PodGitOperations {
|
|
if cfg.GitUser == "" {
|
|
cfg.GitUser = "rdev-worker"
|
|
}
|
|
if cfg.GitEmail == "" {
|
|
cfg.GitEmail = "worker@threesix.ai"
|
|
}
|
|
return &PodGitOperations{
|
|
namespace: cfg.Namespace,
|
|
giteaToken: cfg.GiteaToken,
|
|
gitUser: cfg.GitUser,
|
|
gitEmail: cfg.GitEmail,
|
|
}
|
|
}
|
|
|
|
// PostBuildResult contains the result of post-build git operations.
|
|
type PostBuildResult struct {
|
|
HasChanges bool
|
|
CommitSHA string
|
|
FilesChanged []string
|
|
Pushed bool
|
|
Error error
|
|
}
|
|
|
|
// CloneResult contains the result of a git clone operation.
|
|
type CloneResult struct {
|
|
Cloned bool // True if repo was cloned, false if already existed
|
|
Error error
|
|
}
|
|
|
|
// IsGitRepo checks if the given directory is a git repository.
|
|
func (g *PodGitOperations) IsGitRepo(ctx context.Context, podName, workDir string) bool {
|
|
// Check if .git directory exists
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"test", "-d", workDir + "/.git",
|
|
}
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
// CloneRepo clones a git repository into the workspace if it doesn't already exist.
|
|
// If the workspace already contains a git repo, it pulls the latest changes instead.
|
|
// If the workspace exists but is not a git repo, it clears the directory first.
|
|
func (g *PodGitOperations) CloneRepo(ctx context.Context, podName, workDir, cloneURL string) *CloneResult {
|
|
log := logging.FromContext(ctx).WithWorker("pod-git-ops")
|
|
result := &CloneResult{}
|
|
|
|
if cloneURL == "" {
|
|
result.Error = fmt.Errorf("git clone URL is required")
|
|
return result
|
|
}
|
|
|
|
// Check if already a git repo with the correct remote
|
|
if g.IsGitRepo(ctx, podName, workDir) {
|
|
// Verify the remote URL matches the expected clone URL
|
|
currentRemote, err := g.runGitInPodOutput(ctx, podName, workDir, "config", "--get", "remote.origin.url")
|
|
currentRemote = strings.TrimSpace(currentRemote)
|
|
|
|
// Strip token from currentRemote for comparison, since clone stores the authenticated URL
|
|
// Format: https://token:TOKEN@host/path -> https://host/path
|
|
normalizedRemote := currentRemote
|
|
if idx := strings.Index(currentRemote, "@"); idx != -1 && strings.HasPrefix(currentRemote, "https://") {
|
|
normalizedRemote = "https://" + currentRemote[idx+1:]
|
|
}
|
|
expectedURL := cloneURL
|
|
|
|
// Normalize URLs for comparison (both should be HTTPS without credentials)
|
|
if err == nil && normalizedRemote == expectedURL {
|
|
log.Info("workspace is already a git repo with correct remote, pulling latest",
|
|
logging.FieldPodName, podName,
|
|
"workDir", workDir,
|
|
)
|
|
// Pull latest changes
|
|
if err := g.runGitInPod(ctx, podName, workDir, "pull", "--ff-only"); err != nil {
|
|
// Pull failed, but repo exists - not fatal, might have local changes
|
|
log.Warn("git pull failed, continuing with existing state",
|
|
logging.FieldPodName, podName,
|
|
logging.FieldError, err,
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Remote doesn't match - this is a different project's repo
|
|
log.Info("workspace has different git remote, will re-clone",
|
|
logging.FieldPodName, podName,
|
|
"workDir", workDir,
|
|
"currentRemote", currentRemote,
|
|
"expectedURL", expectedURL,
|
|
)
|
|
}
|
|
|
|
// Check if directory exists but is not a git repo - clear it first
|
|
if g.dirExists(ctx, podName, workDir) {
|
|
log.Info("workspace exists but is not a git repo, clearing",
|
|
logging.FieldPodName, podName,
|
|
"workDir", workDir,
|
|
)
|
|
// Clear the directory contents (but keep the directory itself)
|
|
clearArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"sh", "-c", fmt.Sprintf("rm -rf %s/* %s/.[!.]*", workDir, workDir),
|
|
}
|
|
cmd := exec.CommandContext(ctx, "kubectl", clearArgs...)
|
|
if err := cmd.Run(); err != nil {
|
|
log.Warn("failed to clear workspace, attempting clone anyway",
|
|
logging.FieldPodName, podName,
|
|
logging.FieldError, err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Configure credential helper for clone (for private repos)
|
|
authCloneURL := cloneURL
|
|
if g.giteaToken != "" {
|
|
// Inject token into clone URL for authentication
|
|
// https://git.example.com/owner/repo.git -> https://token:TOKEN@git.example.com/owner/repo.git
|
|
authCloneURL = strings.Replace(cloneURL, "https://", "https://token:"+g.giteaToken+"@", 1)
|
|
}
|
|
|
|
log.Info("cloning repository",
|
|
logging.FieldPodName, podName,
|
|
"workDir", workDir,
|
|
"url", cloneURL, // Log without token
|
|
)
|
|
|
|
// Clone the repository
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"git", "clone", authCloneURL, workDir,
|
|
}
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := g.redactToken(stderr.String())
|
|
result.Error = fmt.Errorf("git clone failed: %s: %s", err, errMsg)
|
|
return result
|
|
}
|
|
|
|
result.Cloned = true
|
|
log.Info("repository cloned successfully",
|
|
logging.FieldPodName, podName,
|
|
"workDir", workDir,
|
|
)
|
|
|
|
return result
|
|
}
|
|
|
|
// dirExists checks if a directory exists in the pod.
|
|
func (g *PodGitOperations) dirExists(ctx context.Context, podName, path string) bool {
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"test", "-d", path,
|
|
}
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
// ResetToMain resets the workspace to the main branch with a clean state.
|
|
// This ensures each task starts from a known-good state regardless of
|
|
// what previous tasks may have done (e.g., switched to a feature branch).
|
|
func (g *PodGitOperations) ResetToMain(ctx context.Context, podName, workDir string) error {
|
|
resetScript := fmt.Sprintf(
|
|
"cd %s && git fetch origin && git checkout main && git reset --hard origin/main",
|
|
workDir,
|
|
)
|
|
args := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"sh", "-c", resetScript,
|
|
}
|
|
cmd := exec.CommandContext(ctx, "kubectl", args...)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("reset to main: %s: %w", stderr.String(), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CommitAndPush performs post-build git operations inside the pod:
|
|
// 1. Configures git user/email
|
|
// 2. Checks for changes (git status)
|
|
// 3. Stages all changes (git add -A)
|
|
// 4. Commits with the given message
|
|
// 5. Pushes if requested
|
|
//
|
|
// This is the programmatic alternative to relying on LLMs for git operations.
|
|
func (g *PodGitOperations) CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *PostBuildResult {
|
|
log := logging.FromContext(ctx).WithWorker("pod-git-ops")
|
|
result := &PostBuildResult{}
|
|
|
|
// Configure git user for commits
|
|
if err := g.runGitInPod(ctx, podName, workDir, "config", "user.name", g.gitUser); err != nil {
|
|
result.Error = fmt.Errorf("git config user.name: %w", err)
|
|
return result
|
|
}
|
|
if err := g.runGitInPod(ctx, podName, workDir, "config", "user.email", g.gitEmail); err != nil {
|
|
result.Error = fmt.Errorf("git config user.email: %w", err)
|
|
return result
|
|
}
|
|
|
|
// Check for changes
|
|
status, err := g.runGitInPodOutput(ctx, podName, workDir, "status", "--porcelain")
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("git status: %w", err)
|
|
return result
|
|
}
|
|
if strings.TrimSpace(status) == "" {
|
|
log.Info("no changes to commit", logging.FieldPodName, podName, "workDir", workDir)
|
|
return result
|
|
}
|
|
result.HasChanges = true
|
|
|
|
// Stage all changes
|
|
if err := g.runGitInPod(ctx, podName, workDir, "add", "-A"); err != nil {
|
|
result.Error = fmt.Errorf("git add: %w", err)
|
|
return result
|
|
}
|
|
|
|
// Get list of staged files
|
|
diffOutput, err := g.runGitInPodOutput(ctx, podName, workDir, "diff", "--cached", "--name-only")
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("git diff: %w", err)
|
|
return result
|
|
}
|
|
for _, f := range strings.Split(strings.TrimSpace(diffOutput), "\n") {
|
|
if f != "" {
|
|
result.FilesChanged = append(result.FilesChanged, f)
|
|
}
|
|
}
|
|
|
|
// Commit
|
|
if err := g.runGitInPod(ctx, podName, workDir, "commit", "-m", message); err != nil {
|
|
result.Error = fmt.Errorf("git commit: %w", err)
|
|
return result
|
|
}
|
|
|
|
// Get commit SHA
|
|
sha, err := g.runGitInPodOutput(ctx, podName, workDir, "rev-parse", "HEAD")
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("git rev-parse: %w", err)
|
|
return result
|
|
}
|
|
result.CommitSHA = strings.TrimSpace(sha)
|
|
|
|
log.Info("committed changes",
|
|
logging.FieldPodName, podName,
|
|
"sha", result.CommitSHA,
|
|
"files", len(result.FilesChanged),
|
|
)
|
|
|
|
// Push if requested
|
|
if push {
|
|
// Configure credential helper for push
|
|
if g.giteaToken != "" {
|
|
// Use git credential helper to inject token
|
|
// This avoids putting the token in the URL which would be visible in logs
|
|
// Use single quotes around password to prevent shell interpretation
|
|
credHelper := fmt.Sprintf("!f() { echo username=token; echo 'password=%s'; }; f",
|
|
strings.ReplaceAll(g.giteaToken, "'", "'\\''"))
|
|
if err := g.runGitInPod(ctx, podName, workDir, "config", "credential.helper", credHelper); err != nil {
|
|
log.Warn("failed to configure credential helper", logging.FieldError, err)
|
|
// Continue anyway - push might still work if pod has other auth configured
|
|
}
|
|
}
|
|
|
|
// Pull before push to handle concurrent builds that may have advanced the remote.
|
|
// Use --rebase to replay our commit on top of any new remote commits.
|
|
if err := g.runGitInPod(ctx, podName, workDir, "pull", "--rebase", "origin", "HEAD"); err != nil {
|
|
log.Warn("git pull --rebase before push failed, attempting push anyway",
|
|
logging.FieldPodName, podName,
|
|
logging.FieldError, err,
|
|
)
|
|
}
|
|
|
|
if err := g.runGitInPod(ctx, podName, workDir, "push", "origin", "HEAD"); err != nil {
|
|
result.Error = fmt.Errorf("git push: %w", err)
|
|
return result
|
|
}
|
|
result.Pushed = true
|
|
log.Info("pushed changes", logging.FieldPodName, podName, "sha", result.CommitSHA)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// runGitInPod executes a git command inside the pod via kubectl exec.
|
|
func (g *PodGitOperations) runGitInPod(ctx context.Context, podName, workDir string, args ...string) error {
|
|
// Build: kubectl exec -n <namespace> <pod> -- git -C <workDir> <args...>
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"git", "-C", workDir,
|
|
}
|
|
kubectlArgs = append(kubectlArgs, args...)
|
|
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := g.redactToken(stderr.String())
|
|
return fmt.Errorf("%s: %s", err, errMsg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runGitInPodOutput executes a git command and returns stdout.
|
|
func (g *PodGitOperations) runGitInPodOutput(ctx context.Context, podName, workDir string, args ...string) (string, error) {
|
|
kubectlArgs := []string{
|
|
"exec", "-n", g.namespace, podName, "--",
|
|
"git", "-C", workDir,
|
|
}
|
|
kubectlArgs = append(kubectlArgs, args...)
|
|
|
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := g.redactToken(stderr.String())
|
|
return "", fmt.Errorf("%s: %s", err, errMsg)
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
// redactToken removes the Gitea token from output.
|
|
func (g *PodGitOperations) redactToken(s string) string {
|
|
if g.giteaToken == "" {
|
|
return s
|
|
}
|
|
return strings.ReplaceAll(s, g.giteaToken, "[REDACTED]")
|
|
}
|