rdev/internal/claudebox/git.go
jordan 9cca5cc41b
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: add proper instrumentation to git clone for debugging
- Log clone request with work_dir, URL, and token presence
- Log workspace state (is_git_repo, existing remote)
- Log all decision points (pull vs clone, clear workspace)
- Detect and clear non-empty non-git directories before clone
- Capture both stdout and stderr for clone failures
- Include exit code in error messages
2026-02-07 07:59:53 -07:00

335 lines
9.8 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 %v: %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 != "" {
credHelper := fmt.Sprintf("!f() { echo username=token; echo password=%s; }; f", 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)
}
}
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
}
// 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]")
}