@fuzdev/fuz_gitops 0.72.0 → 0.74.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 (122) hide show
  1. package/README.md +15 -8
  2. package/dist/PageHeader.svelte +3 -3
  3. package/dist/PageHeader.svelte.d.ts +3 -3
  4. package/dist/PageHeader.svelte.d.ts.map +1 -1
  5. package/dist/changeset_generator.d.ts.map +1 -1
  6. package/dist/changeset_generator.js +2 -16
  7. package/dist/changeset_reader.d.ts +1 -1
  8. package/dist/changeset_reader.d.ts.map +1 -1
  9. package/dist/changeset_reader.js +1 -1
  10. package/dist/dependency_graph.d.ts.map +1 -1
  11. package/dist/dependency_graph.js +6 -6
  12. package/dist/fetch_repo_data.d.ts.map +1 -1
  13. package/dist/fetch_repo_data.js +3 -2
  14. package/dist/git_operations.d.ts +5 -0
  15. package/dist/git_operations.d.ts.map +1 -1
  16. package/dist/git_operations.js +5 -0
  17. package/dist/gitops_analyze.task.d.ts +1 -0
  18. package/dist/gitops_analyze.task.d.ts.map +1 -1
  19. package/dist/gitops_analyze.task.js +12 -17
  20. package/dist/gitops_plan.task.d.ts +1 -0
  21. package/dist/gitops_plan.task.d.ts.map +1 -1
  22. package/dist/gitops_plan.task.js +9 -2
  23. package/dist/gitops_publish.task.d.ts +2 -2
  24. package/dist/gitops_publish.task.d.ts.map +1 -1
  25. package/dist/gitops_publish.task.js +83 -28
  26. package/dist/gitops_run.task.d.ts +2 -1
  27. package/dist/gitops_run.task.d.ts.map +1 -1
  28. package/dist/gitops_run.task.js +40 -7
  29. package/dist/gitops_sync.task.d.ts +1 -0
  30. package/dist/gitops_sync.task.d.ts.map +1 -1
  31. package/dist/gitops_sync.task.js +16 -2
  32. package/dist/gitops_task_helpers.d.ts +16 -2
  33. package/dist/gitops_task_helpers.d.ts.map +1 -1
  34. package/dist/gitops_task_helpers.js +11 -3
  35. package/dist/gitops_validate.task.d.ts +1 -0
  36. package/dist/gitops_validate.task.d.ts.map +1 -1
  37. package/dist/gitops_validate.task.js +20 -20
  38. package/dist/graph_validation.d.ts +16 -1
  39. package/dist/graph_validation.d.ts.map +1 -1
  40. package/dist/graph_validation.js +15 -0
  41. package/dist/local_repo.d.ts +25 -12
  42. package/dist/local_repo.d.ts.map +1 -1
  43. package/dist/local_repo.js +99 -79
  44. package/dist/log_helpers.d.ts +0 -5
  45. package/dist/log_helpers.d.ts.map +1 -1
  46. package/dist/log_helpers.js +8 -19
  47. package/dist/multi_repo_publisher.d.ts +21 -2
  48. package/dist/multi_repo_publisher.d.ts.map +1 -1
  49. package/dist/multi_repo_publisher.js +279 -287
  50. package/dist/operations.d.ts +6 -26
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations_defaults.d.ts.map +1 -1
  53. package/dist/operations_defaults.js +4 -20
  54. package/dist/output_helpers.d.ts.map +1 -1
  55. package/dist/output_helpers.js +7 -6
  56. package/dist/paths.d.ts +0 -4
  57. package/dist/paths.d.ts.map +1 -1
  58. package/dist/paths.js +0 -4
  59. package/dist/publish_gate.d.ts +44 -0
  60. package/dist/publish_gate.d.ts.map +1 -0
  61. package/dist/publish_gate.js +32 -0
  62. package/dist/publish_steps.d.ts +60 -0
  63. package/dist/publish_steps.d.ts.map +1 -0
  64. package/dist/publish_steps.js +113 -0
  65. package/dist/publishing_event.d.ts +12 -19
  66. package/dist/publishing_event.d.ts.map +1 -1
  67. package/dist/publishing_event.js +16 -25
  68. package/dist/publishing_plan.d.ts +1 -2
  69. package/dist/publishing_plan.d.ts.map +1 -1
  70. package/dist/publishing_plan.js +21 -4
  71. package/dist/publishing_plan_helpers.d.ts +1 -1
  72. package/dist/publishing_plan_helpers.d.ts.map +1 -1
  73. package/dist/publishing_plan_helpers.js +2 -15
  74. package/dist/publishing_plan_logging.d.ts.map +1 -1
  75. package/dist/publishing_plan_logging.js +15 -23
  76. package/dist/repo.svelte.d.ts +10 -6
  77. package/dist/repo.svelte.d.ts.map +1 -1
  78. package/dist/repo.svelte.js +6 -14
  79. package/dist/version_utils.d.ts +17 -3
  80. package/dist/version_utils.d.ts.map +1 -1
  81. package/dist/version_utils.js +22 -0
  82. package/package.json +14 -15
  83. package/src/lib/changeset_generator.ts +10 -19
  84. package/src/lib/changeset_reader.ts +2 -3
  85. package/src/lib/dependency_graph.ts +6 -8
  86. package/src/lib/fetch_repo_data.ts +3 -2
  87. package/src/lib/git_operations.ts +5 -0
  88. package/src/lib/gitops_analyze.task.ts +18 -23
  89. package/src/lib/gitops_plan.task.ts +10 -2
  90. package/src/lib/gitops_publish.task.ts +92 -27
  91. package/src/lib/gitops_run.task.ts +39 -7
  92. package/src/lib/gitops_sync.task.ts +18 -3
  93. package/src/lib/gitops_task_helpers.ts +20 -3
  94. package/src/lib/gitops_validate.task.ts +30 -22
  95. package/src/lib/graph_validation.ts +26 -0
  96. package/src/lib/local_repo.ts +124 -88
  97. package/src/lib/log_helpers.ts +8 -26
  98. package/src/lib/multi_repo_publisher.ts +336 -353
  99. package/src/lib/operations.ts +6 -22
  100. package/src/lib/operations_defaults.ts +4 -20
  101. package/src/lib/output_helpers.ts +7 -6
  102. package/src/lib/paths.ts +0 -5
  103. package/src/lib/publish_gate.ts +53 -0
  104. package/src/lib/publish_steps.ts +149 -0
  105. package/src/lib/publishing_event.ts +16 -25
  106. package/src/lib/publishing_plan.ts +26 -6
  107. package/src/lib/publishing_plan_helpers.ts +5 -18
  108. package/src/lib/publishing_plan_logging.ts +19 -39
  109. package/src/lib/repo.svelte.ts +12 -15
  110. package/src/lib/version_utils.ts +30 -9
  111. package/dist/npm_install_helpers.d.ts +0 -23
  112. package/dist/npm_install_helpers.d.ts.map +0 -1
  113. package/dist/npm_install_helpers.js +0 -60
  114. package/dist/semver.d.ts +0 -26
  115. package/dist/semver.d.ts.map +0 -1
  116. package/dist/semver.js +0 -137
  117. package/dist/serialization_types.d.ts +0 -59
  118. package/dist/serialization_types.d.ts.map +0 -1
  119. package/dist/serialization_types.js +0 -42
  120. package/src/lib/npm_install_helpers.ts +0 -85
  121. package/src/lib/semver.ts +0 -170
  122. package/src/lib/serialization_types.ts +0 -92
@@ -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,16 +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';
12
13
  import {stdout_handler} from './publishing_event_handler.js';
13
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';
14
17
  import {format_and_output, type OutputFormatters} from './output_helpers.js';
15
18
  import {GITOPS_CONFIG_PATH_DEFAULT, GITOPS_NPM_WAIT_TIMEOUT_DEFAULT} from './gitops_constants.js';
16
19
 
@@ -34,25 +37,31 @@ export const Args = z.strictObject({
34
37
  .meta({description: 'output format'})
35
38
  .default('stdout'),
36
39
  deploy: z.boolean().meta({description: 'deploy all repos after publishing'}).default(false),
37
- plan: z.boolean().meta({description: 'dual of no-plan'}).default(true),
38
- 'no-plan': z
40
+ plan: z
39
41
  .boolean()
40
- .meta({description: 'skip plan confirmation before publishing'})
41
- .default(false),
42
+ .meta({description: 'show the plan and confirm before publishing; --no-plan to skip'})
43
+ .default(true),
42
44
  max_wait: z
43
45
  .number()
44
46
  .meta({description: 'max time to wait for npm propagation in ms'})
45
47
  .default(GITOPS_NPM_WAIT_TIMEOUT_DEFAULT),
46
- skip_install: z
47
- .boolean()
48
- .meta({description: 'skip npm install after dependency updates'})
49
- .default(false),
50
48
  emit_json: z
51
49
  .boolean()
52
50
  .meta({description: 'stream structured publishing events as JSON-lines to stdout'})
53
51
  .default(false),
54
52
  outfile: z.string().meta({description: 'write output to file instead of logging'}).optional(),
55
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),
56
65
  });
57
66
  export type Args = z.infer<typeof Args>;
58
67
 
@@ -70,29 +79,42 @@ export const task: Task<Args> = {
70
79
  deploy,
71
80
  plan,
72
81
  max_wait,
73
- skip_install,
74
82
  emit_json,
75
83
  outfile,
76
84
  verbose,
85
+ sync,
86
+ preview,
77
87
  } = args;
78
88
 
79
- // 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.
80
91
  const {local_repos: repos} = await get_gitops_ready({
81
92
  config,
82
93
  dir,
83
94
  download: false, // Don't download if missing
95
+ sync: sync || wetrun,
84
96
  log,
85
97
  });
86
98
 
87
- // Show plan if requested (skip for dry runs)
88
- 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') {
89
110
  log.info(st('cyan', 'Publishing Plan'));
90
- const plan_result = await generate_publishing_plan(repos, {log, verbose});
91
- log_publishing_plan(plan_result, log, {verbose});
111
+ log_publishing_plan(publishing_plan, log, {verbose});
112
+ }
92
113
 
93
- if (plan_result.errors.length > 0) {
94
- throw new Error('Cannot proceed with publishing due to errors');
95
- }
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);
96
118
 
97
119
  // Ask for confirmation
98
120
  log.info(st('yellow', '⚠️ This will publish the packages shown above.'));
@@ -102,16 +124,19 @@ export const task: Task<Args> = {
102
124
  log.info('Publishing cancelled');
103
125
  process.exit(0);
104
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);
105
132
  }
106
133
 
107
134
  // Publishing options
108
135
  const options: PublishingOptions = {
109
136
  wetrun,
110
- update_deps: true, // Always update dependencies
111
137
  version_strategy: peer_strategy,
112
138
  deploy,
113
139
  max_wait,
114
- skip_install,
115
140
  log,
116
141
  // Live JSON-lines stream when requested; events also surface on the result.
117
142
  events: emit_json ? stdout_handler() : undefined,
@@ -122,7 +147,7 @@ export const task: Task<Args> = {
122
147
  let fatal_error: Error | null = null;
123
148
 
124
149
  try {
125
- result = await publish_repos(repos, options);
150
+ result = await execute_publishing_plan(repos, publishing_plan, options);
126
151
  } catch (error) {
127
152
  // Construct a failure result for fatal errors so output can still be generated
128
153
  fatal_error = error instanceof Error ? error : new Error(String(error));
@@ -134,13 +159,15 @@ export const task: Task<Args> = {
134
159
  duration: 0,
135
160
  events: [],
136
161
  summary: {total: 0, published: 0, failed: 1, skipped: 0, duration: 0},
162
+ plan_errors: publishing_plan.errors,
163
+ plan_warnings: publishing_plan.warnings,
137
164
  };
138
165
  }
139
166
 
140
167
  // Format and output result (always runs, even on fatal errors)
141
- // Note: stdout format is handled by publish_repos function's logging
168
+ // Note: stdout format is handled by the executor's logging
142
169
  if (format !== 'stdout') {
143
- await format_and_output({result, fatal_error}, create_publish_formatters(), {
170
+ await format_and_output({result, fatal_error, preview_steps}, create_publish_formatters(), {
144
171
  format,
145
172
  outfile,
146
173
  log,
@@ -148,7 +175,7 @@ export const task: Task<Args> = {
148
175
  }
149
176
 
150
177
  // Exit with error if failed
151
- if (!result.ok || fatal_error) {
178
+ if (publish_run_failed(result, fatal_error)) {
152
179
  process.exit(1);
153
180
  }
154
181
  },
@@ -157,21 +184,36 @@ export const task: Task<Args> = {
157
184
  interface PublishResultData {
158
185
  result: PublishingResult;
159
186
  fatal_error: Error | null;
187
+ preview_steps: Array<PublishStep> | null;
160
188
  }
161
189
 
162
190
  const create_publish_formatters = (): OutputFormatters<PublishResultData> => ({
163
- json: (data) => JSON.stringify(data.result, null, 2),
164
- 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),
165
198
  stdout: () => {
166
- // stdout format is handled by publish_repos function's logging
199
+ // stdout format is handled by the executor's logging
167
200
  // This should never be called due to early return in task
168
201
  },
169
202
  });
170
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
+
171
212
  // Format the publishing result as markdown
172
213
  const format_result_markdown = (
173
214
  result: PublishingResult,
174
215
  fatal_error: Error | null,
216
+ preview_steps: Array<PublishStep> | null,
175
217
  ): Array<string> => {
176
218
  const lines: Array<string> = [];
177
219
 
@@ -215,6 +257,29 @@ const format_result_markdown = (
215
257
  }
216
258
  }
217
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
+
218
283
  return lines;
219
284
  };
220
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};
@@ -3,10 +3,13 @@ import {z} from 'zod';
3
3
  import {styleText as st} from 'node:util';
4
4
 
5
5
  import {get_gitops_ready} from './gitops_task_helpers.js';
6
- import {validate_dependency_graph} from './graph_validation.js';
7
- import {DependencyGraphBuilder} from './dependency_graph.js';
8
- import {generate_publishing_plan, log_publishing_plan} from './publishing_plan.js';
9
- import {publish_repos, type PublishingOptions} from './multi_repo_publisher.js';
6
+ import {analyze_repos, type DependencyAnalysis} from './graph_validation.js';
7
+ import {
8
+ generate_publishing_plan,
9
+ log_publishing_plan,
10
+ type PublishingPlan,
11
+ } from './publishing_plan.js';
12
+ import {execute_publishing_plan, type PublishingOptions} from './multi_repo_publisher.js';
10
13
  import {log_dependency_analysis} from './log_helpers.js';
11
14
  import {GITOPS_CONFIG_PATH_DEFAULT} from './gitops_constants.js';
12
15
 
@@ -21,6 +24,13 @@ export const Args = z.strictObject({
21
24
  .meta({description: 'path containing the repos, defaults to the parent of the config dir'})
22
25
  .optional(),
23
26
  verbose: z.boolean().meta({description: 'show additional details'}).default(false),
27
+ sync: z
28
+ .boolean()
29
+ .meta({
30
+ description:
31
+ 'sync repos (switch branch, pull, install) before validating instead of reading the working tree as-is',
32
+ })
33
+ .default(false),
24
34
  });
25
35
  export type Args = z.infer<typeof Args>;
26
36
 
@@ -30,7 +40,7 @@ export const task: Task<Args> = {
30
40
  summary:
31
41
  'validate gitops configuration by running all read-only commands and checking for issues',
32
42
  run: async ({args, log}) => {
33
- const {config, dir, verbose} = args;
43
+ const {config, dir, verbose, sync} = args;
34
44
 
35
45
  log.info(st('cyan', 'Running Gitops Validation Suite'));
36
46
  log.info(st('dim', 'This runs all read-only commands and checks for consistency.'));
@@ -43,30 +53,22 @@ export const task: Task<Args> = {
43
53
  duration: number;
44
54
  warning_details?: Array<string>;
45
55
  info_details?: Array<string>;
46
- analysis?: ReturnType<DependencyGraphBuilder['analyze']>;
56
+ analysis?: DependencyAnalysis;
47
57
  }> = [];
48
58
 
49
59
  const start_time = Date.now();
50
60
 
51
- // Load repos once (shared by all commands)
61
+ // Load repos once (shared by all commands); read the working tree as-is unless `--sync`
52
62
  log.info(st('dim', 'Loading repositories...'));
53
- const {local_repos} = await get_gitops_ready({config, dir, download: false, log});
63
+ const {local_repos} = await get_gitops_ready({config, dir, download: false, sync, log});
54
64
  log.info(st('dim', ` Found ${local_repos.length} local repos`));
55
65
 
56
66
  // 1. Run gitops_analyze
57
67
  log.info(st('yellow', 'Running gitops_analyze...'));
58
68
  const analyze_start = Date.now();
59
69
  try {
60
- // Build dependency graph and validate (but don't throw on cycles for analyze)
61
- const {graph} = validate_dependency_graph(local_repos, {
62
- throw_on_prod_cycles: false, // Analyze should report, not throw
63
- log_cycles: false, // We'll collect our own statistics
64
- log_order: false,
65
- });
66
-
67
- // Perform additional analysis
68
- const builder = new DependencyGraphBuilder();
69
- const analysis = builder.analyze(graph);
70
+ // Build dependency graph and analyze cycles/wildcards (tolerating cycles)
71
+ const {analysis} = analyze_repos(local_repos);
70
72
 
71
73
  const analyze_duration = Date.now() - analyze_start;
72
74
 
@@ -113,11 +115,13 @@ export const task: Task<Args> = {
113
115
  log.error(st('red', ` ✗ gitops_analyze failed: ${error}`));
114
116
  }
115
117
 
116
- // 2. Run gitops_plan
118
+ // 2. Run gitops_plan (generated once here and reused by the dry run below)
119
+ let publishing_plan: PublishingPlan | undefined;
117
120
  log.info(st('yellow', 'Running gitops_plan...'));
118
121
  const plan_start = Date.now();
119
122
  try {
120
- const plan = await generate_publishing_plan(local_repos, {log: undefined, verbose});
123
+ const plan = await generate_publishing_plan(local_repos, {verbose});
124
+ publishing_plan = plan;
121
125
  const plan_duration = Date.now() - plan_start;
122
126
 
123
127
  const warnings = plan.warnings.length;
@@ -159,11 +163,15 @@ export const task: Task<Args> = {
159
163
  try {
160
164
  const options: PublishingOptions = {
161
165
  wetrun: false,
162
- update_deps: true,
163
166
  log: undefined, // Silent for validation
164
167
  };
165
168
 
166
- const result = await publish_repos(local_repos, options);
169
+ // Reuse the plan from step 2 (regenerate only if that step failed to produce one).
170
+ const result = await execute_publishing_plan(
171
+ local_repos,
172
+ publishing_plan ?? (await generate_publishing_plan(local_repos, {verbose})),
173
+ options,
174
+ );
167
175
  const dry_duration = Date.now() - dry_start;
168
176
 
169
177
  // Dry run doesn't have warnings/errors in the same format