Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions module/pipeline_step_git_checkout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package module

import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"

"github.com/CrisisTextLine/modular"
)

// GitCheckoutStep checks out a branch, tag, or creates a new branch.
type GitCheckoutStep struct {
name string
directory string
branch string
create bool
tmpl *TemplateEngine
execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
}

// NewGitCheckoutStepFactory returns a StepFactory that creates GitCheckoutStep instances.
func NewGitCheckoutStepFactory() StepFactory {
return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) {
directory, _ := config["directory"].(string)
if directory == "" {
return nil, fmt.Errorf("git_checkout step %q: 'directory' is required", name)
}

branch, _ := config["branch"].(string)
if branch == "" {
return nil, fmt.Errorf("git_checkout step %q: 'branch' is required", name)
}

create, _ := config["create"].(bool)

return &GitCheckoutStep{
name: name,
directory: directory,
branch: branch,
create: create,
tmpl: NewTemplateEngine(),
execCommand: exec.CommandContext,
}, nil
}
}

// Name returns the step name.
func (s *GitCheckoutStep) Name() string { return s.name }

// Execute checks out the configured branch or creates it.
func (s *GitCheckoutStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) {
directory, err := s.tmpl.Resolve(s.directory, pc)
if err != nil {
return nil, fmt.Errorf("git_checkout step %q: failed to resolve directory: %w", s.name, err)
}

branch, err := s.tmpl.Resolve(s.branch, pc)
if err != nil {
return nil, fmt.Errorf("git_checkout step %q: failed to resolve branch: %w", s.name, err)
}

// Build checkout args.
args := []string{"-C", directory, "checkout"}
if s.create {
args = append(args, "-b")
}
args = append(args, branch)

var stdout, stderr bytes.Buffer
cmd := s.execCommand(ctx, "git", args...) //nolint:gosec // G204: args from trusted pipeline config
cmd.Stdout = &stdout
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git_checkout step %q: git checkout failed: %w\nstdout: %s\nstderr: %s",
s.name, err, stdout.String(), stderr.String())
}

// Get commit SHA after checkout.
commitSHA, err := s.getCommitSHA(ctx, directory)
if err != nil {
commitSHA = ""
}

return &StepResult{
Output: map[string]any{
"branch": branch,
"commit_sha": commitSHA,
"created": s.create,
"success": true,
},
}, nil
}

// getCommitSHA returns the HEAD commit SHA for the given directory.
func (s *GitCheckoutStep) getCommitSHA(ctx context.Context, dir string) (string, error) {
var stdout bytes.Buffer
cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "HEAD") //nolint:gosec // G204: args from trusted pipeline config
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return "", err
}
return strings.TrimSpace(stdout.String()), nil
}
Comment on lines +97 to +106
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getCommitSHA helper method is duplicated across three git step implementations (GitCloneStep, GitCommitStep, and GitCheckoutStep) with nearly identical code. Consider extracting this into a shared helper function or a common git utilities struct to reduce code duplication and improve maintainability. The same logic for running git rev-parse HEAD and parsing the output appears in all three files.

Copilot uses AI. Check for mistakes.
222 changes: 222 additions & 0 deletions module/pipeline_step_git_checkout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package module

import (
"context"
"os/exec"
"strings"
"testing"
)

func TestGitCheckoutStep_FactoryRequiresDirectory(t *testing.T) {
factory := NewGitCheckoutStepFactory()
_, err := factory("checkout", map[string]any{"branch": "main"}, nil)
if err == nil {
t.Fatal("expected error when directory is missing")
}
if !strings.Contains(err.Error(), "directory") {
t.Errorf("expected error to mention directory, got: %v", err)
}
}

func TestGitCheckoutStep_FactoryRequiresBranch(t *testing.T) {
factory := NewGitCheckoutStepFactory()
_, err := factory("checkout", map[string]any{"directory": "/tmp/repo"}, nil)
if err == nil {
t.Fatal("expected error when branch is missing")
}
if !strings.Contains(err.Error(), "branch") {
t.Errorf("expected error to mention branch, got: %v", err)
}
}

func TestGitCheckoutStep_Name(t *testing.T) {
factory := NewGitCheckoutStepFactory()
step, err := factory("my-checkout", map[string]any{
"directory": "/tmp/repo",
"branch": "main",
}, nil)
if err != nil {
t.Fatalf("unexpected factory error: %v", err)
}
if step.Name() != "my-checkout" {
t.Errorf("expected name %q, got %q", "my-checkout", step.Name())
}
}

func TestGitCheckoutStep_FactoryWithCreate(t *testing.T) {
factory := NewGitCheckoutStepFactory()
raw, err := factory("checkout", map[string]any{
"directory": "/tmp/repo",
"branch": "feature/new",
"create": true,
}, nil)
if err != nil {
t.Fatalf("unexpected factory error: %v", err)
}
s := raw.(*GitCheckoutStep)
if !s.create {
t.Error("expected create=true")
}
}

func TestGitCheckoutStep_Execute_CheckoutExistingBranch(t *testing.T) {
var checkoutArgs []string
callCount := 0

s := &GitCheckoutStep{
name: "checkout",
directory: "/tmp/repo",
branch: "main",
create: false,
tmpl: NewTemplateEngine(),
execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd {
callCount++
if callCount == 1 {
checkoutArgs = append([]string{name}, args...)
return exec.Command("true") // git checkout
}
return exec.Command("echo", "abc123") // git rev-parse HEAD
},
}

result, err := s.Execute(context.Background(), &PipelineContext{
Current: map[string]any{},
StepOutputs: map[string]map[string]any{},
TriggerData: map[string]any{},
Metadata: map[string]any{},
})
if err != nil {
t.Fatalf("Execute failed: %v", err)
}

if result.Output["branch"] != "main" {
t.Errorf("expected branch=main, got %v", result.Output["branch"])
}
if result.Output["success"] != true {
t.Errorf("expected success=true, got %v", result.Output["success"])
}
if result.Output["created"] != false {
t.Errorf("expected created=false, got %v", result.Output["created"])
}

argsStr := strings.Join(checkoutArgs, " ")
if !strings.Contains(argsStr, "checkout") {
t.Errorf("expected checkout in args, got: %v", checkoutArgs)
}
if strings.Contains(argsStr, "-b") {
t.Errorf("expected no -b flag for existing branch checkout, got: %v", checkoutArgs)
}
}

func TestGitCheckoutStep_Execute_CreateNewBranch(t *testing.T) {
var checkoutArgs []string
callCount := 0

s := &GitCheckoutStep{
name: "checkout",
directory: "/tmp/repo",
branch: "feature/new",
create: true,
tmpl: NewTemplateEngine(),
execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd {
callCount++
if callCount == 1 {
checkoutArgs = append([]string{name}, args...)
return exec.Command("true") // git checkout -b
}
return exec.Command("echo", "abc123") // git rev-parse HEAD
},
}

result, err := s.Execute(context.Background(), &PipelineContext{
Current: map[string]any{},
StepOutputs: map[string]map[string]any{},
TriggerData: map[string]any{},
Metadata: map[string]any{},
})
if err != nil {
t.Fatalf("Execute failed: %v", err)
}

if result.Output["created"] != true {
t.Errorf("expected created=true, got %v", result.Output["created"])
}

argsStr := strings.Join(checkoutArgs, " ")
if !strings.Contains(argsStr, "-b") {
t.Errorf("expected -b flag for new branch, got: %v", checkoutArgs)
}
if !strings.Contains(argsStr, "feature/new") {
t.Errorf("expected branch name in args, got: %v", checkoutArgs)
}
}

func TestGitCheckoutStep_Execute_CheckoutFailure(t *testing.T) {
s := &GitCheckoutStep{
name: "checkout",
directory: "/tmp/repo",
branch: "nonexistent",
tmpl: NewTemplateEngine(),
execCommand: func(_ context.Context, _ string, _ ...string) *exec.Cmd {
return exec.Command("false")
},
}

_, err := s.Execute(context.Background(), &PipelineContext{
Current: map[string]any{},
StepOutputs: map[string]map[string]any{},
TriggerData: map[string]any{},
Metadata: map[string]any{},
})
if err == nil {
t.Fatal("expected error when git checkout fails")
}
if !strings.Contains(err.Error(), "git checkout") {
t.Errorf("expected error to mention git checkout, got: %v", err)
}
}

func TestGitCheckoutStep_Execute_TemplateResolution(t *testing.T) {
var checkoutArgs []string
callCount := 0

s := &GitCheckoutStep{
name: "checkout",
directory: "/tmp/workspace/{{ .repo }}",
branch: "feature/{{ .feature_name }}",
create: true,
tmpl: NewTemplateEngine(),
execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd {
callCount++
if callCount == 1 {
checkoutArgs = append([]string{name}, args...)
return exec.Command("true")
}
return exec.Command("echo", "abc123")
},
}

pc := &PipelineContext{
Current: map[string]any{"repo": "my-repo", "feature_name": "add-logging"},
StepOutputs: map[string]map[string]any{},
TriggerData: map[string]any{"repo": "my-repo", "feature_name": "add-logging"},
Metadata: map[string]any{},
}

result, err := s.Execute(context.Background(), pc)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}

if result.Output["branch"] != "feature/add-logging" {
t.Errorf("expected branch=feature/add-logging, got %v", result.Output["branch"])
}

argsStr := strings.Join(checkoutArgs, " ")
if !strings.Contains(argsStr, "/tmp/workspace/my-repo") {
t.Errorf("expected resolved directory in args, got: %v", checkoutArgs)
}
if !strings.Contains(argsStr, "feature/add-logging") {
t.Errorf("expected resolved branch in args, got: %v", checkoutArgs)
}
}
Loading
Loading