-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add git pipeline steps for GitOps workflows #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
getCommitSHAhelper 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 runninggit rev-parse HEADand parsing the output appears in all three files.