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>
357 lines
11 KiB
Go
357 lines
11 KiB
Go
package claudebox
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// GitOperations provides local git operations in the container.
|
|
type GitOperations struct {
|
|
workDir string
|
|
giteaToken string
|
|
gitUser string
|
|
gitEmail string
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// GitOperationsConfig holds configuration for git operations.
|
|
type GitOperationsConfig struct {
|
|
// WorkDir is the default working directory.
|
|
WorkDir 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
|
|
|
|
// Logger is an optional logger for debug output.
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewGitOperations creates a new git operations helper.
|
|
func NewGitOperations(cfg GitOperationsConfig) *GitOperations {
|
|
if cfg.GitUser == "" {
|
|
cfg.GitUser = "rdev-worker"
|
|
}
|
|
if cfg.GitEmail == "" {
|
|
cfg.GitEmail = "worker@threesix.ai"
|
|
}
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &GitOperations{
|
|
workDir: cfg.WorkDir,
|
|
giteaToken: cfg.GiteaToken,
|
|
gitUser: cfg.GitUser,
|
|
gitEmail: cfg.GitEmail,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// CloneRepo clones a git repository into the workspace if it doesn't exist.
|
|
// If the workspace already contains a git repo, it pulls the latest changes.
|
|
func (g *GitOperations) CloneRepo(ctx context.Context, workDir, cloneURL string) *CloneResult {
|
|
result := &CloneResult{}
|
|
|
|
g.logger.Info("git clone request",
|
|
"work_dir", workDir,
|
|
"clone_url", g.redactToken(cloneURL),
|
|
"has_token", g.giteaToken != "")
|
|
|
|
if cloneURL == "" {
|
|
result.Error = fmt.Errorf("git clone URL is required")
|
|
g.logger.Error("git clone failed: empty URL")
|
|
return result
|
|
}
|
|
|
|
// Check if already a git repo with the correct remote
|
|
isRepo := g.isGitRepo(ctx, workDir)
|
|
g.logger.Info("workspace check", "is_git_repo", isRepo, "work_dir", workDir)
|
|
|
|
if isRepo {
|
|
currentRemote, err := g.runGitOutput(ctx, workDir, "config", "--get", "remote.origin.url")
|
|
currentRemote = strings.TrimSpace(currentRemote)
|
|
g.logger.Info("existing repo detected",
|
|
"current_remote", g.redactToken(currentRemote),
|
|
"expected_remote", g.redactToken(cloneURL),
|
|
"remote_match", currentRemote == cloneURL,
|
|
"remote_err", err)
|
|
|
|
if err == nil && currentRemote == cloneURL {
|
|
// Pull latest changes
|
|
g.logger.Info("pulling latest changes", "work_dir", workDir)
|
|
if err := g.runGit(ctx, workDir, "pull", "--ff-only"); err != nil {
|
|
// Pull failed but repo exists - continue with existing state
|
|
g.logger.Warn("git pull failed, continuing with existing state",
|
|
"error", err,
|
|
"work_dir", workDir)
|
|
}
|
|
g.logger.Info("git clone complete (existing repo)", "cloned", false)
|
|
return result
|
|
}
|
|
|
|
// Different remote - clear and re-clone
|
|
g.logger.Info("clearing workspace for re-clone", "work_dir", workDir)
|
|
if err := g.clearDir(ctx, workDir); err != nil {
|
|
result.Error = fmt.Errorf("clear workspace: %w", err)
|
|
g.logger.Error("failed to clear workspace", "error", result.Error)
|
|
return result
|
|
}
|
|
}
|
|
|
|
// Check if directory exists and is non-empty (would cause clone to fail)
|
|
if !isRepo {
|
|
checkCmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("ls -A %s 2>/dev/null | head -1", workDir))
|
|
output, _ := checkCmd.Output()
|
|
if len(strings.TrimSpace(string(output))) > 0 {
|
|
g.logger.Warn("workspace not empty but not a git repo, clearing",
|
|
"work_dir", workDir,
|
|
"first_file", strings.TrimSpace(string(output)))
|
|
if err := g.clearDir(ctx, workDir); err != nil {
|
|
result.Error = fmt.Errorf("clear non-empty workspace: %w", err)
|
|
g.logger.Error("failed to clear non-empty workspace", "error", result.Error)
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inject token for authentication
|
|
authCloneURL := cloneURL
|
|
if g.giteaToken != "" {
|
|
authCloneURL = strings.Replace(cloneURL, "https://", "https://token:"+g.giteaToken+"@", 1)
|
|
} else {
|
|
g.logger.Warn("no gitea token configured, clone may fail for private repos")
|
|
}
|
|
|
|
// Clone the repository
|
|
g.logger.Info("executing git clone", "work_dir", workDir)
|
|
cmd := exec.CommandContext(ctx, "git", "clone", authCloneURL, workDir)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
errMsg := g.redactToken(stderr.String())
|
|
stdoutMsg := g.redactToken(stdout.String())
|
|
result.Error = fmt.Errorf("git clone exit %w: %s", err, errMsg)
|
|
g.logger.Error("git clone failed",
|
|
"error", err,
|
|
"stderr", errMsg,
|
|
"stdout", stdoutMsg,
|
|
"work_dir", workDir)
|
|
return result
|
|
}
|
|
|
|
result.Cloned = true
|
|
g.logger.Info("git clone complete", "cloned", true, "work_dir", workDir)
|
|
return result
|
|
}
|
|
|
|
// CommitAndPushResult contains the result of commit and push operations.
|
|
type CommitAndPushResult struct {
|
|
HasChanges bool
|
|
CommitSHA string
|
|
FilesChanged []string
|
|
Pushed bool
|
|
Error error
|
|
}
|
|
|
|
// CommitAndPush commits and optionally pushes changes.
|
|
func (g *GitOperations) CommitAndPush(ctx context.Context, workDir, message string, push bool) *CommitAndPushResult {
|
|
result := &CommitAndPushResult{}
|
|
|
|
// Configure git user
|
|
if err := g.runGit(ctx, workDir, "config", "user.name", g.gitUser); err != nil {
|
|
result.Error = fmt.Errorf("git config user.name: %w", err)
|
|
return result
|
|
}
|
|
if err := g.runGit(ctx, 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.runGitOutput(ctx, workDir, "status", "--porcelain")
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("git status: %w", err)
|
|
return result
|
|
}
|
|
if strings.TrimSpace(status) == "" {
|
|
return result // No changes
|
|
}
|
|
result.HasChanges = true
|
|
|
|
// Stage all changes
|
|
if err := g.runGit(ctx, workDir, "add", "-A"); err != nil {
|
|
result.Error = fmt.Errorf("git add: %w", err)
|
|
return result
|
|
}
|
|
|
|
// Get list of staged files
|
|
diffOutput, err := g.runGitOutput(ctx, 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.runGit(ctx, workDir, "commit", "-m", message); err != nil {
|
|
result.Error = fmt.Errorf("git commit: %w", err)
|
|
return result
|
|
}
|
|
|
|
// Get commit SHA
|
|
sha, err := g.runGitOutput(ctx, workDir, "rev-parse", "HEAD")
|
|
if err != nil {
|
|
result.Error = fmt.Errorf("git rev-parse: %w", err)
|
|
return result
|
|
}
|
|
result.CommitSHA = strings.TrimSpace(sha)
|
|
|
|
// Push if requested
|
|
if push {
|
|
// Configure credential helper
|
|
if g.giteaToken != "" {
|
|
// 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.runGit(ctx, workDir, "config", "credential.helper", credHelper); err != nil {
|
|
g.logger.Debug("credential helper config failed, continuing with push", "error", err)
|
|
}
|
|
}
|
|
|
|
// Pull before push to handle concurrent builds that may have advanced the remote.
|
|
if err := g.runGit(ctx, workDir, "pull", "--rebase", "origin", "HEAD"); err != nil {
|
|
g.logger.Warn("git pull --rebase before push failed, attempting push anyway",
|
|
"error", err, "work_dir", workDir)
|
|
}
|
|
|
|
if err := g.runGit(ctx, workDir, "push", "origin", "HEAD"); err != nil {
|
|
result.Error = fmt.Errorf("git push: %w", err)
|
|
return result
|
|
}
|
|
result.Pushed = true
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ResetToMain resets the workspace to the main branch with a clean state.
|
|
func (g *GitOperations) ResetToMain(ctx context.Context, workDir string) error {
|
|
if err := g.runGit(ctx, workDir, "fetch", "origin"); err != nil {
|
|
return fmt.Errorf("git fetch: %w", err)
|
|
}
|
|
if err := g.runGit(ctx, workDir, "checkout", "main"); err != nil {
|
|
return fmt.Errorf("git checkout main: %w", err)
|
|
}
|
|
if err := g.runGit(ctx, workDir, "reset", "--hard", "origin/main"); err != nil {
|
|
return fmt.Errorf("git reset --hard: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GitStatusResult contains git status information.
|
|
type GitStatusResult struct {
|
|
IsRepo bool `json:"is_repo"`
|
|
HasChanges bool `json:"has_changes"`
|
|
ChangedFiles []string `json:"changed_files,omitempty"`
|
|
Branch string `json:"branch,omitempty"`
|
|
}
|
|
|
|
// Status returns the git status of the workspace.
|
|
func (g *GitOperations) Status(ctx context.Context, workDir string) (*GitStatusResult, error) {
|
|
result := &GitStatusResult{}
|
|
|
|
if !g.isGitRepo(ctx, workDir) {
|
|
return result, nil
|
|
}
|
|
result.IsRepo = true
|
|
|
|
// Get current branch
|
|
branch, err := g.runGitOutput(ctx, workDir, "rev-parse", "--abbrev-ref", "HEAD")
|
|
if err == nil {
|
|
result.Branch = strings.TrimSpace(branch)
|
|
}
|
|
|
|
// Get status
|
|
status, err := g.runGitOutput(ctx, workDir, "status", "--porcelain")
|
|
if err != nil {
|
|
return result, fmt.Errorf("git status: %w", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(status), "\n")
|
|
for _, line := range lines {
|
|
if len(line) > 3 {
|
|
result.ChangedFiles = append(result.ChangedFiles, strings.TrimSpace(line[3:]))
|
|
}
|
|
}
|
|
result.HasChanges = len(result.ChangedFiles) > 0
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// isGitRepo checks if the directory is a git repository.
|
|
func (g *GitOperations) isGitRepo(ctx context.Context, workDir string) bool {
|
|
cmd := exec.CommandContext(ctx, "test", "-d", workDir+"/.git")
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
// clearDir clears the contents of a directory.
|
|
func (g *GitOperations) clearDir(ctx context.Context, dir string) error {
|
|
cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("rm -rf %s/* %s/.[!.]*", dir, dir))
|
|
return cmd.Run()
|
|
}
|
|
|
|
// runGit executes a git command.
|
|
func (g *GitOperations) runGit(ctx context.Context, workDir string, args ...string) error {
|
|
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", workDir}, args...)...)
|
|
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
|
|
}
|
|
|
|
// runGitOutput executes a git command and returns stdout.
|
|
func (g *GitOperations) runGitOutput(ctx context.Context, workDir string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", workDir}, args...)...)
|
|
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 *GitOperations) redactToken(s string) string {
|
|
if g.giteaToken == "" {
|
|
return s
|
|
}
|
|
return strings.ReplaceAll(s, g.giteaToken, "[REDACTED]")
|
|
}
|