@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.
- package/GIT_WORKER_PROJECT_PLAN.md +499 -0
- package/README.md +114 -2
- package/demo/README.md +58 -0
- package/demo/index.ts +526 -0
- package/dist/app.d.ts +2 -0
- package/dist/app.js +48 -0
- package/dist/cli/commands.d.ts +6 -0
- package/dist/cli/commands.js +111 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +115 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +44 -0
- package/dist/config/load.d.ts +3 -0
- package/dist/config/load.js +65 -0
- package/dist/config/schema.d.ts +200 -0
- package/dist/config/schema.js +45 -0
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/pipeline.d.ts +3 -0
- package/dist/core/pipeline.js +14 -0
- package/dist/core/types.d.ts +153 -0
- package/dist/core/types.js +16 -0
- package/dist/services/editor/index.d.ts +9 -0
- package/dist/services/editor/index.js +59 -0
- package/dist/services/git/ops.d.ts +30 -0
- package/dist/services/git/ops.js +139 -0
- package/dist/services/git/reader.d.ts +14 -0
- package/dist/services/git/reader.js +124 -0
- package/dist/services/report/index.d.ts +18 -0
- package/dist/services/report/index.js +68 -0
- package/dist/services/review/index.d.ts +30 -0
- package/dist/services/review/index.js +59 -0
- package/dist/services/state/index.d.ts +20 -0
- package/dist/services/state/index.js +49 -0
- package/dist/services/template/env.d.ts +23 -0
- package/dist/services/template/env.js +146 -0
- package/dist/services/template/interpolator.d.ts +30 -0
- package/dist/services/template/interpolator.js +75 -0
- package/dist/services/template/transform.d.ts +22 -0
- package/dist/services/template/transform.js +75 -0
- package/dist/services/template/validator.d.ts +5 -0
- package/dist/services/template/validator.js +68 -0
- package/dist/services/workspace/cache.d.ts +32 -0
- package/dist/services/workspace/cache.js +107 -0
- package/dist/services/workspace/manager.d.ts +44 -0
- package/dist/services/workspace/manager.js +301 -0
- package/dist/stages/apply.d.ts +3 -0
- package/dist/stages/apply.js +161 -0
- package/dist/stages/edit-template.d.ts +3 -0
- package/dist/stages/edit-template.js +198 -0
- package/dist/stages/eligibility.d.ts +3 -0
- package/dist/stages/eligibility.js +44 -0
- package/dist/stages/load-template.d.ts +3 -0
- package/dist/stages/load-template.js +168 -0
- package/dist/stages/plan.d.ts +3 -0
- package/dist/stages/plan.js +270 -0
- package/dist/stages/push.d.ts +3 -0
- package/dist/stages/push.js +99 -0
- package/dist/stages/report.d.ts +3 -0
- package/dist/stages/report.js +103 -0
- package/dist/stages/resolve-project.d.ts +3 -0
- package/dist/stages/resolve-project.js +294 -0
- package/dist/stages/review.d.ts +3 -0
- package/dist/stages/review.js +197 -0
- package/dist/stages/select.d.ts +13 -0
- package/dist/stages/select.js +250 -0
- package/dist/stages/sync.d.ts +3 -0
- package/dist/stages/sync.js +47 -0
- package/dist/utils/hash.d.ts +2 -0
- package/dist/utils/hash.js +7 -0
- package/dist/utils/ui.d.ts +21 -0
- package/dist/utils/ui.js +113 -0
- package/dist/utils/which.d.ts +5 -0
- package/dist/utils/which.js +16 -0
- package/docs/FEATURES.md +84 -0
- package/docs/SERVICES.md +57 -0
- package/docs/architecture.md +38 -0
- package/docs/usage.md +337 -0
- package/package.json +48 -4
- package/src/app.ts +69 -0
- package/src/cli/commands.ts +142 -0
- package/src/cli/index.ts +123 -0
- package/src/config/defaults.ts +46 -0
- package/src/config/load.ts +70 -0
- package/src/config/schema.ts +48 -0
- package/src/core/errors.ts +45 -0
- package/src/core/pipeline.ts +17 -0
- package/src/core/types.ts +188 -0
- package/src/services/editor/index.ts +85 -0
- package/src/services/git/ops.ts +170 -0
- package/src/services/git/reader.ts +148 -0
- package/src/services/report/index.ts +104 -0
- package/src/services/review/index.ts +80 -0
- package/src/services/state/index.ts +77 -0
- package/src/services/template/env.ts +169 -0
- package/src/services/template/interpolator.ts +109 -0
- package/src/services/template/transform.ts +97 -0
- package/src/services/template/validator.ts +75 -0
- package/src/services/workspace/cache.ts +144 -0
- package/src/services/workspace/manager.ts +370 -0
- package/src/stages/apply.ts +194 -0
- package/src/stages/edit-template.ts +227 -0
- package/src/stages/eligibility.ts +67 -0
- package/src/stages/load-template.ts +188 -0
- package/src/stages/plan.ts +362 -0
- package/src/stages/push.ts +120 -0
- package/src/stages/report.ts +126 -0
- package/src/stages/resolve-project.ts +341 -0
- package/src/stages/review.ts +228 -0
- package/src/stages/select.ts +302 -0
- package/src/stages/sync.ts +63 -0
- package/src/utils/hash.ts +15 -0
- package/src/utils/ui.ts +137 -0
- package/src/utils/which.ts +18 -0
- 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
|
+
};
|