Skip to content

Commit d119064

Browse files
authored
[feat] Add rimba exec command for parallel worktree execution (#50)
* [feat] Add executor package for parallel shell execution (#40) Core execution engine with injectable RunFunc, bounded concurrency via semaphore, fail-fast context cancellation, and deterministic result ordering. Includes ShellRunner for production use and 9 unit tests with mock runners. * [feat] Add FilterByType to operations package (#40) Extract worktree type filtering to the operations layer for reuse by the exec command. Uses resolver.TaskFromBranch for prefix matching. * [feat] Add rimba exec command for parallel worktree execution (#40) Cobra command that runs a shell command across worktrees in parallel. Supports --all, --type, --dirty, --fail-fast, and --concurrency flags. Uses collect-then-print output pattern with colored status badges. * [test] Add E2E tests for rimba exec (#40) Nine end-to-end tests covering --all, --type filter, --dirty filter, --fail-fast, --concurrency, non-zero exit propagation, missing flag error, invalid type error, and no-match output. * [test] Add ShellRunner and completion tests to meet 95% coverage (#40) Add integration tests for ShellRunner (success, non-zero exit, process error) which also exercises safeBuffer. Add exec --type flag completion test. Total coverage: 95.9%.
1 parent 1d58173 commit d119064

7 files changed

Lines changed: 1054 additions & 0 deletions

File tree

cmd/exec.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
"sync"
8+
9+
"github.com/lugassawan/rimba/internal/config"
10+
"github.com/lugassawan/rimba/internal/executor"
11+
"github.com/lugassawan/rimba/internal/git"
12+
"github.com/lugassawan/rimba/internal/hint"
13+
"github.com/lugassawan/rimba/internal/operations"
14+
"github.com/lugassawan/rimba/internal/resolver"
15+
"github.com/lugassawan/rimba/internal/spinner"
16+
"github.com/lugassawan/rimba/internal/termcolor"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
const (
21+
flagFailFast = "fail-fast"
22+
flagConcurrency = "concurrency"
23+
24+
hintExecAll = "Run command in all eligible worktrees"
25+
hintExecType = "Filter by prefix type (feature, bugfix, hotfix, etc.)"
26+
hintExecDirty = "Run only in worktrees with uncommitted changes"
27+
hintFailFast = "Stop execution after the first failure"
28+
hintConcurrency = "Limit the number of parallel executions"
29+
)
30+
31+
func init() {
32+
execCmd.Flags().Bool(flagAll, false, "run in all eligible worktrees")
33+
execCmd.Flags().String(flagType, "", "filter by prefix type (e.g. feature, bugfix)")
34+
execCmd.Flags().Bool(flagDirty, false, "run only in dirty worktrees")
35+
execCmd.Flags().Bool(flagFailFast, false, "stop after the first failure")
36+
execCmd.Flags().Int(flagConcurrency, 0, "max parallel executions (0 = unlimited)")
37+
38+
_ = execCmd.RegisterFlagCompletionFunc(flagType, func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
39+
var types []string
40+
for _, p := range resolver.AllPrefixes() {
41+
t := strings.TrimSuffix(p, "/")
42+
if strings.HasPrefix(t, toComplete) {
43+
types = append(types, t)
44+
}
45+
}
46+
return types, cobra.ShellCompDirectiveNoFileComp
47+
})
48+
49+
rootCmd.AddCommand(execCmd)
50+
}
51+
52+
var execCmd = &cobra.Command{
53+
Use: "exec <command>",
54+
Short: "Run a shell command across worktrees",
55+
Long: "Executes a shell command in parallel across matching worktrees. Use --all to target all worktrees, or --type to filter by prefix type.",
56+
Args: cobra.ExactArgs(1),
57+
RunE: func(cmd *cobra.Command, args []string) error {
58+
cfg := config.FromContext(cmd.Context())
59+
60+
r := newRunner()
61+
all, _ := cmd.Flags().GetBool(flagAll)
62+
typeFilter, _ := cmd.Flags().GetString(flagType)
63+
dirty, _ := cmd.Flags().GetBool(flagDirty)
64+
failFast, _ := cmd.Flags().GetBool(flagFailFast)
65+
concurrency, _ := cmd.Flags().GetInt(flagConcurrency)
66+
67+
if !all && typeFilter == "" {
68+
return errors.New("provide --all or --type to select worktrees")
69+
}
70+
71+
if typeFilter != "" && !resolver.ValidPrefixType(typeFilter) {
72+
valid := make([]string, 0, len(resolver.AllPrefixes()))
73+
for _, p := range resolver.AllPrefixes() {
74+
valid = append(valid, strings.TrimSuffix(p, "/"))
75+
}
76+
return fmt.Errorf("invalid type %q; valid types: %s", typeFilter, strings.Join(valid, ", "))
77+
}
78+
79+
hint.New(cmd, hintPainter(cmd)).
80+
Add(flagAll, hintExecAll).
81+
Add(flagType, hintExecType).
82+
Add(flagDirty, hintExecDirty).
83+
Add(flagFailFast, hintFailFast).
84+
Add(flagConcurrency, hintConcurrency).
85+
Show()
86+
87+
s := spinner.New(spinnerOpts(cmd))
88+
defer s.Stop()
89+
s.Start("Collecting worktrees...")
90+
91+
worktrees, err := listWorktreeInfos(r)
92+
if err != nil {
93+
return err
94+
}
95+
96+
prefixes := resolver.AllPrefixes()
97+
98+
var filtered []resolver.WorktreeInfo
99+
if typeFilter != "" {
100+
filtered = operations.FilterByType(worktrees, prefixes, typeFilter)
101+
} else {
102+
allTasks := operations.CollectTasks(worktrees, prefixes)
103+
filtered = operations.FilterEligible(worktrees, prefixes, cfg.DefaultSource, allTasks, true)
104+
}
105+
106+
if dirty {
107+
filtered = filterDirtyWorktrees(r, s, filtered)
108+
}
109+
110+
if len(filtered) == 0 {
111+
s.Stop()
112+
fmt.Fprintln(cmd.OutOrStdout(), "No worktrees match the given filters.")
113+
return nil
114+
}
115+
116+
targets := make([]executor.Target, len(filtered))
117+
for i, wt := range filtered {
118+
task, _ := resolver.TaskFromBranch(wt.Branch, prefixes)
119+
targets[i] = executor.Target{
120+
Path: wt.Path,
121+
Branch: wt.Branch,
122+
Task: task,
123+
}
124+
}
125+
126+
s.Update(fmt.Sprintf("Running in %d worktree(s)...", len(targets)))
127+
128+
results := executor.Run(cmd.Context(), executor.Config{
129+
Targets: targets,
130+
Command: args[0],
131+
Concurrency: concurrency,
132+
FailFast: failFast,
133+
Runner: executor.ShellRunner(),
134+
})
135+
136+
s.Stop()
137+
138+
noColor, _ := cmd.Flags().GetBool(flagNoColor)
139+
p := termcolor.NewPainter(noColor)
140+
141+
printExecResults(cmd, p, results, prefixes)
142+
143+
if hasFailure(results) {
144+
return errors.New("one or more commands failed")
145+
}
146+
return nil
147+
},
148+
}
149+
150+
// filterDirtyWorktrees filters worktrees to only those with uncommitted changes.
151+
func filterDirtyWorktrees(r git.Runner, s *spinner.Spinner, worktrees []resolver.WorktreeInfo) []resolver.WorktreeInfo {
152+
isDirty := make([]bool, len(worktrees))
153+
var wg sync.WaitGroup
154+
sem := make(chan struct{}, 8)
155+
156+
for i, wt := range worktrees {
157+
s.Update(fmt.Sprintf("Checking dirty status... (%d/%d)", i+1, len(worktrees)))
158+
wg.Add(1)
159+
go func(idx int, path string) {
160+
defer wg.Done()
161+
sem <- struct{}{}
162+
defer func() { <-sem }()
163+
164+
dirty, err := git.IsDirty(r, path)
165+
if err == nil && dirty {
166+
isDirty[idx] = true
167+
}
168+
}(i, wt.Path)
169+
}
170+
wg.Wait()
171+
172+
var out []resolver.WorktreeInfo
173+
for i, wt := range worktrees {
174+
if isDirty[i] {
175+
out = append(out, wt)
176+
}
177+
}
178+
return out
179+
}
180+
181+
// printExecResults prints the formatted output for each execution result.
182+
func printExecResults(cmd *cobra.Command, p *termcolor.Painter, results []executor.Result, prefixes []string) {
183+
for _, r := range results {
184+
_, matchedPrefix := resolver.TaskFromBranch(r.Target.Branch, prefixes)
185+
typeName := strings.TrimSuffix(matchedPrefix, "/")
186+
187+
taskLabel := r.Target.Task
188+
if c := typeColor(typeName); c != "" {
189+
taskLabel = p.Paint(taskLabel, c)
190+
}
191+
192+
var status string
193+
switch {
194+
case r.Cancelled:
195+
status = p.Paint("cancelled", termcolor.Gray)
196+
case r.Err != nil:
197+
status = p.Paint("error", termcolor.Red)
198+
case r.ExitCode != 0:
199+
status = p.Paint(fmt.Sprintf("exit %d", r.ExitCode), termcolor.Red)
200+
default:
201+
status = p.Paint("ok", termcolor.Green)
202+
}
203+
204+
fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", taskLabel, status)
205+
206+
if out := strings.TrimRight(string(r.Stdout), "\n"); out != "" {
207+
for line := range strings.SplitSeq(out, "\n") {
208+
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", line)
209+
}
210+
}
211+
212+
if r.Err != nil {
213+
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", r.Err)
214+
} else if errOut := strings.TrimRight(string(r.Stderr), "\n"); errOut != "" {
215+
for line := range strings.SplitSeq(errOut, "\n") {
216+
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", line)
217+
}
218+
}
219+
}
220+
}
221+
222+
// hasFailure returns true if any result has a non-zero exit code or error.
223+
func hasFailure(results []executor.Result) bool {
224+
for _, r := range results {
225+
if r.ExitCode != 0 || r.Err != nil {
226+
return true
227+
}
228+
}
229+
return false
230+
}

0 commit comments

Comments
 (0)