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 }