diff --git a/pkg/cli/git.go b/pkg/cli/git.go index 1feeca0639..9a9ce64860 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -533,3 +533,116 @@ func checkWorkflowFileStatus(workflowPath string) (*WorkflowFileStatus, error) { return status, nil } + +// hasChangesToCommit checks if there are any changes in the working directory +func hasChangesToCommit() (bool, error) { + gitLog.Print("Checking for modified files") + cmd := exec.Command("git", "status", "--porcelain") + output, err := cmd.Output() + if err != nil { + gitLog.Printf("Failed to check git status: %v", err) + return false, fmt.Errorf("failed to check git status: %w", err) + } + + hasChanges := len(strings.TrimSpace(string(output))) > 0 + gitLog.Printf("Has changes to commit: %v", hasChanges) + return hasChanges, nil +} + +// hasRemote checks if a remote repository named 'origin' is configured +func hasRemote() bool { + gitLog.Print("Checking for remote repository") + cmd := exec.Command("git", "remote", "get-url", "origin") + err := cmd.Run() + hasRemoteRepo := err == nil + gitLog.Printf("Has remote repository: %v", hasRemoteRepo) + return hasRemoteRepo +} + +// pullFromRemote pulls the latest changes from the remote repository using rebase +func pullFromRemote(verbose bool) error { + gitLog.Print("Pulling latest changes from remote") + pullCmd := exec.Command("git", "pull", "--rebase") + if output, err := pullCmd.CombinedOutput(); err != nil { + gitLog.Printf("Failed to pull changes: %v, output: %s", err, string(output)) + return fmt.Errorf("failed to pull changes: %w", err) + } + gitLog.Print("Successfully pulled latest changes") + return nil +} + +// stageAllChanges stages all modified files using git add -A +func stageAllChanges(verbose bool) error { + gitLog.Print("Staging all changes") + addCmd := exec.Command("git", "add", "-A") + if output, err := addCmd.CombinedOutput(); err != nil { + gitLog.Printf("Failed to stage changes: %v, output: %s", err, string(output)) + return fmt.Errorf("failed to stage changes: %w", err) + } + gitLog.Print("Successfully staged all changes") + return nil +} + +// pushToRemote pushes the current branch to the remote repository +func pushToRemote(verbose bool) error { + gitLog.Print("Pushing changes to remote") + pushCmd := exec.Command("git", "push") + if output, err := pushCmd.CombinedOutput(); err != nil { + gitLog.Printf("Failed to push changes: %v, output: %s", err, string(output)) + return fmt.Errorf("failed to push changes: %w\nOutput: %s", err, string(output)) + } + gitLog.Print("Successfully pushed changes to remote") + return nil +} + +// commitAndPushChanges is a helper that orchestrates the full commit and push workflow +// It checks for changes, pulls from remote (if exists), stages all changes, commits, and pushes (if remote exists) +func commitAndPushChanges(commitMessage string, verbose bool) error { + gitLog.Printf("Starting commit and push workflow with message: %s", commitMessage) + + // Check if there are any changes to commit + hasChanges, err := hasChangesToCommit() + if err != nil { + return err + } + + if !hasChanges { + gitLog.Print("No changes to commit") + return nil + } + + // Pull latest changes from remote before committing (if remote exists) + if hasRemote() { + gitLog.Print("Remote repository exists, pulling latest changes") + if err := pullFromRemote(verbose); err != nil { + return err + } + } else { + gitLog.Print("No remote repository configured, skipping pull") + } + + // Stage all modified files + if err := stageAllChanges(verbose); err != nil { + return err + } + + // Commit the changes + gitLog.Printf("Committing changes with message: %s", commitMessage) + if err := commitChanges(commitMessage, verbose); err != nil { + gitLog.Printf("Failed to commit changes: %v", err) + return fmt.Errorf("failed to commit changes: %w", err) + } + + // Push the changes (only if remote exists) + if hasRemote() { + gitLog.Print("Remote repository exists, pushing changes") + if err := pushToRemote(verbose); err != nil { + return err + } + } else { + gitLog.Print("No remote repository configured, skipping push") + } + + gitLog.Print("Commit and push workflow completed successfully") + return nil +} diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 72ffd0554c..2f39b0edff 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -444,9 +444,18 @@ func attemptSetSecret(secretName, repoSlug string, verbose bool) error { } // InitRepository initializes the repository for agentic workflows -func InitRepository(verbose bool, mcp bool, campaign bool, tokens bool, engine string, codespaceRepos []string, codespaceEnabled bool, completions bool, rootCmd CommandProvider) error { +func InitRepository(verbose bool, mcp bool, campaign bool, tokens bool, engine string, codespaceRepos []string, codespaceEnabled bool, completions bool, push bool, rootCmd CommandProvider) error { initLog.Print("Starting repository initialization for agentic workflows") + // If --push is enabled, ensure git status is clean before starting + if push { + initLog.Print("Checking for clean working directory (--push enabled)") + if err := checkCleanWorkingDirectory(verbose); err != nil { + initLog.Printf("Git status check failed: %v", err) + return fmt.Errorf("--push requires a clean working directory: %w", err) + } + } + // Ensure we're in a git repository if !isGitRepo() { initLog.Print("Not in a git repository, initialization failed") @@ -680,6 +689,34 @@ func InitRepository(verbose bool, mcp bool, campaign bool, tokens bool, engine s initLog.Print("Repository initialization completed successfully") + // If --push is enabled, commit and push changes + if push { + initLog.Print("Push enabled - preparing to commit and push changes") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Preparing to commit and push changes...")) + + // Use the helper function to orchestrate the full workflow + commitMessage := "chore: initialize agentic workflows" + if err := commitAndPushChanges(commitMessage, verbose); err != nil { + // Check if it's the "no changes" case + hasChanges, checkErr := hasChangesToCommit() + if checkErr == nil && !hasChanges { + initLog.Print("No changes to commit") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No changes to commit")) + } else { + return err + } + } else { + // Print success messages based on whether remote exists + fmt.Fprintln(os.Stderr, "") + if hasRemote() { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Changes pushed to remote")) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Changes committed locally (no remote configured)")) + } + } + } + // Display success message with next steps fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Repository initialized for agentic workflows!")) diff --git a/pkg/cli/init_command.go b/pkg/cli/init_command.go index d532f841ee..ffc5272b57 100644 --- a/pkg/cli/init_command.go +++ b/pkg/cli/init_command.go @@ -84,7 +84,8 @@ Examples: ` + string(constants.CLIExtensionPrefix) + ` init --tokens --engine copilot # Check Copilot tokens ` + string(constants.CLIExtensionPrefix) + ` init --codespaces # Configure Codespaces ` + string(constants.CLIExtensionPrefix) + ` init --codespaces repo1,repo2 # Codespaces with additional repos - ` + string(constants.CLIExtensionPrefix) + ` init --completions # Install shell completions`, + ` + string(constants.CLIExtensionPrefix) + ` init --completions # Install shell completions + ` + string(constants.CLIExtensionPrefix) + ` init --push # Initialize and automatically commit/push`, RunE: func(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose") mcpFlag, _ := cmd.Flags().GetBool("mcp") @@ -95,6 +96,7 @@ Examples: codespaceReposStr, _ := cmd.Flags().GetString("codespaces") codespaceEnabled := cmd.Flags().Changed("codespaces") completions, _ := cmd.Flags().GetBool("completions") + push, _ := cmd.Flags().GetBool("push") // Determine MCP state: default true, unless --no-mcp is specified // --mcp flag is kept for backward compatibility (hidden from help) @@ -122,15 +124,15 @@ Examples: if !cmd.Flags().Changed("mcp") && !cmd.Flags().Changed("no-mcp") && !cmd.Flags().Changed("campaign") && !cmd.Flags().Changed("tokens") && !cmd.Flags().Changed("engine") && !cmd.Flags().Changed("codespaces") && - !cmd.Flags().Changed("completions") { + !cmd.Flags().Changed("completions") && !cmd.Flags().Changed("push") { // Enter interactive mode initCommandLog.Print("Entering interactive mode") return InitRepositoryInteractive(verbose, cmd.Root()) } - initCommandLog.Printf("Executing init command: verbose=%v, mcp=%v, campaign=%v, tokens=%v, engine=%v, codespaces=%v, codespaceEnabled=%v, completions=%v", verbose, mcp, campaign, tokens, engine, codespaceRepos, codespaceEnabled, completions) - if err := InitRepository(verbose, mcp, campaign, tokens, engine, codespaceRepos, codespaceEnabled, completions, cmd.Root()); err != nil { + initCommandLog.Printf("Executing init command: verbose=%v, mcp=%v, campaign=%v, tokens=%v, engine=%v, codespaces=%v, codespaceEnabled=%v, completions=%v, push=%v", verbose, mcp, campaign, tokens, engine, codespaceRepos, codespaceEnabled, completions, push) + if err := InitRepository(verbose, mcp, campaign, tokens, engine, codespaceRepos, codespaceEnabled, completions, push, cmd.Root()); err != nil { initCommandLog.Printf("Init command failed: %v", err) return err } @@ -148,6 +150,7 @@ Examples: // NoOptDefVal allows using --codespaces without a value (returns empty string when no value provided) cmd.Flags().Lookup("codespaces").NoOptDefVal = " " cmd.Flags().Bool("completions", false, "Install shell completion for the detected shell (bash, zsh, fish, or PowerShell)") + cmd.Flags().Bool("push", false, "Automatically commit and push changes after successful initialization") // Hide the deprecated --mcp flag from help (kept for backward compatibility) _ = cmd.Flags().MarkHidden("mcp") diff --git a/pkg/cli/init_command_test.go b/pkg/cli/init_command_test.go index 43c6285e53..ba89d58fa1 100644 --- a/pkg/cli/init_command_test.go +++ b/pkg/cli/init_command_test.go @@ -180,9 +180,9 @@ func TestInitRepositoryBasic(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test basic init with MCP enabled by default (mcp=true, noMcp=false behavior) - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) failed: %v", err) + t.Fatalf("InitRepository(, false, false, nil) failed: %v", err) } // Verify .gitattributes was created/updated @@ -245,9 +245,9 @@ func TestInitRepositoryWithMCP(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test init with MCP explicitly enabled (same as default) - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) with MCP failed: %v", err) + t.Fatalf("InitRepository(, false, false, nil) with MCP failed: %v", err) } // Verify .vscode/mcp.json was created @@ -288,9 +288,9 @@ func TestInitRepositoryWithNoMCP(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test init with --no-mcp flag (mcp=false) - err = InitRepository(false, false, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, false, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) with --no-mcp failed: %v", err) + t.Fatalf("InitRepository(, false, false, nil) with --no-mcp failed: %v", err) } // Verify .vscode/mcp.json was NOT created @@ -336,9 +336,9 @@ func TestInitRepositoryWithMCPBackwardCompatibility(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test init with deprecated --mcp flag for backward compatibility (mcp=true) - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) with deprecated --mcp flag failed: %v", err) + t.Fatalf("InitRepository(, false, false, nil) with deprecated --mcp flag failed: %v", err) } // Verify .vscode/mcp.json was created @@ -379,9 +379,9 @@ func TestInitRepositoryVerbose(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test verbose mode with MCP enabled by default (should not error, just produce more output) - err = InitRepository(true, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(true, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) in verbose mode failed: %v", err) + t.Fatalf("InitRepository(, false, false, nil) in verbose mode failed: %v", err) } // Verify basic files were still created @@ -406,12 +406,12 @@ func TestInitRepositoryNotInGitRepo(t *testing.T) { } // Don't initialize git repo - should fail for some operations - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) // The function should handle this gracefully or return an error // Based on the implementation, ensureGitAttributes requires git if err == nil { - t.Log("InitRepository(, false, nil) succeeded despite not being in a git repo") + t.Log("InitRepository(, false, false, nil) succeeded despite not being in a git repo") } } @@ -440,15 +440,15 @@ func TestInitRepositoryIdempotent(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Run init twice with MCP enabled by default - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("First InitRepository(, false, nil) failed: %v", err) + t.Fatalf("First InitRepository(, false, false, nil) failed: %v", err) } // Second run should be idempotent - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("Second InitRepository(, false, nil) failed: %v", err) + t.Fatalf("Second InitRepository(, false, false, nil) failed: %v", err) } // Verify .gitattributes still correct @@ -491,14 +491,14 @@ func TestInitRepositoryWithMCPIdempotent(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Run init with MCP twice - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("First InitRepository(, false, nil) with MCP failed: %v", err) + t.Fatalf("First InitRepository(, false, false, nil) with MCP failed: %v", err) } - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("Second InitRepository(, false, nil) with MCP failed: %v", err) + t.Fatalf("Second InitRepository(, false, false, nil) with MCP failed: %v", err) } // Verify files still exist and are correct @@ -538,9 +538,9 @@ func TestInitRepositoryCreatesDirectories(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Run init with MCP - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) failed: %v", err) + t.Fatalf("InitRepository(, false, false, nil) failed: %v", err) } // Verify directory structure @@ -606,7 +606,7 @@ func TestInitRepositoryErrorHandling(t *testing.T) { } // Test init without git repo (with MCP enabled by default) - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) // Should handle error gracefully or return error // The actual behavior depends on implementation @@ -649,9 +649,9 @@ func TestInitRepositoryWithExistingFiles(t *testing.T) { } // Run init with MCP enabled by default - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) failed: %v", err) + t.Fatalf("InitRepository(, false, false, nil) failed: %v", err) } // Verify existing content is preserved and new entry is added @@ -699,9 +699,9 @@ func TestInitRepositoryWithCodespace(t *testing.T) { // Test init with --codespaces flag (with MCP enabled by default and additional repos) additionalRepos := []string{"org/repo1", "owner/repo2"} - err = InitRepository(false, true, false, false, "", additionalRepos, true, false, nil) + err = InitRepository(false, true, false, false, "", additionalRepos, true, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) with codespaces failed: %v", err) + t.Fatalf("InitRepository(, false, false, nil) with codespaces failed: %v", err) } // Verify .devcontainer/devcontainer.json was created at default location @@ -764,9 +764,9 @@ func TestInitCommandWithCodespacesNoArgs(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test init with --codespaces flag (no additional repos, MCP enabled by default) - err = InitRepository(false, true, false, false, "", []string{}, true, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, true, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) with codespaces (no args) failed: %v", err) + t.Fatalf("InitRepository(, false, false, nil) with codespaces (no args) failed: %v", err) } // Verify .devcontainer/devcontainer.json was created at default location diff --git a/pkg/cli/init_mcp_test.go b/pkg/cli/init_mcp_test.go index 9ab92a75c8..f5e32bda3e 100644 --- a/pkg/cli/init_mcp_test.go +++ b/pkg/cli/init_mcp_test.go @@ -40,9 +40,9 @@ func TestInitRepository_WithMCP(t *testing.T) { } // Call the function with MCP flag (no campaign agent) - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) with MCP returned error: %v", err) + t.Fatalf("InitRepository(, false, false, nil) with MCP returned error: %v", err) } // Verify standard files were created @@ -133,15 +133,15 @@ func TestInitRepository_MCP_Idempotent(t *testing.T) { } // Call the function first time with MCP - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) with MCP returned error on first call: %v", err) + t.Fatalf("InitRepository(, false, false, nil) with MCP returned error on first call: %v", err) } // Call the function second time with MCP - err = InitRepository(false, true, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) with MCP returned error on second call: %v", err) + t.Fatalf("InitRepository(, false, false, nil) with MCP returned error on second call: %v", err) } // Verify files still exist diff --git a/pkg/cli/init_test.go b/pkg/cli/init_test.go index 21ed1253a3..5a00cb3c0e 100644 --- a/pkg/cli/init_test.go +++ b/pkg/cli/init_test.go @@ -54,18 +54,18 @@ func TestInitRepository(t *testing.T) { } // Call the function (no MCP or campaign) - err = InitRepository(false, false, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, false, false, false, "", []string{}, false, false, false, nil) // Check error expectation if tt.wantError { if err == nil { - t.Errorf("InitRepository(, false, nil) expected error, got nil") + t.Errorf("InitRepository(, false, false, nil) expected error, got nil") } return } if err != nil { - t.Fatalf("InitRepository(, false, nil) returned unexpected error: %v", err) + t.Fatalf("InitRepository(, false, false, nil) returned unexpected error: %v", err) } // Verify .gitattributes was created @@ -161,15 +161,15 @@ func TestInitRepository_Idempotent(t *testing.T) { } // Call the function first time - err = InitRepository(false, false, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, false, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) returned error on first call: %v", err) + t.Fatalf("InitRepository(, false, false, nil) returned error on first call: %v", err) } // Call the function second time - err = InitRepository(false, false, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, false, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) returned error on second call: %v", err) + t.Fatalf("InitRepository(, false, false, nil) returned error on second call: %v", err) } // Verify files still exist and are correct @@ -233,9 +233,9 @@ func TestInitRepository_Verbose(t *testing.T) { } // Call the function with verbose=true (should not error) - err = InitRepository(true, false, false, false, "", []string{}, false, false, nil) + err = InitRepository(true, false, false, false, "", []string{}, false, false, false, nil) if err != nil { - t.Fatalf("InitRepository(, false, nil) returned error with verbose=true: %v", err) + t.Fatalf("InitRepository(, false, false, nil) returned error with verbose=true: %v", err) } // Verify files were created @@ -268,7 +268,7 @@ func TestInitRepository_Campaign(t *testing.T) { } // Call the function with campaign flag enabled - err = InitRepository(false, true, true, false, "", []string{}, false, false, nil) + err = InitRepository(false, true, true, false, "", []string{}, false, false, false, nil) if err != nil { t.Fatalf("InitRepository with campaign flag returned error: %v", err) } diff --git a/pkg/cli/interfaces_test.go b/pkg/cli/interfaces_test.go index 74566bd465..cc8b835f08 100644 --- a/pkg/cli/interfaces_test.go +++ b/pkg/cli/interfaces_test.go @@ -65,7 +65,7 @@ func TestInitRepository_WithNilRootCmd(t *testing.T) { require.NoError(t, err, "Failed to init git repo") // InitRepository with nil rootCmd and completions disabled should succeed - err = InitRepository(false, false, false, false, "", []string{}, false, false, nil) + err = InitRepository(false, false, false, false, "", []string{}, false, false, false, nil) require.NoError(t, err, "InitRepository with nil rootCmd should succeed when completions are disabled") } @@ -94,7 +94,7 @@ func TestInitRepository_WithRootCmd(t *testing.T) { } // InitRepository with real rootCmd should succeed - err = InitRepository(false, false, false, false, "", []string{}, false, false, rootCmd) + err = InitRepository(false, false, false, false, "", []string{}, false, false, false, rootCmd) require.NoError(t, err, "InitRepository with rootCmd should succeed") } diff --git a/pkg/cli/upgrade_command.go b/pkg/cli/upgrade_command.go index 9e289b861f..3105ac471d 100644 --- a/pkg/cli/upgrade_command.go +++ b/pkg/cli/upgrade_command.go @@ -17,11 +17,12 @@ type UpgradeConfig struct { Verbose bool WorkflowDir string NoFix bool + Push bool } // RunUpgrade runs the upgrade command with the given configuration func RunUpgrade(config UpgradeConfig) error { - return runUpgradeCommand(config.Verbose, config.WorkflowDir, config.NoFix, false) + return runUpgradeCommand(config.Verbose, config.WorkflowDir, config.NoFix, false, config.Push) } // NewUpgradeCommand creates the upgrade command @@ -49,19 +50,22 @@ This command always upgrades all Markdown files in .github/workflows. Examples: ` + string(constants.CLIExtensionPrefix) + ` upgrade # Upgrade all workflows ` + string(constants.CLIExtensionPrefix) + ` upgrade --no-fix # Update agent files only (skip codemods and compilation) + ` + string(constants.CLIExtensionPrefix) + ` upgrade --push # Upgrade and automatically commit/push changes ` + string(constants.CLIExtensionPrefix) + ` upgrade --dir custom/workflows # Upgrade workflows in custom directory`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose") dir, _ := cmd.Flags().GetString("dir") noFix, _ := cmd.Flags().GetBool("no-fix") + push, _ := cmd.Flags().GetBool("push") - return runUpgradeCommand(verbose, dir, noFix, false) + return runUpgradeCommand(verbose, dir, noFix, false, push) }, } cmd.Flags().StringP("dir", "d", "", "Workflow directory (default: .github/workflows)") cmd.Flags().Bool("no-fix", false, "Skip applying codemods and compiling workflows (only update agent files)") + cmd.Flags().Bool("push", false, "Automatically commit and push changes after successful upgrade") // Register completions RegisterDirFlagCompletion(cmd, "dir") @@ -70,11 +74,24 @@ Examples: } // runUpgradeCommand executes the upgrade process -func runUpgradeCommand(verbose bool, workflowDir string, noFix bool, noCompile bool) error { - upgradeLog.Printf("Running upgrade command: verbose=%v, workflowDir=%s, noFix=%v, noCompile=%v", - verbose, workflowDir, noFix, noCompile) +func runUpgradeCommand(verbose bool, workflowDir string, noFix bool, noCompile bool, push bool) error { + upgradeLog.Printf("Running upgrade command: verbose=%v, workflowDir=%s, noFix=%v, noCompile=%v, push=%v", + verbose, workflowDir, noFix, noCompile, push) + + // Step 0a: If --push is enabled, ensure git status is clean before starting + if push { + upgradeLog.Print("Checking for clean working directory (--push enabled)") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Checking git status...")) + if err := checkCleanWorkingDirectory(verbose); err != nil { + upgradeLog.Printf("Git status check failed: %v", err) + return fmt.Errorf("--push requires a clean working directory: %w", err) + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Working directory is clean")) + } + } - // Step 0: Ensure gh-aw extension is on the latest version + // Step 0b: Ensure gh-aw extension is on the latest version fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Checking gh-aw extension version...")) if err := ensureLatestExtensionVersion(verbose); err != nil { upgradeLog.Printf("Extension version check failed: %v", err) @@ -161,6 +178,34 @@ func runUpgradeCommand(verbose bool, workflowDir string, noFix bool, noCompile b fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Upgrade complete")) + // Step 4: If --push is enabled, commit and push changes + if push { + upgradeLog.Print("Push enabled - preparing to commit and push changes") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Preparing to commit and push changes...")) + + // Use the helper function to orchestrate the full workflow + commitMessage := "chore: upgrade agentic workflows" + if err := commitAndPushChanges(commitMessage, verbose); err != nil { + // Check if it's the "no changes" case + hasChanges, checkErr := hasChangesToCommit() + if checkErr == nil && !hasChanges { + upgradeLog.Print("No changes to commit") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No changes to commit")) + return nil + } + return err + } + + // Print success messages based on whether remote exists + fmt.Fprintln(os.Stderr, "") + if hasRemote() { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Changes pushed to remote")) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Changes committed locally (no remote configured)")) + } + } + return nil } diff --git a/pkg/cli/upgrade_command_test.go b/pkg/cli/upgrade_command_test.go index 64be8ef692..abb961397c 100644 --- a/pkg/cli/upgrade_command_test.go +++ b/pkg/cli/upgrade_command_test.go @@ -50,6 +50,7 @@ This is a test workflow. Verbose: false, NoFix: true, // Skip codemods for this test WorkflowDir: "", + Push: false, } err = RunUpgrade(config) @@ -125,6 +126,7 @@ This workflow also has deprecated timeout_minutes field. Verbose: false, NoFix: false, // Apply codemods WorkflowDir: "", + Push: false, } err = RunUpgrade(config) @@ -185,6 +187,7 @@ This workflow should not be modified when --no-fix is used. Verbose: false, NoFix: true, // Skip codemods WorkflowDir: "", + Push: false, } err = RunUpgrade(config) @@ -215,6 +218,7 @@ func TestUpgradeCommand_NonGitRepo(t *testing.T) { Verbose: false, NoFix: true, WorkflowDir: "", + Push: false, } err := RunUpgrade(config) @@ -262,6 +266,7 @@ This is a test workflow that should be compiled during upgrade. Verbose: false, NoFix: false, // Apply codemods and compile WorkflowDir: "", + Push: false, } err = RunUpgrade(config) @@ -317,6 +322,7 @@ This workflow should not be compiled with --no-fix. Verbose: false, NoFix: true, // Skip codemods and compilation WorkflowDir: "", + Push: false, } err = RunUpgrade(config) @@ -326,3 +332,116 @@ This workflow should not be compiled with --no-fix. lockFile := filepath.Join(workflowsDir, "test-workflow.lock.yml") assert.NoFileExists(t, lockFile, "Lock file should not be created with --no-fix") } + +func TestUpgradeCommand_PushRequiresCleanWorkingDirectory(t *testing.T) { + // Create a temporary directory for test files + tmpDir := t.TempDir() + originalDir, _ := os.Getwd() + defer os.Chdir(originalDir) + + // Initialize git repository + os.Chdir(tmpDir) + exec.Command("git", "init").Run() + exec.Command("git", "config", "user.email", "test@example.com").Run() + exec.Command("git", "config", "user.name", "Test User").Run() + + // Create .github/workflows directory + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create a workflow file + workflowFile := filepath.Join(workflowsDir, "test-workflow.md") + content := `--- +on: + workflow_dispatch: + +permissions: + contents: read +--- + +# Test Workflow + +This is a test workflow. +` + err = os.WriteFile(workflowFile, []byte(content), 0644) + require.NoError(t, err, "Failed to create test workflow file") + + // Commit the workflow file first + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "Initial commit").Run() + + // Create an uncommitted change + unstagedFile := filepath.Join(tmpDir, "uncommitted.txt") + err = os.WriteFile(unstagedFile, []byte("uncommitted content"), 0644) + require.NoError(t, err, "Failed to create uncommitted file") + + // Run upgrade command with --push (should fail due to uncommitted changes) + config := UpgradeConfig{ + Verbose: false, + NoFix: true, + WorkflowDir: "", + Push: true, + } + + err = RunUpgrade(config) + require.Error(t, err, "Upgrade with --push should fail when there are uncommitted changes") + assert.Contains(t, strings.ToLower(err.Error()), "clean", "Error message should mention clean working directory") +} + +func TestUpgradeCommand_PushWithNoChanges(t *testing.T) { + // Create a temporary directory for test files + tmpDir := t.TempDir() + originalDir, _ := os.Getwd() + defer os.Chdir(originalDir) + + // Initialize git repository + os.Chdir(tmpDir) + exec.Command("git", "init").Run() + exec.Command("git", "config", "user.email", "test@example.com").Run() + exec.Command("git", "config", "user.name", "Test User").Run() + + // Create .github/workflows directory + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create a simple workflow + workflowFile := filepath.Join(workflowsDir, "test-workflow.md") + content := `--- +on: + workflow_dispatch: + +permissions: + contents: read +--- + +# Test Workflow + +This workflow is already up to date. +` + err = os.WriteFile(workflowFile, []byte(content), 0644) + require.NoError(t, err, "Failed to create test workflow file") + + // Commit everything first + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "Initial commit").Run() + + // Create all the agent files to ensure no changes will be made + if err := ensureCopilotInstructions(false, false); err == nil { + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "Add agent files").Run() + } + + // Run upgrade command with --push (should succeed but not create a new commit) + config := UpgradeConfig{ + Verbose: false, + NoFix: true, // Skip codemods to avoid changes + WorkflowDir: "", + Push: true, + } + + err = RunUpgrade(config) + // Should succeed even if no changes to commit + require.NoError(t, err, "Upgrade with --push should succeed when working directory is clean") +}