All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
RC-1: Gitea org fallback already removed (no-op, confirmed) RC-3: Push/pull now explicitly target origin main (HEAD:main) in both pod_git_operations.go and claudebox/git.go — fixes Woodpecker webhook trigger by ensuring pushes always land on the main branch RC-4: wait_for_pipeline records baseline pipeline number before polling; only returns success when a NEWER pipeline completes — prevents false positive when a prior pipeline was already success RC-5: Redis WRONGPASS fixed on live persona-community-5 instance; platform gap noted (no reprovision endpoint for Redis ACL drift) RC-6: Removed on_error:continue from all infra provisioning steps (add-db, add-redis) across persona-community, slackpath-2/3/4/5 trees — infra failures now fail the tree instead of silently continuing to a crash RC-7: Added .pnpm-store/ to skeleton .gitignore — prevents thousands of cache files being committed by agents after pnpm install RC-2: Updated all 12 cookbook trees — git_clone_url jordan/ → threesix/ (24 occurrences across all slackpath, aeries, full-stack, genkit trees) Also: strings.Cut and strings.SplitSeq lint fixes in pod_git_operations.go and claudebox/git.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
356 lines
11 KiB
Go
356 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.SplitSeq(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", "main"); 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:main"); 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)
|
|
}
|
|
|
|
for line := range strings.SplitSeq(strings.TrimSpace(status), "\n") {
|
|
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]")
|
|
}
|