feat: update tui

This commit is contained in:
eric
2026-03-21 01:40:42 +01:00
parent 146b1e9501
commit b6528466e0
5 changed files with 351 additions and 146 deletions

View File

@@ -73,14 +73,6 @@ func parseReleaseCLIArgs(args []string) ([]string, release.ExecutionOptions, boo
switch arg { switch arg {
case "select": case "select":
selectMode = true selectMode = true
case "--dry-run":
execution.DryRun = true
case "--commit":
execution.Commit = true
case "--tag":
execution.Tag = true
case "--push":
execution.Push = true
default: default:
if strings.HasPrefix(arg, "--") { if strings.HasPrefix(arg, "--") {
return nil, release.ExecutionOptions{}, false, fmt.Errorf("unknown flag %q", arg) return nil, release.ExecutionOptions{}, false, fmt.Errorf("unknown flag %q", arg)

View File

@@ -256,13 +256,16 @@ func TestRunnerExecutesReleaseFlow(t *testing.T) {
} }
} }
func TestRunnerLeavesChangesUncommittedByDefault(t *testing.T) { func TestRunnerAlwaysCommitsTagsAndPushes(t *testing.T) {
t.Parallel() t.Parallel()
root := t.TempDir() root := t.TempDir()
remote := filepath.Join(t.TempDir(), "remote.git")
mustRun(t, root, "git", "init") mustRun(t, root, "git", "init")
mustRun(t, root, "git", "config", "user.name", "Release Test") 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", "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 { if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil {
t.Fatalf("WriteFile(flake.nix): %v", err) 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", "add", "-A")
mustRun(t, root, "git", "commit", "-m", "init") 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() binDir := t.TempDir()
if err := os.MkdirAll(binDir, 0o755); err != nil { 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") assertFileEquals(t, filepath.Join(root, "VERSION"), "1.0.1\nstable\n0\n")
status := strings.TrimSpace(mustOutput(t, root, "git", "status", "--short")) 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 != "" { if status != "" {
t.Fatalf("git status --short = %q, want empty", 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)
} }
} }

View File

@@ -20,7 +20,6 @@ type Config struct {
} }
type ExecutionOptions struct { type ExecutionOptions struct {
DryRun bool
Commit bool Commit bool
Tag bool Tag bool
Push bool Push bool
@@ -31,13 +30,11 @@ type Runner struct {
} }
func (o ExecutionOptions) Normalize() ExecutionOptions { func (o ExecutionOptions) Normalize() ExecutionOptions {
if o.Push { return ExecutionOptions{
o.Commit = true Commit: true,
Tag: true,
Push: true,
} }
if o.Tag {
o.Commit = true
}
return o
} }
func (r *Runner) Run(args []string) error { func (r *Runner) Run(args []string) error {
@@ -60,11 +57,6 @@ func (r *Runner) Run(args []string) error {
return err 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 { if err := requireCleanGit(rootDir); err != nil {
return err return err
} }
@@ -114,10 +106,6 @@ func (r *Runner) finalizeRelease(rootDir string, version Version, execution Exec
return err return err
} }
if !execution.Commit {
return nil
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "add", "-A"); err != nil { if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "add", "-A"); err != nil {
return err return err
} }
@@ -127,34 +115,15 @@ func (r *Runner) finalizeRelease(rootDir string, version Version, execution Exec
return err return err
} }
if execution.Tag {
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "tag", version.Tag()); err != nil { if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "tag", version.Tag()); err != nil {
return err return err
} }
}
if !execution.Push {
return nil
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push"); err != nil { if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push"); err != nil {
return err return err
} }
if execution.Tag {
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push", "--tags"); err != nil { if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push", "--tags"); err != nil {
return err return err
} }
}
return nil 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))
}

View File

@@ -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()) 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() finalModel, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
if err != nil { if err != nil {
return nil, false, err return nil, false, err
@@ -111,18 +111,6 @@ func formatReleaseCommand(args []string) string {
func formatReleaseCommandWithExecution(args []string, execution ExecutionOptions) string { func formatReleaseCommandWithExecution(args []string, execution ExecutionOptions) string {
var parts []string var parts []string
parts = append(parts, "release") 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 { if len(args) == 0 {
return strings.Join(parts, " ") 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 commit: "+yesNo(execution.Commit),
" git tag: "+yesNo(execution.Tag), " git tag: "+yesNo(execution.Tag),
" git push: "+yesNo(execution.Push), " git push: "+yesNo(execution.Push),
" dry run: "+yesNo(execution.DryRun),
) )
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
@@ -224,19 +211,32 @@ func capitalize(s string) string {
} }
type commandPickerModel struct { type commandPickerModel struct {
config Config
current Version current Version
options []CommandOption
cursor int
width int width int
height int height int
focusSection int
focusIndex int
bumpOptions []selectionOption
channelOptions []selectionOption
bumpCursor int
channelCursor int
confirmed bool confirmed bool
selected CommandOption 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{ return commandPickerModel{
config: config,
current: current, current: current,
options: options, 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() { switch msg.String() {
case "ctrl+c", "q", "esc": case "ctrl+c", "q", "esc":
return m, tea.Quit return m, tea.Quit
case "up", "k": case "up", "k", "shift+tab":
if m.cursor > 0 { m.moveFocus(-1)
m.cursor-- case "down", "j", "tab":
} m.moveFocus(1)
case "down", "j": case " ":
if m.cursor < len(m.options)-1 { m.selectFocused()
m.cursor++
}
case "enter": case "enter":
option, err := m.selectedOption()
if err != nil {
m.err = err.Error()
return m, nil
}
m.confirmed = true m.confirmed = true
m.selected = m.options[m.cursor] m.selected = option
return m, tea.Quit return m, tea.Quit
} }
} }
@@ -271,28 +274,180 @@ func (m commandPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m commandPickerModel) View() string { 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" return "No release commands available.\n"
} }
preview := m.options[m.cursor].Preview preview := m.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()) 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{
listLines := make([]string, 0, len(m.options)+1) m.renderSection("Bump type", m.bumpOptions, m.bumpCursor, m.focusSection == 0, m.focusedOptionIndex()),
listLines = append(listLines, "Commands") "",
for i, option := range m.options { m.renderSection("Channel", m.channelOptions, m.channelCursor, m.focusSection == 1, m.focusedOptionIndex()),
cursor := " " }, "\n")
if i == m.cursor {
cursor = "> "
}
listLines = append(listLines, fmt.Sprintf("%s%s\n %s", cursor, option.Command, option.Description))
}
list := strings.Join(listLines, "\n")
if m.width >= 100 { 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 { func renderColumns(left string, right string, width int) string {

View File

@@ -3,6 +3,8 @@ package release
import ( import (
"strings" "strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea"
) )
func TestBuildCommandOptionsForStableVersion(t *testing.T) { 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) { func findOptionByCommand(options []CommandOption, command string) (CommandOption, bool) {
for _, option := range options { for _, option := range options {
if option.Command == command { if option.Command == command {