@fuzdev/fuz_gitops 0.78.1 → 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 (179) hide show
  1. package/README.md +78 -24
  2. package/dist/ModulesDetail.svelte +15 -22
  3. package/dist/ModulesDetail.svelte.d.ts.map +1 -1
  4. package/dist/ModulesNav.svelte +4 -1
  5. package/dist/ModulesPage.svelte +1 -3
  6. package/dist/ModulesPage.svelte.d.ts.map +1 -1
  7. package/dist/PageHeader.svelte +2 -2
  8. package/dist/PageHeader.svelte.d.ts +0 -3
  9. package/dist/PageHeader.svelte.d.ts.map +1 -1
  10. package/dist/PullRequestsDetail.svelte +10 -9
  11. package/dist/PullRequestsDetail.svelte.d.ts.map +1 -1
  12. package/dist/PullRequestsPage.svelte +1 -3
  13. package/dist/PullRequestsPage.svelte.d.ts.map +1 -1
  14. package/dist/ReposTable.svelte +27 -48
  15. package/dist/ReposTable.svelte.d.ts.map +1 -1
  16. package/dist/ReposTree.svelte +5 -19
  17. package/dist/ReposTree.svelte.d.ts.map +1 -1
  18. package/dist/ReposTreeNav.svelte +5 -7
  19. package/dist/ReposTreeNav.svelte.d.ts.map +1 -1
  20. package/dist/TablePage.svelte +1 -3
  21. package/dist/TablePage.svelte.d.ts.map +1 -1
  22. package/dist/TreeItemPage.svelte +1 -3
  23. package/dist/TreeItemPage.svelte.d.ts.map +1 -1
  24. package/dist/TreePage.svelte +1 -3
  25. package/dist/TreePage.svelte.d.ts.map +1 -1
  26. package/dist/ci_reconcile.d.ts +6 -10
  27. package/dist/ci_reconcile.d.ts.map +1 -1
  28. package/dist/ci_reconcile.js +6 -8
  29. package/dist/dependency_graph.d.ts +26 -49
  30. package/dist/dependency_graph.d.ts.map +1 -1
  31. package/dist/dependency_graph.js +37 -80
  32. package/dist/dependency_updater.d.ts +1 -1
  33. package/dist/dependency_updater.d.ts.map +1 -1
  34. package/dist/dependency_updater.js +6 -1
  35. package/dist/fetch_repo_data.d.ts +34 -8
  36. package/dist/fetch_repo_data.d.ts.map +1 -1
  37. package/dist/fetch_repo_data.js +44 -22
  38. package/dist/fs_fetch_value_cache.d.ts +1 -1
  39. package/dist/fs_fetch_value_cache.js +1 -1
  40. package/dist/git_operations.d.ts +6 -49
  41. package/dist/git_operations.d.ts.map +1 -1
  42. package/dist/git_operations.js +13 -126
  43. package/dist/github.d.ts +17 -2
  44. package/dist/github.d.ts.map +1 -1
  45. package/dist/github.js +15 -6
  46. package/dist/github_helpers.d.ts.map +1 -1
  47. package/dist/github_helpers.js +3 -11
  48. package/dist/gitops_analyze.task.d.ts +24 -2
  49. package/dist/gitops_analyze.task.d.ts.map +1 -1
  50. package/dist/gitops_analyze.task.js +43 -30
  51. package/dist/gitops_config.d.ts +34 -79
  52. package/dist/gitops_config.d.ts.map +1 -1
  53. package/dist/gitops_config.js +76 -53
  54. package/dist/gitops_constants.d.ts +6 -4
  55. package/dist/gitops_constants.d.ts.map +1 -1
  56. package/dist/gitops_constants.js +6 -4
  57. package/dist/gitops_plan.task.d.ts +29 -3
  58. package/dist/gitops_plan.task.d.ts.map +1 -1
  59. package/dist/gitops_plan.task.js +80 -58
  60. package/dist/gitops_publish.task.d.ts +62 -2
  61. package/dist/gitops_publish.task.d.ts.map +1 -1
  62. package/dist/gitops_publish.task.js +187 -102
  63. package/dist/gitops_run.task.d.ts +74 -0
  64. package/dist/gitops_run.task.d.ts.map +1 -1
  65. package/dist/gitops_run.task.js +174 -160
  66. package/dist/gitops_sync.task.d.ts +65 -3
  67. package/dist/gitops_sync.task.d.ts.map +1 -1
  68. package/dist/gitops_sync.task.js +154 -30
  69. package/dist/gitops_task_helpers.d.ts +86 -59
  70. package/dist/gitops_task_helpers.d.ts.map +1 -1
  71. package/dist/gitops_task_helpers.js +120 -79
  72. package/dist/gitops_validate.task.d.ts +1 -2
  73. package/dist/gitops_validate.task.d.ts.map +1 -1
  74. package/dist/gitops_validate.task.js +15 -22
  75. package/dist/graph_validation.d.ts +16 -27
  76. package/dist/graph_validation.d.ts.map +1 -1
  77. package/dist/graph_validation.js +23 -64
  78. package/dist/local_repo.d.ts +50 -62
  79. package/dist/local_repo.d.ts.map +1 -1
  80. package/dist/local_repo.js +80 -193
  81. package/dist/log_helpers.d.ts +8 -8
  82. package/dist/log_helpers.d.ts.map +1 -1
  83. package/dist/multi_repo_publisher.d.ts +10 -0
  84. package/dist/multi_repo_publisher.d.ts.map +1 -1
  85. package/dist/multi_repo_publisher.js +115 -51
  86. package/dist/npm_registry.d.ts +23 -19
  87. package/dist/npm_registry.d.ts.map +1 -1
  88. package/dist/npm_registry.js +20 -47
  89. package/dist/operations.d.ts +75 -180
  90. package/dist/operations.d.ts.map +1 -1
  91. package/dist/operations.js +15 -7
  92. package/dist/operations_defaults.d.ts +17 -2
  93. package/dist/operations_defaults.d.ts.map +1 -1
  94. package/dist/operations_defaults.js +104 -121
  95. package/dist/output_helpers.d.ts +36 -0
  96. package/dist/output_helpers.d.ts.map +1 -1
  97. package/dist/output_helpers.js +37 -3
  98. package/dist/preflight_checks.d.ts +20 -27
  99. package/dist/preflight_checks.d.ts.map +1 -1
  100. package/dist/preflight_checks.js +37 -126
  101. package/dist/publish_steps.d.ts +2 -4
  102. package/dist/publish_steps.d.ts.map +1 -1
  103. package/dist/publish_steps.js +2 -6
  104. package/dist/publishing_event.d.ts +2 -8
  105. package/dist/publishing_event.d.ts.map +1 -1
  106. package/dist/publishing_event.js +5 -4
  107. package/dist/publishing_event_handler.d.ts +10 -8
  108. package/dist/publishing_event_handler.d.ts.map +1 -1
  109. package/dist/publishing_event_handler.js +14 -12
  110. package/dist/publishing_plan.d.ts +16 -1
  111. package/dist/publishing_plan.d.ts.map +1 -1
  112. package/dist/publishing_plan.js +328 -250
  113. package/dist/publishing_plan_logging.d.ts +1 -1
  114. package/dist/publishing_plan_logging.d.ts.map +1 -1
  115. package/dist/publishing_plan_logging.js +21 -14
  116. package/dist/repo.svelte.d.ts +7 -0
  117. package/dist/repo.svelte.d.ts.map +1 -1
  118. package/dist/repo.svelte.js +3 -0
  119. package/dist/repo_readiness.d.ts +210 -0
  120. package/dist/repo_readiness.d.ts.map +1 -0
  121. package/dist/repo_readiness.js +516 -0
  122. package/dist/repos_status.d.ts +3340 -0
  123. package/dist/repos_status.d.ts.map +1 -0
  124. package/dist/repos_status.js +555 -0
  125. package/dist/repos_status_load.d.ts +65 -0
  126. package/dist/repos_status_load.d.ts.map +1 -0
  127. package/dist/repos_status_load.js +131 -0
  128. package/package.json +7 -27
  129. package/src/lib/ci_reconcile.ts +7 -11
  130. package/src/lib/dependency_graph.ts +45 -97
  131. package/src/lib/dependency_updater.ts +7 -1
  132. package/src/lib/fetch_repo_data.ts +66 -29
  133. package/src/lib/fs_fetch_value_cache.ts +1 -1
  134. package/src/lib/git_operations.ts +15 -187
  135. package/src/lib/github.ts +20 -8
  136. package/src/lib/github_helpers.ts +5 -12
  137. package/src/lib/gitops_analyze.task.ts +79 -41
  138. package/src/lib/gitops_config.ts +88 -137
  139. package/src/lib/gitops_constants.ts +6 -4
  140. package/src/lib/gitops_plan.task.ts +113 -61
  141. package/src/lib/gitops_publish.task.ts +255 -122
  142. package/src/lib/gitops_run.task.ts +250 -178
  143. package/src/lib/gitops_sync.task.ts +208 -33
  144. package/src/lib/gitops_task_helpers.ts +166 -110
  145. package/src/lib/gitops_validate.task.ts +17 -27
  146. package/src/lib/graph_validation.ts +25 -86
  147. package/src/lib/local_repo.ts +109 -316
  148. package/src/lib/log_helpers.ts +8 -18
  149. package/src/lib/multi_repo_publisher.ts +138 -53
  150. package/src/lib/npm_registry.ts +37 -62
  151. package/src/lib/operations.ts +78 -150
  152. package/src/lib/operations_defaults.ts +108 -154
  153. package/src/lib/output_helpers.ts +55 -3
  154. package/src/lib/preflight_checks.ts +47 -183
  155. package/src/lib/publish_steps.ts +15 -13
  156. package/src/lib/publishing_event.ts +5 -4
  157. package/src/lib/publishing_event_handler.ts +18 -16
  158. package/src/lib/publishing_plan.ts +451 -287
  159. package/src/lib/publishing_plan_logging.ts +32 -23
  160. package/src/lib/repo.svelte.ts +8 -0
  161. package/src/lib/repo_readiness.ts +647 -0
  162. package/src/lib/repos_status.ts +671 -0
  163. package/src/lib/repos_status_load.ts +173 -0
  164. package/dist/config_reconcile.d.ts +0 -46
  165. package/dist/config_reconcile.d.ts.map +0 -1
  166. package/dist/config_reconcile.js +0 -64
  167. package/dist/paths.d.ts +0 -7
  168. package/dist/paths.d.ts.map +0 -1
  169. package/dist/paths.js +0 -6
  170. package/dist/repo_ops.d.ts +0 -59
  171. package/dist/repo_ops.d.ts.map +0 -1
  172. package/dist/repo_ops.js +0 -170
  173. package/dist/resolved_gitops_config.d.ts +0 -9
  174. package/dist/resolved_gitops_config.d.ts.map +0 -1
  175. package/dist/resolved_gitops_config.js +0 -12
  176. package/src/lib/config_reconcile.ts +0 -98
  177. package/src/lib/paths.ts +0 -6
  178. package/src/lib/repo_ops.ts +0 -216
  179. package/src/lib/resolved_gitops_config.ts +0 -27
@@ -1,162 +1,113 @@
1
1
  /**
2
- * Configuration types and normalization for gitops multi-repo management.
2
+ * The gitops config: which repos a project's gitops tasks work over, as a flat
3
+ * list of `repos.toml` registry keys.
3
4
  *
4
- * Two-phase configuration system:
5
- * - `RawGitopsConfig` - User-friendly format with optional fields and flexible types
6
- * - `GitopsConfig` - Internal format with required fields and strict types
7
- *
8
- * This allows users to provide minimal configs (e.g., just URLs as strings) while
9
- * the system works with normalized configs internally for type safety.
5
+ * Every other fact about a repo — its dir, URL, branch, visibility, `ci`, and
6
+ * `archived` — comes from the registry, through `repos status --json`, so the
7
+ * config declares nothing that could drift from it.
10
8
  *
11
9
  * @module
12
10
  */
13
11
 
14
- import type { Url } from '@fuzdev/fuz_util/url.ts';
15
12
  import { existsSync } from 'node:fs';
16
- import { strip_end } from '@fuzdev/fuz_util/string.ts';
17
- import type { GitBranch } from '@fuzdev/fuz_util/git.ts';
13
+ import { z } from 'zod';
18
14
 
19
- import { DEFAULT_REPOS_DIR } from './paths.ts';
15
+ import type { ReposEntryStatus } from './repos_status.ts';
20
16
 
21
- export interface GitopsConfig {
22
- repos: Array<GitopsRepoConfig>;
23
- repos_dir: string;
24
- }
17
+ /** A project's gitops config. */
18
+ export const GitopsConfig = z.strictObject({
19
+ repos: z
20
+ .array(z.string().min(1))
21
+ .meta({ description: 'repos.toml registry keys of owned repos, in the order tasks list them' })
22
+ });
23
+ export type GitopsConfig = z.infer<typeof GitopsConfig>;
25
24
 
26
- export type CreateGitopsConfig = (
27
- base_config: GitopsConfig
28
- ) => RawGitopsConfig | Promise<RawGitopsConfig>;
25
+ /** A config module's default export in function form. */
26
+ export type CreateGitopsConfig = () => GitopsConfig | Promise<GitopsConfig>;
29
27
 
30
- export interface RawGitopsConfig {
31
- repos?: Array<Url | RawGitopsRepoConfig>;
32
- repos_dir?: string;
28
+ export interface GitopsConfigModule {
29
+ readonly default: GitopsConfig | CreateGitopsConfig;
33
30
  }
34
31
 
35
32
  /**
36
- * Visibility of a repo on its host, mirroring the host's own model
37
- * (e.g. GitHub's `visibility` field). Named to avoid confusion with the npm
38
- * `package.json` `private` flag, which is a separate publishing concern.
33
+ * The private repos a public host package must not publish. `gitops_sync` writes
34
+ * every configured repo's GitHub metadata into the host project's generated
35
+ * `repos.json` — a public site's data when the host package is public — so a
36
+ * private repo in that config would leak. Empty when the host is private.
37
+ *
38
+ * @param entries - the configured repos' registry entries
39
+ * @param host_is_private - whether the host `package.json` sets `private: true`
39
40
  */
40
- export type GitopsRepoVisibility = 'public' | 'private';
41
-
42
- export interface GitopsRepoConfig {
43
- /**
44
- * The HTTPS URL to the repo. Does not include a `.git` suffix.
45
- * @example
46
- * ```ts
47
- * 'https://github.com/fuzdev/fuz_ui'
48
- * ```
49
- */
50
- repo_url: Url;
51
-
52
- /**
53
- * Relative or absolute path to the repo's local directory.
54
- * If `null`, the directory is inferred from the URL and cwd.
55
- * @example
56
- * ```ts
57
- * 'relative/path/to/repo'
58
- * ```
59
- * @example
60
- * ```ts
61
- * '/absolute/path/to/repo'
62
- * ```
63
- */
64
- repo_dir: string | null;
65
-
66
- /**
67
- * The branch name to use when fetching the repo. Defaults to `main`.
68
- */
69
- branch: GitBranch;
70
-
71
- /**
72
- * Visibility of the repo on its host. Defaults to `'public'`.
73
- */
74
- visibility: GitopsRepoVisibility;
75
-
76
- /**
77
- * Whether the repo runs CI. Defaults to `true` for public repos and `false`
78
- * for private repos, unless set explicitly.
79
- */
80
- ci: boolean;
81
-
82
- /**
83
- * Whether the repo is archived (read-only) on its host. Defaults to `false`.
84
- */
85
- archived: boolean;
86
- }
87
-
88
- export interface RawGitopsRepoConfig {
89
- repo_url: Url;
90
- repo_dir?: string | null;
91
- branch?: GitBranch;
92
- /** Visibility of the repo on its host. Defaults to `'public'`. */
93
- visibility?: GitopsRepoVisibility;
94
- /** Whether the repo runs CI. Defaults to `true` for public, `false` for private. */
95
- ci?: boolean;
96
- /** Whether the repo is archived (read-only) on its host. Defaults to `false`. */
97
- archived?: boolean;
98
- }
99
-
100
- export const create_empty_gitops_config = (): GitopsConfig => ({
101
- repos: [],
102
- repos_dir: DEFAULT_REPOS_DIR
103
- });
41
+ export const gitops_config_leaked_private_repos = (
42
+ entries: ReadonlyArray<ReposEntryStatus>,
43
+ host_is_private: boolean
44
+ ): Array<ReposEntryStatus> =>
45
+ host_is_private ? [] : entries.filter((e) => e.visibility === 'private');
104
46
 
105
47
  /**
106
- * Transforms a `RawGitopsConfig` to the more strict `GitopsConfig`.
107
- * This allows users to provide a more relaxed config.
48
+ * Loads a gitops config module and validates it.
49
+ *
50
+ * @returns the config, or `null` when no file exists at `config_path`
51
+ * @throws {Error} if the module's default export isn't a valid config
108
52
  */
109
- export const normalize_gitops_config = (raw_config: RawGitopsConfig): GitopsConfig => {
110
- const empty_config = create_empty_gitops_config();
111
- // All of the raw config properties are optional,
112
- // so fall back to the empty values when `undefined`.
113
- const { repos, repos_dir } = raw_config;
114
- return {
115
- repos: repos ? repos.map((r) => parse_fuz_repo_config(r)) : empty_config.repos,
116
- // Default to two dirs up from config if not specified
117
- repos_dir: repos_dir ?? DEFAULT_REPOS_DIR
118
- };
119
- };
120
-
121
- const parse_fuz_repo_config = (r: Url | RawGitopsRepoConfig): GitopsRepoConfig => {
122
- if (typeof r === 'string') {
123
- return {
124
- repo_url: r,
125
- repo_dir: null,
126
- branch: 'main' as GitBranch, // TODO @zts use flavored for GitBranch
127
- visibility: 'public',
128
- ci: true,
129
- archived: false
130
- };
131
- }
132
- const visibility = r.visibility ?? 'public';
133
- return {
134
- repo_url: strip_end(r.repo_url, '.git'),
135
- repo_dir: r.repo_dir ?? null,
136
- branch: r.branch ?? ('main' as GitBranch), // TODO @zts use flavored for GitBranch
137
- visibility,
138
- ci: r.ci ?? visibility === 'public',
139
- archived: r.archived ?? false
140
- };
141
- };
142
-
143
- export interface GitopsConfigModule {
144
- readonly default: RawGitopsConfig | CreateGitopsConfig;
145
- }
146
-
147
53
  export const load_gitops_config = async (config_path: string): Promise<GitopsConfig | null> => {
148
54
  if (!existsSync(config_path)) {
149
55
  // No user config file found.
150
56
  return null;
151
57
  }
152
- // Import the user's `gitops.config.ts`.
153
- const config_module = await import(config_path);
58
+ // Import the user's `gitops.config.ts`. An import or call failure keeps its
59
+ // stack, which carries the config's file and line; validation errors don't need one.
60
+ let config_module: unknown;
61
+ try {
62
+ config_module = await import(config_path);
63
+ } catch (err) {
64
+ throw Error(`Failed to import the gitops config at ${config_path}:\n${error_with_stack(err)}`);
65
+ }
154
66
  validate_gitops_config_module(config_module, config_path);
155
- return normalize_gitops_config(
156
- typeof config_module.default === 'function'
157
- ? await config_module.default(create_empty_gitops_config())
158
- : config_module.default
159
- );
67
+ let raw: unknown;
68
+ try {
69
+ raw =
70
+ typeof config_module.default === 'function'
71
+ ? await config_module.default()
72
+ : config_module.default;
73
+ } catch (err) {
74
+ throw Error(`The gitops config at ${config_path} threw:\n${error_with_stack(err)}`);
75
+ }
76
+ return parse_gitops_config(raw, config_path);
77
+ };
78
+
79
+ const error_with_stack = (err: unknown): string =>
80
+ err instanceof Error ? (err.stack ?? err.message) : String(err);
81
+
82
+ /**
83
+ * Validates a loaded config value: registry keys, each listed once.
84
+ *
85
+ * @throws {Error} naming the config and what's wrong with it
86
+ */
87
+ export const parse_gitops_config = (raw: unknown, config_path: string): GitopsConfig => {
88
+ const parsed = GitopsConfig.safeParse(raw);
89
+ if (!parsed.success) {
90
+ const hint =
91
+ "\n `repos` lists repos.toml registry keys; a repo's url, dir, branch, visibility, ci, and archived come from the registry";
92
+ throw Error(
93
+ `Invalid gitops config at ${config_path}:\n${z.prettifyError(parsed.error)}${hint}`
94
+ );
95
+ }
96
+ const config = parsed.data;
97
+ const problems: Array<string> = [];
98
+ const seen: Set<string> = new Set();
99
+ for (const key of config.repos) {
100
+ if (key.includes('/')) {
101
+ problems.push(`\`${key}\` isn't a registry key — list repos by their repos.toml key`);
102
+ } else if (seen.has(key)) {
103
+ problems.push(`\`${key}\` is listed more than once`);
104
+ }
105
+ seen.add(key);
106
+ }
107
+ if (problems.length) {
108
+ throw Error(`Invalid gitops config at ${config_path}:\n ${problems.join('\n ')}`);
109
+ }
110
+ return config;
160
111
  };
161
112
 
162
113
  export const validate_gitops_config_module: (
@@ -165,10 +116,10 @@ export const validate_gitops_config_module: (
165
116
  ) => asserts config_module is GitopsConfigModule = (config_module, config_path) => {
166
117
  const config = config_module.default;
167
118
  if (!config) {
168
- throw Error(`Invalid Fuz config module at ${config_path}: expected a default export`);
119
+ throw Error(`Invalid gitops config module at ${config_path}: expected a default export`);
169
120
  } else if (!(typeof config === 'function' || typeof config === 'object')) {
170
121
  throw Error(
171
- `Invalid Fuz config module at ${config_path}: the default export must be a function or object`
122
+ `Invalid gitops config module at ${config_path}: the default export must be a function or object`
172
123
  );
173
124
  }
174
125
  };
@@ -7,11 +7,13 @@
7
7
  */
8
8
 
9
9
  /**
10
- * Maximum number of iterations for fixed-point iteration during publishing.
11
- * Used in both plan generation and actual publishing to resolve transitive dependency cascades.
10
+ * Maximum number of fixed-point iterations plan generation runs to resolve
11
+ * transitive dependency cascades. Publishing executes the frozen plan in a
12
+ * single pass and doesn't iterate.
12
13
  *
13
- * In practice, most repos converge in 2-3 iterations.
14
- * Deep dependency chains may require more iterations.
14
+ * Each iteration reaches at least one more level of dependents, so a deep dependency
15
+ * chain needs more; a plan that hits the limit still changing warns, naming
16
+ * the packages left.
15
17
  */
16
18
  export const GITOPS_MAX_ITERATIONS_DEFAULT = 10;
17
19
 
@@ -1,15 +1,28 @@
1
1
  import type { Task } from '@fuzdev/gro';
2
+ import type { Logger } from '@fuzdev/fuz_util/log.ts';
2
3
  import { z } from 'zod';
3
4
  import { styleText as st } from 'node:util';
4
5
 
5
- import { get_gitops_ready } from './gitops_task_helpers.ts';
6
+ import {
7
+ get_gitops_ready,
8
+ log_readiness_block,
9
+ type ResolveGitopsReposOptions
10
+ } from './gitops_task_helpers.ts';
11
+ import type { LocalRepo } from './local_repo.ts';
12
+ import type { ChangesetOperations } from './operations.ts';
13
+ import { default_changeset_operations } from './operations_defaults.ts';
6
14
  import {
7
15
  generate_publishing_plan,
8
- log_publishing_plan,
9
- type PublishingPlan,
10
- type LogPlanOptions
16
+ version_change_kind,
17
+ type PublishingPlan
11
18
  } from './publishing_plan.ts';
12
- import { format_and_output, type OutputFormatters } from './output_helpers.ts';
19
+ import { log_publishing_plan, type LogPlanOptions } from './publishing_plan_logging.ts';
20
+ import {
21
+ format_and_output,
22
+ output_is_machine,
23
+ route_human_output,
24
+ type OutputFormatters
25
+ } from './output_helpers.ts';
13
26
  import { GITOPS_CONFIG_PATH_DEFAULT } from './gitops_constants.ts';
14
27
 
15
28
  /** @nodocs */
@@ -18,23 +31,19 @@ export const Args = z.strictObject({
18
31
  .string()
19
32
  .meta({ description: 'path to the gitops config file, absolute or relative to the cwd' })
20
33
  .default(GITOPS_CONFIG_PATH_DEFAULT),
21
- dir: z
34
+ registry: z
22
35
  .string()
23
- .meta({ description: 'path containing the repos, defaults to the parent of the config dir' })
36
+ .meta({
37
+ description:
38
+ 'path to the repos.toml registry, when `repos` would not find it walking up from the cwd'
39
+ })
24
40
  .optional(),
25
41
  format: z
26
42
  .enum(['stdout', 'json', 'markdown'])
27
43
  .meta({ description: 'output format' })
28
44
  .default('stdout'),
29
45
  outfile: z.string().meta({ description: 'write output to file instead of logging' }).optional(),
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)
46
+ verbose: z.boolean().meta({ description: 'show additional details' }).default(false)
38
47
  });
39
48
  export type Args = z.infer<typeof Args>;
40
49
 
@@ -44,7 +53,7 @@ export type Args = z.infer<typeof Args>;
44
53
  *
45
54
  * Usage:
46
55
  * `gro gitops_plan`
47
- * `gro gitops_plan --dir ../repos`
56
+ * `gro gitops_plan --registry ../repos.toml`
48
57
  * `gro gitops_plan --config ./custom.config.ts`
49
58
  *
50
59
  * @nodocs
@@ -53,36 +62,67 @@ export const task: Task<Args> = {
53
62
  summary: 'generate a publishing plan based on changesets',
54
63
  Args,
55
64
  run: async ({ args, log }): Promise<void> => {
56
- const { dir, config, format, outfile, verbose, sync } = args;
65
+ await run_gitops_plan(args, log);
66
+ }
67
+ };
68
+
69
+ /**
70
+ * The side effects `run_gitops_plan` reaches through, injectable for tests.
71
+ *
72
+ * @nodocs
73
+ */
74
+ export interface GitopsPlanDeps {
75
+ /** Loads the configured repos as they sit (`get_gitops_ready`). */
76
+ load_repos: (options: ResolveGitopsReposOptions) => Promise<{ local_repos: Array<LocalRepo> }>;
77
+ /** Reads each repo's changesets for the plan. */
78
+ changeset_ops: ChangesetOperations;
79
+ }
57
80
 
58
- log.info(st('cyan', 'Generating multi-repo publishing plan...'));
81
+ const default_gitops_plan_deps: GitopsPlanDeps = {
82
+ load_repos: get_gitops_ready,
83
+ changeset_ops: default_changeset_operations
84
+ };
59
85
 
60
- // Load local repos; read the working tree as-is unless `--sync`
61
- const { local_repos } = await get_gitops_ready({
62
- config,
63
- dir,
64
- download: false, // Don't download if missing
65
- sync,
66
- log
67
- });
86
+ /**
87
+ * Runs `gro gitops_plan`: loads the repos as they sit, logs the readiness
88
+ * block, generates the plan, and outputs it. Under `--format json` or
89
+ * `markdown` without `--outfile`, the log goes to stderr and stdout carries
90
+ * the document alone (`route_human_output`).
91
+ *
92
+ * @throws {Error} when the plan has errors that would block publishing, after outputting it
93
+ * @nodocs
94
+ */
95
+ export const run_gitops_plan = async (
96
+ args: Args,
97
+ log: Logger,
98
+ deps: Partial<GitopsPlanDeps> = {}
99
+ ): Promise<void> => {
100
+ const { load_repos, changeset_ops } = { ...default_gitops_plan_deps, ...deps };
101
+ const { config, registry, format, outfile, verbose } = args;
102
+ const write_stdout = route_human_output(log, output_is_machine(format, outfile));
68
103
 
69
- if (local_repos.length === 0) {
70
- log.error('No local repos found');
71
- return;
72
- }
104
+ log.info(st('cyan', 'Generating multi-repo publishing plan...'));
73
105
 
74
- log.info(` Found ${local_repos.length} local repos`);
106
+ // Load local repos as they sit, and say which aren't at rest
107
+ const { local_repos } = await load_repos({ config, registry, log });
108
+ log_readiness_block(local_repos, log);
75
109
 
76
- // Generate publishing plan
77
- const plan = await generate_publishing_plan(local_repos, { log, verbose });
110
+ log.info(` Found ${local_repos.length} local repos`);
78
111
 
79
- // Format and output using output_helpers
80
- await format_and_output(plan, create_plan_formatters({ verbose }), { format, outfile, log });
112
+ // Generate publishing plan
113
+ const plan = await generate_publishing_plan(local_repos, { log, verbose, ops: changeset_ops });
81
114
 
82
- // Exit with error if there are blocking issues
83
- if (plan.errors.length > 0) {
84
- throw new Error('Publishing plan found errors that would block publishing');
85
- }
115
+ // Format and output using output_helpers
116
+ await format_and_output(plan, create_plan_formatters({ verbose }), {
117
+ format,
118
+ outfile,
119
+ log,
120
+ write_stdout
121
+ });
122
+
123
+ // Exit with error if there are blocking issues
124
+ if (plan.errors.length > 0) {
125
+ throw new Error('Publishing plan found errors that would block publishing');
86
126
  }
87
127
  };
88
128
 
@@ -97,6 +137,7 @@ const create_plan_formatters = (
97
137
  breaking_cascades: Object.fromEntries(plan.breaking_cascades),
98
138
  warnings: plan.warnings,
99
139
  info: plan.info,
140
+ no_changes: plan.no_changes,
100
141
  errors: plan.errors
101
142
  };
102
143
  return JSON.stringify(output, null, 2);
@@ -114,6 +155,7 @@ const format_plan_as_markdown = (plan: PublishingPlan): Array<string> => {
114
155
  breaking_cascades,
115
156
  warnings,
116
157
  info,
158
+ no_changes,
117
159
  errors
118
160
  } = plan;
119
161
 
@@ -140,21 +182,21 @@ const format_plan_as_markdown = (plan: PublishingPlan): Array<string> => {
140
182
 
141
183
  // Version changes
142
184
  if (version_changes.length > 0) {
143
- const with_changesets = version_changes.filter(
144
- (vc) => vc.has_changesets && !vc.needs_bump_escalation
185
+ const with_changesets = version_changes.filter((vc) => version_change_kind(vc) === 'explicit');
186
+ const with_escalation = version_changes.filter(
187
+ (vc) => version_change_kind(vc) === 'escalation'
145
188
  );
146
- const with_escalation = version_changes.filter((vc) => vc.needs_bump_escalation);
147
- const with_auto_changesets = version_changes.filter((vc) => vc.will_generate_changeset);
189
+ const with_auto_changesets = version_changes.filter((vc) => version_change_kind(vc) === 'auto');
148
190
 
149
191
  if (with_changesets.length > 0) {
150
192
  lines.push('## Version Changes (from changesets)');
151
193
  lines.push('');
152
- lines.push('| Package | From | To | Bump | Major |');
153
- lines.push('|---------|------|----|------|-------|');
194
+ lines.push('| Package | From | To | Bump | Breaking |');
195
+ lines.push('|---------|------|----|------|----------|');
154
196
  for (const change of with_changesets) {
155
- const is_major = change.bump_type === 'major' ? '💥 Yes' : 'No';
197
+ const breaking = change.breaking ? '💥 Yes' : 'No';
156
198
  lines.push(
157
- `| \`${change.package_name}\` | ${change.from} | ${change.to} | ${change.bump_type} | ${is_major} |`
199
+ `| \`${change.package_name}\` | ${change.from} | ${change.to} | ${change.bump_type} | ${breaking} |`
158
200
  );
159
201
  }
160
202
  lines.push('');
@@ -163,12 +205,12 @@ const format_plan_as_markdown = (plan: PublishingPlan): Array<string> => {
163
205
  if (with_escalation.length > 0) {
164
206
  lines.push('## Version Changes (bump escalation required)');
165
207
  lines.push('');
166
- lines.push('| Package | From | To | Changesets Bump | Required Bump | Major |');
167
- lines.push('|---------|------|-----|-----------------|---------------|-------|');
208
+ lines.push('| Package | From | To | Changesets Bump | Required Bump | Breaking |');
209
+ lines.push('|---------|------|-----|-----------------|---------------|----------|');
168
210
  for (const change of with_escalation) {
169
- const is_major = change.bump_type === 'major' ? '💥 Yes' : 'No';
211
+ const breaking = change.breaking ? '💥 Yes' : 'No';
170
212
  lines.push(
171
- `| \`${change.package_name}\` | ${change.from} | ${change.to} | ${change.existing_bump} | ${change.required_bump} | ${is_major} |`
213
+ `| \`${change.package_name}\` | ${change.from} | ${change.to} | ${change.existing_bump} | ${change.required_bump} | ${breaking} |`
172
214
  );
173
215
  }
174
216
  lines.push('');
@@ -181,12 +223,12 @@ const format_plan_as_markdown = (plan: PublishingPlan): Array<string> => {
181
223
  if (with_auto_changesets.length > 0) {
182
224
  lines.push('## Version Changes (auto-generated for dependency updates)');
183
225
  lines.push('');
184
- lines.push('| Package | From | To | Bump | Major |');
185
- lines.push('|---------|------|-----|------|-------|');
226
+ lines.push('| Package | From | To | Bump | Breaking |');
227
+ lines.push('|---------|------|-----|------|----------|');
186
228
  for (const change of with_auto_changesets) {
187
- const is_major = change.bump_type === 'major' ? '💥 Yes' : 'No';
229
+ const breaking = change.breaking ? '💥 Yes' : 'No';
188
230
  lines.push(
189
- `| \`${change.package_name}\` | ${change.from} | ${change.to} | ${change.bump_type} | ${is_major} |`
231
+ `| \`${change.package_name}\` | ${change.from} | ${change.to} | ${change.bump_type} | ${breaking} |`
190
232
  );
191
233
  }
192
234
  lines.push('');
@@ -255,25 +297,35 @@ const format_plan_as_markdown = (plan: PublishingPlan): Array<string> => {
255
297
  lines.push('');
256
298
  }
257
299
 
258
- // Info (packages with no changes - normal status)
300
+ // Info (normal status, not warnings)
259
301
  if (info.length > 0) {
260
- lines.push('## ℹ️ No Changes to Publish');
302
+ lines.push('## ℹ️ Info');
303
+ lines.push('');
304
+ for (const line of info) {
305
+ lines.push(`- ${line}`);
306
+ }
307
+ lines.push('');
308
+ }
309
+
310
+ // Packages with nothing to publish (normal status)
311
+ if (no_changes.length > 0) {
312
+ lines.push('## No Changes to Publish');
261
313
  lines.push('');
262
- lines.push('*These packages have no changesets and no dependency updates:*');
314
+ lines.push('*These packages have no changesets and nothing to publish:*');
263
315
  lines.push('');
264
- for (const pkg of info) {
316
+ for (const pkg of no_changes) {
265
317
  lines.push(`- \`${pkg}\``);
266
318
  }
267
319
  lines.push('');
268
320
  }
269
321
 
270
322
  // Summary
271
- const major_bump_count = version_changes.filter((vc) => vc.bump_type === 'major').length;
323
+ const breaking_count = version_changes.filter((vc) => vc.breaking).length;
272
324
  lines.push('## Summary');
273
325
  lines.push('');
274
326
  lines.push(`- **Packages to publish**: ${version_changes.length}`);
275
327
  lines.push(`- **Dependency updates**: ${dependency_updates.length}`);
276
- lines.push(`- **Major version bumps**: ${major_bump_count}`);
328
+ lines.push(`- **Breaking changes**: ${breaking_count}`);
277
329
  lines.push(`- **Warnings**: ${warnings.length}`);
278
330
  lines.push(`- **Errors**: ${errors.length}`);
279
331