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