5 Commits

Author SHA1 Message Date
eric
3b38bc8f38 chore(release): v3.6.2 2026-03-21 01:43:45 +01:00
eric
a5ab22f426 fix: update steps 2026-03-21 01:43:20 +01:00
eric
54b54fdece fix: typo 2026-03-21 01:42:48 +01:00
eric
60d0c27db7 fix: typo 2026-03-21 01:42:38 +01:00
eric
b6528466e0 feat: update tui 2026-03-21 01:40:42 +01:00
10 changed files with 370 additions and 151 deletions

View File

@@ -17,7 +17,7 @@ Audit and replacement review: [`docs/reviews/2026-03-21-repo-lib-audit.md`](/Use
## Use the template
```bash
nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.5.1#default' --refresh
+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.1#default' --refresh
```
The generated repo includes:
@@ -34,7 +34,7 @@ The generated repo includes:
Add this flake input:
```nix
inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.5.1";
+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.1";
inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs";
```

View File

@@ -1,3 +1,3 @@
3.5.1
3.6.2
stable
0

View File

@@ -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)

View File

@@ -33,8 +33,9 @@ func translateReplacementBackrefs(raw string) string {
for i := 0; i < len(raw); i++ {
if raw[i] == '\\' && i+1 < len(raw) && raw[i+1] >= '1' && raw[i+1] <= '9' {
b.WriteByte('$')
b.WriteString("${")
b.WriteByte(raw[i+1])
b.WriteByte('}')
i++
continue
}

View File

@@ -0,0 +1,13 @@
package release
import "testing"
func TestTranslateReplacementBackrefsWrapsCaptureNumbers(t *testing.T) {
t.Parallel()
got := translateReplacementBackrefs(`\1git+https://example.test/ref\2`)
want := `${1}git+https://example.test/ref${2}`
if got != want {
t.Fatalf("translateReplacementBackrefs() = %q, want %q", got, want)
}
}

View File

@@ -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)
}
}

View File

@@ -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", "push"); err != nil {
return err
}
if execution.Tag {
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))
}

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())
}
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 {
config Config
current Version
options []CommandOption
cursor int
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{
config: config,
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() {
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 {

View File

@@ -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 {

View File

@@ -3,7 +3,7 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.5.1";
repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.2";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};