@samaksh/git-worker 0.0.0-stage → 0.1.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 (115) hide show
  1. package/GIT_WORKER_PROJECT_PLAN.md +499 -0
  2. package/README.md +114 -2
  3. package/demo/README.md +58 -0
  4. package/demo/index.ts +526 -0
  5. package/dist/app.d.ts +2 -0
  6. package/dist/app.js +48 -0
  7. package/dist/cli/commands.d.ts +6 -0
  8. package/dist/cli/commands.js +111 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +115 -0
  11. package/dist/config/defaults.d.ts +2 -0
  12. package/dist/config/defaults.js +44 -0
  13. package/dist/config/load.d.ts +3 -0
  14. package/dist/config/load.js +65 -0
  15. package/dist/config/schema.d.ts +200 -0
  16. package/dist/config/schema.js +45 -0
  17. package/dist/core/errors.d.ts +12 -0
  18. package/dist/core/errors.js +34 -0
  19. package/dist/core/pipeline.d.ts +3 -0
  20. package/dist/core/pipeline.js +14 -0
  21. package/dist/core/types.d.ts +153 -0
  22. package/dist/core/types.js +16 -0
  23. package/dist/services/editor/index.d.ts +9 -0
  24. package/dist/services/editor/index.js +59 -0
  25. package/dist/services/git/ops.d.ts +30 -0
  26. package/dist/services/git/ops.js +139 -0
  27. package/dist/services/git/reader.d.ts +14 -0
  28. package/dist/services/git/reader.js +124 -0
  29. package/dist/services/report/index.d.ts +18 -0
  30. package/dist/services/report/index.js +68 -0
  31. package/dist/services/review/index.d.ts +30 -0
  32. package/dist/services/review/index.js +59 -0
  33. package/dist/services/state/index.d.ts +20 -0
  34. package/dist/services/state/index.js +49 -0
  35. package/dist/services/template/env.d.ts +23 -0
  36. package/dist/services/template/env.js +146 -0
  37. package/dist/services/template/interpolator.d.ts +30 -0
  38. package/dist/services/template/interpolator.js +75 -0
  39. package/dist/services/template/transform.d.ts +22 -0
  40. package/dist/services/template/transform.js +75 -0
  41. package/dist/services/template/validator.d.ts +5 -0
  42. package/dist/services/template/validator.js +68 -0
  43. package/dist/services/workspace/cache.d.ts +32 -0
  44. package/dist/services/workspace/cache.js +107 -0
  45. package/dist/services/workspace/manager.d.ts +44 -0
  46. package/dist/services/workspace/manager.js +301 -0
  47. package/dist/stages/apply.d.ts +3 -0
  48. package/dist/stages/apply.js +161 -0
  49. package/dist/stages/edit-template.d.ts +3 -0
  50. package/dist/stages/edit-template.js +198 -0
  51. package/dist/stages/eligibility.d.ts +3 -0
  52. package/dist/stages/eligibility.js +44 -0
  53. package/dist/stages/load-template.d.ts +3 -0
  54. package/dist/stages/load-template.js +168 -0
  55. package/dist/stages/plan.d.ts +3 -0
  56. package/dist/stages/plan.js +270 -0
  57. package/dist/stages/push.d.ts +3 -0
  58. package/dist/stages/push.js +99 -0
  59. package/dist/stages/report.d.ts +3 -0
  60. package/dist/stages/report.js +103 -0
  61. package/dist/stages/resolve-project.d.ts +3 -0
  62. package/dist/stages/resolve-project.js +294 -0
  63. package/dist/stages/review.d.ts +3 -0
  64. package/dist/stages/review.js +197 -0
  65. package/dist/stages/select.d.ts +13 -0
  66. package/dist/stages/select.js +250 -0
  67. package/dist/stages/sync.d.ts +3 -0
  68. package/dist/stages/sync.js +47 -0
  69. package/dist/utils/hash.d.ts +2 -0
  70. package/dist/utils/hash.js +7 -0
  71. package/dist/utils/ui.d.ts +21 -0
  72. package/dist/utils/ui.js +113 -0
  73. package/dist/utils/which.d.ts +5 -0
  74. package/dist/utils/which.js +16 -0
  75. package/docs/FEATURES.md +84 -0
  76. package/docs/SERVICES.md +57 -0
  77. package/docs/architecture.md +38 -0
  78. package/docs/usage.md +337 -0
  79. package/package.json +48 -4
  80. package/src/app.ts +69 -0
  81. package/src/cli/commands.ts +142 -0
  82. package/src/cli/index.ts +123 -0
  83. package/src/config/defaults.ts +46 -0
  84. package/src/config/load.ts +70 -0
  85. package/src/config/schema.ts +48 -0
  86. package/src/core/errors.ts +45 -0
  87. package/src/core/pipeline.ts +17 -0
  88. package/src/core/types.ts +188 -0
  89. package/src/services/editor/index.ts +85 -0
  90. package/src/services/git/ops.ts +170 -0
  91. package/src/services/git/reader.ts +148 -0
  92. package/src/services/report/index.ts +104 -0
  93. package/src/services/review/index.ts +80 -0
  94. package/src/services/state/index.ts +77 -0
  95. package/src/services/template/env.ts +169 -0
  96. package/src/services/template/interpolator.ts +109 -0
  97. package/src/services/template/transform.ts +97 -0
  98. package/src/services/template/validator.ts +75 -0
  99. package/src/services/workspace/cache.ts +144 -0
  100. package/src/services/workspace/manager.ts +370 -0
  101. package/src/stages/apply.ts +194 -0
  102. package/src/stages/edit-template.ts +227 -0
  103. package/src/stages/eligibility.ts +67 -0
  104. package/src/stages/load-template.ts +188 -0
  105. package/src/stages/plan.ts +362 -0
  106. package/src/stages/push.ts +120 -0
  107. package/src/stages/report.ts +126 -0
  108. package/src/stages/resolve-project.ts +341 -0
  109. package/src/stages/review.ts +228 -0
  110. package/src/stages/select.ts +302 -0
  111. package/src/stages/sync.ts +63 -0
  112. package/src/utils/hash.ts +15 -0
  113. package/src/utils/ui.ts +137 -0
  114. package/src/utils/which.ts +18 -0
  115. package/tsconfig.json +18 -0
@@ -0,0 +1,362 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps, BranchPlan, ok, err } from "../core/types.js";
3
+ import { workspaceError } from "../core/errors.js";
4
+ import { parseEnvFile } from "../services/template/env.js";
5
+ import { transformEnv } from "../services/template/transform.js";
6
+ import { interpolateTemplate } from "../services/template/interpolator.js";
7
+ import { sha256, planHash as computePlanHash } from "../utils/hash.js";
8
+ import { computeDiff } from "../services/review/index.js";
9
+ import { clack, sectionHeader, statusLine, sym } from "../utils/ui.js";
10
+ import chalk from "chalk";
11
+
12
+ export const planChangesStage: Stage<RunContext, AppDeps> = async (context, deps) => {
13
+ if (
14
+ (!context.template && !context.activeTemplate) ||
15
+ !context.eligible ||
16
+ !context.target ||
17
+ !context.project
18
+ ) {
19
+ return err(workspaceError("Prerequisites missing for planning"));
20
+ }
21
+
22
+ sectionHeader("Planning Changes");
23
+
24
+ const plans: BranchPlan[] = [];
25
+ const isCi = context.input.ci ?? false;
26
+ const targetPath = context.target.path;
27
+ const isEnv = targetPath === ".env" || targetPath.startsWith(".env.");
28
+ const engine = context.activeTemplate?.engine ?? (isEnv ? "env-reconcile" : "source-of-truth");
29
+ const templateRaw = context.activeTemplate?.raw ?? context.template?.raw ?? "";
30
+
31
+ for (const branch of context.eligible) {
32
+ const s = clack.spinner();
33
+ s.start(`Planning ${branch.name}…`);
34
+
35
+ // ─────────────────────────────────────────────────────────────────
36
+ // 1. SOURCE-OF-TRUTH ENGINE (for Dockerfile, JSON, YAML, scripts)
37
+ // ─────────────────────────────────────────────────────────────────
38
+ if (engine === "source-of-truth") {
39
+ // A. Read the branch's .env to extract variables
40
+ const envRead = await deps.services.git.readFileFromBranch(
41
+ context.project.managedRepoPath,
42
+ branch,
43
+ ".env"
44
+ );
45
+
46
+ const envMap = new Map<string, string>();
47
+ if (envRead.ok) {
48
+ const parsedEnv = parseEnvFile(envRead.value);
49
+ if (parsedEnv.ok) {
50
+ for (const [key, entry] of parsedEnv.value.map) {
51
+ envMap.set(key, entry.value);
52
+ }
53
+ }
54
+ }
55
+
56
+ // B. Read existing target file from branch (if present)
57
+ const targetRead = await deps.services.git.readFileFromBranch(
58
+ context.project.managedRepoPath,
59
+ branch,
60
+ targetPath
61
+ );
62
+ const isNewFile = !targetRead.ok;
63
+ const beforeContent = targetRead.ok ? targetRead.value : "";
64
+ const beforeContentHash = sha256(beforeContent);
65
+
66
+ // C. Interpolate template with branch's .env vars and built-ins
67
+ const interpolated = interpolateTemplate(templateRaw, envMap, {
68
+ branchName: branch.name,
69
+ repoName: context.project.id,
70
+ commitSha: branch.sha,
71
+ });
72
+
73
+ if (!interpolated.ok) {
74
+ s.stop(chalk.red(`${branch.name} — interpolation error`));
75
+ plans.push({
76
+ branch,
77
+ baseCommitSha: branch.sha,
78
+ path: targetPath,
79
+ beforeContent,
80
+ beforeContentHash,
81
+ afterContent: beforeContent,
82
+ afterContentHash: beforeContentHash,
83
+ diff: "",
84
+ planHash: computePlanHash(
85
+ branch.name,
86
+ branch.sha,
87
+ targetPath,
88
+ beforeContentHash,
89
+ beforeContentHash
90
+ ),
91
+ status: "error",
92
+ reason: interpolated.error.message,
93
+ addedKeys: [],
94
+ updatedKeys: [],
95
+ isNewFile,
96
+ });
97
+ continue;
98
+ }
99
+
100
+ const { content: afterContent, missingVars } = interpolated.value;
101
+
102
+ // D. Handle missing variables without fallback
103
+ if (missingVars.length > 0) {
104
+ const errorReason = `Missing required variable(s) from .env: ${missingVars.join(", ")}`;
105
+ s.stop(chalk.red(`${branch.name} — ${errorReason}`));
106
+ plans.push({
107
+ branch,
108
+ baseCommitSha: branch.sha,
109
+ path: targetPath,
110
+ beforeContent,
111
+ beforeContentHash,
112
+ afterContent: beforeContent,
113
+ afterContentHash: beforeContentHash,
114
+ diff: "",
115
+ planHash: computePlanHash(
116
+ branch.name,
117
+ branch.sha,
118
+ targetPath,
119
+ beforeContentHash,
120
+ beforeContentHash
121
+ ),
122
+ status: "error",
123
+ reason: errorReason,
124
+ addedKeys: [],
125
+ updatedKeys: [],
126
+ missingVars,
127
+ isNewFile,
128
+ });
129
+ continue;
130
+ }
131
+
132
+ // E. Diff & Plan Hash
133
+ const afterContentHash = sha256(afterContent);
134
+ const changed = afterContent !== beforeContent;
135
+ const diff = computeDiff(beforeContent, afterContent, targetPath);
136
+ const hash = computePlanHash(
137
+ branch.name,
138
+ branch.sha,
139
+ targetPath,
140
+ beforeContentHash,
141
+ afterContentHash
142
+ );
143
+
144
+ if (changed) {
145
+ s.stop(
146
+ chalk.green(
147
+ `${branch.name} — ${isNewFile ? "new file created" : "file updated"}`
148
+ )
149
+ );
150
+ } else {
151
+ s.stop(chalk.gray(`${branch.name} — no changes`));
152
+ }
153
+
154
+ plans.push({
155
+ branch,
156
+ baseCommitSha: branch.sha,
157
+ path: targetPath,
158
+ beforeContent,
159
+ beforeContentHash,
160
+ afterContent,
161
+ afterContentHash,
162
+ diff,
163
+ planHash: hash,
164
+ status: changed ? "changed" : "unchanged",
165
+ addedKeys: isNewFile ? [targetPath] : [],
166
+ updatedKeys: !isNewFile && changed ? [targetPath] : [],
167
+ isNewFile,
168
+ });
169
+ continue;
170
+ }
171
+
172
+ // ─────────────────────────────────────────────────────────────────
173
+ // 2. ENV RECONCILE ENGINE (for .env files)
174
+ // ─────────────────────────────────────────────────────────────────
175
+ const readResult = await deps.services.git.readFileFromBranch(
176
+ context.project.managedRepoPath,
177
+ branch,
178
+ targetPath
179
+ );
180
+
181
+ const beforeContent = readResult.ok ? readResult.value : "";
182
+ const beforeContentHash = sha256(beforeContent);
183
+
184
+ // Parse existing file to find which keys exist
185
+ const existingParse = parseEnvFile(beforeContent);
186
+ if (!existingParse.ok) {
187
+ s.stop(chalk.red(`${branch.name} — file parse error`));
188
+ plans.push({
189
+ branch,
190
+ baseCommitSha: branch.sha,
191
+ path: targetPath,
192
+ beforeContent,
193
+ beforeContentHash,
194
+ afterContent: beforeContent,
195
+ afterContentHash: beforeContentHash,
196
+ diff: "",
197
+ planHash: computePlanHash(
198
+ branch.name,
199
+ branch.sha,
200
+ targetPath,
201
+ beforeContentHash,
202
+ beforeContentHash
203
+ ),
204
+ status: "error",
205
+ reason: existingParse.error.message,
206
+ addedKeys: [],
207
+ updatedKeys: [],
208
+ });
209
+ continue;
210
+ }
211
+
212
+ const existingMap = existingParse.value.map;
213
+ const branchValues = new Map<string, string>();
214
+ let hadError = false;
215
+ let errorReason = "";
216
+
217
+ const envTemplate = context.activeTemplate?.parsedEnv ?? context.template;
218
+
219
+ if (envTemplate) {
220
+ for (const assignment of envTemplate.assignments) {
221
+ if (!assignment.isPlaceholder || !assignment.placeholderName) continue;
222
+
223
+ if (existingMap.has(assignment.key)) {
224
+ branchValues.set(assignment.key, existingMap.get(assignment.key)!.value);
225
+ } else {
226
+ if (isCi) {
227
+ hadError = true;
228
+ errorReason = `Missing value for '${assignment.key}' in CI mode`;
229
+ break;
230
+ }
231
+
232
+ s.stop(`${branch.name} — needs input`);
233
+ console.log("");
234
+ console.log(chalk.bold(` ${sym.branch} Branch: ${chalk.cyan(branch.name)}`));
235
+ console.log(chalk.yellow(` Missing key: ${chalk.bold(assignment.key)}`));
236
+
237
+ const value = await clack.text({
238
+ message: `Value for ${chalk.bold(assignment.key)} in ${chalk.cyan(branch.name)}:`,
239
+ placeholder: `enter value for ${assignment.key}`,
240
+ validate: (v: string | undefined) => {
241
+ if (!v || v.trim() === "") return `Value cannot be empty`;
242
+ },
243
+ });
244
+
245
+ if (clack.isCancel(value)) {
246
+ hadError = true;
247
+ errorReason = "User cancelled value input";
248
+ s.stop(chalk.yellow(`${branch.name} — skipped`));
249
+ break;
250
+ }
251
+
252
+ branchValues.set(assignment.key, value as string);
253
+ s.start(`Planning ${branch.name}…`);
254
+ }
255
+ }
256
+ }
257
+
258
+ if (hadError) {
259
+ s.stop(chalk.red(`${branch.name} — ${errorReason}`));
260
+ plans.push({
261
+ branch,
262
+ baseCommitSha: branch.sha,
263
+ path: targetPath,
264
+ beforeContent,
265
+ beforeContentHash,
266
+ afterContent: beforeContent,
267
+ afterContentHash: beforeContentHash,
268
+ diff: "",
269
+ planHash: computePlanHash(
270
+ branch.name,
271
+ branch.sha,
272
+ targetPath,
273
+ beforeContentHash,
274
+ beforeContentHash
275
+ ),
276
+ status: "error",
277
+ reason: errorReason,
278
+ addedKeys: [],
279
+ updatedKeys: [],
280
+ });
281
+ continue;
282
+ }
283
+
284
+ // Transform
285
+ const transformResult = transformEnv(beforeContent, envTemplate!, branchValues);
286
+ if (!transformResult.ok) {
287
+ s.stop(chalk.red(`${branch.name} — transform error`));
288
+ plans.push({
289
+ branch,
290
+ baseCommitSha: branch.sha,
291
+ path: targetPath,
292
+ beforeContent,
293
+ beforeContentHash,
294
+ afterContent: beforeContent,
295
+ afterContentHash: beforeContentHash,
296
+ diff: "",
297
+ planHash: computePlanHash(
298
+ branch.name,
299
+ branch.sha,
300
+ targetPath,
301
+ beforeContentHash,
302
+ beforeContentHash
303
+ ),
304
+ status: "error",
305
+ reason: transformResult.error.message,
306
+ addedKeys: [],
307
+ updatedKeys: [],
308
+ });
309
+ continue;
310
+ }
311
+
312
+ const { content: afterContent, changed, addedKeys, updatedKeys } = transformResult.value;
313
+ const afterContentHash = sha256(afterContent);
314
+ const diff = computeDiff(beforeContent, afterContent, targetPath);
315
+ const hash = computePlanHash(
316
+ branch.name,
317
+ branch.sha,
318
+ targetPath,
319
+ beforeContentHash,
320
+ afterContentHash
321
+ );
322
+
323
+ if (changed) {
324
+ const summary = [
325
+ updatedKeys.length > 0 ? `${updatedKeys.length} updated` : "",
326
+ addedKeys.length > 0 ? `${addedKeys.length} added` : "",
327
+ ]
328
+ .filter(Boolean)
329
+ .join(", ");
330
+ s.stop(chalk.green(`${branch.name} — ${summary || "changed"}`));
331
+ } else {
332
+ s.stop(chalk.gray(`${branch.name} — no changes`));
333
+ }
334
+
335
+ plans.push({
336
+ branch,
337
+ baseCommitSha: branch.sha,
338
+ path: targetPath,
339
+ beforeContent,
340
+ beforeContentHash,
341
+ afterContent,
342
+ afterContentHash,
343
+ diff,
344
+ planHash: hash,
345
+ status: changed ? "changed" : "unchanged",
346
+ addedKeys,
347
+ updatedKeys,
348
+ });
349
+ }
350
+
351
+ const changed = plans.filter((p) => p.status === "changed").length;
352
+ const unchanged = plans.filter((p) => p.status === "unchanged").length;
353
+ const errors = plans.filter((p) => p.status === "error").length;
354
+
355
+ console.log("");
356
+ statusLine(
357
+ "ok",
358
+ `Planning complete — ${changed} changed, ${unchanged} unchanged, ${errors} error(s)`
359
+ );
360
+
361
+ return ok({ ...context, plans });
362
+ };
@@ -0,0 +1,120 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps, PushResult, ok, err } from "../core/types.js";
3
+ import { workspaceError, inputCancelledError } from "../core/errors.js";
4
+ import { clack, sectionHeader, statusLine, sym } from "../utils/ui.js";
5
+ import chalk from "chalk";
6
+
7
+ export const pushChangesStage: Stage<RunContext, AppDeps> = async (context, deps) => {
8
+ if (!context.results || !context.project) {
9
+ return err(workspaceError("Prerequisites missing for push"));
10
+ }
11
+
12
+ const pushPolicy = context.input.push || deps.config.git.pushPolicy;
13
+ const remote = context.input.remote || deps.config.git.remote;
14
+ const committedBranches = context.results.filter((r) => r.status === "committed");
15
+
16
+ const pushResults: PushResult[] = [];
17
+
18
+ // Skipped non-committed branches
19
+ for (const result of context.results) {
20
+ if (result.status !== "committed") {
21
+ pushResults.push({ branch: result.branch, status: "skipped", reason: "Not committed" });
22
+ }
23
+ }
24
+
25
+ if (committedBranches.length === 0) {
26
+ return ok({ ...context, pushResults });
27
+ }
28
+
29
+ sectionHeader("Push");
30
+
31
+ if (pushPolicy === "never") {
32
+ for (const r of committedBranches) {
33
+ pushResults.push({ branch: r.branch, status: "skipped", reason: "Push policy: never" });
34
+ statusLine("skip", r.branch.name, "push skipped (policy: never)");
35
+ }
36
+ return ok({ ...context, pushResults });
37
+ }
38
+
39
+ if (pushPolicy === "prompt" && context.input.ci) {
40
+ for (const r of committedBranches) {
41
+ pushResults.push({
42
+ branch: r.branch,
43
+ status: "skipped",
44
+ reason: "Cannot prompt in CI mode (use --push always to push in CI)",
45
+ });
46
+ statusLine("skip", r.branch.name, "push skipped (CI mode without --push always)");
47
+ }
48
+ return ok({ ...context, pushResults });
49
+ }
50
+
51
+ if (pushPolicy === "prompt" && !context.input.ci) {
52
+ // Ask per branch or batch
53
+ const batchChoice = await clack.select<string>({
54
+ message: `Push ${committedBranches.length} committed branch(es) to '${remote}'?`,
55
+ options: [
56
+ { value: "all", label: `Push all ${committedBranches.length} branches`, hint: "recommended" },
57
+ { value: "per-branch", label: "Choose per branch" },
58
+ { value: "none", label: "Skip push (commit only)" },
59
+ ],
60
+ });
61
+
62
+ if (clack.isCancel(batchChoice) || batchChoice === "none") {
63
+ for (const r of committedBranches) {
64
+ pushResults.push({ branch: r.branch, status: "skipped", reason: "User declined" });
65
+ }
66
+ statusLine("skip", "Push skipped");
67
+ return ok({ ...context, pushResults });
68
+ }
69
+
70
+ if (batchChoice === "per-branch") {
71
+ for (const result of committedBranches) {
72
+ const confirm = await clack.confirm({
73
+ message: `Push ${result.branch.name} → ${remote}?`,
74
+ initialValue: true,
75
+ });
76
+ if (clack.isCancel(confirm) || !confirm) {
77
+ pushResults.push({ branch: result.branch, status: "skipped", reason: "User declined" });
78
+ statusLine("skip", result.branch.name, "push skipped");
79
+ continue;
80
+ }
81
+
82
+ const s = clack.spinner();
83
+ s.start(`Pushing ${result.branch.name} → ${remote}…`);
84
+ const pushResult = await deps.services.gitOps.push(
85
+ context.project.managedRepoPath,
86
+ remote,
87
+ result.branch.name
88
+ );
89
+ if (!pushResult.ok) {
90
+ s.stop(chalk.red(`${result.branch.name} — push failed`));
91
+ pushResults.push({ branch: result.branch, status: "failed", reason: pushResult.error.message });
92
+ } else {
93
+ s.stop(chalk.green(`${result.branch.name} — pushed`));
94
+ pushResults.push({ branch: result.branch, status: "pushed" });
95
+ }
96
+ }
97
+ return ok({ ...context, pushResults });
98
+ }
99
+ }
100
+
101
+ // Push all (policy === "always" or batchChoice === "all")
102
+ for (const result of committedBranches) {
103
+ const s = clack.spinner();
104
+ s.start(`Pushing ${result.branch.name} → ${remote}…`);
105
+ const pushResult = await deps.services.gitOps.push(
106
+ context.project.managedRepoPath,
107
+ remote,
108
+ result.branch.name
109
+ );
110
+ if (!pushResult.ok) {
111
+ s.stop(chalk.red(`${result.branch.name} — push failed`));
112
+ pushResults.push({ branch: result.branch, status: "failed", reason: pushResult.error.message });
113
+ } else {
114
+ s.stop(chalk.green(`${result.branch.name} — pushed`));
115
+ pushResults.push({ branch: result.branch, status: "pushed" });
116
+ }
117
+ }
118
+
119
+ return ok({ ...context, pushResults });
120
+ };
@@ -0,0 +1,126 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps, ok } from "../core/types.js";
3
+ import { sectionHeader, statusLine, divider, sym, renderBranchTable } from "../utils/ui.js";
4
+ import { saveJson, saveMarkdown, RunReport } from "../services/report/index.js";
5
+ import chalk from "chalk";
6
+
7
+ export const reportResultsStage: Stage<RunContext, AppDeps> = async (context, deps) => {
8
+ sectionHeader("Run Report");
9
+
10
+ const results = context.results ?? [];
11
+ const pushResults = context.pushResults ?? [];
12
+
13
+ // Per-branch status
14
+ renderBranchTable(
15
+ results.map((r) => ({
16
+ name: r.branch.name,
17
+ status: r.status,
18
+ detail: r.commitSha
19
+ ? `commit ${r.commitSha.slice(0, 8)}`
20
+ : r.reason ?? "",
21
+ }))
22
+ );
23
+
24
+ // Push results (only non-skipped)
25
+ const activePushResults = pushResults.filter((p) => p.status !== "skipped");
26
+ if (activePushResults.length > 0) {
27
+ console.log("");
28
+ console.log(chalk.gray(" Push:"));
29
+ for (const pr of activePushResults) {
30
+ if (pr.status === "pushed") {
31
+ statusLine("ok", pr.branch.name, "pushed");
32
+ } else {
33
+ statusLine("fail", pr.branch.name, pr.reason ?? "push failed");
34
+ }
35
+ }
36
+ }
37
+
38
+ // Aggregate summary
39
+ const committed = results.filter((r) => r.status === "committed").length;
40
+ const failed = results.filter((r) => r.status === "failed").length;
41
+ const rejected = results.filter((r) => r.status === "rejected").length;
42
+ const unchanged = results.filter((r) => r.status === "unchanged").length;
43
+ const skipped = results.filter((r) => r.status === "skipped").length;
44
+ const pushed = pushResults.filter((p) => p.status === "pushed").length;
45
+
46
+ divider();
47
+ console.log("");
48
+ console.log(
49
+ ` ${chalk.bold("Total:")} ` +
50
+ [
51
+ committed > 0 ? chalk.green(`${committed} committed`) : "",
52
+ pushed > 0 ? chalk.green(`${pushed} pushed`) : "",
53
+ failed > 0 ? chalk.red(`${failed} failed`) : "",
54
+ rejected > 0 ? chalk.yellow(`${rejected} rejected`) : "",
55
+ unchanged > 0 ? chalk.gray(`${unchanged} unchanged`) : "",
56
+ skipped > 0 ? chalk.gray(`${skipped} skipped`) : "",
57
+ ].filter(Boolean).join(" ")
58
+ );
59
+ console.log("");
60
+
61
+ // Save template state if any commits succeeded
62
+ const rawTemplate = context.activeTemplate?.raw ?? context.template?.raw;
63
+ if (committed > 0 && context.project && context.target && rawTemplate) {
64
+ const summary = {
65
+ committed,
66
+ failed,
67
+ skipped: skipped + rejected,
68
+ total: results.length,
69
+ };
70
+ const saveResult = await deps.services.state.saveTemplate(
71
+ context.project.stateDir,
72
+ context.target.path,
73
+ rawTemplate,
74
+ summary
75
+ );
76
+ if (saveResult.ok) {
77
+ statusLine("ok", `Template saved for '${context.target.path}' (ready for next run)`);
78
+ } else {
79
+ statusLine("warn", "Could not save template state", saveResult.error.message);
80
+ }
81
+ } else if (committed === 0 && context.project && context.target && rawTemplate) {
82
+ // No commits but everything matches — offer to save reviewed template
83
+ const { clack } = await import("../utils/ui.js");
84
+ if (!context.input.ci) {
85
+ const save = await clack.confirm({
86
+ message: "No changes committed. Save current template for next run?",
87
+ initialValue: true,
88
+ });
89
+ if (!clack.isCancel(save) && save) {
90
+ await deps.services.state.saveTemplate(
91
+ context.project.stateDir,
92
+ context.target.path,
93
+ rawTemplate
94
+ );
95
+ statusLine("ok", `Template saved for '${context.target.path}'`);
96
+ }
97
+ }
98
+ }
99
+
100
+ // Write JSON/Markdown reports if configured
101
+ if (context.project && (deps.config.report.writeJson || deps.config.report.writeMarkdown)) {
102
+ const report: RunReport = {
103
+ runId: context.runId,
104
+ targetPath: context.target?.path ?? "",
105
+ startedAt: new Date().toISOString(),
106
+ completedAt: new Date().toISOString(),
107
+ plans: context.plans ?? [],
108
+ results,
109
+ pushResults,
110
+ };
111
+
112
+ await saveJson(context.project.reportsDir, report, deps.config).catch(() => {});
113
+ await saveMarkdown(context.project.reportsDir, report, deps.config).catch(() => {});
114
+
115
+ if (deps.config.report.writeJson) {
116
+ statusLine("info", `JSON report: ${context.project.reportsDir}/${context.runId}.json`);
117
+ }
118
+ if (deps.config.report.writeMarkdown) {
119
+ statusLine("info", `Markdown report: ${context.project.reportsDir}/${context.runId}.md`);
120
+ }
121
+ }
122
+
123
+ console.log("");
124
+
125
+ return ok(context);
126
+ };