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