@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.
@@ -3,7 +3,7 @@ import { workspaceError, inputCancelledError } from "../core/errors.js";
3
3
  import { parseEnvTemplate } from "../services/template/env.js";
4
4
  import { extractTemplateVariables } from "../services/template/interpolator.js";
5
5
  import { validateTemplateSyntax } from "../services/template/validator.js";
6
- import { clack, sectionHeader, statusLine } from "../utils/ui.js";
6
+ import { clack, sectionHeader, statusLine, divider } from "../utils/ui.js";
7
7
  import { writeFile, readFile, mkdtemp, rm } from "fs/promises";
8
8
  import { tmpdir } from "os";
9
9
  import { join } from "path";
@@ -54,143 +54,199 @@ function stripInstructionHeader(content) {
54
54
  }
55
55
  return lines.slice(headerEnd).join("\n");
56
56
  }
57
+ async function editSingleTemplate(template, index, total, tmpDir, deps, context) {
58
+ const targetPath = template.path;
59
+ const isEnv = targetPath === ".env" || targetPath.startsWith(".env.");
60
+ let currentRaw = template.raw;
61
+ const safeFileName = targetPath.replace(/[^a-zA-Z0-9._-]/g, "_");
62
+ const templateFile = join(tmpDir, `${index + 1}_${safeFileName}`);
63
+ while (true) {
64
+ const header = buildInstructionHeader(targetPath);
65
+ const content = header ? `${header}${currentRaw}` : currentRaw;
66
+ await writeFile(templateFile, content, "utf-8");
67
+ const editorName = context.input.editor || deps.config.editor.name;
68
+ statusLine("info", `[${index + 1}/${total}] Opening ${chalk.bold.cyan(targetPath)} in ${editorName}`);
69
+ console.log(chalk.gray(` Path: ${templateFile}`));
70
+ if (template.description) {
71
+ console.log(chalk.gray(` Purpose: ${template.description}`));
72
+ }
73
+ console.log("");
74
+ const editResult = await deps.services.editor.openForEdit(templateFile, deps.config);
75
+ if (!editResult.ok) {
76
+ clack.log.warn(editResult.error.message);
77
+ const proceed = await clack.confirm({
78
+ message: `Editor unavailable for ${targetPath}. Continue with current content?`,
79
+ initialValue: true,
80
+ });
81
+ if (clack.isCancel(proceed) || !proceed) {
82
+ return { ok: false, error: inputCancelledError(`Template edit cancelled for ${targetPath}`) };
83
+ }
84
+ break;
85
+ }
86
+ // Read edited content
87
+ const edited = await readFile(templateFile, "utf-8");
88
+ const stripped = stripInstructionHeader(edited).trimEnd() + "\n";
89
+ // 1. Validate syntax immediately
90
+ const validation = validateTemplateSyntax(targetPath, stripped);
91
+ if (!validation.ok) {
92
+ console.log("");
93
+ clack.log.error(`Syntax Validation Failed for ${targetPath}:\n ${chalk.red(validation.error.message)}`);
94
+ console.log("");
95
+ const fixChoice = await clack.select({
96
+ message: "How would you like to handle this syntax error?",
97
+ options: [
98
+ { value: "re-edit", label: "✏️ Re-edit template to fix the error" },
99
+ { value: "discard", label: "❌ Discard changes and cancel" },
100
+ ],
101
+ });
102
+ if (clack.isCancel(fixChoice) || fixChoice === "discard") {
103
+ return { ok: false, error: inputCancelledError(`Template edit cancelled for ${targetPath}`) };
104
+ }
105
+ currentRaw = stripped;
106
+ continue;
107
+ }
108
+ // 2. Syntax is valid
109
+ currentRaw = stripped;
110
+ statusLine("ok", `Syntax validated for ${chalk.bold.cyan(targetPath)}`);
111
+ break;
112
+ }
113
+ // Build updated TargetTemplate
114
+ if (isEnv) {
115
+ const parseResult = parseEnvTemplate(currentRaw);
116
+ if (!parseResult.ok) {
117
+ return { ok: false, error: parseResult.error };
118
+ }
119
+ return {
120
+ ok: true,
121
+ template: {
122
+ ...template,
123
+ raw: currentRaw,
124
+ parsedEnv: parseResult.value,
125
+ },
126
+ };
127
+ }
128
+ else {
129
+ const variables = extractTemplateVariables(currentRaw);
130
+ return {
131
+ ok: true,
132
+ template: {
133
+ ...template,
134
+ raw: currentRaw,
135
+ variables,
136
+ },
137
+ };
138
+ }
139
+ }
57
140
  export const editTemplateStage = async (context, deps) => {
58
- if (!context.template && !context.activeTemplate) {
59
- return err(workspaceError("No template loaded"));
141
+ const templates = context.templates || (context.activeTemplate ? [context.activeTemplate] : []);
142
+ if (templates.length === 0) {
143
+ return err(workspaceError("No templates loaded for editing"));
60
144
  }
61
- const targetPath = context.target?.path ?? ".env";
62
- const isEnv = targetPath === ".env" || targetPath.startsWith(".env.");
63
- let currentRaw = context.activeTemplate?.raw ?? context.template?.raw ?? "";
64
- // In CI mode, validate syntax and proceed without interactive editor
145
+ // In CI mode, validate all templates and proceed
65
146
  if (context.input.ci) {
66
- statusLine("info", "CI mode — skipping template editor");
67
- const validation = validateTemplateSyntax(targetPath, currentRaw);
68
- if (!validation.ok) {
69
- clack.log.error(`Template syntax error: ${validation.error.message}`);
70
- return validation;
147
+ statusLine("info", `CI mode — validating syntax for ${templates.length} template(s)`);
148
+ for (const t of templates) {
149
+ const validation = validateTemplateSyntax(t.path, t.raw);
150
+ if (!validation.ok) {
151
+ clack.log.error(`Template syntax error in ${t.path}: ${validation.error.message}`);
152
+ return validation;
153
+ }
71
154
  }
72
155
  return ok(context);
73
156
  }
74
- sectionHeader("Edit Template");
157
+ sectionHeader(`Edit Template(s) (${templates.length} file(s) selected)`);
75
158
  const tmpDir = await mkdtemp(join(tmpdir(), "git-worker-"));
76
- const templateFileName = targetPath.split("/").pop() ?? "template.env";
77
- const templateFile = join(tmpDir, templateFileName);
159
+ const updatedTemplates = [];
78
160
  try {
161
+ for (let i = 0; i < templates.length; i++) {
162
+ const template = templates[i];
163
+ const res = await editSingleTemplate(template, i, templates.length, tmpDir, deps, context);
164
+ if (!res.ok) {
165
+ return err(res.error);
166
+ }
167
+ updatedTemplates.push(res.template);
168
+ }
169
+ // Interactive summary and post-edit menu if multiple templates
79
170
  while (true) {
80
- const header = buildInstructionHeader(targetPath);
81
- const content = header ? `${header}${currentRaw}` : currentRaw;
82
- await writeFile(templateFile, content, "utf-8");
83
- const editorName = context.input.editor || deps.config.editor.name;
84
- statusLine("info", `Opening ${targetPath} in ${editorName} — save and close when done`);
85
- console.log(chalk.gray(` File: ${templateFile}`));
86
- if (!header) {
87
- console.log(chalk.gray(` Tip: Use \${VAR} or \${VAR:-default} to resolve variables from each branch's .env`));
171
+ divider();
172
+ console.log(chalk.bold(" Edited Templates Summary:"));
173
+ for (const t of updatedTemplates) {
174
+ const details = t.engine === "env-reconcile"
175
+ ? `${t.parsedEnv?.assignments.length ?? 0} assignment(s)`
176
+ : `${t.variables?.length ?? 0} variable placeholder(s)`;
177
+ statusLine("ok", t.path, details);
88
178
  }
89
179
  console.log("");
90
- const editResult = await deps.services.editor.openForEdit(templateFile, deps.config);
91
- if (!editResult.ok) {
92
- clack.log.warn(editResult.error.message);
93
- const proceed = await clack.confirm({
94
- message: "Editor not available. Continue with current template?",
95
- initialValue: true,
96
- });
97
- if (clack.isCancel(proceed) || !proceed) {
98
- return err(inputCancelledError("Template edit cancelled — editor unavailable"));
99
- }
100
- break;
101
- }
102
- // Read edited content
103
- const edited = await readFile(templateFile, "utf-8");
104
- const stripped = stripInstructionHeader(edited).trimEnd() + "\n";
105
- // 1. Validate syntax immediately
106
- const validation = validateTemplateSyntax(targetPath, stripped);
107
- if (!validation.ok) {
108
- console.log("");
109
- clack.log.error(`Syntax Validation Failed:\n ${chalk.red(validation.error.message)}`);
110
- console.log("");
111
- const fixChoice = await clack.select({
112
- message: "How would you like to handle this syntax error?",
113
- options: [
114
- { value: "re-edit", label: "✏️ Re-edit template to fix the error" },
115
- { value: "discard", label: "❌ Discard changes and cancel" },
116
- ],
117
- });
118
- if (clack.isCancel(fixChoice) || fixChoice === "discard") {
119
- return err(inputCancelledError("Template edit cancelled due to syntax error"));
120
- }
121
- currentRaw = stripped;
122
- continue;
123
- }
124
- // 2. Syntax is valid!
125
- currentRaw = stripped;
126
- statusLine("ok", "Template syntax validated successfully");
127
- // 3. Post-edit menu
128
- const actionChoice = await clack.select({
129
- message: "Template ready. What would you like to do next?",
180
+ const action = await clack.select({
181
+ message: "All templates reviewed. What would you like to do next?",
130
182
  options: [
131
183
  {
132
184
  value: "proceed",
133
185
  label: "➜ Proceed to plan changes",
134
186
  hint: "generate diffs across target branches",
135
187
  },
136
- {
137
- value: "re-edit",
138
- label: "✏️ Edit template again",
139
- hint: "re-open in editor",
140
- },
188
+ ...(updatedTemplates.length > 1
189
+ ? [
190
+ {
191
+ value: "re-edit-one",
192
+ label: "✏️ Re-edit a specific file",
193
+ hint: "choose one file to modify again",
194
+ },
195
+ ]
196
+ : [
197
+ {
198
+ value: "re-edit-first",
199
+ label: "✏️ Re-edit template",
200
+ hint: "re-open in editor",
201
+ },
202
+ ]),
141
203
  {
142
204
  value: "discard",
143
205
  label: "❌ Discard changes and cancel",
144
206
  },
145
207
  ],
146
208
  });
147
- if (clack.isCancel(actionChoice) || actionChoice === "discard") {
148
- return err(inputCancelledError("Template edit cancelled"));
209
+ if (clack.isCancel(action) || action === "discard") {
210
+ return err(inputCancelledError("Template editing cancelled"));
149
211
  }
150
- if (actionChoice === "re-edit") {
212
+ if (action === "proceed") {
213
+ break;
214
+ }
215
+ if (action === "re-edit-first") {
216
+ const res = await editSingleTemplate(updatedTemplates[0], 0, 1, tmpDir, deps, context);
217
+ if (!res.ok)
218
+ return err(res.error);
219
+ updatedTemplates[0] = res.template;
151
220
  continue;
152
221
  }
153
- break;
154
- }
155
- // Update context with the validated template
156
- if (isEnv) {
157
- const parseResult = parseEnvTemplate(currentRaw);
158
- if (!parseResult.ok)
159
- return parseResult;
160
- const activeTemplate = {
161
- path: targetPath,
162
- engine: "env-reconcile",
163
- raw: currentRaw,
164
- parsedEnv: parseResult.value,
165
- };
166
- statusLine("ok", `Template ready — ${parseResult.value.assignments.length} assignment(s)`);
167
- return ok({
168
- ...context,
169
- template: parseResult.value,
170
- activeTemplate,
171
- templates: [activeTemplate],
172
- });
173
- }
174
- else {
175
- const variables = extractTemplateVariables(currentRaw);
176
- const activeTemplate = {
177
- path: targetPath,
178
- engine: "source-of-truth",
179
- raw: currentRaw,
180
- variables,
181
- };
182
- statusLine("ok", `Template ready — ${variables.length} variable placeholder(s) detected`);
183
- return ok({
184
- ...context,
185
- template: {
186
- raw: currentRaw,
187
- assignments: [],
188
- placeholders: variables.map((v) => v.name),
189
- },
190
- activeTemplate,
191
- templates: [activeTemplate],
192
- });
222
+ if (action === "re-edit-one") {
223
+ const pickedPath = await clack.select({
224
+ message: "Select file to re-edit:",
225
+ options: updatedTemplates.map((t, idx) => ({
226
+ value: idx,
227
+ label: t.path,
228
+ })),
229
+ });
230
+ if (!clack.isCancel(pickedPath)) {
231
+ const idx = Number(pickedPath);
232
+ const res = await editSingleTemplate(updatedTemplates[idx], idx, updatedTemplates.length, tmpDir, deps, context);
233
+ if (!res.ok)
234
+ return err(res.error);
235
+ updatedTemplates[idx] = res.template;
236
+ }
237
+ }
193
238
  }
239
+ const primaryTemplate = updatedTemplates[0];
240
+ return ok({
241
+ ...context,
242
+ templates: updatedTemplates,
243
+ activeTemplate: primaryTemplate,
244
+ template: primaryTemplate.parsedEnv ?? {
245
+ raw: primaryTemplate.raw,
246
+ assignments: [],
247
+ placeholders: primaryTemplate.variables?.map((v) => v.name) ?? [],
248
+ },
249
+ });
194
250
  }
195
251
  finally {
196
252
  await rm(tmpDir, { recursive: true, force: true }).catch(() => { });
@@ -1,19 +1,21 @@
1
1
  import { ok, err } from "../core/types.js";
2
2
  import { workspaceError } from "../core/errors.js";
3
+ import { parseEnvFile } from "../services/template/env.js";
3
4
  import { clack, sectionHeader, statusLine } from "../utils/ui.js";
4
5
  export const checkEligibilityStage = async (context, deps) => {
5
- if (!context.selectedBranches || !context.target || !context.project) {
6
+ const targets = context.targets || (context.target ? [context.target] : []);
7
+ if (!context.selectedBranches || targets.length === 0 || !context.project) {
6
8
  return err(workspaceError("Prerequisites missing for eligibility check"));
7
9
  }
8
10
  sectionHeader("Eligibility Check");
9
11
  const eligible = [];
10
12
  const ineligible = [];
11
- const isEnvTarget = context.target.path === ".env" || context.target.path.startsWith(".env.");
13
+ const hasEnvTarget = targets.some((t) => t.path === ".env" || t.path.startsWith(".env."));
12
14
  const total = context.selectedBranches.length;
13
15
  const isBatchMode = total > 8;
14
16
  const s = clack.spinner();
15
17
  if (isBatchMode) {
16
- s.start(`Checking eligibility for ${total} branches…`);
18
+ s.start(`Checking eligibility for ${total} branches across ${targets.length} target file(s)…`);
17
19
  }
18
20
  // Concurrent execution pool (up to 16 workers in parallel)
19
21
  const concurrency = Math.min(16, total);
@@ -23,28 +25,55 @@ export const checkEligibilityStage = async (context, deps) => {
23
25
  while (currentIndex < total) {
24
26
  const idx = currentIndex++;
25
27
  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";
28
+ // Read branch .env if available to resolve dynamic paths or check variable availability
29
+ let envRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, ".env");
30
+ const envMap = new Map();
31
+ if (envRead.ok) {
32
+ const parsed = parseEnvFile(envRead.value);
33
+ if (parsed.ok) {
34
+ for (const [k, v] of parsed.value.map) {
35
+ envMap.set(k.toLowerCase(), v.value);
36
+ envMap.set(k, v.value);
37
+ }
38
+ }
39
+ }
40
+ let existingFileCount = 0;
41
+ let missingDynamicVar;
42
+ for (const target of targets) {
43
+ let checkPath = target.path;
44
+ if (target.isDynamic && (target.dynamicVar || target.path.includes("{servicename}"))) {
45
+ const varName = target.dynamicVar || "servicename";
46
+ const resolvedVal = envMap.get(varName) ||
47
+ envMap.get(varName.toLowerCase()) ||
48
+ envMap.get(varName.toUpperCase()) ||
49
+ envMap.get("SERVICE_NAME");
50
+ if (resolvedVal) {
51
+ checkPath = target.path.replace(`{${varName}}`, resolvedVal);
52
+ }
53
+ else {
54
+ missingDynamicVar = varName;
55
+ }
35
56
  }
36
- else {
37
- envNote = " • .env missing (variables will require fallbacks)";
57
+ const targetRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, checkPath);
58
+ if (targetRead.ok) {
59
+ existingFileCount++;
38
60
  }
39
61
  }
40
- const fileStatus = targetFound ? "target file found" : "target file will be created";
62
+ let envNote = "";
63
+ if (!hasEnvTarget) {
64
+ envNote = envRead.ok ? " • .env present" : " • .env missing";
65
+ }
66
+ if (missingDynamicVar) {
67
+ envNote += ` • note: '${missingDynamicVar}' missing in .env`;
68
+ }
69
+ const fileStatus = `${existingFileCount}/${targets.length} target file(s) found`;
41
70
  eligible.push(branch);
42
71
  completed++;
43
72
  if (isBatchMode) {
44
73
  s.message(`Checking eligibility (${completed}/${total})…`);
45
74
  }
46
75
  else {
47
- statusLine(targetFound ? "ok" : "info", branch.name, `${branch.sha.slice(0, 8)} — ${fileStatus}${envNote}`);
76
+ statusLine("ok", branch.name, `${branch.sha.slice(0, 8)} — ${fileStatus}${envNote}`);
48
77
  }
49
78
  }
50
79
  });