@samaksh/git-worker 0.0.0-stage → 0.2.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 (78) hide show
  1. package/README.md +114 -2
  2. package/dist/app.d.ts +2 -0
  3. package/dist/app.js +48 -0
  4. package/dist/cli/commands.d.ts +6 -0
  5. package/dist/cli/commands.js +111 -0
  6. package/dist/cli/index.d.ts +2 -0
  7. package/dist/cli/index.js +115 -0
  8. package/dist/config/defaults.d.ts +2 -0
  9. package/dist/config/defaults.js +44 -0
  10. package/dist/config/load.d.ts +3 -0
  11. package/dist/config/load.js +65 -0
  12. package/dist/config/schema.d.ts +200 -0
  13. package/dist/config/schema.js +45 -0
  14. package/dist/core/errors.d.ts +12 -0
  15. package/dist/core/errors.js +34 -0
  16. package/dist/core/pipeline.d.ts +3 -0
  17. package/dist/core/pipeline.js +14 -0
  18. package/dist/core/types.d.ts +153 -0
  19. package/dist/core/types.js +16 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +3 -0
  22. package/dist/services/editor/index.d.ts +9 -0
  23. package/dist/services/editor/index.js +59 -0
  24. package/dist/services/git/ops.d.ts +30 -0
  25. package/dist/services/git/ops.js +139 -0
  26. package/dist/services/git/reader.d.ts +14 -0
  27. package/dist/services/git/reader.js +195 -0
  28. package/dist/services/report/index.d.ts +18 -0
  29. package/dist/services/report/index.js +68 -0
  30. package/dist/services/review/index.d.ts +30 -0
  31. package/dist/services/review/index.js +59 -0
  32. package/dist/services/state/index.d.ts +20 -0
  33. package/dist/services/state/index.js +49 -0
  34. package/dist/services/template/env.d.ts +23 -0
  35. package/dist/services/template/env.js +146 -0
  36. package/dist/services/template/interpolator.d.ts +30 -0
  37. package/dist/services/template/interpolator.js +75 -0
  38. package/dist/services/template/transform.d.ts +22 -0
  39. package/dist/services/template/transform.js +75 -0
  40. package/dist/services/template/validator.d.ts +5 -0
  41. package/dist/services/template/validator.js +68 -0
  42. package/dist/services/workspace/cache.d.ts +32 -0
  43. package/dist/services/workspace/cache.js +107 -0
  44. package/dist/services/workspace/manager.d.ts +44 -0
  45. package/dist/services/workspace/manager.js +301 -0
  46. package/dist/stages/apply.d.ts +3 -0
  47. package/dist/stages/apply.js +161 -0
  48. package/dist/stages/edit-template.d.ts +3 -0
  49. package/dist/stages/edit-template.js +198 -0
  50. package/dist/stages/eligibility.d.ts +3 -0
  51. package/dist/stages/eligibility.js +68 -0
  52. package/dist/stages/load-template.d.ts +3 -0
  53. package/dist/stages/load-template.js +168 -0
  54. package/dist/stages/plan.d.ts +3 -0
  55. package/dist/stages/plan.js +270 -0
  56. package/dist/stages/push.d.ts +3 -0
  57. package/dist/stages/push.js +99 -0
  58. package/dist/stages/report.d.ts +3 -0
  59. package/dist/stages/report.js +103 -0
  60. package/dist/stages/resolve-project.d.ts +3 -0
  61. package/dist/stages/resolve-project.js +294 -0
  62. package/dist/stages/review.d.ts +3 -0
  63. package/dist/stages/review.js +197 -0
  64. package/dist/stages/select.d.ts +13 -0
  65. package/dist/stages/select.js +265 -0
  66. package/dist/stages/sync.d.ts +3 -0
  67. package/dist/stages/sync.js +47 -0
  68. package/dist/utils/hash.d.ts +2 -0
  69. package/dist/utils/hash.js +7 -0
  70. package/dist/utils/ui.d.ts +21 -0
  71. package/dist/utils/ui.js +113 -0
  72. package/dist/utils/which.d.ts +5 -0
  73. package/dist/utils/which.js +16 -0
  74. package/docs/FEATURES.md +84 -0
  75. package/docs/SERVICES.md +57 -0
  76. package/docs/architecture.md +38 -0
  77. package/docs/usage.md +337 -0
  78. package/package.json +58 -4
@@ -0,0 +1,68 @@
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
+ export const checkEligibilityStage = async (context, deps) => {
5
+ if (!context.selectedBranches || !context.target || !context.project) {
6
+ return err(workspaceError("Prerequisites missing for eligibility check"));
7
+ }
8
+ sectionHeader("Eligibility Check");
9
+ const eligible = [];
10
+ const ineligible = [];
11
+ const isEnvTarget = context.target.path === ".env" || context.target.path.startsWith(".env.");
12
+ const total = context.selectedBranches.length;
13
+ const isBatchMode = total > 8;
14
+ const s = clack.spinner();
15
+ if (isBatchMode) {
16
+ s.start(`Checking eligibility for ${total} branches…`);
17
+ }
18
+ // Concurrent execution pool (up to 16 workers in parallel)
19
+ const concurrency = Math.min(16, total);
20
+ let completed = 0;
21
+ let currentIndex = 0;
22
+ const workers = Array.from({ length: concurrency }, async () => {
23
+ while (currentIndex < total) {
24
+ const idx = currentIndex++;
25
+ const branch = context.selectedBranches[idx];
26
+ // 1. Check if the target file exists in the branch
27
+ const targetRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, context.target.path);
28
+ const targetFound = targetRead.ok;
29
+ // 2. If target is a non-.env file, check if .env is available as variable source
30
+ let envNote = "";
31
+ if (!isEnvTarget) {
32
+ const envRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, ".env");
33
+ if (envRead.ok) {
34
+ envNote = " • .env present";
35
+ }
36
+ else {
37
+ envNote = " • .env missing (variables will require fallbacks)";
38
+ }
39
+ }
40
+ const fileStatus = targetFound ? "target file found" : "target file will be created";
41
+ eligible.push(branch);
42
+ completed++;
43
+ if (isBatchMode) {
44
+ s.message(`Checking eligibility (${completed}/${total})…`);
45
+ }
46
+ else {
47
+ statusLine(targetFound ? "ok" : "info", branch.name, `${branch.sha.slice(0, 8)} — ${fileStatus}${envNote}`);
48
+ }
49
+ }
50
+ });
51
+ await Promise.all(workers);
52
+ if (isBatchMode) {
53
+ s.stop(`Checked ${total} branches (${eligible.length} eligible)`);
54
+ }
55
+ if (ineligible.length > 0) {
56
+ console.log("");
57
+ clack.log.warn(`${ineligible.length} branch(es) ineligible:`);
58
+ for (const { branch, reason } of ineligible) {
59
+ statusLine("warn", branch.name, reason);
60
+ }
61
+ }
62
+ if (eligible.length === 0) {
63
+ clack.log.error("No eligible branches found.");
64
+ return err(workspaceError("No eligible branches"));
65
+ }
66
+ statusLine("ok", `${eligible.length} eligible branch(es) ready`);
67
+ return ok({ ...context, eligible });
68
+ };
@@ -0,0 +1,3 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps } from "../core/types.js";
3
+ export declare const loadTemplateStage: Stage<RunContext, AppDeps>;
@@ -0,0 +1,168 @@
1
+ import { ok, err } from "../core/types.js";
2
+ import { workspaceError } from "../core/errors.js";
3
+ import { parseEnvTemplate, synthesizeTemplate } from "../services/template/env.js";
4
+ import { extractTemplateVariables } from "../services/template/interpolator.js";
5
+ import { sectionHeader, statusLine } from "../utils/ui.js";
6
+ export const loadTemplateStage = async (context, deps) => {
7
+ if (!context.project || !context.target) {
8
+ return err(workspaceError("Prerequisites missing for template load"));
9
+ }
10
+ sectionHeader("Template");
11
+ const targetPath = context.target.path;
12
+ const isEnvTarget = targetPath === ".env" || targetPath.startsWith(".env.");
13
+ // 1. Try to load saved template from ~/.git-worker state
14
+ const stateResult = await deps.services.state.loadTemplate(context.project.stateDir, targetPath);
15
+ if (stateResult.ok && stateResult.value) {
16
+ const state = stateResult.value;
17
+ statusLine("ok", "Loaded saved template", `last updated ${state.updatedAt}`);
18
+ if (state.lastRunSummary) {
19
+ const s = state.lastRunSummary;
20
+ statusLine("info", `Last run: ${s.committed} committed, ${s.failed} failed, ${s.skipped} skipped of ${s.total}`);
21
+ }
22
+ if (isEnvTarget) {
23
+ const parseResult = parseEnvTemplate(state.template);
24
+ if (parseResult.ok) {
25
+ const activeTemplate = {
26
+ path: targetPath,
27
+ engine: "env-reconcile",
28
+ raw: state.template,
29
+ parsedEnv: parseResult.value,
30
+ };
31
+ return ok({
32
+ ...context,
33
+ template: parseResult.value,
34
+ activeTemplate,
35
+ templates: [activeTemplate],
36
+ });
37
+ }
38
+ }
39
+ else {
40
+ const activeTemplate = {
41
+ path: targetPath,
42
+ engine: "source-of-truth",
43
+ raw: state.template,
44
+ variables: extractTemplateVariables(state.template),
45
+ };
46
+ return ok({
47
+ ...context,
48
+ template: {
49
+ raw: state.template,
50
+ assignments: [],
51
+ placeholders: activeTemplate.variables?.map((v) => v.name) ?? [],
52
+ },
53
+ activeTemplate,
54
+ templates: [activeTemplate],
55
+ });
56
+ }
57
+ statusLine("warn", "Saved template was invalid — synthesizing a new one");
58
+ }
59
+ else {
60
+ statusLine("info", "No saved template — synthesizing first-run draft");
61
+ }
62
+ // 2. First run: synthesize from existing branch content
63
+ if (context.eligible && context.eligible.length > 0 && context.project) {
64
+ if (isEnvTarget) {
65
+ const contents = [];
66
+ for (const branch of context.eligible.slice(0, 5)) {
67
+ const readResult = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, targetPath);
68
+ if (readResult.ok) {
69
+ contents.push({ branchName: branch.name, content: readResult.value });
70
+ }
71
+ }
72
+ const synthesized = synthesizeTemplate(contents);
73
+ const parseResult = parseEnvTemplate(synthesized);
74
+ if (parseResult.ok) {
75
+ statusLine("ok", "Synthesized .env template from branch content");
76
+ const activeTemplate = {
77
+ path: targetPath,
78
+ engine: "env-reconcile",
79
+ raw: synthesized,
80
+ parsedEnv: parseResult.value,
81
+ };
82
+ return ok({
83
+ ...context,
84
+ template: parseResult.value,
85
+ activeTemplate,
86
+ templates: [activeTemplate],
87
+ });
88
+ }
89
+ }
90
+ else {
91
+ // Source-of-truth: read sample from first branch that has the file
92
+ for (const branch of context.eligible) {
93
+ const readResult = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, targetPath);
94
+ if (readResult.ok) {
95
+ statusLine("ok", `Loaded draft template from branch '${branch.name}'`);
96
+ const activeTemplate = {
97
+ path: targetPath,
98
+ engine: "source-of-truth",
99
+ raw: readResult.value,
100
+ variables: extractTemplateVariables(readResult.value),
101
+ };
102
+ return ok({
103
+ ...context,
104
+ template: {
105
+ raw: readResult.value,
106
+ assignments: [],
107
+ placeholders: activeTemplate.variables?.map((v) => v.name) ?? [],
108
+ },
109
+ activeTemplate,
110
+ templates: [activeTemplate],
111
+ });
112
+ }
113
+ }
114
+ }
115
+ }
116
+ // 3. Fallback starters for new files
117
+ let starterContent = "";
118
+ if (isEnvTarget) {
119
+ starterContent =
120
+ "# Git Worker Template\n# Use KEY=value for static values enforced on all branches\n# Use KEY={KEY} to read existing value from each branch\n";
121
+ const parseResult = parseEnvTemplate(starterContent);
122
+ if (!parseResult.ok)
123
+ return parseResult;
124
+ const activeTemplate = {
125
+ path: targetPath,
126
+ engine: "env-reconcile",
127
+ raw: starterContent,
128
+ parsedEnv: parseResult.value,
129
+ };
130
+ statusLine("info", "Using starter template for .env");
131
+ return ok({
132
+ ...context,
133
+ template: parseResult.value,
134
+ activeTemplate,
135
+ templates: [activeTemplate],
136
+ });
137
+ }
138
+ else if (targetPath.endsWith(".json")) {
139
+ starterContent = `{\n "name": "\${BRANCH_SLUG}",\n "version": "1.0.0"\n}\n`;
140
+ }
141
+ else if (targetPath.endsWith(".yaml") || targetPath.endsWith(".yml")) {
142
+ starterContent = `# Git Worker YAML Template\nname: "\${BRANCH_SLUG}"\nservice:\n port: \${PORT:-8080}\n`;
143
+ }
144
+ else if (targetPath.toLowerCase().includes("dockerfile")) {
145
+ starterContent = `# Git Worker Dockerfile Template\nFROM node:20-alpine\nENV SERVICE=\${BRANCH_SLUG}\nEXPOSE \${PORT:-8080}\n`;
146
+ }
147
+ else {
148
+ starterContent = `# Git Worker Template for ${targetPath}\n`;
149
+ }
150
+ statusLine("info", `Using starter template for ${targetPath}`);
151
+ const activeTemplate = {
152
+ path: targetPath,
153
+ engine: "source-of-truth",
154
+ raw: starterContent,
155
+ variables: extractTemplateVariables(starterContent),
156
+ isNewFile: true,
157
+ };
158
+ return ok({
159
+ ...context,
160
+ template: {
161
+ raw: starterContent,
162
+ assignments: [],
163
+ placeholders: activeTemplate.variables?.map((v) => v.name) ?? [],
164
+ },
165
+ activeTemplate,
166
+ templates: [activeTemplate],
167
+ });
168
+ };
@@ -0,0 +1,3 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps } from "../core/types.js";
3
+ export declare const planChangesStage: Stage<RunContext, AppDeps>;
@@ -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>;