Skip to content

feat: add git pipeline steps for GitOps workflows#168

Merged
intel352 merged 1 commit intomainfrom
feat/git-pipeline-steps
Feb 25, 2026
Merged

feat: add git pipeline steps for GitOps workflows#168
intel352 merged 1 commit intomainfrom
feat/git-pipeline-steps

Conversation

@intel352
Copy link
Contributor

Summary

  • Adds five new native git operation pipeline steps to the cicd plugin: step.git_clone, step.git_commit, step.git_push, step.git_tag, and step.git_checkout
  • Each step resolves Go templates in all config fields ({{ .repo }}, {{ .version }}, etc.) using the existing TemplateEngine
  • All steps use an injectable execCommand function for full testability (same pattern as step.build_from_config)
  • 49 unit tests across 5 test files cover factory validation, template resolution, auth injection, error handling, and behavior flags

New Step Types

Step Description
step.git_clone Clone repos with HTTPS token auth or SSH key injection
step.git_commit Stage files (add_all or add_files) and create commits with optional author metadata
step.git_push Push to remotes with configurable remote/branch, force flag, tags flag, and token injection
step.git_tag Create lightweight or annotated tags, optionally auto-push
step.git_checkout Checkout existing branches or create new ones with -b

Test plan

  • go build ./... passes cleanly
  • go test ./module/... -run TestGit — all 49 git tests pass
  • go test ./plugins/cicd/... — all 4 plugin tests pass (updated counts for 18 total step factories)
  • go test ./module/... — full module test suite passes

🤖 Generated with Claude Code

Adds five new native git operation steps to the CI/CD plugin:
- step.git_clone: clone repos with HTTPS token or SSH key auth
- step.git_commit: stage files and create commits with author metadata
- step.git_push: push to remotes with optional force/tags and token injection
- step.git_tag: create lightweight or annotated tags, optionally push
- step.git_checkout: checkout or create branches

All steps resolve Go templates in config fields, use an injectable
execCommand for testability, and include 49 unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 25, 2026 07:24
@intel352 intel352 merged commit 32eb847 into main Feb 25, 2026
15 of 16 checks passed
@intel352 intel352 deleted the feat/git-pipeline-steps branch February 25, 2026 07:26
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request adds five new native Git operation pipeline steps to the cicd plugin to enable GitOps workflows: step.git_clone, step.git_commit, step.git_push, step.git_tag, and step.git_checkout. Each step follows established patterns from the codebase including injectable execCommand for testability (matching step.build_from_config) and Go template resolution for all configuration fields. The implementation includes 49 unit tests providing comprehensive coverage of factory validation, template resolution, authentication injection, error handling, and behavior flags.

Changes:

  • Adds five new git operation step types with full HTTPS token auth and SSH key support
  • Updates plugin manifest and registration to include the new step types (13→18 total steps)
  • Provides comprehensive unit test coverage following existing test patterns

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
plugins/cicd/plugin.go Registers five new git step factories and updates plugin descriptions
plugins/cicd/plugin_test.go Updates test expectations for 18 total step factories (was 13)
module/pipeline_step_git_clone.go Implements git clone with token/SSH auth, branch, and depth support
module/pipeline_step_git_clone_test.go 10 unit tests covering factory, execution, auth injection, and templates
module/pipeline_step_git_commit.go Implements git commit with selective staging and author metadata
module/pipeline_step_git_commit_test.go 10 unit tests including "nothing to commit" handling and file staging
module/pipeline_step_git_push.go Implements git push with force/tags flags, token injection, and branch inference
module/pipeline_step_git_push_test.go 9 unit tests covering push options and branch resolution
module/pipeline_step_git_tag.go Implements annotated/lightweight tags with optional auto-push
module/pipeline_step_git_tag_test.go 10 unit tests verifying tag creation and push behavior
module/pipeline_step_git_checkout.go Implements branch checkout with create flag support
module/pipeline_step_git_checkout_test.go 10 unit tests covering existing and new branch checkout

Comment on lines +151 to +160
// getCurrentBranch returns the current branch name for the given directory.
func (s *GitPushStep) getCurrentBranch(ctx context.Context, dir string) (string, error) {
var stdout bytes.Buffer
cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "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
}
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 getCurrentBranch helper method is duplicated in both GitCloneStep and GitPushStep with identical implementations. Consider extracting this into a shared helper function to reduce code duplication. Both methods execute git rev-parse --abbrev-ref HEAD with the same logic.

Suggested change
// getCurrentBranch returns the current branch name for the given directory.
func (s *GitPushStep) getCurrentBranch(ctx context.Context, dir string) (string, error) {
var stdout bytes.Buffer
cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "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
}
// getCurrentBranch runs `git rev-parse --abbrev-ref HEAD` in the specified directory using the provided execCommand.
func getCurrentBranch(ctx context.Context, execCommand func(context.Context, string, ...string) *exec.Cmd, dir string) (string, error) {
var stdout bytes.Buffer
cmd := execCommand(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "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
}
// getCurrentBranch returns the current branch name for the given directory.
func (s *GitPushStep) getCurrentBranch(ctx context.Context, dir string) (string, error) {
return getCurrentBranch(ctx, s.execCommand, dir)
}

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +140
// Inject token into HTTPS URL if provided.
cloneURL := repository
if token != "" && strings.HasPrefix(repository, "https://") {
cloneURL = strings.Replace(repository, "https://", "https://"+token+"@", 1)
}

// Build git clone args.
args := []string{"clone"}
if branch != "" {
args = append(args, "--branch", branch)
}
if s.depth > 0 {
args = append(args, "--depth", fmt.Sprintf("%d", s.depth))
}
args = append(args, cloneURL, directory)

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

// Set up SSH key if provided.
if sshKey != "" {
keyFile, err := os.CreateTemp("", "git-ssh-key-*")
if err != nil {
return nil, fmt.Errorf("git_clone step %q: failed to create SSH key temp file: %w", s.name, err)
}
defer os.Remove(keyFile.Name())

if _, err := keyFile.WriteString(sshKey); err != nil {
keyFile.Close()
return nil, fmt.Errorf("git_clone step %q: failed to write SSH key: %w", s.name, err)
}
keyFile.Close()

if err := os.Chmod(keyFile.Name(), 0600); err != nil {
return nil, fmt.Errorf("git_clone step %q: failed to chmod SSH key: %w", s.name, err)
}

cmd.Env = append(os.Environ(),
fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=no", keyFile.Name()),
)
}

if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git_clone step %q: git clone failed: %w\nstdout: %s\nstderr: %s",
s.name, err, stdout.String(), stderr.String())
}
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.

When a token is provided and injected into the HTTPS URL on line 96, the token will be included in the git command arguments. If git clone fails, the error message on line 138-139 includes stdout and stderr which may contain the full clone URL with the embedded token. This could lead to credential exposure in logs. Consider redacting the token from error messages or using git credential helpers as a more secure alternative.

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +94
// Stage files.
if s.addAll {
if err := s.runGit(ctx, directory, "add", "-A"); err != nil {
return nil, fmt.Errorf("git_commit step %q: git add -A failed: %w", s.name, err)
}
} else if len(s.addFiles) > 0 {
addArgs := append([]string{"add", "--"}, s.addFiles...)
if err := s.runGit(ctx, directory, addArgs...); err != nil {
return nil, fmt.Errorf("git_commit step %q: git add failed: %w", s.name, err)
}
}
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 add_files array elements are not being resolved through the template engine, unlike other string fields like directory and message. This means template variables in file paths (e.g., {{ .output_dir }}/config.yaml) will not be resolved at execution time. Consider iterating through s.addFiles and resolving each path through s.tmpl.Resolve() for consistency with other string parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +100
// Build commit args.
commitArgs := []string{"commit", "-m", message}
if s.authorName != "" && s.authorEmail != "" {
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", s.authorName, s.authorEmail))
}
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 authorName and authorEmail fields are not being resolved through the template engine before being used on line 99. Template variables in these fields (e.g., {{ .bot_name }} or {{ .bot_email }}) will not be resolved at execution time. Consider adding template resolution for these fields, similar to how directory and message are resolved on lines 74-82.

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +133
cmd.Env = append(os.Environ(),
fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=no", keyFile.Name()),
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 SSH configuration on line 133 uses -o StrictHostKeyChecking=no, which disables host key verification. This makes the connection vulnerable to man-in-the-middle attacks. While this is common in CI/CD environments, consider documenting this security trade-off in the step documentation or providing an option to enable strict host key checking with a known_hosts configuration.

Suggested change
cmd.Env = append(os.Environ(),
fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=no", keyFile.Name()),
// Configure SSH host key checking behavior.
// If GIT_SSH_STRICT_HOST_KEY_CHECKING is unset, default to "accept-new" for safer defaults
// while remaining non-interactive in modern OpenSSH.
strictHostKeyChecking := os.Getenv("GIT_SSH_STRICT_HOST_KEY_CHECKING")
if strictHostKeyChecking == "" {
strictHostKeyChecking = "accept-new"
}
cmd.Env = append(os.Environ(),
fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=%s", keyFile.Name(), strictHostKeyChecking),

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +106
// 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
}
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.
Comment on lines +127 to +149
// injectTokenIntoRemote rewrites the remote URL to embed the token.
func (s *GitPushStep) injectTokenIntoRemote(ctx context.Context, dir, remote, token string) error {
// Get current remote URL.
var stdout bytes.Buffer
cmd := s.execCommand(ctx, "git", "-C", dir, "remote", "get-url", remote) //nolint:gosec // G204: args from trusted pipeline config
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to get remote URL: %w", err)
}
remoteURL := strings.TrimSpace(stdout.String())

// Inject token into HTTPS URL.
if strings.HasPrefix(remoteURL, "https://") {
newURL := strings.Replace(remoteURL, "https://", "https://"+token+"@", 1)
var stderr bytes.Buffer
setCmd := s.execCommand(ctx, "git", "-C", dir, "remote", "set-url", remote, newURL) //nolint:gosec // G204: args from trusted pipeline config
setCmd.Stderr = &stderr
if err := setCmd.Run(); err != nil {
return fmt.Errorf("failed to set remote URL: %w\nstderr: %s", err, stderr.String())
}
}
return nil
}
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 injectTokenIntoRemote helper method is duplicated in both GitPushStep and GitTagStep with nearly identical implementations. Consider extracting this into a shared helper function to reduce code duplication and ensure consistent token injection behavior across git operations. The method performs the same sequence of git remote get-url, token URL transformation, and git remote set-url in both files.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants