Skip to content

Commit c2c75ce

Browse files
indacoclaude
andauthored
feat(discover): support three versioning models in multi-module detection (#187)
* feat(discover): support three versioning models in multi-module detection * test(discover): add tests for the three versioning models in multi-module detection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 923b9e7 commit c2c75ce

10 files changed

Lines changed: 2090 additions & 1315 deletions

internal/commands/discover/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
type Prompter interface {
1010
Confirm(title, description string) (bool, error)
1111
MultiSelect(title, description string, options []huh.Option[string], defaults []string) ([]string, error)
12+
Select(title, description string, options []huh.Option[string]) (string, error)
1213
}
1314

1415
// TUIPrompter implements Prompter using the tui package.
@@ -29,6 +30,11 @@ func (p *TUIPrompter) MultiSelect(title, description string, options []huh.Optio
2930
return tui.MultiSelect(title, description, options, defaults)
3031
}
3132

33+
// Select shows a single-select prompt.
34+
func (p *TUIPrompter) Select(title, description string, options []huh.Option[string]) (string, error) {
35+
return tui.Select(title, description, options)
36+
}
37+
3238
// OutputFormat controls how discovery results are displayed.
3339
type OutputFormat string
3440

internal/commands/discover/workflow.go

Lines changed: 245 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ func (w *Workflow) runInitWorkflow(ctx context.Context) (bool, error) {
7474
return false, nil
7575
}
7676

77-
// If we have sync candidates (multi-module project), run the dependency-check setup
77+
// Handle multi-module projects specially
78+
if w.result.Mode == discovery.MultiModule {
79+
return w.runMultiModuleSetup(ctx)
80+
}
81+
82+
// If we have sync candidates (manifest files), run the dependency-check setup
7883
if len(w.result.SyncCandidates) > 0 {
7984
return w.runDependencyCheckSetup(ctx)
8085
}
@@ -83,6 +88,245 @@ func (w *Workflow) runInitWorkflow(ctx context.Context) (bool, error) {
8388
return w.createConfigWithDefaults(ctx)
8489
}
8590

91+
// WorkspaceChoice represents the user's choice for multi-module configuration.
92+
type WorkspaceChoice string
93+
94+
const (
95+
// WorkspaceChoiceCoordinated syncs all .version files to root (coordinated versioning).
96+
WorkspaceChoiceCoordinated WorkspaceChoice = "coordinated"
97+
// WorkspaceChoiceWorkspace configures independent module versions (workspace mode).
98+
WorkspaceChoiceWorkspace WorkspaceChoice = "workspace"
99+
// WorkspaceChoiceSingleRoot uses only the root .version for all manifests.
100+
WorkspaceChoiceSingleRoot WorkspaceChoice = "single"
101+
)
102+
103+
// runMultiModuleSetup handles configuration for multi-module/monorepo projects.
104+
func (w *Workflow) runMultiModuleSetup(ctx context.Context) (bool, error) {
105+
fmt.Println()
106+
printer.PrintInfo(fmt.Sprintf("Found %d modules - this appears to be a monorepo.", len(w.result.Modules)))
107+
fmt.Println()
108+
109+
// Show discovered modules
110+
printer.PrintFaint("Discovered modules:")
111+
for _, m := range w.result.Modules {
112+
fmt.Printf(" - %s (%s)\n", m.Name, m.RelPath)
113+
}
114+
fmt.Println()
115+
116+
// Ask how to configure the project
117+
choice, err := w.prompter.Select(
118+
"How would you like to configure versioning?",
119+
"Choose how to manage versions in this monorepo.",
120+
[]huh.Option[string]{
121+
huh.NewOption("Coordinated versioning (recommended) - all .version files sync to root", string(WorkspaceChoiceCoordinated)),
122+
huh.NewOption("Independent workspace - each module versioned separately", string(WorkspaceChoiceWorkspace)),
123+
huh.NewOption("Single root only - ignore submodule .version files", string(WorkspaceChoiceSingleRoot)),
124+
},
125+
)
126+
if err != nil {
127+
return false, err
128+
}
129+
130+
if choice == "" {
131+
printer.PrintFaint("Configuration canceled.")
132+
return false, nil
133+
}
134+
135+
switch WorkspaceChoice(choice) {
136+
case WorkspaceChoiceCoordinated:
137+
return w.createConfigWithCoordinatedVersioning(ctx)
138+
case WorkspaceChoiceWorkspace:
139+
return w.createConfigWithWorkspace(ctx)
140+
case WorkspaceChoiceSingleRoot:
141+
// Only configure dependency-check for manifest files found near root
142+
return w.runDependencyCheckSetup(ctx)
143+
default:
144+
return w.createConfigWithDefaults(ctx)
145+
}
146+
}
147+
148+
// createConfigWithCoordinatedVersioning creates .sley.yaml with coordinated versioning.
149+
// All submodule .version files and manifest files sync to the root .version.
150+
func (w *Workflow) createConfigWithCoordinatedVersioning(ctx context.Context) (bool, error) {
151+
// Ensure root .version exists
152+
if err := w.ensureVersionFile(ctx); err != nil {
153+
return false, err
154+
}
155+
156+
// Combine: submodule .version files + manifest files as sync candidates
157+
var allSyncCandidates []discovery.SyncCandidate
158+
159+
// Add submodule .version files (excluding root)
160+
for _, m := range w.result.Modules {
161+
if m.RelPath == ".version" {
162+
continue // Skip root
163+
}
164+
allSyncCandidates = append(allSyncCandidates, discovery.SyncCandidate{
165+
Path: m.RelPath,
166+
Format: parser.FormatRaw,
167+
Field: "",
168+
Version: m.Version,
169+
Description: "Version file (" + m.RelPath + ")",
170+
})
171+
}
172+
173+
// Add manifest files
174+
allSyncCandidates = append(allSyncCandidates, w.result.SyncCandidates...)
175+
176+
// Create config with dependency-check for ALL files
177+
return w.createConfigWithDependencyCheck(ctx, allSyncCandidates)
178+
}
179+
180+
// createConfigWithWorkspace creates .sley.yaml with workspace configuration for multi-module projects.
181+
func (w *Workflow) createConfigWithWorkspace(ctx context.Context) (bool, error) {
182+
// Initialize .version file if needed
183+
if err := w.ensureVersionFile(ctx); err != nil {
184+
return false, err
185+
}
186+
187+
// Default plugins: commit-parser and tag-manager
188+
selectedPlugins := []string{"commit-parser", "tag-manager"}
189+
190+
// Generate config with workspace discovery enabled
191+
configData, err := generateConfigYAMLWithWorkspace(defaultVersionPath(), selectedPlugins, w.result.SyncCandidates)
192+
if err != nil {
193+
return false, fmt.Errorf("failed to generate config: %w", err)
194+
}
195+
196+
// Write config file
197+
if err := os.WriteFile(".sley.yaml", configData, config.ConfigFilePerm); err != nil {
198+
return false, fmt.Errorf("failed to write config file: %w", err)
199+
}
200+
201+
w.printWorkspaceInitSuccess(selectedPlugins)
202+
return true, nil
203+
}
204+
205+
// printWorkspaceInitSuccess prints success messages after workspace initialization.
206+
func (w *Workflow) printWorkspaceInitSuccess(plugins []string) {
207+
fmt.Println()
208+
printer.PrintSuccess(fmt.Sprintf("Created .sley.yaml with workspace configuration and %d plugin(s)", len(plugins)))
209+
210+
// Show enabled plugins
211+
fmt.Println()
212+
printer.PrintInfo("Enabled plugins:")
213+
for _, p := range plugins {
214+
fmt.Printf(" - %s\n", p)
215+
}
216+
217+
// Show workspace info
218+
fmt.Println()
219+
printer.PrintInfo("Workspace configuration:")
220+
fmt.Println(" - Auto-discovery enabled")
221+
fmt.Println(" - Each module manages its own .version file")
222+
223+
// Show discovered modules
224+
fmt.Println()
225+
printer.PrintInfo(fmt.Sprintf("Discovered %d module(s):", len(w.result.Modules)))
226+
for _, m := range w.result.Modules {
227+
fmt.Printf(" - %s (%s)\n", m.Name, m.RelPath)
228+
}
229+
230+
// Note about per-module dependency-check
231+
if len(w.result.SyncCandidates) > 0 {
232+
fmt.Println()
233+
printer.PrintFaint("Tip: Each module can have its own .sley.yaml with dependency-check")
234+
printer.PrintFaint(" configured for manifests in that module's directory.")
235+
}
236+
237+
// Next steps
238+
fmt.Println()
239+
printer.PrintInfo("Next steps:")
240+
fmt.Println(" - Review .sley.yaml and adjust settings")
241+
fmt.Println(" - Run 'sley bump patch' to see available modules")
242+
fmt.Println(" - Run 'sley doctor' to verify setup")
243+
}
244+
245+
// generateConfigYAMLWithWorkspace generates the YAML configuration content with workspace settings.
246+
func generateConfigYAMLWithWorkspace(versionPath string, plugins []string, syncCandidates []discovery.SyncCandidate) ([]byte, error) {
247+
cfg := &config.Config{
248+
Path: versionPath,
249+
}
250+
251+
// Create workspace config with discovery enabled
252+
enabled := true
253+
recursive := true
254+
moduleMaxDepth := 10
255+
cfg.Workspace = &config.WorkspaceConfig{
256+
Discovery: &config.DiscoveryConfig{
257+
Enabled: &enabled,
258+
Recursive: &recursive,
259+
ModuleMaxDepth: &moduleMaxDepth,
260+
Exclude: []string{"testdata", "node_modules"},
261+
},
262+
}
263+
264+
// Create plugins config based on selections
265+
pluginsCfg := &config.PluginConfig{}
266+
267+
for _, name := range plugins {
268+
switch name {
269+
case "commit-parser":
270+
pluginsCfg.CommitParser = true
271+
case "tag-manager":
272+
pluginsCfg.TagManager = &config.TagManagerConfig{
273+
Enabled: true,
274+
}
275+
case "dependency-check":
276+
depCheck := &config.DependencyCheckConfig{
277+
Enabled: true,
278+
AutoSync: true,
279+
}
280+
if len(syncCandidates) > 0 {
281+
depCheck.Files = make([]config.DependencyFileConfig, len(syncCandidates))
282+
for i, c := range syncCandidates {
283+
depCheck.Files[i] = config.DependencyFileConfig{
284+
Path: c.Path,
285+
Format: c.Format.String(),
286+
Field: c.Field,
287+
Pattern: c.Pattern,
288+
}
289+
}
290+
}
291+
pluginsCfg.DependencyCheck = depCheck
292+
}
293+
}
294+
295+
cfg.Plugins = pluginsCfg
296+
297+
return marshalConfigWithWorkspaceComments(cfg, plugins)
298+
}
299+
300+
// marshalConfigWithWorkspaceComments marshals config to YAML with helpful comments for workspace.
301+
func marshalConfigWithWorkspaceComments(cfg *config.Config, plugins []string) ([]byte, error) {
302+
data, err := marshalToYAML(cfg)
303+
if err != nil {
304+
return nil, err
305+
}
306+
307+
// Add header comments
308+
var result strings.Builder
309+
result.WriteString("# sley configuration file\n")
310+
result.WriteString("# Documentation: https://github.com/indaco/sley\n")
311+
result.WriteString("# Generated by 'sley discover'\n")
312+
result.WriteString("\n")
313+
result.WriteString("# This is a workspace configuration for a multi-module project.\n")
314+
result.WriteString("# Each module with a .version file is discovered automatically.\n")
315+
result.WriteString("# Modules can have their own .sley.yaml for module-specific settings.\n")
316+
result.WriteString("\n")
317+
318+
if len(plugins) > 0 {
319+
result.WriteString("# Enabled plugins:\n")
320+
for _, name := range plugins {
321+
result.WriteString(fmt.Sprintf("# - %s\n", name))
322+
}
323+
result.WriteString("\n")
324+
}
325+
326+
result.Write(data)
327+
return []byte(result.String()), nil
328+
}
329+
86330
// runExistingConfigWorkflow handles the case when .sley.yaml already exists.
87331
func (w *Workflow) runExistingConfigWorkflow(ctx context.Context) (bool, error) {
88332
// Check for mismatches and offer to fix

0 commit comments

Comments
 (0)