rdev/internal/claudebox/git.go
jordan b6e778d5ab
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(git): harden git flow for concurrent SDLC stress test failures
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>
2026-02-10 20:57:27 -07:00

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