@samaksh/git-worker 0.2.3 → 0.3.1

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.
@@ -8,50 +8,174 @@ import { computeDiff } from "../services/review/index.js";
8
8
  import { clack, sectionHeader, statusLine, sym } from "../utils/ui.js";
9
9
  import chalk from "chalk";
10
10
  export const planChangesStage = async (context, deps) => {
11
- if ((!context.template && !context.activeTemplate) ||
12
- !context.eligible ||
13
- !context.target ||
14
- !context.project) {
11
+ const templates = context.templates || (context.activeTemplate ? [context.activeTemplate] : []);
12
+ if (templates.length === 0 || !context.eligible || !context.project) {
15
13
  return err(workspaceError("Prerequisites missing for planning"));
16
14
  }
17
- sectionHeader("Planning Changes");
15
+ sectionHeader(`Planning Changes across ${context.eligible.length} branch(es) & ${templates.length} template(s)`);
18
16
  const plans = [];
19
17
  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
18
  for (const branch of context.eligible) {
25
19
  const s = clack.spinner();
26
20
  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
- }
21
+ // Read branch's existing .env once to populate envMap and extract dynamic variables
22
+ const envRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, ".env");
23
+ const envMap = new Map();
24
+ if (envRead.ok) {
25
+ const parsedEnv = parseEnvFile(envRead.value);
26
+ if (parsedEnv.ok) {
27
+ for (const [key, entry] of parsedEnv.value.map) {
28
+ envMap.set(key, entry.value);
29
+ }
30
+ }
31
+ }
32
+ // Resolve branch-level servicename (support lowercase, uppercase, snake_case)
33
+ let branchServiceName = envMap.get("servicename") ||
34
+ envMap.get("SERVICE_NAME") ||
35
+ envMap.get("service_name") ||
36
+ envMap.get("SERVICENAME");
37
+ // Fallback: check if an env template in context statically defines servicename
38
+ if (!branchServiceName) {
39
+ const envTmpl = templates.find((t) => t.engine === "env-reconcile");
40
+ if (envTmpl?.parsedEnv) {
41
+ const assign = envTmpl.parsedEnv.assignments.find((a) => a.key === "servicename" ||
42
+ a.key === "SERVICE_NAME" ||
43
+ a.key.toLowerCase() === "servicename");
44
+ if (assign && assign.isStatic && assign.value) {
45
+ branchServiceName = assign.value;
46
+ }
47
+ }
48
+ }
49
+ let branchChangedCount = 0;
50
+ let branchErrorCount = 0;
51
+ for (const template of templates) {
52
+ let targetPath = template.path;
53
+ // Handle dynamic path substitution (e.g. {servicename}/requirements.txt)
54
+ if (template.isDynamic || targetPath.includes("{servicename}")) {
55
+ const varName = template.dynamicVar || "servicename";
56
+ const val = branchServiceName ||
57
+ envMap.get(varName) ||
58
+ envMap.get(varName.toLowerCase()) ||
59
+ envMap.get(varName.toUpperCase());
60
+ if (!val) {
61
+ branchErrorCount++;
62
+ const errorReason = `Missing '${varName}' in branch .env required for dynamic path ${targetPath}`;
63
+ plans.push({
64
+ branch,
65
+ baseCommitSha: branch.sha,
66
+ path: targetPath,
67
+ beforeContent: "",
68
+ beforeContentHash: sha256(""),
69
+ afterContent: "",
70
+ afterContentHash: sha256(""),
71
+ diff: "",
72
+ planHash: computePlanHash(branch.name, branch.sha, targetPath, sha256(""), sha256("")),
73
+ status: "error",
74
+ reason: errorReason,
75
+ addedKeys: [],
76
+ updatedKeys: [],
77
+ });
78
+ continue;
79
+ }
80
+ targetPath = targetPath.replace(`{${varName}}`, val);
81
+ }
82
+ const isEnv = targetPath === ".env" || targetPath.startsWith(".env.");
83
+ const engine = template.engine ?? (isEnv ? "env-reconcile" : "source-of-truth");
84
+ const templateRaw = template.raw;
85
+ // ─────────────────────────────────────────────────────────────────
86
+ // 1. SOURCE-OF-TRUTH ENGINE (Dockerfile, scripts, JSON, requirements)
87
+ // ─────────────────────────────────────────────────────────────────
88
+ if (engine === "source-of-truth") {
89
+ const targetRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, targetPath);
90
+ const isNewFile = !targetRead.ok;
91
+ const beforeContent = targetRead.ok ? targetRead.value : "";
92
+ const beforeContentHash = sha256(beforeContent);
93
+ // Add dynamic servicename to envMap if available
94
+ if (branchServiceName && !envMap.has("servicename")) {
95
+ envMap.set("servicename", branchServiceName);
96
+ envMap.set("SERVICE_NAME", branchServiceName);
40
97
  }
98
+ const interpolated = interpolateTemplate(templateRaw, envMap, {
99
+ branchName: branch.name,
100
+ repoName: context.project.id,
101
+ commitSha: branch.sha,
102
+ });
103
+ if (!interpolated.ok) {
104
+ branchErrorCount++;
105
+ plans.push({
106
+ branch,
107
+ baseCommitSha: branch.sha,
108
+ path: targetPath,
109
+ beforeContent,
110
+ beforeContentHash,
111
+ afterContent: beforeContent,
112
+ afterContentHash: beforeContentHash,
113
+ diff: "",
114
+ planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
115
+ status: "error",
116
+ reason: interpolated.error.message,
117
+ addedKeys: [],
118
+ updatedKeys: [],
119
+ isNewFile,
120
+ });
121
+ continue;
122
+ }
123
+ const { content: afterContent, missingVars } = interpolated.value;
124
+ if (missingVars.length > 0) {
125
+ branchErrorCount++;
126
+ const errorReason = `Missing required variable(s) from .env: ${missingVars.join(", ")}`;
127
+ plans.push({
128
+ branch,
129
+ baseCommitSha: branch.sha,
130
+ path: targetPath,
131
+ beforeContent,
132
+ beforeContentHash,
133
+ afterContent: beforeContent,
134
+ afterContentHash: beforeContentHash,
135
+ diff: "",
136
+ planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
137
+ status: "error",
138
+ reason: errorReason,
139
+ addedKeys: [],
140
+ updatedKeys: [],
141
+ missingVars,
142
+ isNewFile,
143
+ });
144
+ continue;
145
+ }
146
+ const afterContentHash = sha256(afterContent);
147
+ const changed = afterContent !== beforeContent;
148
+ const diff = computeDiff(beforeContent, afterContent, targetPath);
149
+ const hash = computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, afterContentHash);
150
+ if (changed) {
151
+ branchChangedCount++;
152
+ }
153
+ plans.push({
154
+ branch,
155
+ baseCommitSha: branch.sha,
156
+ path: targetPath,
157
+ beforeContent,
158
+ beforeContentHash,
159
+ afterContent,
160
+ afterContentHash,
161
+ diff,
162
+ planHash: hash,
163
+ status: changed ? "changed" : "unchanged",
164
+ addedKeys: isNewFile ? [targetPath] : [],
165
+ updatedKeys: !isNewFile && changed ? [targetPath] : [],
166
+ isNewFile,
167
+ });
168
+ continue;
41
169
  }
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 : "";
170
+ // ─────────────────────────────────────────────────────────────────
171
+ // 2. ENV RECONCILE ENGINE (.env)
172
+ // ─────────────────────────────────────────────────────────────────
173
+ const readResult = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, targetPath);
174
+ const beforeContent = readResult.ok ? readResult.value : "";
46
175
  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`));
176
+ const existingParse = parseEnvFile(beforeContent);
177
+ if (!existingParse.ok) {
178
+ branchErrorCount++;
55
179
  plans.push({
56
180
  branch,
57
181
  baseCommitSha: branch.sha,
@@ -63,18 +187,55 @@ export const planChangesStage = async (context, deps) => {
63
187
  diff: "",
64
188
  planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
65
189
  status: "error",
66
- reason: interpolated.error.message,
190
+ reason: existingParse.error.message,
67
191
  addedKeys: [],
68
192
  updatedKeys: [],
69
- isNewFile,
70
193
  });
71
194
  continue;
72
195
  }
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}`));
196
+ const existingMap = existingParse.value.map;
197
+ const branchValues = new Map();
198
+ let hadError = false;
199
+ let errorReason = "";
200
+ const envTemplate = template.parsedEnv ?? context.template;
201
+ if (envTemplate) {
202
+ for (const assignment of envTemplate.assignments) {
203
+ if (!assignment.isPlaceholder || !assignment.placeholderName)
204
+ continue;
205
+ if (existingMap.has(assignment.key)) {
206
+ branchValues.set(assignment.key, existingMap.get(assignment.key).value);
207
+ }
208
+ else {
209
+ if (isCi) {
210
+ hadError = true;
211
+ errorReason = `Missing value for '${assignment.key}' in CI mode`;
212
+ break;
213
+ }
214
+ s.stop(`${branch.name} — needs input for ${targetPath}`);
215
+ console.log("");
216
+ console.log(chalk.bold(` ${sym.branch} Branch: ${chalk.cyan(branch.name)}`));
217
+ console.log(chalk.yellow(` Missing key in ${targetPath}: ${chalk.bold(assignment.key)}`));
218
+ const value = await clack.text({
219
+ message: `Value for ${chalk.bold(assignment.key)} in ${chalk.cyan(branch.name)}:`,
220
+ placeholder: `enter value for ${assignment.key}`,
221
+ validate: (v) => {
222
+ if (!v || v.trim() === "")
223
+ return `Value cannot be empty`;
224
+ },
225
+ });
226
+ if (clack.isCancel(value)) {
227
+ hadError = true;
228
+ errorReason = "User cancelled value input";
229
+ s.stop(chalk.yellow(`${branch.name} — skipped`));
230
+ break;
231
+ }
232
+ branchValues.set(assignment.key, value);
233
+ s.start(`Planning ${branch.name}…`);
234
+ }
235
+ }
236
+ }
237
+ if (hadError) {
238
+ branchErrorCount++;
78
239
  plans.push({
79
240
  branch,
80
241
  baseCommitSha: branch.sha,
@@ -89,21 +250,35 @@ export const planChangesStage = async (context, deps) => {
89
250
  reason: errorReason,
90
251
  addedKeys: [],
91
252
  updatedKeys: [],
92
- missingVars,
93
- isNewFile,
94
253
  });
95
254
  continue;
96
255
  }
97
- // E. Diff & Plan Hash
256
+ const transformResult = transformEnv(beforeContent, envTemplate, branchValues);
257
+ if (!transformResult.ok) {
258
+ branchErrorCount++;
259
+ plans.push({
260
+ branch,
261
+ baseCommitSha: branch.sha,
262
+ path: targetPath,
263
+ beforeContent,
264
+ beforeContentHash,
265
+ afterContent: beforeContent,
266
+ afterContentHash: beforeContentHash,
267
+ diff: "",
268
+ planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
269
+ status: "error",
270
+ reason: transformResult.error.message,
271
+ addedKeys: [],
272
+ updatedKeys: [],
273
+ });
274
+ continue;
275
+ }
276
+ const { content: afterContent, changed, addedKeys, updatedKeys } = transformResult.value;
98
277
  const afterContentHash = sha256(afterContent);
99
- const changed = afterContent !== beforeContent;
100
278
  const diff = computeDiff(beforeContent, afterContent, targetPath);
101
279
  const hash = computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, afterContentHash);
102
280
  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`));
281
+ branchChangedCount++;
107
282
  }
108
283
  plans.push({
109
284
  branch,
@@ -116,155 +291,24 @@ export const planChangesStage = async (context, deps) => {
116
291
  diff,
117
292
  planHash: hash,
118
293
  status: changed ? "changed" : "unchanged",
119
- addedKeys: isNewFile ? [targetPath] : [],
120
- updatedKeys: !isNewFile && changed ? [targetPath] : [],
121
- isNewFile,
294
+ addedKeys,
295
+ updatedKeys,
122
296
  });
123
- continue;
124
297
  }
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;
298
+ if (branchErrorCount > 0) {
299
+ s.stop(chalk.red(`${branch.name} — ${branchErrorCount} error(s)`));
232
300
  }
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"}`));
301
+ else if (branchChangedCount > 0) {
302
+ s.stop(chalk.green(`${branch.name} — ${branchChangedCount} file(s) changed`));
245
303
  }
246
304
  else {
247
305
  s.stop(chalk.gray(`${branch.name} — no changes`));
248
306
  }
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
307
  }
264
308
  const changed = plans.filter((p) => p.status === "changed").length;
265
309
  const unchanged = plans.filter((p) => p.status === "unchanged").length;
266
310
  const errors = plans.filter((p) => p.status === "error").length;
267
311
  console.log("");
268
- statusLine("ok", `Planning complete — ${changed} changed, ${unchanged} unchanged, ${errors} error(s)`);
312
+ statusLine("ok", `Planning complete — ${changed} file change(s) across branches (${unchanged} unchanged, ${errors} error(s))`);
269
313
  return ok({ ...context, plans });
270
314
  };
@@ -47,34 +47,39 @@ export const reportResultsStage = async (context, deps) => {
47
47
  skipped > 0 ? chalk.gray(`${skipped} skipped`) : "",
48
48
  ].filter(Boolean).join(" "));
49
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) {
50
+ // Save template state for all edited templates
51
+ const templates = context.templates || (context.activeTemplate ? [context.activeTemplate] : []);
52
+ if (context.project && templates.length > 0) {
53
53
  const summary = {
54
54
  committed,
55
55
  failed,
56
56
  skipped: skipped + rejected,
57
57
  total: results.length,
58
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);
59
+ if (committed > 0) {
60
+ for (const t of templates) {
61
+ if (t.raw) {
62
+ const saveResult = await deps.services.state.saveTemplate(context.project.stateDir, t.path, t.raw, summary);
63
+ if (saveResult.ok) {
64
+ statusLine("ok", `Template state saved for '${t.path}' (ready for future runs)`);
65
+ }
66
+ }
67
+ }
65
68
  }
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) {
69
+ else if (!context.input.ci) {
70
+ // No commits — offer to save reviewed templates
71
+ const { clack } = await import("../utils/ui.js");
71
72
  const save = await clack.confirm({
72
- message: "No changes committed. Save current template for next run?",
73
+ message: `No changes committed. Save ${templates.length} reviewed template(s) for next run?`,
73
74
  initialValue: true,
74
75
  });
75
76
  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}'`);
77
+ for (const t of templates) {
78
+ if (t.raw) {
79
+ await deps.services.state.saveTemplate(context.project.stateDir, t.path, t.raw);
80
+ statusLine("ok", `Template saved for '${t.path}'`);
81
+ }
82
+ }
78
83
  }
79
84
  }
80
85
  }
@@ -82,7 +87,7 @@ export const reportResultsStage = async (context, deps) => {
82
87
  if (context.project && (deps.config.report.writeJson || deps.config.report.writeMarkdown)) {
83
88
  const report = {
84
89
  runId: context.runId,
85
- targetPath: context.target?.path ?? "",
90
+ targetPath: context.target?.path ?? templates.map((t) => t.path).join(", "),
86
91
  startedAt: new Date().toISOString(),
87
92
  completedAt: new Date().toISOString(),
88
93
  plans: context.plans ?? [],