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