diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5f9e35..98d7ce3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,11 +4,6 @@ on: push: tags: - "v*" - workflow_dispatch: - inputs: - tag: - description: "Tag to release (e.g. v1.5.0)" - required: true permissions: contents: write @@ -20,14 +15,20 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ github.ref }} + persist-credentials: false - uses: actions/setup-go@v5 with: go-version-file: go.mod + - name: Verify release source + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + go test ./... - uses: goreleaser/goreleaser-action@v6 with: version: "~> v2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 4430f43..652c469 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -2,11 +2,6 @@ version: 2 project_name: ganbatte -before: - hooks: - - go mod tidy - - go test ./... - builds: - binary: gnb env: @@ -22,24 +17,31 @@ builds: - -s -w archives: - - format: tar.gz + - formats: [tar.gz] name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" format_overrides: - goos: windows - format: zip + formats: [zip] -brews: +homebrew_casks: - repository: owner: bssm-oss name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + binaries: + - gnb homepage: https://github.com/bssm-oss/ganbatte description: "Workflow/shortcut management CLI for lazy developers" - license: MIT - install: | - bin.install "gnb" - test: | - system "#{bin}/gnb", "--help" + directory: Casks + generate_completions_from_executable: + executable: "bin/gnb" + args: + - completion + shell_parameter_format: cobra + shells: + - bash + - zsh + - fish checksum: name_template: checksums.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index a1bab2b..b9d4bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added +- `gnb list --scope global|project` for inspecting one config scope at a time. +- Project config discovery now walks parent directories, so `.ganbatte.*` works from nested repo paths. + +### Fixed +- `gnb run` and `gnb show` now use merged global/project config, matching `gnb list` behavior. +- `gnb run` now asks for confirmation when a project item overrides a global item unless `--yes` is used. +- Release workflow now passes the Homebrew tap token using the environment variable expected by GoReleaser. +- Release workflow now validates manual dispatch tags and runs tests before publishing tokens are injected. +- CI lint issues reported by golangci-lint v1.64.8. + +## [1.5.3] - 2026-04-19 + +### Added +- `gnb doctor` detects aliases that collide with system commands and reports actionable shell-integration guidance. + +### Fixed +- Shell Integration diagnostics now produce clearer output and avoid stale p10k guidance. + +## [1.5.2] - 2026-04-19 + +### Fixed +- `gnb shell-init` writes generated shell functions to stdout when executed through the root command. + +## [1.5.1] - 2026-04-19 + +### Fixed +- `gnb shell-init` output stream handling for shell eval usage. + +## [1.5.0] - 2026-04-19 + +### Fixed +- Config loading now warns and consistently picks one active file when multiple format files coexist. +- `doctor --fix` p10k handling avoids false positives and cleans up leftover blank lines. + +### Added +- `doctor --fix` can repair p10k instant prompt ordering for shell-init lines. + +## [1.4.2] - 2026-04-18 + +### Fixed +- Alias tag filtering behavior. +- `gnb export --aliases-only` output. +- Homebrew tap repository path in release configuration. + ## [1.4.1] - 2026-04-18 ### Added diff --git a/README.md b/README.md index c579497..fd669bf 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,10 @@ ## Install ```bash -# Homebrew (macOS / Linux) -brew install bssm-oss/tap/ganbatte +# Homebrew Cask (macOS) +brew install --cask bssm-oss/tap/ganbatte -# go install +# Go install (macOS / Linux) go install github.com/justn-hyeok/ganbatte@latest # 로컬 빌드 diff --git a/cmd/add_test.go b/cmd/add_test.go index be39dda..98381d8 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -7,6 +7,7 @@ import ( "runtime" "testing" + "github.com/justn-hyeok/ganbatte/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,6 +23,7 @@ func executeCmd(args ...string) (string, error) { _ = runCmd.Flags().Set("dry-run", "false") _ = runCmd.Flags().Set("yes", "false") _ = listCmd.Flags().Set("tag", "") + _ = listCmd.Flags().Set("scope", "") _ = suggestCmd.Flags().Set("apply", "false") _ = suggestCmd.Flags().Set("min-frequency", "5") _ = suggestCmd.Flags().Set("min-sequence", "3") @@ -265,6 +267,89 @@ run = "pnpm build" assert.NotContains(t, out, "deploy") } +func TestListScopeFilter(t *testing.T) { + home := setupTestHome(t) + projectDir := t.TempDir() + t.Chdir(projectDir) + + configDir := filepath.Join(home, ".config", "ganbatte") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(` +version = "0.1.0" +[alias.global] +cmd = "echo global" +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, ".ganbatte.toml"), []byte(` +version = "0.1.0" +[alias.project] +cmd = "echo project" +`), 0o644)) + + out, err := executeCmd("list", "--scope", "global") + require.NoError(t, err) + assert.Contains(t, out, "global") + assert.NotContains(t, out, "project") + + out, err = executeCmd("list", "--scope", "project") + require.NoError(t, err) + assert.Contains(t, out, "project") + assert.NotContains(t, out, "global") +} + +func TestListScopeFilterConflictLabels(t *testing.T) { + home := setupTestHome(t) + projectDir := t.TempDir() + t.Chdir(projectDir) + + configDir := filepath.Join(home, ".config", "ganbatte") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(` +version = "0.1.0" +[alias.shared] +cmd = "echo global" +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, ".ganbatte.toml"), []byte(` +version = "0.1.0" +[alias.shared] +cmd = "echo project" +`), 0o644)) + + out, err := executeCmd("list", "--scope", "global") + require.NoError(t, err) + assert.Contains(t, out, "shared: echo global [global]") + assert.NotContains(t, out, "[project]") + + out, err = executeCmd("list", "--scope", "project") + require.NoError(t, err) + assert.Contains(t, out, "shared: echo project [project]") +} + +func TestProjectOverrides(t *testing.T) { + scoped := &config.ScopedConfig{ + Global: &config.Config{ + Aliases: map[string]config.Alias{"shared": {Cmd: "echo global"}}, + Workflows: map[string]config.Workflow{"deploy": {Description: "global"}}, + }, + Project: &config.Config{ + Aliases: map[string]config.Alias{"shared": {Cmd: "echo project"}}, + Workflows: map[string]config.Workflow{"deploy": {Description: "project"}}, + }, + } + + assert.True(t, projectOverrides(scoped, "shared", "alias")) + assert.True(t, projectOverrides(scoped, "deploy", "workflow")) + assert.False(t, projectOverrides(scoped, "missing", "alias")) +} + +func TestListInvalidScope(t *testing.T) { + setupTestHome(t) + _, _ = executeCmd("init", "--format", "toml") + + _, err := executeCmd("list", "--scope", "local") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid scope") +} + func TestShowWorkflow(t *testing.T) { home := setupTestHome(t) @@ -319,6 +404,64 @@ confirm = true assert.Contains(t, out, "git push -f origin main") } +func TestRunWorkflowYesSkipsConfirm(t *testing.T) { + home := setupTestHome(t) + + configDir := filepath.Join(home, ".config", "ganbatte") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(` +version = "0.1.0" +[workflow.deploy] +description = "Deploy" +[[workflow.deploy.steps]] +run = "printf deploy" +confirm = true +`), 0o644)) + + out, err := executeCmd("run", "deploy", "--yes") + require.NoError(t, err) + assert.Contains(t, out, "Running workflow: Deploy") + assert.Contains(t, out, "Step 1/1: printf deploy") + assert.NotContains(t, out, "Run 'printf deploy'?") +} + +func TestRunProjectAliasFromParentDir(t *testing.T) { + setupTestHome(t) + projectRoot := t.TempDir() + nestedDir := filepath.Join(projectRoot, "sub", "dir") + require.NoError(t, os.MkdirAll(nestedDir, 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(projectRoot, ".ganbatte.toml"), []byte(` +version = "0.1.0" +[alias.project] +cmd = "printf project" +`), 0o644)) + t.Chdir(nestedDir) + + out, err := executeCmd("run", "project") + require.NoError(t, err) + assert.Contains(t, out, "Running: printf project") +} + +func TestShowProjectAliasFromParentDir(t *testing.T) { + setupTestHome(t) + projectRoot := t.TempDir() + nestedDir := filepath.Join(projectRoot, "sub", "dir") + require.NoError(t, os.MkdirAll(nestedDir, 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(projectRoot, ".ganbatte.toml"), []byte(` +version = "0.1.0" +[alias.project] +cmd = "echo project" +`), 0o644)) + t.Chdir(nestedDir) + + out, err := executeCmd("show", "project") + require.NoError(t, err) + assert.Contains(t, out, "Alias: project") + assert.Contains(t, out, "Command: echo project") +} + func TestAddGlobalFlag(t *testing.T) { setupTestHome(t) _, _ = executeCmd("init", "--format", "toml") diff --git a/cmd/doctor.go b/cmd/doctor.go index b4bff8d..9e74963 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -270,7 +270,7 @@ func checkP10kOrdering(cmd *cobra.Command, home string, fix bool) bool { } result := applyP10kFix(lines, gnbLine, p10kLine) - if err := os.WriteFile(zshrc, []byte(strings.Join(result, "\n")), 0644); err != nil { + if err := os.WriteFile(zshrc, []byte(strings.Join(result, "\n")), 0o644); err != nil { cmd.Printf("[ERROR] Could not write %s: %v\n", zshrc, err) return true } diff --git a/cmd/list.go b/cmd/list.go index af33fa3..5ebd6df 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -14,19 +14,24 @@ var listCmd = &cobra.Command{ Long: `List all aliases and workflows in the configuration. Example: gnb list - gnb list --tag deploy`, + gnb list --tag deploy + gnb list --scope project`, RunE: func(cmd *cobra.Command, args []string) error { tagFilter, _ := cmd.Flags().GetString("tag") + scopeFilter, _ := cmd.Flags().GetString("scope") + if scopeFilter != "" && scopeFilter != "global" && scopeFilter != "project" { + return fmt.Errorf("invalid scope %q: must be global or project", scopeFilter) + } scoped, err := config.LoadScoped() if err != nil { return fmt.Errorf("loading config: %w", err) } - hasProject := scoped.Project != nil + hasProject := scoped.Project != nil && scopeFilter == "" // Show conflicts if any - if len(scoped.Conflicts) > 0 { + if scopeFilter == "" && len(scoped.Conflicts) > 0 { cmd.Println("=== Conflicts ===") for _, c := range scoped.Conflicts { cmd.Printf(" %s '%s': global=%s, project=%s (project wins)\n", c.Type, c.Name, c.GlobalVal, c.ProjectVal) @@ -34,7 +39,7 @@ Example: cmd.Println() } - cfg := scoped.Merged + cfg := configForListScope(scoped, scopeFilter) cmd.Println("=== Aliases ===") aliasCount := 0 @@ -42,7 +47,7 @@ Example: if tagFilter != "" && !containsTag(alias.Tags, tagFilter) { continue } - scope := scopeLabel(name, scoped, "alias", hasProject) + scope := scopeLabel(name, scoped, "alias", hasProject, scopeFilter) cmd.Printf("- %s: %s%s\n", name, alias.Cmd, scope) aliasCount++ } @@ -56,7 +61,7 @@ Example: if tagFilter != "" && !containsTag(workflow.Tags, tagFilter) { continue } - scope := scopeLabel(name, scoped, "workflow", hasProject) + scope := scopeLabel(name, scoped, "workflow", hasProject, scopeFilter) cmd.Printf("- %s: %s%s\n", name, workflow.Description, scope) if len(workflow.Tags) > 0 { cmd.Printf(" Tags: %v\n", workflow.Tags) @@ -70,8 +75,32 @@ Example: }, } +func configForListScope(scoped *config.ScopedConfig, scopeFilter string) *config.Config { + switch scopeFilter { + case "global": + return scoped.Global + case "project": + if scoped.Project != nil { + return scoped.Project + } + return emptyListConfig() + default: + return scoped.Merged + } +} + +func emptyListConfig() *config.Config { + return &config.Config{ + Aliases: make(map[string]config.Alias), + Workflows: make(map[string]config.Workflow), + } +} + // scopeLabel returns " [global]" or " [project]" when both scopes exist. -func scopeLabel(name string, scoped *config.ScopedConfig, itemType string, hasProject bool) string { +func scopeLabel(name string, scoped *config.ScopedConfig, itemType string, hasProject bool, scopeFilter string) string { + if scopeFilter != "" { + return " [" + scopeFilter + "]" + } if !hasProject { return "" } @@ -106,5 +135,6 @@ func containsTag(tags []string, tag string) bool { func init() { listCmd.Flags().StringP("tag", "t", "", "Filter by tag") + listCmd.Flags().String("scope", "", "Filter by scope (global or project)") RootCmd.AddCommand(listCmd) } diff --git a/cmd/root.go b/cmd/root.go index ab51638..7e17844 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,11 +1,9 @@ package cmd import ( - "bufio" "fmt" "os" "os/exec" - "strings" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -51,7 +49,7 @@ var RootCmd = &cobra.Command{ switch result.SelectedAction { case tui.ActionRun: - return handleRun(item) + return handleRun(scoped, item) case tui.ActionEdit: return handleEdit(cfg, item) case tui.ActionDelete: @@ -62,20 +60,16 @@ var RootCmd = &cobra.Command{ }, } -func handleRun(item *tui.Item) error { +func handleRun(scoped *config.ScopedConfig, item *tui.Item) error { switch item.Type { case tui.AliasItem: + if projectOverrides(scoped, item.Name, "alias") { + if !confirmPrompt(os.Stdout, fmt.Sprintf("Project alias '%s' overrides a global alias. Continue? [y/N] ", item.Name)) { + return nil + } + } if item.Confirm { - fmt.Fprintf(os.Stdout, "⚠ Run \"%s\"? [y/N] ", item.Command) - scanner := bufio.NewScanner(os.Stdin) - if scanner.Scan() { - input := strings.TrimSpace(strings.ToLower(scanner.Text())) - if input != "y" && input != "yes" { - fmt.Fprintln(os.Stdout, "Cancelled") - return nil - } - } else { - fmt.Fprintln(os.Stdout, "Cancelled (no input)") + if !confirmPrompt(os.Stdout, fmt.Sprintf("⚠ Run %q? [y/N] ", item.Command)) { return nil } } @@ -83,6 +77,11 @@ func handleRun(item *tui.Item) error { ex := &workflow.RealExecutor{} return ex.Execute(item.Command) case tui.WorkflowItem: + if projectOverrides(scoped, item.Name, "workflow") { + if !confirmPrompt(os.Stdout, fmt.Sprintf("Project workflow '%s' overrides a global workflow. Continue? [y/N] ", item.Name)) { + return nil + } + } fmt.Fprintf(os.Stdout, "Running workflow: %s\n", item.Description) wf := workflow.Workflow{ Description: item.Description, diff --git a/cmd/run.go b/cmd/run.go index 920070d..fdb839b 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -3,6 +3,7 @@ package cmd import ( "bufio" "fmt" + "io" "os" "strings" @@ -27,10 +28,11 @@ Example: runArgs := args[1:] dryRun, _ := cmd.Flags().GetBool("dry-run") - cfg, err := config.Load() + scoped, err := config.LoadScoped() if err != nil { return fmt.Errorf("loading config: %w", err) } + cfg := scoped.Merged yes, _ := cmd.Flags().GetBool("yes") @@ -50,16 +52,12 @@ Example: } if alias.Confirm && !yes { - fmt.Fprintf(cmd.OutOrStdout(), "⚠ Run \"%s\"? [y/N] ", resolved) - scanner := bufio.NewScanner(os.Stdin) - if scanner.Scan() { - input := strings.TrimSpace(strings.ToLower(scanner.Text())) - if input != "y" && input != "yes" { - cmd.Println("Cancelled") - return nil - } - } else { - cmd.Println("Cancelled (no input)") + if !confirmRun(cmd, fmt.Sprintf("⚠ Run %q? [y/N] ", resolved)) { + return nil + } + } + if projectOverrides(scoped, name, "alias") && !yes { + if !confirmRun(cmd, fmt.Sprintf("Project alias '%s' overrides a global alias. Continue? [y/N] ", name)) { return nil } } @@ -71,6 +69,11 @@ Example: // Check if it's a workflow if wfDef, exists := cfg.Workflows[name]; exists { + if projectOverrides(scoped, name, "workflow") && !yes && !dryRun { + if !confirmRun(cmd, fmt.Sprintf("Project workflow '%s' overrides a global workflow. Continue? [y/N] ", name)) { + return nil + } + } wf := workflow.Workflow{ Description: wfDef.Description, Params: wfDef.Params, @@ -91,8 +94,9 @@ Example: } return workflow.Run(wf, runArgs, &workflow.RealExecutor{}, workflow.RunOptions{ - DryRun: dryRun, - Writer: cmd.OutOrStdout(), + DryRun: dryRun, + SkipConfirm: yes, + Writer: cmd.OutOrStdout(), }) } @@ -100,6 +104,43 @@ Example: }, } +func projectOverrides(scoped *config.ScopedConfig, name, itemType string) bool { + if scoped.Global == nil || scoped.Project == nil { + return false + } + switch itemType { + case "alias": + _, inGlobal := scoped.Global.Aliases[name] + _, inProject := scoped.Project.Aliases[name] + return inGlobal && inProject + case "workflow": + _, inGlobal := scoped.Global.Workflows[name] + _, inProject := scoped.Project.Workflows[name] + return inGlobal && inProject + default: + return false + } +} + +func confirmRun(cmd *cobra.Command, prompt string) bool { + return confirmPrompt(cmd.OutOrStdout(), prompt) +} + +func confirmPrompt(w io.Writer, prompt string) bool { + fmt.Fprint(w, prompt) + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + input := strings.TrimSpace(strings.ToLower(scanner.Text())) + if input == "y" || input == "yes" { + return true + } + fmt.Fprintln(w, "Cancelled") + return false + } + fmt.Fprintln(w, "Cancelled (no input)") + return false +} + // resolveAliasCmd substitutes parameters in alias command string. func resolveAliasCmd(alias config.Alias, args []string) string { resolved := alias.Cmd diff --git a/cmd/show.go b/cmd/show.go index 18336aa..9fe1fe1 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -19,10 +19,11 @@ Example: RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - cfg, err := config.Load() + scoped, err := config.LoadScoped() if err != nil { return fmt.Errorf("loading config: %w", err) } + cfg := scoped.Merged if alias, exists := cfg.Aliases[name]; exists { cmd.Printf("Alias: %s\n", name) diff --git a/docs/man/gnb-add.1 b/docs/man/gnb-add.1 index 0f7269b..971300f 100644 --- a/docs/man/gnb-add.1 +++ b/docs/man/gnb-add.1 @@ -31,4 +31,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-config-convert.1 b/docs/man/gnb-config-convert.1 index 46a9104..ffb9554 100644 --- a/docs/man/gnb-config-convert.1 +++ b/docs/man/gnb-config-convert.1 @@ -30,4 +30,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-config-path.1 b/docs/man/gnb-config-path.1 index 846481b..7413b69 100644 --- a/docs/man/gnb-config-path.1 +++ b/docs/man/gnb-config-path.1 @@ -26,4 +26,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-config.1 b/docs/man/gnb-config.1 index 0f3512b..e11fc6f 100644 --- a/docs/man/gnb-config.1 +++ b/docs/man/gnb-config.1 @@ -26,4 +26,4 @@ Subcommands: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-doctor.1 b/docs/man/gnb-doctor.1 index e808441..f97a32e 100644 --- a/docs/man/gnb-doctor.1 +++ b/docs/man/gnb-doctor.1 @@ -14,6 +14,10 @@ Check configuration validity, shell integration status, and report issues. .SH OPTIONS +\fB--fix\fP[=false] + Automatically fix detected issues + +.PP \fB-h\fP, \fB--help\fP[=false] help for doctor @@ -23,4 +27,4 @@ Check configuration validity, shell integration status, and report issues. .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-edit.1 b/docs/man/gnb-edit.1 index e5efd97..544b3cb 100644 --- a/docs/man/gnb-edit.1 +++ b/docs/man/gnb-edit.1 @@ -30,4 +30,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-export.1 b/docs/man/gnb-export.1 index ef9e972..3fd7584 100644 --- a/docs/man/gnb-export.1 +++ b/docs/man/gnb-export.1 @@ -18,6 +18,10 @@ Example: .SH OPTIONS +\fB--aliases-only\fP[=false] + Export only aliases, skip workflows + +.PP \fB-f\fP, \fB--format\fP="toml" Output format (toml, yaml, json) @@ -35,4 +39,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-import.1 b/docs/man/gnb-import.1 index 881ff05..9f10eaf 100644 --- a/docs/man/gnb-import.1 +++ b/docs/man/gnb-import.1 @@ -32,4 +32,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-init.1 b/docs/man/gnb-init.1 index 48da27a..b10be5b 100644 --- a/docs/man/gnb-init.1 +++ b/docs/man/gnb-init.1 @@ -36,4 +36,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-list.1 b/docs/man/gnb-list.1 index 71dfd39..4bee65f 100644 --- a/docs/man/gnb-list.1 +++ b/docs/man/gnb-list.1 @@ -14,12 +14,17 @@ List all aliases and workflows in the configuration. Example: gnb list gnb list --tag deploy + gnb list --scope project .SH OPTIONS \fB-h\fP, \fB--help\fP[=false] help for list +.PP +\fB--scope\fP="" + Filter by scope (global or project) + .PP \fB-t\fP, \fB--tag\fP="" Filter by tag @@ -30,4 +35,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-migrate.1 b/docs/man/gnb-migrate.1 index 8d2f081..e7a3142 100644 --- a/docs/man/gnb-migrate.1 +++ b/docs/man/gnb-migrate.1 @@ -42,4 +42,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-remove.1 b/docs/man/gnb-remove.1 index 8d244f2..6efcc24 100644 --- a/docs/man/gnb-remove.1 +++ b/docs/man/gnb-remove.1 @@ -26,4 +26,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-run.1 b/docs/man/gnb-run.1 index 360b9a5..d90df0e 100644 --- a/docs/man/gnb-run.1 +++ b/docs/man/gnb-run.1 @@ -36,4 +36,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-shell-init.1 b/docs/man/gnb-shell-init.1 index cf4f057..b74f647 100644 --- a/docs/man/gnb-shell-init.1 +++ b/docs/man/gnb-shell-init.1 @@ -22,6 +22,10 @@ Add this to your shell config: # fish gnb shell-init | source +.PP +Powerlevel10k users: place the eval line BEFORE the instant prompt preamble +in ~/.zshrc, or run 'gnb doctor --fix' to reorder automatically. + .PP Example: gnb shell-init @@ -42,4 +46,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-show.1 b/docs/man/gnb-show.1 index 49c3369..f6e3ee6 100644 --- a/docs/man/gnb-show.1 +++ b/docs/man/gnb-show.1 @@ -26,4 +26,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb-suggest.1 b/docs/man/gnb-suggest.1 index 9d80807..35a0210 100644 --- a/docs/man/gnb-suggest.1 +++ b/docs/man/gnb-suggest.1 @@ -12,14 +12,26 @@ gnb-suggest - Suggest aliases and workflows from shell history .SH DESCRIPTION Analyze shell history to suggest frequently used commands as aliases and repeated command sequences as workflows. + +.PP +ganbatte learns passively when eval "$(gnb shell-init)" is active. Once +enough commands are collected, suggest uses that data instead of raw shell +history for better accuracy. + +.PP Example: gnb suggest gnb suggest --apply + gnb suggest --from-history # force shell history source .SH OPTIONS \fB--apply\fP[=false] - Apply suggested aliases to config + Apply suggested aliases and workflows to config + +.PP +\fB--from-history\fP[=false] + Force shell history source instead of track log .PP \fB-h\fP, \fB--help\fP[=false] @@ -39,4 +51,4 @@ Example: .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/docs/man/gnb.1 b/docs/man/gnb.1 index 3ef089e..a8c757e 100644 --- a/docs/man/gnb.1 +++ b/docs/man/gnb.1 @@ -23,4 +23,4 @@ gnb - ganbatte \- for lazy developers | 頑張って ! .SH HISTORY -13-Apr-2026 Auto generated by spf13/cobra +29-Apr-2026 Auto generated by spf13/cobra diff --git a/go.mod b/go.mod index 9fb02e8..c1dcf6c 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect @@ -39,6 +40,7 @@ require ( github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -49,6 +51,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index ac70dbe..c06420e 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,7 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -72,6 +73,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= @@ -107,6 +109,7 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= diff --git a/internal/config/owner_unix.go b/internal/config/owner_unix.go new file mode 100644 index 0000000..47e66b8 --- /dev/null +++ b/internal/config/owner_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package config + +import ( + "fmt" + "os" + "syscall" +) + +func isOwnedByCurrentUser(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + return false, fmt.Errorf("checking owner: %w", err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false, fmt.Errorf("checking owner: unsupported file info for %s", path) + } + return stat.Uid == uint32(os.Geteuid()), nil +} diff --git a/internal/config/owner_windows.go b/internal/config/owner_windows.go new file mode 100644 index 0000000..fdf4bd5 --- /dev/null +++ b/internal/config/owner_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package config + +func isOwnedByCurrentUser(path string) (bool, error) { + return true, nil +} diff --git a/internal/config/permissions_unix.go b/internal/config/permissions_unix.go new file mode 100644 index 0000000..ecacd04 --- /dev/null +++ b/internal/config/permissions_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package config + +import "os" + +func isOtherWritable(mode os.FileMode) bool { + return mode.Perm()&0o002 != 0 +} diff --git a/internal/config/permissions_windows.go b/internal/config/permissions_windows.go new file mode 100644 index 0000000..4f51a92 --- /dev/null +++ b/internal/config/permissions_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package config + +import "os" + +func isOtherWritable(mode os.FileMode) bool { + return false +} diff --git a/internal/config/scope.go b/internal/config/scope.go index 7536acd..67c84bb 100644 --- a/internal/config/scope.go +++ b/internal/config/scope.go @@ -56,21 +56,29 @@ func loadFromScope(scope string) (*Config, error) { if err != nil { return nil, fmt.Errorf("getting home directory: %w", err) } - v.SetConfigName("config") - v.AddConfigPath(filepath.Join(home, ".config", "ganbatte")) - case "project": - // Look for .ganbatte.* in current directory - for _, ext := range []string{"toml", "yaml", "yml", "json"} { - name := ".ganbatte." + ext - if _, err := os.Stat(name); err == nil { - v.SetConfigFile(name) - break + configFile, format := detectGlobalConfig(filepath.Join(home, ".config", "ganbatte")) + if _, err := os.Stat(configFile); err != nil { + if os.IsNotExist(err) { + return &Config{ + Version: "0.1.0", + Aliases: make(map[string]Alias), + Workflows: make(map[string]Workflow), + }, nil } + return nil, fmt.Errorf("checking global config: %w", err) + } + v.SetConfigFile(configFile) + v.SetConfigType(format) + case "project": + path, err := findProjectConfig() + if err != nil { + return nil, err } - if v.ConfigFileUsed() == "" { + if path == "" { // No project config found return nil, nil } + v.SetConfigFile(path) default: return nil, fmt.Errorf("unknown scope: %s", scope) } @@ -103,3 +111,83 @@ func loadFromScope(scope string) (*Config, error) { return &cfg, nil } + +// findProjectConfig walks from the current directory upward looking for .ganbatte.*. +func findProjectConfig() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting working directory: %w", err) + } + startDir := dir + + for { + for _, ext := range []string{"toml", "yaml", "yml", "json"} { + path := filepath.Join(dir, ".ganbatte."+ext) + if _, err := os.Stat(path); err == nil { + if dir != startDir && !hasVCSMarker(dir) { + continue + } + if safe, err := isSafeProjectConfig(path); err != nil { + return "", err + } else if !safe { + continue + } + return path, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("checking project config: %w", err) + } + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", nil + } + dir = parent + } +} + +func hasVCSMarker(dir string) bool { + for _, marker := range []string{".git", ".hg", ".svn"} { + if _, err := os.Stat(filepath.Join(dir, marker)); err == nil { + return true + } + } + return false +} + +func isSafeProjectConfig(path string) (bool, error) { + info, err := os.Lstat(path) + if err != nil { + return false, fmt.Errorf("checking project config safety: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return false, nil + } + if !info.Mode().IsRegular() { + return false, nil + } + if isOtherWritable(info.Mode()) { + return false, nil + } + if owned, err := isOwnedByCurrentUser(path); err != nil { + return false, err + } else if !owned { + return false, nil + } + + dir := filepath.Dir(path) + dirInfo, err := os.Stat(dir) + if err != nil { + return false, fmt.Errorf("checking project config directory: %w", err) + } + if isOtherWritable(dirInfo.Mode()) { + return false, nil + } + if owned, err := isOwnedByCurrentUser(dir); err != nil { + return false, err + } else if !owned { + return false, nil + } + + return true, nil +} diff --git a/internal/config/scope_test.go b/internal/config/scope_test.go index 5a93254..38aeb54 100644 --- a/internal/config/scope_test.go +++ b/internal/config/scope_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -115,6 +116,97 @@ cmd = "git status" assert.Empty(t, scoped.Conflicts) } +func TestLoadScoped_GlobalFormatPriority(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + configDir := filepath.Join(tmpDir, ".config", "ganbatte") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.json"), []byte(`{ + "version": "0.1.0", + "alias": {"json_alias": {"cmd": "echo json"}} +}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(` +version = "0.1.0" +[alias.toml_alias] +cmd = "echo toml" +`), 0o644)) + + scoped, err := LoadScoped() + require.NoError(t, err) + assert.Contains(t, scoped.Global.Aliases, "toml_alias") + assert.NotContains(t, scoped.Global.Aliases, "json_alias") +} + +func TestLoadScoped_ProjectParentDiscovery(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + projectRoot := filepath.Join(tmpDir, "repo") + nestedDir := filepath.Join(projectRoot, "apps", "cli") + require.NoError(t, os.MkdirAll(nestedDir, 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(projectRoot, ".ganbatte.toml"), []byte(` +version = "0.1.0" +[alias.project] +cmd = "echo project" +`), 0o644)) + t.Chdir(nestedDir) + + scoped, err := LoadScoped() + require.NoError(t, err) + require.NotNil(t, scoped.Project) + assert.Equal(t, "echo project", scoped.Project.Aliases["project"].Cmd) + assert.Equal(t, "echo project", scoped.Merged.Aliases["project"].Cmd) +} + +func TestLoadScoped_SkipsUnsafeProjectConfig(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + projectRoot := filepath.Join(tmpDir, "repo") + nestedDir := filepath.Join(projectRoot, "sub") + require.NoError(t, os.MkdirAll(nestedDir, 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "unsafe.toml"), []byte(` +version = "0.1.0" +[alias.unsafe] +cmd = "echo unsafe" +`), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(tmpDir, "unsafe.toml"), filepath.Join(projectRoot, ".ganbatte.toml"))) + t.Chdir(nestedDir) + + scoped, err := LoadScoped() + require.NoError(t, err) + assert.Nil(t, scoped.Project) +} + +func TestLoadScoped_SkipsWritableProjectConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows file mode bits do not expose Unix-style world-writable permissions") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + projectRoot := filepath.Join(tmpDir, "repo") + nestedDir := filepath.Join(projectRoot, "sub") + require.NoError(t, os.MkdirAll(nestedDir, 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, ".git"), 0o755)) + configPath := filepath.Join(projectRoot, ".ganbatte.toml") + require.NoError(t, os.WriteFile(configPath, []byte(` +version = "0.1.0" +[alias.unsafe] +cmd = "echo unsafe" +`), 0o666)) + require.NoError(t, os.Chmod(configPath, 0o666)) + t.Chdir(nestedDir) + + scoped, err := LoadScoped() + require.NoError(t, err) + assert.Nil(t, scoped.Project) +} + func TestLoadScoped_NoConfig(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -131,8 +223,8 @@ func TestSaveGlobal(t *testing.T) { setTestHome(t, tmpDir) cfg := &Config{ - Version: "0.1.0", - Aliases: map[string]Alias{"gs": {Cmd: "git status -sb"}}, + Version: "0.1.0", + Aliases: map[string]Alias{"gs": {Cmd: "git status -sb"}}, Workflows: map[string]Workflow{}, } require.NoError(t, cfg.SaveGlobal()) diff --git a/internal/history/suggest.go b/internal/history/suggest.go index b1bccb9..e38ccda 100644 --- a/internal/history/suggest.go +++ b/internal/history/suggest.go @@ -159,7 +159,7 @@ func detectParamPatterns(entries []Entry, existing map[string]string, minCount i if len(tokens) < 3 { continue } - for prefixLen := 2; prefixLen <= min(3, len(tokens)-1); prefixLen++ { + for prefixLen := 2; prefixLen <= minInt(3, len(tokens)-1); prefixLen++ { prefix := strings.Join(tokens[:prefixLen], " ") if hasUnmatchedQuote(prefix) { continue @@ -405,7 +405,7 @@ func safeAliasName(cmd string, used map[string]bool) string { // First char of first word + first 2 chars of second w0 := strings.TrimLeft(tokens[0], "-./") w1 := strings.TrimLeft(tokens[1], "-./") - if len(w0) > 0 && len(w1) > 0 { + if w0 != "" && w1 != "" { if len(w1) >= 2 { candidates = append(candidates, string(w0[0])+w1[:2]) } @@ -663,7 +663,7 @@ func paramNameForPrefix(prefix string) string { "add": "target", "remove": "target", } - for i := len(tokens) - 1; i >= max(0, len(tokens)-2); i-- { + for i := len(tokens) - 1; i >= maxInt(0, len(tokens)-2); i-- { t := strings.ToLower(strings.TrimLeft(tokens[i], "-")) if hint, ok := lastTokenHints[t]; ok { return hint @@ -672,14 +672,14 @@ func paramNameForPrefix(prefix string) string { return "arg" } -func min(a, b int) int { +func minInt(a, b int) int { if a < b { return a } return b } -func max(a, b int) int { +func maxInt(a, b int) int { if a > b { return a } diff --git a/internal/track/track_test.go b/internal/track/track_test.go index df82f22..7832294 100644 --- a/internal/track/track_test.go +++ b/internal/track/track_test.go @@ -22,7 +22,7 @@ func TestLogPath_RespectsXDG(t *testing.T) { t.Setenv("XDG_DATA_HOME", "/custom/data") path, err := LogPath() require.NoError(t, err) - assert.Equal(t, filepath.Join("/custom/data", "ganbatte", "track.log"), path) + assert.Equal(t, filepath.Join(string(filepath.Separator), "custom", "data", "ganbatte", "track.log"), path) } func TestParse_ValidLog(t *testing.T) { diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 78d06d7..2c5e5e0 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -42,9 +42,10 @@ type Workflow struct { // RunOptions configures workflow execution behavior type RunOptions struct { - DryRun bool - Writer io.Writer - Reader io.Reader // for confirm prompts; defaults to os.Stdin + DryRun bool + SkipConfirm bool + Writer io.Writer + Reader io.Reader // for confirm prompts; defaults to os.Stdin } // Run executes a workflow with the given arguments @@ -92,7 +93,7 @@ func Run(wf Workflow, args []string, ex Executor, opts RunOptions) error { } // Confirm prompt - if step.Confirm { + if step.Confirm && !opts.SkipConfirm { fmt.Fprintf(w, "Run '%s'? [y/N] ", stepCmd) scanner := bufio.NewScanner(r) if scanner.Scan() { diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go index 39e5790..ffd7e6d 100644 --- a/internal/workflow/workflow_test.go +++ b/internal/workflow/workflow_test.go @@ -219,6 +219,26 @@ func TestWorkflowRun_ConfirmNo(t *testing.T) { assert.Contains(t, outBuf.String(), "Skipped") } +func TestWorkflowRun_SkipConfirm(t *testing.T) { + wf := Workflow{ + Steps: []Step{ + {Run: "echo dangerous", Confirm: true}, + }, + } + + outBuf := new(bytes.Buffer) + executor := &MockExecutor{} + + err := Run(wf, []string{}, executor, RunOptions{ + SkipConfirm: true, + Writer: outBuf, + }) + + assert.NoError(t, err) + assert.Equal(t, []string{"echo dangerous"}, executor.ExecuteCalls) + assert.NotContains(t, outBuf.String(), "Run 'echo dangerous'?") +} + func TestWorkflowRun_OnFailPrompt_Continue(t *testing.T) { mock := &MockExecutor{ ExecuteFunc: func(cmd string) error { diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..06600d9 --- /dev/null +++ b/tools.go @@ -0,0 +1,5 @@ +//go:build tools + +package main + +import _ "github.com/spf13/cobra/doc"