@fuzdev/fuz_gitops 0.71.0 → 0.73.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 (117) hide show
  1. package/README.md +13 -6
  2. package/dist/changeset_generator.d.ts.map +1 -1
  3. package/dist/changeset_generator.js +2 -16
  4. package/dist/changeset_reader.d.ts +1 -1
  5. package/dist/changeset_reader.d.ts.map +1 -1
  6. package/dist/dependency_graph.js +1 -1
  7. package/dist/git_operations.d.ts +5 -0
  8. package/dist/git_operations.d.ts.map +1 -1
  9. package/dist/git_operations.js +5 -0
  10. package/dist/gitops_analyze.task.d.ts +1 -0
  11. package/dist/gitops_analyze.task.d.ts.map +1 -1
  12. package/dist/gitops_analyze.task.js +12 -17
  13. package/dist/gitops_plan.task.d.ts +1 -0
  14. package/dist/gitops_plan.task.d.ts.map +1 -1
  15. package/dist/gitops_plan.task.js +9 -2
  16. package/dist/gitops_publish.task.d.ts +3 -2
  17. package/dist/gitops_publish.task.d.ts.map +1 -1
  18. package/dist/gitops_publish.task.js +90 -26
  19. package/dist/gitops_run.task.d.ts +2 -1
  20. package/dist/gitops_run.task.d.ts.map +1 -1
  21. package/dist/gitops_run.task.js +40 -7
  22. package/dist/gitops_sync.task.d.ts +1 -0
  23. package/dist/gitops_sync.task.d.ts.map +1 -1
  24. package/dist/gitops_sync.task.js +16 -2
  25. package/dist/gitops_task_helpers.d.ts +16 -2
  26. package/dist/gitops_task_helpers.d.ts.map +1 -1
  27. package/dist/gitops_task_helpers.js +11 -3
  28. package/dist/gitops_validate.task.d.ts +1 -0
  29. package/dist/gitops_validate.task.d.ts.map +1 -1
  30. package/dist/gitops_validate.task.js +20 -20
  31. package/dist/graph_validation.d.ts +16 -1
  32. package/dist/graph_validation.d.ts.map +1 -1
  33. package/dist/graph_validation.js +15 -0
  34. package/dist/local_repo.d.ts +22 -10
  35. package/dist/local_repo.d.ts.map +1 -1
  36. package/dist/local_repo.js +92 -75
  37. package/dist/log_helpers.d.ts +0 -5
  38. package/dist/log_helpers.d.ts.map +1 -1
  39. package/dist/log_helpers.js +8 -19
  40. package/dist/multi_repo_publisher.d.ts +29 -2
  41. package/dist/multi_repo_publisher.d.ts.map +1 -1
  42. package/dist/multi_repo_publisher.js +307 -233
  43. package/dist/operations.d.ts +6 -26
  44. package/dist/operations.d.ts.map +1 -1
  45. package/dist/operations_defaults.d.ts.map +1 -1
  46. package/dist/operations_defaults.js +1 -19
  47. package/dist/output_helpers.d.ts.map +1 -1
  48. package/dist/output_helpers.js +7 -6
  49. package/dist/paths.d.ts +0 -4
  50. package/dist/paths.d.ts.map +1 -1
  51. package/dist/paths.js +0 -4
  52. package/dist/publish_gate.d.ts +44 -0
  53. package/dist/publish_gate.d.ts.map +1 -0
  54. package/dist/publish_gate.js +32 -0
  55. package/dist/publish_steps.d.ts +60 -0
  56. package/dist/publish_steps.d.ts.map +1 -0
  57. package/dist/publish_steps.js +113 -0
  58. package/dist/publishing_event.d.ts +123 -0
  59. package/dist/publishing_event.d.ts.map +1 -0
  60. package/dist/publishing_event.js +140 -0
  61. package/dist/publishing_event_handler.d.ts +42 -0
  62. package/dist/publishing_event_handler.d.ts.map +1 -0
  63. package/dist/publishing_event_handler.js +75 -0
  64. package/dist/publishing_plan.d.ts +1 -2
  65. package/dist/publishing_plan.d.ts.map +1 -1
  66. package/dist/publishing_plan.js +18 -1
  67. package/dist/publishing_plan_helpers.d.ts +1 -1
  68. package/dist/publishing_plan_helpers.d.ts.map +1 -1
  69. package/dist/publishing_plan_helpers.js +2 -15
  70. package/dist/publishing_plan_logging.d.ts.map +1 -1
  71. package/dist/publishing_plan_logging.js +15 -23
  72. package/dist/repo_ops.d.ts +1 -1
  73. package/dist/repo_ops.js +1 -1
  74. package/dist/version_utils.d.ts +17 -3
  75. package/dist/version_utils.d.ts.map +1 -1
  76. package/dist/version_utils.js +22 -0
  77. package/package.json +6 -7
  78. package/src/lib/changeset_generator.ts +10 -19
  79. package/src/lib/changeset_reader.ts +1 -2
  80. package/src/lib/dependency_graph.ts +1 -1
  81. package/src/lib/git_operations.ts +5 -0
  82. package/src/lib/gitops_analyze.task.ts +18 -23
  83. package/src/lib/gitops_plan.task.ts +10 -2
  84. package/src/lib/gitops_publish.task.ts +100 -25
  85. package/src/lib/gitops_run.task.ts +39 -7
  86. package/src/lib/gitops_sync.task.ts +18 -3
  87. package/src/lib/gitops_task_helpers.ts +20 -3
  88. package/src/lib/gitops_validate.task.ts +30 -22
  89. package/src/lib/graph_validation.ts +26 -0
  90. package/src/lib/local_repo.ts +114 -83
  91. package/src/lib/log_helpers.ts +8 -26
  92. package/src/lib/multi_repo_publisher.ts +387 -269
  93. package/src/lib/operations.ts +6 -22
  94. package/src/lib/operations_defaults.ts +1 -19
  95. package/src/lib/output_helpers.ts +7 -6
  96. package/src/lib/paths.ts +0 -5
  97. package/src/lib/publish_gate.ts +53 -0
  98. package/src/lib/publish_steps.ts +149 -0
  99. package/src/lib/publishing_event.ts +151 -0
  100. package/src/lib/publishing_event_handler.ts +98 -0
  101. package/src/lib/publishing_plan.ts +23 -3
  102. package/src/lib/publishing_plan_helpers.ts +5 -18
  103. package/src/lib/publishing_plan_logging.ts +19 -39
  104. package/src/lib/repo_ops.ts +1 -1
  105. package/src/lib/version_utils.ts +30 -9
  106. package/dist/npm_install_helpers.d.ts +0 -23
  107. package/dist/npm_install_helpers.d.ts.map +0 -1
  108. package/dist/npm_install_helpers.js +0 -60
  109. package/dist/semver.d.ts +0 -26
  110. package/dist/semver.d.ts.map +0 -1
  111. package/dist/semver.js +0 -137
  112. package/dist/serialization_types.d.ts +0 -59
  113. package/dist/serialization_types.d.ts.map +0 -1
  114. package/dist/serialization_types.js +0 -42
  115. package/src/lib/npm_install_helpers.ts +0 -85
  116. package/src/lib/semver.ts +0 -170
  117. package/src/lib/serialization_types.ts +0 -92
@@ -66,7 +66,7 @@ export class DependencyGraph {
66
66
  repo,
67
67
  dependencies: new Map(),
68
68
  dependents: new Set(),
69
- publishable: !!library.package_json.private === false, // eslint-disable-line @typescript-eslint/no-unnecessary-boolean-literal-compare
69
+ publishable: !library.package_json.private,
70
70
  };
71
71
 
72
72
  // Extract dependencies
@@ -84,6 +84,11 @@ export const git_push_tag = async (
84
84
  }
85
85
  };
86
86
 
87
+ /**
88
+ * Returns `true` if the working tree has any changes — staged, unstaged, or
89
+ * untracked (`git status --porcelain`). Broader than `git_list_uncommitted_files`,
90
+ * which reports only tracked working-tree changes relative to HEAD.
91
+ */
87
92
  export const git_has_changes = async (options?: SpawnOptions): Promise<boolean> => {
88
93
  const {stdout} = await spawn_out('git', ['status', '--porcelain'], options);
89
94
  return stdout ? stdout.trim().length > 0 : false;
@@ -4,9 +4,9 @@ import {styleText as st} from 'node:util';
4
4
  import type {Logger} from '@fuzdev/fuz_util/log.js';
5
5
 
6
6
  import {get_gitops_ready} from './gitops_task_helpers.js';
7
- import {type DependencyGraph, DependencyGraphBuilder} from './dependency_graph.js';
7
+ import type {DependencyGraph} from './dependency_graph.js';
8
8
  import type {LocalRepo} from './local_repo.js';
9
- import {validate_dependency_graph} from './graph_validation.js';
9
+ import {analyze_repos, type DependencyAnalysis} from './graph_validation.js';
10
10
  import {
11
11
  format_wildcard_dependencies,
12
12
  format_dev_cycles,
@@ -30,6 +30,13 @@ export const Args = z.strictObject({
30
30
  .meta({description: 'output format'})
31
31
  .default('stdout'),
32
32
  outfile: z.string().meta({description: 'write output to file instead of logging'}).optional(),
33
+ sync: z
34
+ .boolean()
35
+ .meta({
36
+ description:
37
+ 'sync repos (switch branch, pull, install) before analyzing instead of reading the working tree as-is',
38
+ })
39
+ .default(false),
33
40
  });
34
41
  export type Args = z.infer<typeof Args>;
35
42
 
@@ -38,25 +45,13 @@ export const task: Task<Args> = {
38
45
  Args,
39
46
  summary: 'analyze dependency structure and relationships across repos',
40
47
  run: async ({args, log}) => {
41
- const {config, dir, format, outfile} = args;
48
+ const {config, dir, format, outfile, sync} = args;
42
49
 
43
- // Get repos ready (without downloading)
44
- const {local_repos} = await get_gitops_ready({config, dir, download: false, log});
50
+ // Get repos ready (without downloading); read the working tree as-is unless `--sync`
51
+ const {local_repos} = await get_gitops_ready({config, dir, download: false, sync, log});
45
52
 
46
- // Build dependency graph and validate (but don't throw on cycles for analyze)
47
- const {graph, publishing_order: order} = validate_dependency_graph(local_repos, {
48
- log,
49
- throw_on_prod_cycles: false, // Analyze should report, not throw
50
- log_cycles: false, // We'll show cycles in our formatted output
51
- log_order: false, // We'll show order in our formatted output
52
- });
53
-
54
- // Perform additional analysis
55
- const builder = new DependencyGraphBuilder();
56
- const analysis = builder.analyze(graph);
57
-
58
- // Publishing order (may be null if prod cycles exist)
59
- const publishing_order = order.length > 0 ? order : null;
53
+ // Build the dependency graph and analyze cycles/wildcards (tolerating cycles)
54
+ const {graph, analysis, publishing_order} = analyze_repos(local_repos);
60
55
 
61
56
  // Format and output using output_helpers
62
57
  const data = {
@@ -74,7 +69,7 @@ export const task: Task<Args> = {
74
69
  interface AnalysisData {
75
70
  repos: Array<LocalRepo>;
76
71
  graph: DependencyGraph;
77
- analysis: ReturnType<DependencyGraphBuilder['analyze']>;
72
+ analysis: DependencyAnalysis;
78
73
  publishing_order: Array<string> | null;
79
74
  }
80
75
 
@@ -102,7 +97,7 @@ const calculate_stats = (graph: DependencyGraph) => {
102
97
 
103
98
  const format_json = (
104
99
  graph: DependencyGraph,
105
- analysis: ReturnType<DependencyGraphBuilder['analyze']>,
100
+ analysis: DependencyAnalysis,
106
101
  publishing_order: Array<string> | null,
107
102
  ): string => {
108
103
  const output = {
@@ -116,7 +111,7 @@ const format_json = (
116
111
  const format_markdown = (
117
112
  repos: Array<LocalRepo>,
118
113
  graph: DependencyGraph,
119
- analysis: ReturnType<DependencyGraphBuilder['analyze']>,
114
+ analysis: DependencyAnalysis,
120
115
  publishing_order: Array<string> | null,
121
116
  ): Array<string> => {
122
117
  const lines: Array<string> = ['# Dependency Analysis'];
@@ -192,7 +187,7 @@ const format_markdown = (
192
187
  const format_stdout = (
193
188
  repos: Array<LocalRepo>,
194
189
  graph: DependencyGraph,
195
- analysis: ReturnType<DependencyGraphBuilder['analyze']>,
190
+ analysis: DependencyAnalysis,
196
191
  publishing_order: Array<string> | null,
197
192
  log: Logger,
198
193
  ): void => {
@@ -28,6 +28,13 @@ export const Args = z.strictObject({
28
28
  .default('stdout'),
29
29
  outfile: z.string().meta({description: 'write output to file instead of logging'}).optional(),
30
30
  verbose: z.boolean().meta({description: 'show additional details'}).default(false),
31
+ sync: z
32
+ .boolean()
33
+ .meta({
34
+ description:
35
+ 'sync repos (switch branch, pull, install) before planning instead of reading the working tree as-is',
36
+ })
37
+ .default(false),
31
38
  });
32
39
  export type Args = z.infer<typeof Args>;
33
40
 
@@ -46,15 +53,16 @@ export const task: Task<Args> = {
46
53
  summary: 'generate a publishing plan based on changesets',
47
54
  Args,
48
55
  run: async ({args, log}): Promise<void> => {
49
- const {dir, config, format, outfile, verbose} = args;
56
+ const {dir, config, format, outfile, verbose, sync} = args;
50
57
 
51
58
  log.info(st('cyan', 'Generating multi-repo publishing plan...'));
52
59
 
53
- // Load local repos
60
+ // Load local repos; read the working tree as-is unless `--sync`
54
61
  const {local_repos} = await get_gitops_ready({
55
62
  config,
56
63
  dir,
57
64
  download: false, // Don't download if missing
65
+ sync,
58
66
  log,
59
67
  });
60
68
 
@@ -1,15 +1,19 @@
1
1
  import type {Task} from '@fuzdev/gro';
2
+ import type {Logger} from '@fuzdev/fuz_util/log.js';
2
3
  import {z} from 'zod';
3
4
  import {createInterface} from 'node:readline/promises';
4
5
  import {styleText as st} from 'node:util';
5
6
 
6
7
  import {get_gitops_ready} from './gitops_task_helpers.js';
7
8
  import {
8
- publish_repos,
9
+ execute_publishing_plan,
9
10
  type PublishingOptions,
10
11
  type PublishingResult,
11
12
  } from './multi_repo_publisher.js';
13
+ import {stdout_handler} from './publishing_event_handler.js';
12
14
  import {generate_publishing_plan, log_publishing_plan} from './publishing_plan.js';
15
+ import {derive_publish_steps, format_publish_steps, type PublishStep} from './publish_steps.js';
16
+ import {decide_publish_gate, publish_run_failed} from './publish_gate.js';
13
17
  import {format_and_output, type OutputFormatters} from './output_helpers.js';
14
18
  import {GITOPS_CONFIG_PATH_DEFAULT, GITOPS_NPM_WAIT_TIMEOUT_DEFAULT} from './gitops_constants.js';
15
19
 
@@ -33,21 +37,31 @@ export const Args = z.strictObject({
33
37
  .meta({description: 'output format'})
34
38
  .default('stdout'),
35
39
  deploy: z.boolean().meta({description: 'deploy all repos after publishing'}).default(false),
36
- plan: z.boolean().meta({description: 'dual of no-plan'}).default(true),
37
- 'no-plan': z
40
+ plan: z
38
41
  .boolean()
39
- .meta({description: 'skip plan confirmation before publishing'})
40
- .default(false),
42
+ .meta({description: 'show the plan and confirm before publishing; --no-plan to skip'})
43
+ .default(true),
41
44
  max_wait: z
42
45
  .number()
43
46
  .meta({description: 'max time to wait for npm propagation in ms'})
44
47
  .default(GITOPS_NPM_WAIT_TIMEOUT_DEFAULT),
45
- skip_install: z
48
+ emit_json: z
46
49
  .boolean()
47
- .meta({description: 'skip npm install after dependency updates'})
50
+ .meta({description: 'stream structured publishing events as JSON-lines to stdout'})
48
51
  .default(false),
49
52
  outfile: z.string().meta({description: 'write output to file instead of logging'}).optional(),
50
53
  verbose: z.boolean().meta({description: 'show additional details in plan output'}).default(false),
54
+ sync: z
55
+ .boolean()
56
+ .meta({
57
+ description:
58
+ 'sync repos (switch branch, pull, install) before the dry run instead of reading the working tree as-is; always on for --wetrun',
59
+ })
60
+ .default(false),
61
+ preview: z
62
+ .boolean()
63
+ .meta({description: 'show the ordered side-effects a --wetrun would perform'})
64
+ .default(false),
51
65
  });
52
66
  export type Args = z.infer<typeof Args>;
53
67
 
@@ -65,28 +79,42 @@ export const task: Task<Args> = {
65
79
  deploy,
66
80
  plan,
67
81
  max_wait,
68
- skip_install,
82
+ emit_json,
69
83
  outfile,
70
84
  verbose,
85
+ sync,
86
+ preview,
71
87
  } = args;
72
88
 
73
- // Load repos
89
+ // Load repos. A dry run reads the working tree as-is unless `--sync`;
90
+ // a real publish (`--wetrun`) always syncs so preflight sees the canonical branches.
74
91
  const {local_repos: repos} = await get_gitops_ready({
75
92
  config,
76
93
  dir,
77
94
  download: false, // Don't download if missing
95
+ sync: sync || wetrun,
78
96
  log,
79
97
  });
80
98
 
81
- // Show plan if requested (skip for dry runs)
82
- if (plan && wetrun) {
99
+ // Generate the plan once; the executor consumes this exact plan (no second pass).
100
+ const publishing_plan = await generate_publishing_plan(repos, {verbose});
101
+ const preview_steps = preview ? derive_publish_steps(publishing_plan, {deploy}) : null;
102
+
103
+ // Decide whether to show the plan + confirm, block, or proceed (the decision table lives
104
+ // in `decide_publish_gate`; readline + exit stay here at the edge).
105
+ const gate = decide_publish_gate({wetrun, show_plan: plan, plan: publishing_plan});
106
+
107
+ // A real publish that shows its plan prints it first — including before a `blocked` throw,
108
+ // so the operator sees the errors that blocked it.
109
+ if (gate.action !== 'proceed') {
83
110
  log.info(st('cyan', 'Publishing Plan'));
84
- const plan_result = await generate_publishing_plan(repos, {log, verbose});
85
- log_publishing_plan(plan_result, log, {verbose});
111
+ log_publishing_plan(publishing_plan, log, {verbose});
112
+ }
86
113
 
87
- if (plan_result.errors.length > 0) {
88
- throw new Error('Cannot proceed with publishing due to errors');
89
- }
114
+ if (gate.action === 'blocked') {
115
+ throw new Error(gate.message);
116
+ } else if (gate.action === 'confirm') {
117
+ if (preview_steps) log_preview(preview_steps, log);
90
118
 
91
119
  // Ask for confirmation
92
120
  log.info(st('yellow', '⚠️ This will publish the packages shown above.'));
@@ -96,17 +124,22 @@ export const task: Task<Args> = {
96
124
  log.info('Publishing cancelled');
97
125
  process.exit(0);
98
126
  }
127
+ } else if (preview_steps && format === 'stdout') {
128
+ // proceed (dry run or --no-plan): only render to stdout for the human format;
129
+ // json/markdown carry the preview in their structured output, so logging here too
130
+ // would corrupt that stream.
131
+ log_preview(preview_steps, log);
99
132
  }
100
133
 
101
134
  // Publishing options
102
135
  const options: PublishingOptions = {
103
136
  wetrun,
104
- update_deps: true, // Always update dependencies
105
137
  version_strategy: peer_strategy,
106
138
  deploy,
107
139
  max_wait,
108
- skip_install,
109
140
  log,
141
+ // Live JSON-lines stream when requested; events also surface on the result.
142
+ events: emit_json ? stdout_handler() : undefined,
110
143
  };
111
144
 
112
145
  // Execute publishing (may throw on fatal errors like circular dependencies)
@@ -114,7 +147,7 @@ export const task: Task<Args> = {
114
147
  let fatal_error: Error | null = null;
115
148
 
116
149
  try {
117
- result = await publish_repos(repos, options);
150
+ result = await execute_publishing_plan(repos, publishing_plan, options);
118
151
  } catch (error) {
119
152
  // Construct a failure result for fatal errors so output can still be generated
120
153
  fatal_error = error instanceof Error ? error : new Error(String(error));
@@ -124,13 +157,17 @@ export const task: Task<Args> = {
124
157
  // Note: FATAL_ERROR is a placeholder - only fatal_error.message is displayed in output
125
158
  failed: [{name: 'FATAL_ERROR', error: fatal_error}],
126
159
  duration: 0,
160
+ events: [],
161
+ summary: {total: 0, published: 0, failed: 1, skipped: 0, duration: 0},
162
+ plan_errors: publishing_plan.errors,
163
+ plan_warnings: publishing_plan.warnings,
127
164
  };
128
165
  }
129
166
 
130
167
  // Format and output result (always runs, even on fatal errors)
131
- // Note: stdout format is handled by publish_repos function's logging
168
+ // Note: stdout format is handled by the executor's logging
132
169
  if (format !== 'stdout') {
133
- await format_and_output({result, fatal_error}, create_publish_formatters(), {
170
+ await format_and_output({result, fatal_error, preview_steps}, create_publish_formatters(), {
134
171
  format,
135
172
  outfile,
136
173
  log,
@@ -138,7 +175,7 @@ export const task: Task<Args> = {
138
175
  }
139
176
 
140
177
  // Exit with error if failed
141
- if (!result.ok || fatal_error) {
178
+ if (publish_run_failed(result, fatal_error)) {
142
179
  process.exit(1);
143
180
  }
144
181
  },
@@ -147,21 +184,36 @@ export const task: Task<Args> = {
147
184
  interface PublishResultData {
148
185
  result: PublishingResult;
149
186
  fatal_error: Error | null;
187
+ preview_steps: Array<PublishStep> | null;
150
188
  }
151
189
 
152
190
  const create_publish_formatters = (): OutputFormatters<PublishResultData> => ({
153
- json: (data) => JSON.stringify(data.result, null, 2),
154
- markdown: (data) => format_result_markdown(data.result, data.fatal_error),
191
+ json: (data) =>
192
+ JSON.stringify(
193
+ data.preview_steps ? {...data.result, preview: data.preview_steps} : data.result,
194
+ null,
195
+ 2,
196
+ ),
197
+ markdown: (data) => format_result_markdown(data.result, data.fatal_error, data.preview_steps),
155
198
  stdout: () => {
156
- // stdout format is handled by publish_repos function's logging
199
+ // stdout format is handled by the executor's logging
157
200
  // This should never be called due to early return in task
158
201
  },
159
202
  });
160
203
 
204
+ /** Logs the ordered side-effect preview to stdout. */
205
+ const log_preview = (steps: Array<PublishStep>, log: Logger): void => {
206
+ log.info(st('cyan', '\nSide-effect preview (what a real publish would perform):'));
207
+ for (const line of format_publish_steps(steps)) {
208
+ log.info(st('dim', ` ${line}`));
209
+ }
210
+ };
211
+
161
212
  // Format the publishing result as markdown
162
213
  const format_result_markdown = (
163
214
  result: PublishingResult,
164
215
  fatal_error: Error | null,
216
+ preview_steps: Array<PublishStep> | null,
165
217
  ): Array<string> => {
166
218
  const lines: Array<string> = [];
167
219
 
@@ -205,6 +257,29 @@ const format_result_markdown = (
205
257
  }
206
258
  }
207
259
 
260
+ if (result.plan_warnings.length > 0) {
261
+ lines.push('');
262
+ lines.push('## Plan Warnings');
263
+ lines.push('');
264
+ for (const warning of result.plan_warnings) lines.push(`- ${warning}`);
265
+ }
266
+
267
+ if (result.plan_errors.length > 0) {
268
+ lines.push('');
269
+ lines.push('## Plan Errors');
270
+ lines.push('');
271
+ for (const plan_error of result.plan_errors) lines.push(`- ${plan_error}`);
272
+ }
273
+
274
+ if (preview_steps) {
275
+ lines.push('');
276
+ lines.push('## Side-Effect Preview');
277
+ lines.push('');
278
+ lines.push('```');
279
+ for (const line of format_publish_steps(preview_steps)) lines.push(line);
280
+ lines.push('```');
281
+ }
282
+
208
283
  return lines;
209
284
  };
210
285
 
@@ -2,6 +2,7 @@ import {TaskError, type Task} from '@fuzdev/gro';
2
2
  import {z} from 'zod';
3
3
  import {map_concurrent_settled} from '@fuzdev/fuz_util/async.js';
4
4
  import {spawn_out} from '@fuzdev/fuz_util/process.js';
5
+ import {writeFile} from 'node:fs/promises';
5
6
  import {styleText as st} from 'node:util';
6
7
  import {resolve} from 'node:path';
7
8
 
@@ -9,7 +10,9 @@ import {get_repo_paths} from './repo_ops.js';
9
10
  import {GITOPS_CONCURRENCY_DEFAULT, GITOPS_CONFIG_PATH_DEFAULT} from './gitops_constants.js';
10
11
 
11
12
  export const Args = z.strictObject({
12
- command: z.string().meta({description: 'shell command to run in each repo'}),
13
+ // Positional rest args (gro convention) so `gro gitops_run "npm test"` works;
14
+ // 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([]),
13
16
  config: z
14
17
  .string()
15
18
  .meta({description: 'path to the gitops config file'})
@@ -21,6 +24,10 @@ export const Args = z.strictObject({
21
24
  .meta({description: 'maximum number of repos to run in parallel'})
22
25
  .default(GITOPS_CONCURRENCY_DEFAULT),
23
26
  format: z.enum(['text', 'json']).meta({description: 'output format'}).default('text'),
27
+ outfile: z
28
+ .string()
29
+ .meta({description: 'with --format json, write clean JSON to this file instead of stdout'})
30
+ .optional(),
24
31
  });
25
32
  export type Args = z.infer<typeof Args>;
26
33
 
@@ -39,7 +46,12 @@ export const task: Task<Args> = {
39
46
  Args,
40
47
  summary: 'run a shell command across all repos in parallel',
41
48
  run: async ({args, log}) => {
42
- const {command, config, concurrency, format} = args;
49
+ const {_, config, concurrency, format, outfile} = args;
50
+
51
+ const command = _.join(' ').trim();
52
+ if (!command) {
53
+ throw new TaskError('No command provided, e.g. `gro gitops_run "npm test"`');
54
+ }
43
55
 
44
56
  // Get repo paths (lightweight, no library-metadata loading needed)
45
57
  const config_path = resolve(config);
@@ -140,18 +152,38 @@ export const task: Task<Args> = {
140
152
  duration_ms: Math.round(total_duration_ms),
141
153
  },
142
154
  };
143
- // eslint-disable-next-line no-console
144
- console.log(JSON.stringify(json_output, null, 2));
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}`);
160
+ } else {
161
+ // eslint-disable-next-line no-console
162
+ console.log(json);
163
+ }
145
164
  } else {
146
165
  // Text format
147
166
  log.info(''); // blank line
148
167
 
149
- // Show successes
168
+ // Show successes, including the command's output so read-only commands
169
+ // (`git rev-parse`, `cat package.json`, …) are useful without `--format json`.
150
170
  if (successes.length > 0) {
151
171
  log.info(st('green', `✓ ${successes.length} succeeded:`));
152
172
  for (const result of successes) {
153
- const duration = `${Math.round(result.duration_ms)}ms`;
154
- log.info(st('gray', ` ${result.repo_name} ${st('blue', `(${duration})`)}`));
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
+ }
155
187
  }
156
188
  }
157
189
 
@@ -35,6 +35,13 @@ export const Args = z.strictObject({
35
35
  .boolean()
36
36
  .meta({description: 'check repos are ready without fetching remote data'})
37
37
  .default(false),
38
+ allow_dirty: z
39
+ .boolean()
40
+ .meta({
41
+ description:
42
+ 'sync (switch branch, pull) tolerating uncommitted changes instead of failing on a dirty workspace',
43
+ })
44
+ .default(false),
38
45
  });
39
46
  export type Args = z.infer<typeof Args>;
40
47
 
@@ -47,9 +54,17 @@ export const task: Task<Args> = {
47
54
  Args,
48
55
  summary: 'syncs local repos and generates UI data from repo metadata',
49
56
  run: async ({args, log, svelte_config, invoke_task}) => {
50
- const {config, dir, outdir = svelte_config.routes_path, download, check} = args;
51
-
52
- const {local_repos} = await get_gitops_ready({config, dir, download, log});
57
+ const {config, dir, outdir = svelte_config.routes_path, download, check, allow_dirty} = args;
58
+
59
+ // `gitops_sync` is the task whose job is to mutate working trees, so it always syncs.
60
+ const {local_repos} = await get_gitops_ready({
61
+ config,
62
+ dir,
63
+ download,
64
+ sync: true,
65
+ allow_dirty,
66
+ log,
67
+ });
53
68
 
54
69
  const outfile_json = resolve(outdir, 'repos.json');
55
70
  const outfile_ts = resolve(outdir, 'repos.ts');
@@ -37,6 +37,14 @@ export interface GetGitopsReadyOptions {
37
37
  npm_ops?: NpmOperations;
38
38
  parallel?: boolean;
39
39
  concurrency?: number;
40
+ /**
41
+ * Sync each repo's working tree to its configured branch before loading
42
+ * (switch branch, pull, install). When `false`, repos load exactly as they
43
+ * sit on disk — the safe default for read-only diagnostics. Defaults to `true`.
44
+ */
45
+ sync?: boolean;
46
+ /** When syncing, tolerate uncommitted changes instead of throwing. Defaults to `false`. */
47
+ allow_dirty?: boolean;
40
48
  }
41
49
 
42
50
  /**
@@ -45,8 +53,12 @@ export interface GetGitopsReadyOptions {
45
53
  * Initialization sequence:
46
54
  * 1. Loads and normalizes config from `gitops.config.ts`
47
55
  * 2. Resolves local repo paths (creates missing with `--download`)
48
- * 3. Switches branches and pulls latest changes (in parallel by default)
49
- * 4. Auto-installs deps if `package.json` changed during pull
56
+ * 3. If `sync`, switches branches and pulls latest changes (in parallel by default)
57
+ * 4. If `sync`, auto-installs deps if `package.json` changed during pull
58
+ *
59
+ * With `sync: false` (the default for read-only diagnostics), steps 3-4 are
60
+ * skipped and repos are loaded exactly as checked out — no branch switch, pull,
61
+ * install, or clean-workspace check.
50
62
  *
51
63
  * Priority for path resolution:
52
64
  * - `dir` argument (explicit override)
@@ -57,6 +69,8 @@ export interface GetGitopsReadyOptions {
57
69
  * @param options.npm_ops - for testing (defaults to real npm operations)
58
70
  * @param options.parallel - whether to load repos in parallel (default: true)
59
71
  * @param options.concurrency - max concurrent repo loads (default: 5)
72
+ * @param options.sync - sync working trees before loading (default: true)
73
+ * @param options.allow_dirty - when syncing, tolerate uncommitted changes (default: false)
60
74
  * @returns initialized config and fully loaded repos ready for operations
61
75
  * @throws {TaskError} if config loading or repo resolution fails
62
76
  */
@@ -68,7 +82,8 @@ export const get_gitops_ready = async (
68
82
  gitops_config: GitopsConfig;
69
83
  local_repos: Array<LocalRepo>;
70
84
  }> => {
71
- const {config, dir, download, log, git_ops, npm_ops, parallel, concurrency} = options;
85
+ const {config, dir, download, log, git_ops, npm_ops, parallel, concurrency, sync, allow_dirty} =
86
+ options;
72
87
  const config_path = resolve(config);
73
88
  const gitops_config = await import_gitops_config(config_path);
74
89
 
@@ -101,6 +116,8 @@ export const get_gitops_ready = async (
101
116
  npm_ops,
102
117
  parallel,
103
118
  concurrency,
119
+ sync,
120
+ allow_dirty,
104
121
  });
105
122
 
106
123
  return {config_path, repos_dir, gitops_config, local_repos};