Add TUI add and preview commands

Use charmbracelet/huh for an interactive add flow. Add a preview
subcommand to enter/exit per-project pre-release tags. Move stamp.toml
into .stamp/ and add ConfigPath helper. Prefer --snapshot then project
PreTag when computing versions and promote bumps to pre-release when
appropriate. Export CurrentVersion and add required TUI deps to go.mod.
This commit is contained in:
Thomas
2026-03-12 22:59:20 +01:00
parent fb347eaa54
commit 8049c505a0
12 changed files with 323 additions and 87 deletions

View File

@@ -8,8 +8,15 @@ import (
"github.com/BurntSushi/toml"
)
const DefaultChangesetDir = ".stamp"
const ConfigFileName = "stamp.toml"
const (
DefaultChangesetDir = ".stamp"
ConfigFileName = "stamp.toml"
)
// ConfigPath returns the path to stamp.toml inside the changeset directory.
func ConfigPath(repoRoot string) string {
return filepath.Join(repoRoot, DefaultChangesetDir, ConfigFileName)
}
// Config is the root configuration parsed from stamp.toml.
type Config struct {
@@ -19,8 +26,7 @@ type Config struct {
// GlobalConfig holds repo-level settings.
type GlobalConfig struct {
BaseBranch string `toml:"base_branch,omitempty"`
ChangesetDir string `toml:"changeset_dir,omitempty"`
BaseBranch string `toml:"base_branch,omitempty"`
}
// Project represents a single project in the monorepo.
@@ -29,6 +35,7 @@ type Project struct {
Path string `toml:"path"`
Version string `toml:"version"`
Changelog string `toml:"changelog,omitempty"`
PreTag string `toml:"pre_tag,omitempty"`
Publish PublishConfig `toml:"publish,omitempty"`
}
@@ -39,11 +46,9 @@ type PublishConfig struct {
Artifacts []string `toml:"artifacts"`
}
// ChangesetDir returns the configured changeset directory, defaulting to .stamp.
// ChangesetDir always returns the default changeset directory.
// The config file lives inside this directory, so it is always known.
func (c *Config) ChangesetDir() string {
if c.Config.ChangesetDir != "" {
return c.Config.ChangesetDir
}
return DefaultChangesetDir
}
@@ -93,9 +98,9 @@ func (p *PublishConfig) PublishReleases() bool {
return p.Releases == nil || *p.Releases
}
// Load reads and validates stamp.toml from the given directory.
func Load(dir string) (*Config, error) {
path := filepath.Join(dir, ConfigFileName)
// Load reads and validates stamp.toml from inside the changeset directory.
func Load(repoRoot string) (*Config, error) {
path := ConfigPath(repoRoot)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
@@ -113,9 +118,13 @@ func Load(dir string) (*Config, error) {
return &cfg, nil
}
// Save writes the config back to stamp.toml in the given directory.
func Save(dir string, cfg *Config) error {
path := filepath.Join(dir, ConfigFileName)
// Save writes the config back to stamp.toml inside the changeset directory.
func Save(repoRoot string, cfg *Config) error {
dir := filepath.Join(repoRoot, DefaultChangesetDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", dir, err)
}
path := ConfigPath(repoRoot)
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("creating %s: %w", path, err)

View File

@@ -11,7 +11,6 @@ import (
const validTOML = `
[config]
base_branch = "main"
changeset_dir = ".stamp"
[[projects]]
name = "my-app"
@@ -24,11 +23,20 @@ path = "libs/my-lib"
version = "0.1.0"
`
func TestLoad_Valid(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(validTOML), 0o644); err != nil {
func writeConfig(t *testing.T, dir, content string) {
t.Helper()
stampDir := filepath.Join(dir, ".stamp")
if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stampDir, "stamp.toml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_Valid(t *testing.T) {
dir := t.TempDir()
writeConfig(t, dir, validTOML)
cfg, err := config.Load(dir)
if err != nil {
@@ -42,16 +50,14 @@ func TestLoad_Valid(t *testing.T) {
t.Errorf("expected base_branch=main, got %s", cfg.BaseBranch())
}
if cfg.ChangesetDir() != ".stamp" {
t.Errorf("expected changeset_dir=.stamp, got %s", cfg.ChangesetDir())
t.Errorf("expected ChangesetDir=.stamp, got %s", cfg.ChangesetDir())
}
}
func TestLoad_Defaults(t *testing.T) {
dir := t.TempDir()
minimal := "[[projects]]\nname = \"app\"\npath = \".\"\nversion = \"1.0.0\"\n"
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(minimal), 0o644); err != nil {
t.Fatal(err)
}
writeConfig(t, dir, minimal)
cfg, err := config.Load(dir)
if err != nil {
@@ -75,9 +81,7 @@ func TestLoad_MissingFile(t *testing.T) {
func TestLoad_NoProjects(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte("[config]\n"), 0o644); err != nil {
t.Fatal(err)
}
writeConfig(t, dir, "[config]\n")
_, err := config.Load(dir)
if err == nil {
t.Fatal("expected error for config with no projects")
@@ -97,9 +101,7 @@ name = "app"
path = "other"
version = "2.0.0"
`
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(dup), 0o644); err != nil {
t.Fatal(err)
}
writeConfig(t, dir, dup)
_, err := config.Load(dir)
if err == nil {
t.Fatal("expected error for duplicate project name")
@@ -108,9 +110,7 @@ version = "2.0.0"
func TestFindProject(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(validTOML), 0o644); err != nil {
t.Fatal(err)
}
writeConfig(t, dir, validTOML)
cfg, _ := config.Load(dir)
if p := cfg.FindProject("my-app"); p == nil {