@fuzdev/fuz_gitops 0.78.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +91 -17
  2. package/dist/ModulesDetail.svelte +22 -27
  3. package/dist/ModulesDetail.svelte.d.ts.map +1 -1
  4. package/dist/ModulesNav.svelte +8 -5
  5. package/dist/ModulesNav.svelte.d.ts.map +1 -1
  6. package/dist/ModulesPage.svelte +3 -5
  7. package/dist/ModulesPage.svelte.d.ts.map +1 -1
  8. package/dist/PageFooter.svelte +2 -2
  9. package/dist/PageHeader.svelte +6 -6
  10. package/dist/PageHeader.svelte.d.ts +0 -3
  11. package/dist/PageHeader.svelte.d.ts.map +1 -1
  12. package/dist/PullRequestsDetail.svelte +29 -20
  13. package/dist/PullRequestsDetail.svelte.d.ts.map +1 -1
  14. package/dist/PullRequestsPage.svelte +4 -6
  15. package/dist/PullRequestsPage.svelte.d.ts.map +1 -1
  16. package/dist/ReposTable.svelte +43 -55
  17. package/dist/ReposTable.svelte.d.ts.map +1 -1
  18. package/dist/ReposTree.svelte +9 -21
  19. package/dist/ReposTree.svelte.d.ts.map +1 -1
  20. package/dist/ReposTreeNav.svelte +9 -12
  21. package/dist/ReposTreeNav.svelte.d.ts.map +1 -1
  22. package/dist/TablePage.svelte +3 -5
  23. package/dist/TablePage.svelte.d.ts.map +1 -1
  24. package/dist/TreeItemPage.svelte +3 -5
  25. package/dist/TreeItemPage.svelte.d.ts.map +1 -1
  26. package/dist/TreePage.svelte +3 -5
  27. package/dist/TreePage.svelte.d.ts.map +1 -1
  28. package/dist/changeset_generator.d.ts.map +1 -1
  29. package/dist/changeset_generator.js +2 -2
  30. package/dist/changeset_reader.d.ts.map +1 -1
  31. package/dist/changeset_reader.js +3 -3
  32. package/dist/ci_reconcile.d.ts +6 -10
  33. package/dist/ci_reconcile.d.ts.map +1 -1
  34. package/dist/ci_reconcile.js +6 -8
  35. package/dist/dependency_graph.d.ts +26 -49
  36. package/dist/dependency_graph.d.ts.map +1 -1
  37. package/dist/dependency_graph.js +40 -83
  38. package/dist/dependency_updater.d.ts +1 -1
  39. package/dist/dependency_updater.d.ts.map +1 -1
  40. package/dist/dependency_updater.js +14 -9
  41. package/dist/fetch_repo_data.d.ts +34 -8
  42. package/dist/fetch_repo_data.d.ts.map +1 -1
  43. package/dist/fetch_repo_data.js +44 -22
  44. package/dist/fs_fetch_value_cache.d.ts +1 -1
  45. package/dist/fs_fetch_value_cache.d.ts.map +1 -1
  46. package/dist/fs_fetch_value_cache.js +3 -3
  47. package/dist/git_operations.d.ts +6 -49
  48. package/dist/git_operations.d.ts.map +1 -1
  49. package/dist/git_operations.js +13 -126
  50. package/dist/github.d.ts +17 -2
  51. package/dist/github.d.ts.map +1 -1
  52. package/dist/github.js +17 -8
  53. package/dist/github_helpers.d.ts.map +1 -1
  54. package/dist/github_helpers.js +3 -11
  55. package/dist/gitops_analyze.task.d.ts +24 -2
  56. package/dist/gitops_analyze.task.d.ts.map +1 -1
  57. package/dist/gitops_analyze.task.js +47 -34
  58. package/dist/gitops_config.d.ts +34 -79
  59. package/dist/gitops_config.d.ts.map +1 -1
  60. package/dist/gitops_config.js +76 -53
  61. package/dist/gitops_constants.d.ts +6 -4
  62. package/dist/gitops_constants.d.ts.map +1 -1
  63. package/dist/gitops_constants.js +6 -4
  64. package/dist/gitops_plan.task.d.ts +29 -3
  65. package/dist/gitops_plan.task.d.ts.map +1 -1
  66. package/dist/gitops_plan.task.js +83 -61
  67. package/dist/gitops_publish.task.d.ts +62 -2
  68. package/dist/gitops_publish.task.d.ts.map +1 -1
  69. package/dist/gitops_publish.task.js +193 -105
  70. package/dist/gitops_run.task.d.ts +74 -0
  71. package/dist/gitops_run.task.d.ts.map +1 -1
  72. package/dist/gitops_run.task.js +176 -162
  73. package/dist/gitops_sync.task.d.ts +65 -3
  74. package/dist/gitops_sync.task.d.ts.map +1 -1
  75. package/dist/gitops_sync.task.js +163 -37
  76. package/dist/gitops_task_helpers.d.ts +86 -59
  77. package/dist/gitops_task_helpers.d.ts.map +1 -1
  78. package/dist/gitops_task_helpers.js +120 -79
  79. package/dist/gitops_validate.task.d.ts +1 -2
  80. package/dist/gitops_validate.task.d.ts.map +1 -1
  81. package/dist/gitops_validate.task.js +25 -32
  82. package/dist/graph_validation.d.ts +16 -27
  83. package/dist/graph_validation.d.ts.map +1 -1
  84. package/dist/graph_validation.js +24 -65
  85. package/dist/local_repo.d.ts +50 -62
  86. package/dist/local_repo.d.ts.map +1 -1
  87. package/dist/local_repo.js +83 -196
  88. package/dist/log_helpers.d.ts +8 -8
  89. package/dist/log_helpers.d.ts.map +1 -1
  90. package/dist/multi_repo_publisher.d.ts +10 -0
  91. package/dist/multi_repo_publisher.d.ts.map +1 -1
  92. package/dist/multi_repo_publisher.js +125 -61
  93. package/dist/npm_registry.d.ts +23 -19
  94. package/dist/npm_registry.d.ts.map +1 -1
  95. package/dist/npm_registry.js +21 -48
  96. package/dist/operations.d.ts +75 -180
  97. package/dist/operations.d.ts.map +1 -1
  98. package/dist/operations.js +15 -7
  99. package/dist/operations_defaults.d.ts +17 -2
  100. package/dist/operations_defaults.d.ts.map +1 -1
  101. package/dist/operations_defaults.js +111 -128
  102. package/dist/output_helpers.d.ts +36 -0
  103. package/dist/output_helpers.d.ts.map +1 -1
  104. package/dist/output_helpers.js +37 -3
  105. package/dist/preflight_checks.d.ts +20 -27
  106. package/dist/preflight_checks.d.ts.map +1 -1
  107. package/dist/preflight_checks.js +37 -126
  108. package/dist/publish_gate.d.ts.map +1 -1
  109. package/dist/publish_steps.d.ts +2 -4
  110. package/dist/publish_steps.d.ts.map +1 -1
  111. package/dist/publish_steps.js +4 -8
  112. package/dist/publishing_event.d.ts +2 -8
  113. package/dist/publishing_event.d.ts.map +1 -1
  114. package/dist/publishing_event.js +20 -17
  115. package/dist/publishing_event_handler.d.ts +10 -8
  116. package/dist/publishing_event_handler.d.ts.map +1 -1
  117. package/dist/publishing_event_handler.js +19 -17
  118. package/dist/publishing_plan.d.ts +16 -1
  119. package/dist/publishing_plan.d.ts.map +1 -1
  120. package/dist/publishing_plan.js +332 -254
  121. package/dist/publishing_plan_helpers.d.ts.map +1 -1
  122. package/dist/publishing_plan_helpers.js +3 -3
  123. package/dist/publishing_plan_logging.d.ts +1 -1
  124. package/dist/publishing_plan_logging.d.ts.map +1 -1
  125. package/dist/publishing_plan_logging.js +22 -15
  126. package/dist/repo.svelte.d.ts +7 -0
  127. package/dist/repo.svelte.d.ts.map +1 -1
  128. package/dist/repo.svelte.js +3 -0
  129. package/dist/repo_readiness.d.ts +210 -0
  130. package/dist/repo_readiness.d.ts.map +1 -0
  131. package/dist/repo_readiness.js +516 -0
  132. package/dist/repos_status.d.ts +3340 -0
  133. package/dist/repos_status.d.ts.map +1 -0
  134. package/dist/repos_status.js +555 -0
  135. package/dist/repos_status_load.d.ts +65 -0
  136. package/dist/repos_status_load.d.ts.map +1 -0
  137. package/dist/repos_status_load.js +131 -0
  138. package/dist/version_utils.js +1 -1
  139. package/package.json +15 -34
  140. package/src/lib/cargo_toml.ts +3 -3
  141. package/src/lib/changeset_generator.ts +19 -19
  142. package/src/lib/changeset_reader.ts +18 -18
  143. package/src/lib/ci_reconcile.ts +12 -16
  144. package/src/lib/dependency_graph.ts +61 -113
  145. package/src/lib/dependency_updater.ts +36 -30
  146. package/src/lib/fetch_repo_data.ts +71 -34
  147. package/src/lib/fs_fetch_value_cache.ts +15 -11
  148. package/src/lib/git_operations.ts +22 -194
  149. package/src/lib/github.ts +33 -21
  150. package/src/lib/github_helpers.ts +9 -16
  151. package/src/lib/gitops_analyze.task.ts +104 -66
  152. package/src/lib/gitops_config.ts +90 -139
  153. package/src/lib/gitops_constants.ts +6 -4
  154. package/src/lib/gitops_plan.task.ts +128 -76
  155. package/src/lib/gitops_publish.task.ts +280 -144
  156. package/src/lib/gitops_run.task.ts +265 -193
  157. package/src/lib/gitops_sync.task.ts +236 -52
  158. package/src/lib/gitops_task_helpers.ts +170 -114
  159. package/src/lib/gitops_validate.task.ts +44 -54
  160. package/src/lib/graph_validation.ts +28 -89
  161. package/src/lib/local_repo.ts +128 -335
  162. package/src/lib/log_helpers.ts +18 -28
  163. package/src/lib/multi_repo_publisher.ts +192 -110
  164. package/src/lib/npm_registry.ts +42 -67
  165. package/src/lib/operations.ts +99 -160
  166. package/src/lib/operations_defaults.ts +157 -201
  167. package/src/lib/output_helpers.ts +58 -6
  168. package/src/lib/preflight_checks.ts +52 -188
  169. package/src/lib/publish_gate.ts +7 -9
  170. package/src/lib/publish_steps.ts +27 -25
  171. package/src/lib/publishing_event.ts +26 -23
  172. package/src/lib/publishing_event_handler.ts +24 -22
  173. package/src/lib/publishing_plan.ts +474 -310
  174. package/src/lib/publishing_plan_helpers.ts +16 -12
  175. package/src/lib/publishing_plan_logging.ts +53 -44
  176. package/src/lib/repo.svelte.ts +16 -8
  177. package/src/lib/repo_readiness.ts +647 -0
  178. package/src/lib/repos_status.ts +671 -0
  179. package/src/lib/repos_status_load.ts +173 -0
  180. package/src/lib/version_utils.ts +3 -3
  181. package/dist/config_reconcile.d.ts +0 -46
  182. package/dist/config_reconcile.d.ts.map +0 -1
  183. package/dist/config_reconcile.js +0 -60
  184. package/dist/paths.d.ts +0 -7
  185. package/dist/paths.d.ts.map +0 -1
  186. package/dist/paths.js +0 -6
  187. package/dist/repo_ops.d.ts +0 -59
  188. package/dist/repo_ops.d.ts.map +0 -1
  189. package/dist/repo_ops.js +0 -170
  190. package/dist/resolved_gitops_config.d.ts +0 -9
  191. package/dist/resolved_gitops_config.d.ts.map +0 -1
  192. package/dist/resolved_gitops_config.js +0 -12
  193. package/src/lib/config_reconcile.ts +0 -94
  194. package/src/lib/paths.ts +0 -6
  195. package/src/lib/repo_ops.ts +0 -216
  196. package/src/lib/resolved_gitops_config.ts +0 -27
@@ -1,246 +1,318 @@
1
- import {TaskError, type Task} from '@fuzdev/gro';
2
- import {z} from 'zod';
3
- import {map_concurrent_settled} from '@fuzdev/fuz_util/async.ts';
4
- import {spawn_out} from '@fuzdev/fuz_util/process.ts';
5
- import {writeFile} from 'node:fs/promises';
6
- import {styleText as st} from 'node:util';
7
- import {resolve} from 'node:path';
1
+ import { TaskError, type Task } from '@fuzdev/gro';
2
+ import type { Logger } from '@fuzdev/fuz_util/log.ts';
3
+ import { z } from 'zod';
4
+ import { map_concurrent_settled } from '@fuzdev/fuz_util/async.ts';
5
+ import { spawn_out } from '@fuzdev/fuz_util/process.ts';
6
+ import { to_error_message } from '@fuzdev/fuz_util/error.ts';
7
+ import { writeFile } from 'node:fs/promises';
8
+ import { styleText as st } from 'node:util';
8
9
 
9
- import {get_repo_paths} from './repo_ops.ts';
10
- import {GITOPS_CONCURRENCY_DEFAULT, GITOPS_CONFIG_PATH_DEFAULT} from './gitops_constants.ts';
10
+ import { resolve_gitops_repos } from './gitops_task_helpers.ts';
11
+ import { GITOPS_CONCURRENCY_DEFAULT, GITOPS_CONFIG_PATH_DEFAULT } from './gitops_constants.ts';
12
+ import type { ReposOperations } from './operations.ts';
13
+ import { default_repos_operations } from './operations_defaults.ts';
14
+ import { output_is_machine, route_human_output } from './output_helpers.ts';
11
15
 
16
+ /** @nodocs */
12
17
  export const Args = z.strictObject({
13
18
  // Positional rest args (gro convention) so `gro gitops_run "npm test"` works;
14
19
  // joined with spaces and passed to `sh -c`, so quote commands that contain flags.
15
- _: z.array(z.string()).meta({description: 'shell command to run in each repo'}).default([]),
20
+ _: z.array(z.string()).meta({ description: 'shell command to run in each repo' }).default([]),
16
21
  config: z
17
22
  .string()
18
- .meta({description: 'path to the gitops config file'})
23
+ .meta({ description: 'path to the gitops config file' })
19
24
  .default(GITOPS_CONFIG_PATH_DEFAULT),
25
+ registry: z
26
+ .string()
27
+ .meta({
28
+ description:
29
+ 'path to the repos.toml registry, when `repos` would not find it walking up from the cwd'
30
+ })
31
+ .optional(),
20
32
  concurrency: z
21
33
  .number()
22
34
  .int()
23
35
  .min(1)
24
- .meta({description: 'maximum number of repos to run in parallel'})
36
+ .meta({ description: 'maximum number of repos to run in parallel' })
25
37
  .default(GITOPS_CONCURRENCY_DEFAULT),
26
- format: z.enum(['text', 'json']).meta({description: 'output format'}).default('text'),
38
+ format: z.enum(['text', 'json']).meta({ description: 'output format' }).default('text'),
27
39
  outfile: z
28
40
  .string()
29
- .meta({description: 'with --format json, write clean JSON to this file instead of stdout'})
30
- .optional(),
41
+ .meta({ description: 'with --format json, write clean JSON to this file instead of stdout' })
42
+ .optional()
31
43
  });
32
44
  export type Args = z.infer<typeof Args>;
33
45
 
34
- interface RunResult {
46
+ /**
47
+ * How a command run in one repo ended: it exited with a code, a signal killed
48
+ * it, or it never started.
49
+ *
50
+ * @nodocs
51
+ */
52
+ export type GitopsRunOutcome =
53
+ | { kind: 'exited'; code: number }
54
+ | { kind: 'signaled'; signal: string }
55
+ | { kind: 'error'; message: string };
56
+
57
+ /**
58
+ * What a command run in one repo printed, and how it ended.
59
+ *
60
+ * @nodocs
61
+ */
62
+ export interface GitopsRunCommandOutput {
63
+ outcome: GitopsRunOutcome;
64
+ stdout: string;
65
+ stderr: string;
66
+ }
67
+
68
+ /**
69
+ * One repo's result, as the JSON output carries it.
70
+ *
71
+ * @nodocs
72
+ */
73
+ export interface GitopsRunResult {
35
74
  repo_name: string;
36
75
  repo_dir: string;
37
76
  status: 'success' | 'failure';
38
- exit_code: number;
77
+ /** The command's exit code; `null` when a signal killed it or it never started. */
78
+ exit_code: number | null;
79
+ /** The signal that killed the command, else `null`. */
80
+ signal: string | null;
39
81
  stdout: string;
40
82
  stderr: string;
41
83
  duration_ms: number;
84
+ /** Why the command never started. */
42
85
  error?: string;
43
86
  }
44
87
 
88
+ /**
89
+ * The side effects `run_gitops_run` reaches through, injectable for tests.
90
+ *
91
+ * @nodocs
92
+ */
93
+ export interface GitopsRunDeps {
94
+ /** Runs `repos status` to resolve the config's repos. */
95
+ repos_ops: ReposOperations;
96
+ /** Runs `command` through `sh -c` in `cwd`, capturing its output. */
97
+ run_command: (options: { command: string; cwd: string }) => Promise<GitopsRunCommandOutput>;
98
+ }
99
+
100
+ const default_gitops_run_deps: GitopsRunDeps = {
101
+ repos_ops: default_repos_operations,
102
+ run_command: async ({ command, cwd }) => {
103
+ // through a shell, so pipes, redirects, and globs work
104
+ const { result, stdout, stderr } = await spawn_out('sh', ['-c', command], { cwd });
105
+ const outcome: GitopsRunOutcome =
106
+ result.kind === 'exited'
107
+ ? { kind: 'exited', code: result.code }
108
+ : result.kind === 'signaled'
109
+ ? { kind: 'signaled', signal: result.signal }
110
+ : { kind: 'error', message: to_error_message(result.error) };
111
+ return { outcome, stdout: stdout ?? '', stderr: stderr ?? '' };
112
+ }
113
+ };
114
+
115
+ /** @nodocs */
45
116
  export const task: Task<Args> = {
46
117
  Args,
47
118
  summary: 'run a shell command across all repos in parallel',
48
- run: async ({args, log}) => {
49
- const {_, config, concurrency, format, outfile} = args;
119
+ run: async ({ args, log }) => {
120
+ await run_gitops_run(args, log);
121
+ }
122
+ };
50
123
 
51
- const command = _.join(' ').trim();
52
- if (!command) {
53
- throw new TaskError('No command provided, e.g. `gro gitops_run "npm test"`');
54
- }
124
+ /**
125
+ * Runs `gro gitops_run`: resolves the config's repos through `repos status`,
126
+ * refusing a missing one, runs the command in each, `concurrency` at a time,
127
+ * and reports every result. Under `--format json` without `--outfile`, the log
128
+ * goes to stderr and stdout carries the JSON alone (`route_human_output`).
129
+ *
130
+ * @throws {TaskError} when no command is given, resolving the repos fails, or the command fails in any repo (after reporting)
131
+ * @nodocs
132
+ */
133
+ export const run_gitops_run = async (
134
+ args: Args,
135
+ log: Logger,
136
+ deps: Partial<GitopsRunDeps> = {}
137
+ ): Promise<void> => {
138
+ const { repos_ops, run_command } = { ...default_gitops_run_deps, ...deps };
139
+ const { _, config, registry, concurrency, format, outfile } = args;
140
+ const write_stdout = route_human_output(log, output_is_machine(format, outfile));
55
141
 
56
- // Get repo paths (lightweight, no library-metadata loading needed)
57
- const config_path = resolve(config);
58
- const repos = await get_repo_paths(config_path);
142
+ const command = _.join(' ').trim();
143
+ if (!command) {
144
+ throw new TaskError('No command provided, e.g. `gro gitops_run "npm test"`');
145
+ }
59
146
 
60
- if (repos.length === 0) {
61
- throw new TaskError('No repos found in config');
62
- }
147
+ // Resolve repo paths through `repos status` (no library-metadata loading needed);
148
+ // a configured repo that's missing or not a repo fails the run, naming it
149
+ const { local_repo_paths: repos } = await resolve_gitops_repos({
150
+ config,
151
+ registry,
152
+ log,
153
+ repos_ops
154
+ });
63
155
 
64
- log.info(
65
- `Running ${st('cyan', command)} across ${repos.length} repos (concurrency: ${concurrency})`,
66
- );
156
+ log.info(
157
+ `Running ${st('cyan', command)} across ${repos.length} repos (concurrency: ${concurrency})`
158
+ );
67
159
 
68
- const start_time = performance.now();
160
+ const start_time = performance.now();
69
161
 
70
- // Run command in parallel across all repos
71
- const results = await map_concurrent_settled(repos, concurrency, async (repo) => {
162
+ const settled = await map_concurrent_settled(
163
+ repos,
164
+ concurrency,
165
+ async ({ repo_name, repo_dir }): Promise<GitopsRunResult> => {
72
166
  const repo_start = performance.now();
73
- const repo_name = repo.name;
74
- const repo_dir = repo.path;
75
-
76
- try {
77
- // Parse command into cmd + args for spawn
78
- // For now, we use shell mode to support pipes/redirects/etc
79
- const spawned = await spawn_out('sh', ['-c', command], {
80
- cwd: repo_dir,
81
- });
82
-
83
- const duration_ms = performance.now() - repo_start;
84
- const success = spawned.result.ok;
85
-
86
- const result: RunResult = {
87
- repo_name,
88
- repo_dir,
89
- status: success ? 'success' : 'failure',
90
- exit_code: spawned.result.kind === 'exited' ? spawned.result.code : 0,
91
- stdout: spawned.stdout || '',
92
- stderr: spawned.stderr || '',
93
- duration_ms,
94
- };
95
-
96
- return result;
97
- } catch (error) {
98
- const duration_ms = performance.now() - repo_start;
99
- return {
100
- repo_name,
101
- repo_dir,
102
- status: 'failure' as const,
103
- exit_code: -1,
104
- stdout: '',
105
- stderr: '',
106
- duration_ms,
107
- error: String(error),
108
- };
109
- }
110
- });
167
+ const { outcome, stdout, stderr } = await run_command({ command, cwd: repo_dir });
168
+ return {
169
+ repo_name,
170
+ repo_dir,
171
+ status: outcome.kind === 'exited' && outcome.code === 0 ? 'success' : 'failure',
172
+ exit_code: outcome.kind === 'exited' ? outcome.code : null,
173
+ signal: outcome.kind === 'signaled' ? outcome.signal : null,
174
+ stdout,
175
+ stderr,
176
+ duration_ms: performance.now() - repo_start,
177
+ ...(outcome.kind === 'error' && { error: outcome.message })
178
+ };
179
+ }
180
+ );
111
181
 
112
- const total_duration_ms = performance.now() - start_time;
182
+ const total_duration_ms = performance.now() - start_time;
113
183
 
114
- // Process results
115
- const successes: Array<RunResult> = [];
116
- const failures: Array<RunResult> = [];
184
+ // Process results; a rejection is `run_command` throwing, which the default never does
185
+ const successes: Array<GitopsRunResult> = [];
186
+ const failures: Array<GitopsRunResult> = [];
187
+ settled.forEach((result, i) => {
188
+ if (result.status === 'fulfilled') {
189
+ (result.value.status === 'success' ? successes : failures).push(result.value);
190
+ } else {
191
+ const { repo_name, repo_dir } = repos[i]!;
192
+ failures.push({
193
+ repo_name,
194
+ repo_dir,
195
+ status: 'failure',
196
+ exit_code: null,
197
+ signal: null,
198
+ stdout: '',
199
+ stderr: '',
200
+ duration_ms: 0,
201
+ error: to_error_message(result.reason)
202
+ });
203
+ }
204
+ });
117
205
 
118
- for (const result of results) {
119
- if (result.status === 'fulfilled') {
120
- const run_result = result.value;
121
- if (run_result.status === 'success') {
122
- successes.push(run_result);
123
- } else {
124
- failures.push(run_result);
125
- }
126
- } else {
127
- // This shouldn't happen since we catch errors in the task fn
128
- // but handle it anyway
129
- failures.push({
130
- repo_name: 'unknown',
131
- repo_dir: 'unknown',
132
- status: 'failure',
133
- exit_code: -1,
134
- stdout: '',
135
- stderr: '',
136
- duration_ms: 0,
137
- error: String(result.reason),
138
- });
206
+ // Output results based on format
207
+ if (format === 'json') {
208
+ const json_output = {
209
+ command,
210
+ concurrency,
211
+ repos: [...successes, ...failures],
212
+ summary: {
213
+ total: repos.length,
214
+ success: successes.length,
215
+ failure: failures.length,
216
+ duration_ms: Math.round(total_duration_ms)
139
217
  }
218
+ };
219
+ const json = JSON.stringify(json_output, null, 2);
220
+ if (outfile) {
221
+ // Clean machine-readable output, free of gro's logging prefixes.
222
+ await writeFile(outfile, json);
223
+ log.info(`wrote JSON output to ${outfile}`);
224
+ } else {
225
+ write_stdout(json);
140
226
  }
227
+ } else {
228
+ log_text_results({ successes, failures, total: repos.length, total_duration_ms }, log);
229
+ }
141
230
 
142
- // Output results based on format
143
- if (format === 'json') {
144
- const json_output = {
145
- command,
146
- concurrency,
147
- repos: [...successes, ...failures],
148
- summary: {
149
- total: repos.length,
150
- success: successes.length,
151
- failure: failures.length,
152
- duration_ms: Math.round(total_duration_ms),
153
- },
154
- };
155
- const json = JSON.stringify(json_output, null, 2);
156
- if (outfile) {
157
- // Clean machine-readable output, free of gro's logging prefixes.
158
- await writeFile(outfile, json);
159
- log.info(`wrote JSON output to ${outfile}`);
231
+ // Exit with error if any failures (so CI fails)
232
+ if (failures.length > 0) {
233
+ throw new TaskError(`${failures.length} repos failed`);
234
+ }
235
+ };
236
+
237
+ const log_text_results = (
238
+ data: {
239
+ successes: Array<GitopsRunResult>;
240
+ failures: Array<GitopsRunResult>;
241
+ total: number;
242
+ total_duration_ms: number;
243
+ },
244
+ log: Logger
245
+ ): void => {
246
+ const { successes, failures, total, total_duration_ms } = data;
247
+ log.info(''); // blank line
248
+
249
+ // Show successes, including the command's output so read-only commands
250
+ // (`git rev-parse`, `cat package.json`, …) are useful without `--format json`.
251
+ if (successes.length > 0) {
252
+ log.info(st('green', `✓ ${successes.length} succeeded:`));
253
+ for (const result of successes) {
254
+ const duration = st('blue', `(${Math.round(result.duration_ms)}ms)`);
255
+ const label = st('gray', ` ${result.repo_name}`);
256
+ const out = result.stdout.trim();
257
+ const out_lines = out ? out.split('\n') : [];
258
+ if (out_lines.length <= 1) {
259
+ // single-line (or empty) output inline after the repo name
260
+ log.info(out ? `${label} ${out} ${duration}` : `${label} ${duration}`);
160
261
  } else {
161
- // eslint-disable-next-line no-console
162
- console.log(json);
163
- }
164
- } else {
165
- // Text format
166
- log.info(''); // blank line
167
-
168
- // Show successes, including the command's output so read-only commands
169
- // (`git rev-parse`, `cat package.json`, …) are useful without `--format json`.
170
- if (successes.length > 0) {
171
- log.info(st('green', `✓ ${successes.length} succeeded:`));
172
- for (const result of successes) {
173
- const duration = st('blue', `(${Math.round(result.duration_ms)}ms)`);
174
- const label = st('gray', ` ${result.repo_name}`);
175
- const out = result.stdout.trim();
176
- const out_lines = out ? out.split('\n') : [];
177
- if (out_lines.length <= 1) {
178
- // single-line (or empty) output inline after the repo name
179
- log.info(out ? `${label} ${out} ${duration}` : `${label} ${duration}`);
180
- } else {
181
- // multi-line output indented under the repo
182
- log.info(`${label} ${duration}`);
183
- for (const line of out_lines) {
184
- log.info(st('gray', ` ${line}`));
185
- }
186
- }
262
+ // multi-line output indented under the repo
263
+ log.info(`${label} ${duration}`);
264
+ for (const line of out_lines) {
265
+ log.info(st('gray', ` ${line}`));
187
266
  }
188
267
  }
268
+ }
269
+ }
189
270
 
190
- // Show failures with details
191
- if (failures.length > 0) {
192
- log.info(''); // blank line
193
- log.error(st('red', `✗ ${failures.length} failed:`));
194
- for (const result of failures) {
195
- const duration = `${Math.round(result.duration_ms)}ms`;
196
- log.error(st('gray', ` ${result.repo_name} ${st('blue', `(${duration})`)}`));
197
-
198
- if (result.error) {
199
- log.error(st('gray', ` Error: ${result.error}`));
200
- } else if (result.exit_code !== 0) {
201
- log.error(st('gray', ` Exit code: ${result.exit_code}`));
202
- }
203
-
204
- if (result.stderr) {
205
- // Show first few lines of stderr
206
- const stderr_lines = result.stderr.trim().split('\n');
207
- const preview_lines = stderr_lines.slice(0, 3);
208
- for (const line of preview_lines) {
209
- log.error(st('gray', ` ${line}`));
210
- }
211
- if (stderr_lines.length > 3) {
212
- log.error(st('gray', ` ... (${stderr_lines.length - 3} more lines)`));
213
- }
214
- }
215
- }
271
+ // Show failures with details
272
+ if (failures.length > 0) {
273
+ log.info(''); // blank line
274
+ log.error(st('red', `✗ ${failures.length} failed:`));
275
+ for (const result of failures) {
276
+ const duration = `${Math.round(result.duration_ms)}ms`;
277
+ log.error(st('gray', ` ${result.repo_name} ${st('blue', `(${duration})`)}`));
278
+
279
+ if (result.error) {
280
+ log.error(st('gray', ` Error: ${result.error}`));
281
+ } else if (result.signal) {
282
+ log.error(st('gray', ` Killed by ${result.signal}`));
283
+ } else if (result.exit_code !== null) {
284
+ log.error(st('gray', ` Exit code: ${result.exit_code}`));
216
285
  }
217
286
 
218
- // Summary
219
- log.info(''); // blank line
220
- const total = repos.length;
221
- const success_rate = ((successes.length / total) * 100).toFixed(0);
222
- const duration = `${Math.round(total_duration_ms)}ms`;
223
-
224
- if (failures.length === 0) {
225
- log.info(
226
- st(
227
- 'green',
228
- `✓ All ${total} repos succeeded in ${duration} (${success_rate}% success rate)`,
229
- ),
230
- );
231
- } else {
232
- log.info(
233
- st(
234
- 'yellow',
235
- `⚠ ${successes.length}/${total} repos succeeded in ${duration} (${success_rate}% success rate)`,
236
- ),
237
- );
287
+ if (result.stderr) {
288
+ // Show first few lines of stderr
289
+ const stderr_lines = result.stderr.trim().split('\n');
290
+ const preview_lines = stderr_lines.slice(0, 3);
291
+ for (const line of preview_lines) {
292
+ log.error(st('gray', ` ${line}`));
293
+ }
294
+ if (stderr_lines.length > 3) {
295
+ log.error(st('gray', ` ... (${stderr_lines.length - 3} more lines)`));
296
+ }
238
297
  }
239
298
  }
299
+ }
240
300
 
241
- // Exit with error if any failures (so CI fails)
242
- if (failures.length > 0) {
243
- throw new TaskError(`${failures.length} repos failed`);
244
- }
245
- },
301
+ // Summary
302
+ log.info(''); // blank line
303
+ const success_rate = ((successes.length / total) * 100).toFixed(0);
304
+ const duration = `${Math.round(total_duration_ms)}ms`;
305
+
306
+ if (failures.length === 0) {
307
+ log.info(
308
+ st('green', `✓ All ${total} repos succeeded in ${duration} (${success_rate}% success rate)`)
309
+ );
310
+ } else {
311
+ log.info(
312
+ st(
313
+ 'yellow',
314
+ `⚠ ${successes.length}/${total} repos succeeded in ${duration} (${success_rate}% success rate)`
315
+ )
316
+ );
317
+ }
246
318
  };