diff --git a/packages/release/cmd/release/main.go b/packages/release/cmd/release/main.go index 842b42d..e5629fa 100644 --- a/packages/release/cmd/release/main.go +++ b/packages/release/cmd/release/main.go @@ -73,14 +73,6 @@ func parseReleaseCLIArgs(args []string) ([]string, release.ExecutionOptions, boo switch arg { case "select": selectMode = true - case "--dry-run": - execution.DryRun = true - case "--commit": - execution.Commit = true - case "--tag": - execution.Tag = true - case "--push": - execution.Push = true default: if strings.HasPrefix(arg, "--") { return nil, release.ExecutionOptions{}, false, fmt.Errorf("unknown flag %q", arg) diff --git a/packages/release/internal/release/release_test.go b/packages/release/internal/release/release_test.go index d6216bf..394c810 100644 --- a/packages/release/internal/release/release_test.go +++ b/packages/release/internal/release/release_test.go @@ -256,13 +256,16 @@ func TestRunnerExecutesReleaseFlow(t *testing.T) { } } -func TestRunnerLeavesChangesUncommittedByDefault(t *testing.T) { +func TestRunnerAlwaysCommitsTagsAndPushes(t *testing.T) { t.Parallel() root := t.TempDir() + remote := filepath.Join(t.TempDir(), "remote.git") mustRun(t, root, "git", "init") mustRun(t, root, "git", "config", "user.name", "Release Test") mustRun(t, root, "git", "config", "user.email", "release-test@example.com") + mustRun(t, root, "git", "config", "commit.gpgsign", "false") + mustRun(t, root, "git", "config", "tag.gpgsign", "false") if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil { t.Fatalf("WriteFile(flake.nix): %v", err) } @@ -271,6 +274,9 @@ func TestRunnerLeavesChangesUncommittedByDefault(t *testing.T) { } mustRun(t, root, "git", "add", "-A") mustRun(t, root, "git", "commit", "-m", "init") + mustRun(t, root, "git", "init", "--bare", remote) + mustRun(t, root, "git", "remote", "add", "origin", remote) + mustRun(t, root, "git", "push", "-u", "origin", "HEAD") binDir := t.TempDir() if err := os.MkdirAll(binDir, 0o755); err != nil { @@ -296,58 +302,20 @@ func TestRunnerLeavesChangesUncommittedByDefault(t *testing.T) { assertFileEquals(t, filepath.Join(root, "VERSION"), "1.0.1\nstable\n0\n") status := strings.TrimSpace(mustOutput(t, root, "git", "status", "--short")) - if status != "M VERSION" { - t.Fatalf("git status --short = %q, want %q", status, "M VERSION") - } - - tagList := strings.TrimSpace(mustOutput(t, root, "git", "tag", "--list")) - if tagList != "" { - t.Fatalf("git tag --list = %q, want empty", tagList) - } -} - -func TestRunnerDryRunDoesNotModifyRepo(t *testing.T) { - t.Parallel() - - root := t.TempDir() - mustRun(t, root, "git", "init") - mustRun(t, root, "git", "config", "user.name", "Release Test") - mustRun(t, root, "git", "config", "user.email", "release-test@example.com") - if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil { - t.Fatalf("WriteFile(flake.nix): %v", err) - } - if err := os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.0.0\nstable\n0\n"), 0o644); err != nil { - t.Fatalf("WriteFile(VERSION): %v", err) - } - mustRun(t, root, "git", "add", "-A") - mustRun(t, root, "git", "commit", "-m", "init") - - var stdout strings.Builder - r := &Runner{ - Config: Config{ - RootDir: root, - AllowedChannels: []string{"alpha", "beta", "rc", "internal"}, - Execution: ExecutionOptions{ - DryRun: true, - Commit: true, - Tag: true, - Push: true, - }, - Stdout: &stdout, - }, - } - - if err := r.Run([]string{"patch"}); err != nil { - t.Fatalf("Runner.Run: %v", err) - } - - assertFileEquals(t, filepath.Join(root, "VERSION"), "1.0.0\nstable\n0\n") - status := strings.TrimSpace(mustOutput(t, root, "git", "status", "--short")) if status != "" { t.Fatalf("git status --short = %q, want empty", status) } - if !strings.Contains(stdout.String(), "Dry run: 1.0.1") { - t.Fatalf("dry-run output missing next version:\n%s", stdout.String()) + + tagList := strings.TrimSpace(mustOutput(t, root, "git", "tag", "--list", "v1.0.1")) + if tagList != "v1.0.1" { + t.Fatalf("git tag --list v1.0.1 = %q, want v1.0.1", tagList) + } + + branch := strings.TrimSpace(mustOutput(t, root, "git", "branch", "--show-current")) + remoteHead := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "origin/"+branch)) + localHead := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD")) + if remoteHead != localHead { + t.Fatalf("origin/%s = %q, want %q", branch, remoteHead, localHead) } } diff --git a/packages/release/internal/release/runner.go b/packages/release/internal/release/runner.go index ad55c03..4c40c5b 100644 --- a/packages/release/internal/release/runner.go +++ b/packages/release/internal/release/runner.go @@ -20,7 +20,6 @@ type Config struct { } type ExecutionOptions struct { - DryRun bool Commit bool Tag bool Push bool @@ -31,13 +30,11 @@ type Runner struct { } func (o ExecutionOptions) Normalize() ExecutionOptions { - if o.Push { - o.Commit = true + return ExecutionOptions{ + Commit: true, + Tag: true, + Push: true, } - if o.Tag { - o.Commit = true - } - return o } func (r *Runner) Run(args []string) error { @@ -60,11 +57,6 @@ func (r *Runner) Run(args []string) error { return err } - if execution.DryRun { - printReleasePlan(stdout, nextVersion, execution, strings.TrimSpace(r.Config.ReleaseStepsJSON) != "", strings.TrimSpace(r.Config.PostVersion) != "") - return nil - } - if err := requireCleanGit(rootDir); err != nil { return err } @@ -114,10 +106,6 @@ func (r *Runner) finalizeRelease(rootDir string, version Version, execution Exec return err } - if !execution.Commit { - return nil - } - if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "add", "-A"); err != nil { return err } @@ -127,34 +115,15 @@ func (r *Runner) finalizeRelease(rootDir string, version Version, execution Exec return err } - if execution.Tag { - if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "tag", version.Tag()); err != nil { - return err - } - } - - if !execution.Push { - return nil + if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "tag", version.Tag()); err != nil { + return err } if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push"); err != nil { return err } - if execution.Tag { - if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push", "--tags"); err != nil { - return err - } + if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push", "--tags"); err != nil { + return err } return nil } - -func printReleasePlan(stdout io.Writer, version Version, execution ExecutionOptions, hasReleaseSteps bool, hasPostVersion bool) { - fmt.Fprintf(stdout, "Dry run: %s\n", version.String()) - fmt.Fprintf(stdout, "Tag: %s\n", version.Tag()) - fmt.Fprintf(stdout, "Release steps: %s\n", yesNo(hasReleaseSteps)) - fmt.Fprintf(stdout, "Post-version: %s\n", yesNo(hasPostVersion)) - fmt.Fprintf(stdout, "nix fmt: yes\n") - fmt.Fprintf(stdout, "git commit: %s\n", yesNo(execution.Commit)) - fmt.Fprintf(stdout, "git tag: %s\n", yesNo(execution.Tag)) - fmt.Fprintf(stdout, "git push: %s\n", yesNo(execution.Push)) -} diff --git a/packages/release/internal/release/ui.go b/packages/release/internal/release/ui.go index 76fbbed..1098d5f 100644 --- a/packages/release/internal/release/ui.go +++ b/packages/release/internal/release/ui.go @@ -45,7 +45,7 @@ func SelectCommand(config Config) ([]string, bool, error) { return nil, false, fmt.Errorf("no release commands available for current version %s", versionFile.Version.String()) } - model := newCommandPickerModel(versionFile.Version, options) + model := newCommandPickerModel(config, versionFile.Version) finalModel, err := tea.NewProgram(model, tea.WithAltScreen()).Run() if err != nil { return nil, false, err @@ -111,18 +111,6 @@ func formatReleaseCommand(args []string) string { func formatReleaseCommandWithExecution(args []string, execution ExecutionOptions) string { var parts []string parts = append(parts, "release") - if execution.DryRun { - parts = append(parts, "--dry-run") - } - if execution.Commit { - parts = append(parts, "--commit") - } - if execution.Tag { - parts = append(parts, "--tag") - } - if execution.Push { - parts = append(parts, "--push") - } if len(args) == 0 { return strings.Join(parts, " ") } @@ -199,7 +187,6 @@ func buildPreview(config Config, current Version, args []string, next Version) s " git commit: "+yesNo(execution.Commit), " git tag: "+yesNo(execution.Tag), " git push: "+yesNo(execution.Push), - " dry run: "+yesNo(execution.DryRun), ) return strings.Join(lines, "\n") } @@ -224,19 +211,32 @@ func capitalize(s string) string { } type commandPickerModel struct { - current Version - options []CommandOption - cursor int - width int - height int - confirmed bool - selected CommandOption + config Config + current Version + width int + height int + focusSection int + focusIndex int + bumpOptions []selectionOption + channelOptions []selectionOption + bumpCursor int + channelCursor int + confirmed bool + selected CommandOption + err string } -func newCommandPickerModel(current Version, options []CommandOption) commandPickerModel { +type selectionOption struct { + Label string + Value string +} + +func newCommandPickerModel(config Config, current Version) commandPickerModel { return commandPickerModel{ - current: current, - options: options, + config: config, + current: current, + bumpOptions: buildBumpOptions(current), + channelOptions: buildChannelOptions(current, config.AllowedChannels), } } @@ -253,17 +253,20 @@ func (m commandPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "q", "esc": return m, tea.Quit - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.options)-1 { - m.cursor++ - } + case "up", "k", "shift+tab": + m.moveFocus(-1) + case "down", "j", "tab": + m.moveFocus(1) + case " ": + m.selectFocused() case "enter": + option, err := m.selectedOption() + if err != nil { + m.err = err.Error() + return m, nil + } m.confirmed = true - m.selected = m.options[m.cursor] + m.selected = option return m, tea.Quit } } @@ -271,28 +274,180 @@ func (m commandPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m commandPickerModel) View() string { - if len(m.options) == 0 { + if len(m.bumpOptions) == 0 || len(m.channelOptions) == 0 { return "No release commands available.\n" } - preview := m.options[m.cursor].Preview - header := fmt.Sprintf("Release command picker\nCurrent version: %s\nUse up/down or j/k to choose, Enter to run, q to cancel.\n", m.current.String()) - - listLines := make([]string, 0, len(m.options)+1) - listLines = append(listLines, "Commands") - for i, option := range m.options { - cursor := " " - if i == m.cursor { - cursor = "> " - } - listLines = append(listLines, fmt.Sprintf("%s%s\n %s", cursor, option.Command, option.Description)) - } - list := strings.Join(listLines, "\n") + preview := m.preview() + header := fmt.Sprintf("Release command picker\nCurrent version: %s\nUse up/down to move through options, Space to select, Enter to run, q to cancel.\n", m.current.String()) + sections := strings.Join([]string{ + m.renderSection("Bump type", m.bumpOptions, m.bumpCursor, m.focusSection == 0, m.focusedOptionIndex()), + "", + m.renderSection("Channel", m.channelOptions, m.channelCursor, m.focusSection == 1, m.focusedOptionIndex()), + }, "\n") if m.width >= 100 { - return header + "\n" + renderColumns(list, preview, m.width) + return header + "\n" + renderColumns(sections, preview, m.width) } - return header + "\n" + list + "\n\n" + preview + "\n" + return header + "\n" + sections + "\n\n" + preview + "\n" +} + +func buildBumpOptions(current Version) []selectionOption { + options := []selectionOption{ + {Label: "Patch", Value: "patch"}, + {Label: "Minor", Value: "minor"}, + {Label: "Major", Value: "major"}, + } + if current.Channel != "stable" { + options = append(options, selectionOption{ + Label: "None", + Value: "", + }) + return options + } + options = append(options, selectionOption{ + Label: "None", + Value: "", + }) + return options +} + +func buildChannelOptions(current Version, allowedChannels []string) []selectionOption { + options := []selectionOption{{ + Label: "Current", + Value: current.Channel, + }} + if current.Channel != "stable" { + options = append(options, selectionOption{ + Label: "Stable", + Value: "stable", + }) + } + for _, channel := range allowedChannels { + if channel == current.Channel { + continue + } + options = append(options, selectionOption{ + Label: capitalize(channel), + Value: channel, + }) + } + return options +} + +func (m *commandPickerModel) moveFocus(delta int) { + total := len(m.bumpOptions) + len(m.channelOptions) + if total == 0 { + return + } + + index := wrapIndex(m.focusIndex+delta, total) + m.focusIndex = index + + if index < len(m.bumpOptions) { + m.focusSection = 0 + return + } + + m.focusSection = 1 +} + +func (m *commandPickerModel) selectFocused() { + if m.focusSection == 0 { + m.bumpCursor = m.focusedOptionIndex() + return + } + m.channelCursor = m.focusedOptionIndex() +} + +func wrapIndex(idx int, size int) int { + if size == 0 { + return 0 + } + for idx < 0 { + idx += size + } + return idx % size +} + +func (m commandPickerModel) focusedOptionIndex() int { + if m.focusSection == 0 { + return m.focusIndex + } + return m.focusIndex - len(m.bumpOptions) +} + +func (m commandPickerModel) renderSection(title string, options []selectionOption, cursor int, focused bool, focusedIndex int) string { + lines := []string{title} + for i, option := range options { + pointer := " " + if focused && i == focusedIndex { + pointer = ">" + } + radio := "( )" + if i == cursor { + radio = "(*)" + } + lines = append(lines, fmt.Sprintf("%s %s %s", pointer, radio, option.Label)) + } + return strings.Join(lines, "\n") +} + +func (m commandPickerModel) selectedArgs() []string { + bump := m.bumpOptions[m.bumpCursor].Value + channel := m.channelOptions[m.channelCursor].Value + + if bump == "" { + if channel == "stable" { + return []string{"stable"} + } + if channel == m.current.Channel { + if channel == "stable" { + return nil + } + return []string{channel} + } + return []string{channel} + } + + if channel == m.current.Channel || (channel == "stable" && m.current.Channel == "stable") { + return []string{bump} + } + return []string{bump, channel} +} + +func (m commandPickerModel) selectedOption() (CommandOption, error) { + args := m.selectedArgs() + next, err := ResolveNextVersion(m.current, args, m.config.AllowedChannels) + if err != nil { + return CommandOption{}, err + } + return CommandOption{ + Title: titleForArgs(args), + Description: descriptionForArgs(m.current, args, next), + Command: formatReleaseCommand(args), + Args: append([]string(nil), args...), + NextVersion: next, + Preview: buildPreview(m.config, m.current, args, next), + }, nil +} + +func (m commandPickerModel) preview() string { + option, err := m.selectedOption() + if err != nil { + lines := []string{ + "Command", + " " + formatReleaseCommand(m.selectedArgs()), + "", + "Selection", + " " + err.Error(), + } + if m.err != "" { + lines = append(lines, "", "Error", " "+m.err) + } + return strings.Join(lines, "\n") + } + return option.Preview } func renderColumns(left string, right string, width int) string { diff --git a/packages/release/internal/release/ui_test.go b/packages/release/internal/release/ui_test.go index ec2a82e..c93378e 100644 --- a/packages/release/internal/release/ui_test.go +++ b/packages/release/internal/release/ui_test.go @@ -3,6 +3,8 @@ package release import ( "strings" "testing" + + tea "github.com/charmbracelet/bubbletea" ) func TestBuildCommandOptionsForStableVersion(t *testing.T) { @@ -81,6 +83,125 @@ func TestBuildCommandOptionsForPrereleaseVersion(t *testing.T) { } } +func TestCommandPickerSelectionForStableVersion(t *testing.T) { + t.Parallel() + + model := newCommandPickerModel(Config{ + AllowedChannels: []string{"alpha", "beta"}, + }, MustParseVersion(t, "1.2.3")) + + model.bumpCursor = 1 + model.channelCursor = 0 + + option, err := model.selectedOption() + if err != nil { + t.Fatalf("selectedOption(): %v", err) + } + if got := strings.Join(option.Args, " "); got != "minor" { + t.Fatalf("selected args = %q, want %q", got, "minor") + } + if option.NextVersion.String() != "1.3.0" { + t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.3.0") + } + + model.bumpCursor = 3 + model.channelCursor = 1 + + option, err = model.selectedOption() + if err != nil { + t.Fatalf("selectedOption(channel only): %v", err) + } + if got := strings.Join(option.Args, " "); got != "alpha" { + t.Fatalf("selected args = %q, want %q", got, "alpha") + } + if option.NextVersion.String() != "1.2.4-alpha.1" { + t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.2.4-alpha.1") + } +} + +func TestCommandPickerSelectionForPrereleaseVersion(t *testing.T) { + t.Parallel() + + model := newCommandPickerModel(Config{ + AllowedChannels: []string{"alpha", "beta", "rc"}, + }, MustParseVersion(t, "1.2.3-beta.2")) + + model.bumpCursor = 3 + model.channelCursor = 0 + + option, err := model.selectedOption() + if err != nil { + t.Fatalf("selectedOption(current prerelease): %v", err) + } + if got := strings.Join(option.Args, " "); got != "beta" { + t.Fatalf("selected args = %q, want %q", got, "beta") + } + if option.NextVersion.String() != "1.2.3-beta.3" { + t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.2.3-beta.3") + } + + model.channelCursor = 1 + + option, err = model.selectedOption() + if err != nil { + t.Fatalf("selectedOption(promote): %v", err) + } + if got := strings.Join(option.Args, " "); got != "stable" { + t.Fatalf("selected args = %q, want %q", got, "stable") + } + if option.NextVersion.String() != "1.2.3" { + t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.2.3") + } + + model.bumpCursor = 0 + + if _, err := model.selectedOption(); err == nil { + t.Fatalf("selectedOption(patch stable from prerelease) succeeded, want error") + } +} + +func TestCommandPickerFocusMovesAcrossSections(t *testing.T) { + t.Parallel() + + model := newCommandPickerModel(Config{ + AllowedChannels: []string{"alpha", "beta"}, + }, MustParseVersion(t, "1.2.3")) + + next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = next.(commandPickerModel) + if model.focusSection != 0 || model.focusIndex != 1 || model.bumpCursor != 0 { + t.Fatalf("after first down: focusSection=%d focusIndex=%d bumpCursor=%d", model.focusSection, model.focusIndex, model.bumpCursor) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = next.(commandPickerModel) + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = next.(commandPickerModel) + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = next.(commandPickerModel) + if model.focusSection != 1 || model.focusIndex != 4 || model.channelCursor != 0 { + t.Fatalf("after moving into channel section: focusSection=%d focusIndex=%d channelCursor=%d", model.focusSection, model.focusIndex, model.channelCursor) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = next.(commandPickerModel) + if model.channelCursor != 0 { + t.Fatalf("space changed selection unexpectedly: channelCursor=%d", model.channelCursor) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp}) + model = next.(commandPickerModel) + if model.focusSection != 0 || model.focusIndex != 3 || model.bumpCursor != 0 { + t.Fatalf("after moving back up: focusSection=%d focusIndex=%d bumpCursor=%d", model.focusSection, model.focusIndex, model.bumpCursor) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = next.(commandPickerModel) + if model.bumpCursor != 3 { + t.Fatalf("space did not update bump selection: bumpCursor=%d", model.bumpCursor) + } +} + func findOptionByCommand(options []CommandOption, command string) (CommandOption, bool) { for _, option := range options { if option.Command == command {