rdev/internal/worker/git_operations.go
jordan bc47e426b0 feat: Add CI pipeline proxy, DNS alias management, and worker executor system
- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:05:28 -07:00

234 lines
6.3 KiB
Go

package worker
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
)
// GitOperations provides git clone, commit, and push functionality
// for the build executor. It uses os/exec to run git commands.
type GitOperations struct {
giteaToken string
gitUser string
gitEmail string
logger *slog.Logger
}
// GitOperationsConfig configures git operations.
type GitOperationsConfig struct {
// GiteaToken is the token for HTTPS clone/push authentication.
GiteaToken string
// GitUser is the git commit author name.
GitUser string
// GitEmail is the git commit author email.
GitEmail string
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"
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
return &GitOperations{
giteaToken: cfg.GiteaToken,
gitUser: cfg.GitUser,
gitEmail: cfg.GitEmail,
logger: cfg.Logger.With("component", "git-ops"),
}
}
// CloneToTemp clones a repository to a temporary directory.
// Returns the clone directory and a cleanup function.
func (g *GitOperations) CloneToTemp(ctx context.Context, gitURL string) (string, func(), error) {
tmpDir, err := os.MkdirTemp("", "rdev-build-*")
if err != nil {
return "", nil, fmt.Errorf("create temp dir: %w", err)
}
cleanup := func() {
if err := os.RemoveAll(tmpDir); err != nil {
g.logger.Warn("failed to cleanup temp dir", "dir", tmpDir, "error", err)
}
}
// Inject token into clone URL for authentication
authURL := g.injectToken(gitURL)
if err := g.runGit(ctx, tmpDir, "clone", authURL, "."); err != nil {
cleanup()
return "", nil, fmt.Errorf("git clone: %w", err)
}
// Configure git user for commits
if err := g.runGit(ctx, tmpDir, "config", "user.name", g.gitUser); err != nil {
cleanup()
return "", nil, fmt.Errorf("git config user.name: %w", err)
}
if err := g.runGit(ctx, tmpDir, "config", "user.email", g.gitEmail); err != nil {
cleanup()
return "", nil, fmt.Errorf("git config user.email: %w", err)
}
g.logger.Info("cloned repository", "url", gitURL, "dir", tmpDir)
return tmpDir, cleanup, nil
}
// CommitAndPush stages all changes, commits, and optionally pushes.
// Returns the commit SHA and list of changed files.
func (g *GitOperations) CommitAndPush(ctx context.Context, dir, message string, push bool) (string, []string, error) {
// Stage all changes
if err := g.runGit(ctx, dir, "add", "-A"); err != nil {
return "", nil, fmt.Errorf("git add: %w", err)
}
// Check if there are changes to commit
status, err := g.runGitOutput(ctx, dir, "status", "--porcelain")
if err != nil {
return "", nil, fmt.Errorf("git status: %w", err)
}
if strings.TrimSpace(status) == "" {
g.logger.Info("no changes to commit", "dir", dir)
return "", nil, nil
}
// Get list of changed files
diffOutput, err := g.runGitOutput(ctx, dir, "diff", "--cached", "--name-only")
if err != nil {
return "", nil, fmt.Errorf("git diff: %w", err)
}
var filesChanged []string
for _, f := range strings.Split(strings.TrimSpace(diffOutput), "\n") {
if f != "" {
filesChanged = append(filesChanged, f)
}
}
// Commit
if err := g.runGit(ctx, dir, "commit", "-m", message); err != nil {
return "", nil, fmt.Errorf("git commit: %w", err)
}
// Get commit SHA
sha, err := g.runGitOutput(ctx, dir, "rev-parse", "HEAD")
if err != nil {
return "", nil, fmt.Errorf("git rev-parse: %w", err)
}
sha = strings.TrimSpace(sha)
g.logger.Info("committed changes",
"sha", sha,
"files", len(filesChanged),
)
// Push if requested
if push {
if err := g.runGit(ctx, dir, "push"); err != nil {
return sha, filesChanged, fmt.Errorf("git push: %w", err)
}
g.logger.Info("pushed changes", "sha", sha)
}
return sha, filesChanged, nil
}
// injectToken adds the Gitea token to an HTTPS git URL for authentication.
// Converts "https://git.example.com/org/repo.git" to
// "https://token@git.example.com/org/repo.git".
func (g *GitOperations) injectToken(gitURL string) string {
if g.giteaToken == "" {
return gitURL
}
// Handle https:// URLs
if strings.HasPrefix(gitURL, "https://") {
return "https://" + g.giteaToken + "@" + gitURL[len("https://"):]
}
if strings.HasPrefix(gitURL, "http://") {
return "http://" + g.giteaToken + "@" + gitURL[len("http://"):]
}
return gitURL
}
// gitEnv returns a minimal environment for git subprocesses.
// Only PATH and HOME are inherited; all other host env vars are excluded
// to prevent credential or config leakage.
func gitEnv() []string {
env := []string{"GIT_TERMINAL_PROMPT=0"}
for _, key := range []string{"PATH", "HOME"} {
if v := os.Getenv(key); v != "" {
env = append(env, key+"="+v)
}
}
return env
}
// runGit executes a git command in the given directory.
func (g *GitOperations) runGit(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
cmd.Env = gitEnv()
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Redact token from error messages
errMsg := g.redactToken(stderr.String())
return fmt.Errorf("%s: %s", err, errMsg)
}
return nil
}
// runGitOutput executes a git command and returns its stdout.
func (g *GitOperations) runGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
cmd.Env = gitEnv()
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 log/error output.
func (g *GitOperations) redactToken(s string) string {
if g.giteaToken == "" {
return s
}
return strings.ReplaceAll(s, g.giteaToken, "[REDACTED]")
}
// EnsureGitDir verifies that the given path is a valid git repository.
func (g *GitOperations) EnsureGitDir(dir string) error {
gitDir := filepath.Join(dir, ".git")
info, err := os.Stat(gitDir)
if err != nil {
return fmt.Errorf("not a git repository: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("not a git repository: .git is not a directory")
}
return nil
}