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