@samaksh/git-worker 0.3.2 → 0.3.3

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/dist/cli/index.js CHANGED
@@ -4,7 +4,7 @@ import { createRequire } from "module";
4
4
  import { run } from "../app.js";
5
5
  import { handleCacheList, handleCacheClean } from "./commands.js";
6
6
  import chalk from "chalk";
7
- let version = "0.3.2";
7
+ let version = "0.3.3";
8
8
  try {
9
9
  const require = createRequire(import.meta.url);
10
10
  const pkg = require("../../package.json");
@@ -24,7 +24,7 @@ program
24
24
  .option("-b, --branch <pattern>", "Branch pattern — glob (uat/*) or regex (/uat.*/)")
25
25
  .option("-i, --invert-branch", "Invert the branch selection pattern")
26
26
  .option("-f, --file <path>", "Target file path inside the repository (e.g. .env)")
27
- .option("-e, --editor <name>", "Editor to use: code (VS Code) or vim")
27
+ .option("-e, --editor <editor>", "Editor command or executable path (e.g. 'code', '/path/to/code', 'cursor', 'vim')")
28
28
  .option("-m, --commit-msg <message>", "Commit message")
29
29
  .option("-p, --push <policy>", "Push policy: prompt (default) | always | never")
30
30
  .option("--remote <name>", "Git remote name (default: origin)")
@@ -0,0 +1,15 @@
1
+ export type ResolvedEditor = {
2
+ name: string;
3
+ executable: string;
4
+ editCommand: string[];
5
+ diffCommand: string[];
6
+ };
7
+ /**
8
+ * Resolves the editor command and flags based on CLI input or defaults.
9
+ *
10
+ * Supports:
11
+ * - Direct command names: "code", "cursor", "vim", "nano"
12
+ * - Full executable paths: "/Applications/Visual Studio Code.app/.../code", "/usr/bin/vim"
13
+ * - Auto-discovery for "code" if not found in PATH
14
+ */
15
+ export declare function resolveEditorConfig(editorInput?: string): ResolvedEditor;
@@ -0,0 +1,160 @@
1
+ import { existsSync } from "fs";
2
+ import { join, basename, delimiter } from "path";
3
+ import { homedir, platform } from "os";
4
+ /**
5
+ * Searches system PATH for an executable name.
6
+ */
7
+ function findInPath(binName) {
8
+ const pathEnv = process.env.PATH || "";
9
+ const dirs = pathEnv.split(delimiter);
10
+ // Extensions on Windows
11
+ const exts = platform() === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
12
+ for (const dir of dirs) {
13
+ if (!dir)
14
+ continue;
15
+ for (const ext of exts) {
16
+ const fullPath = join(dir, `${binName}${ext}`);
17
+ try {
18
+ if (existsSync(fullPath)) {
19
+ return fullPath;
20
+ }
21
+ }
22
+ catch {
23
+ // ignore permission or path errors
24
+ }
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+ /**
30
+ * Common locations for VS Code when not linked into PATH.
31
+ */
32
+ function findVsCodeFallback() {
33
+ const osPlatform = platform();
34
+ if (osPlatform === "darwin") {
35
+ const macCandidates = [
36
+ "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
37
+ join(homedir(), "Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"),
38
+ "/opt/homebrew/bin/code",
39
+ "/usr/local/bin/code",
40
+ "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code-insiders",
41
+ "/Applications/VSCodium.app/Contents/Resources/app/bin/codium",
42
+ "/Applications/Cursor.app/Contents/Resources/app/bin/cursor",
43
+ join(homedir(), "Applications/Cursor.app/Contents/Resources/app/bin/cursor"),
44
+ ];
45
+ for (const candidate of macCandidates) {
46
+ if (existsSync(candidate))
47
+ return candidate;
48
+ }
49
+ }
50
+ else if (osPlatform === "win32") {
51
+ const localAppData = process.env.LOCALAPPDATA || "";
52
+ const programFiles = process.env.PROGRAMFILES || "";
53
+ const programFilesX86 = process.env["PROGRAMFILES(X86)"] || "";
54
+ const winCandidates = [
55
+ join(localAppData, "Programs", "Microsoft VS Code", "bin", "code.cmd"),
56
+ join(programFiles, "Microsoft VS Code", "bin", "code.cmd"),
57
+ join(programFilesX86, "Microsoft VS Code", "bin", "code.cmd"),
58
+ ];
59
+ for (const candidate of winCandidates) {
60
+ if (candidate && existsSync(candidate))
61
+ return candidate;
62
+ }
63
+ }
64
+ else {
65
+ // Linux / BSD
66
+ const linuxCandidates = [
67
+ "/usr/bin/code",
68
+ "/usr/local/bin/code",
69
+ "/snap/bin/code",
70
+ "/var/lib/flatpak/exports/bin/com.visualstudio.code",
71
+ ];
72
+ for (const candidate of linuxCandidates) {
73
+ if (existsSync(candidate))
74
+ return candidate;
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ /**
80
+ * Resolves the editor command and flags based on CLI input or defaults.
81
+ *
82
+ * Supports:
83
+ * - Direct command names: "code", "cursor", "vim", "nano"
84
+ * - Full executable paths: "/Applications/Visual Studio Code.app/.../code", "/usr/bin/vim"
85
+ * - Auto-discovery for "code" if not found in PATH
86
+ */
87
+ export function resolveEditorConfig(editorInput) {
88
+ const trimmed = (editorInput || "code").trim();
89
+ // 1. Direct path check (contains slash/backslash or exists)
90
+ const isExplicitPath = trimmed.includes("/") || trimmed.includes("\\") || existsSync(trimmed);
91
+ let resolvedExecutable = trimmed;
92
+ if (isExplicitPath) {
93
+ if (existsSync(trimmed)) {
94
+ resolvedExecutable = trimmed;
95
+ }
96
+ else {
97
+ // Check if tilde expansion needed
98
+ if (trimmed.startsWith("~/")) {
99
+ const expanded = join(homedir(), trimmed.slice(2));
100
+ if (existsSync(expanded)) {
101
+ resolvedExecutable = expanded;
102
+ }
103
+ }
104
+ }
105
+ }
106
+ else {
107
+ // Check if binary is in PATH
108
+ const inPath = findInPath(trimmed);
109
+ if (inPath) {
110
+ resolvedExecutable = inPath;
111
+ }
112
+ else if (trimmed === "code" || trimmed.toLowerCase() === "vscode") {
113
+ // Auto-discover VS Code in well-known locations
114
+ const fallback = findVsCodeFallback();
115
+ if (fallback) {
116
+ resolvedExecutable = fallback;
117
+ }
118
+ }
119
+ }
120
+ const base = basename(resolvedExecutable).toLowerCase();
121
+ const isVsCodeFamily = base === "code" ||
122
+ base === "code.cmd" ||
123
+ base === "code-insiders" ||
124
+ base === "code-insiders.cmd" ||
125
+ base === "codium" ||
126
+ base === "cursor";
127
+ const isVim = base === "vim" || base === "vi" || base === "nvim";
128
+ const isNano = base === "nano";
129
+ if (isVsCodeFamily) {
130
+ return {
131
+ name: isExplicitPath ? basename(trimmed) : trimmed,
132
+ executable: resolvedExecutable,
133
+ editCommand: [resolvedExecutable, "--wait", "{file}"],
134
+ diffCommand: [resolvedExecutable, "--diff", "{before}", "{after}"],
135
+ };
136
+ }
137
+ if (isVim) {
138
+ return {
139
+ name: isExplicitPath ? basename(trimmed) : trimmed,
140
+ executable: resolvedExecutable,
141
+ editCommand: [resolvedExecutable, "{file}"],
142
+ diffCommand: [base === "nvim" ? "nvim" : "vim", "-d", "{before}", "{after}"],
143
+ };
144
+ }
145
+ if (isNano) {
146
+ return {
147
+ name: "nano",
148
+ executable: resolvedExecutable,
149
+ editCommand: [resolvedExecutable, "{file}"],
150
+ diffCommand: ["diff", "-u", "{before}", "{after}"],
151
+ };
152
+ }
153
+ // Generic custom editor
154
+ return {
155
+ name: isExplicitPath ? basename(trimmed) : trimmed,
156
+ executable: resolvedExecutable,
157
+ editCommand: [resolvedExecutable, "{file}"],
158
+ diffCommand: [resolvedExecutable, "{before}", "{after}"],
159
+ };
160
+ }
@@ -1,5 +1,6 @@
1
1
  import { AppConfigSchema } from "./schema.js";
2
2
  import { DEFAULT_CONFIG } from "./defaults.js";
3
+ import { resolveEditorConfig } from "./editor-resolver.js";
3
4
  import { existsSync, readFileSync } from "fs";
4
5
  import { join, resolve } from "path";
5
6
  function loadProjectConfig(repoPath) {
@@ -43,6 +44,9 @@ export function loadConfig(cliInput) {
43
44
  // Load project config from repo directory
44
45
  const repoPath = cliInput.repo || process.cwd();
45
46
  const projectConfig = loadProjectConfig(repoPath);
47
+ // Resolve editor configuration (CLI flag, project config, or auto-discovery)
48
+ const editorInput = cliInput.editor || projectConfig.editor?.name;
49
+ const resolvedEditor = resolveEditorConfig(editorInput);
46
50
  // Merge: defaults < project config < CLI overrides
47
51
  const cliOverrides = {
48
52
  git: {
@@ -52,8 +56,9 @@ export function loadConfig(cliInput) {
52
56
  ...(cliInput.remote ? { remote: cliInput.remote } : {}),
53
57
  },
54
58
  editor: {
55
- ...DEFAULT_CONFIG.editor,
56
- ...(cliInput.editor ? { name: cliInput.editor } : {}),
59
+ name: resolvedEditor.name,
60
+ editCommand: resolvedEditor.editCommand,
61
+ diffCommand: resolvedEditor.diffCommand,
57
62
  },
58
63
  review: {
59
64
  ...DEFAULT_CONFIG.review,
@@ -1,3 +1,11 @@
1
1
  import { Stage } from "../core/pipeline.js";
2
2
  import { RunContext, AppDeps } from "../core/types.js";
3
+ /**
4
+ * Lightweight eligibility pass-through.
5
+ *
6
+ * In-depth eligibility checks (branch readability, dynamic variable existence,
7
+ * and file readiness) are executed directly and concurrently during the planning stage.
8
+ * This eliminates thousands of redundant git subprocess calls and provides instant
9
+ * transition from selection to template editing.
10
+ */
3
11
  export declare const checkEligibilityStage: Stage<RunContext, AppDeps>;
@@ -1,97 +1,18 @@
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";
4
- import { clack, sectionHeader, statusLine } from "../utils/ui.js";
5
- export const checkEligibilityStage = async (context, deps) => {
3
+ /**
4
+ * Lightweight eligibility pass-through.
5
+ *
6
+ * In-depth eligibility checks (branch readability, dynamic variable existence,
7
+ * and file readiness) are executed directly and concurrently during the planning stage.
8
+ * This eliminates thousands of redundant git subprocess calls and provides instant
9
+ * transition from selection to template editing.
10
+ */
11
+ export const checkEligibilityStage = async (context) => {
6
12
  const targets = context.targets || (context.target ? [context.target] : []);
7
13
  if (!context.selectedBranches || targets.length === 0 || !context.project) {
8
14
  return err(workspaceError("Prerequisites missing for eligibility check"));
9
15
  }
10
- sectionHeader("Eligibility Check");
11
- const eligible = [];
12
- const ineligible = [];
13
- const hasEnvTarget = targets.some((t) => t.path === ".env" || t.path.startsWith(".env."));
14
- const total = context.selectedBranches.length;
15
- const isBatchMode = total > 8;
16
- const s = clack.spinner();
17
- if (isBatchMode) {
18
- s.start(`Checking eligibility for ${total} branches across ${targets.length} target file(s)…`);
19
- }
20
- // Concurrent execution pool (up to 16 workers in parallel)
21
- const concurrency = Math.min(16, total);
22
- let completed = 0;
23
- let currentIndex = 0;
24
- const workers = Array.from({ length: concurrency }, async () => {
25
- while (currentIndex < total) {
26
- const idx = currentIndex++;
27
- const branch = context.selectedBranches[idx];
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
- }
56
- }
57
- const targetRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, checkPath);
58
- if (targetRead.ok) {
59
- existingFileCount++;
60
- }
61
- }
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`;
70
- eligible.push(branch);
71
- completed++;
72
- if (isBatchMode) {
73
- s.message(`Checking eligibility (${completed}/${total})…`);
74
- }
75
- else {
76
- statusLine("ok", branch.name, `${branch.sha.slice(0, 8)} — ${fileStatus}${envNote}`);
77
- }
78
- }
79
- });
80
- await Promise.all(workers);
81
- if (isBatchMode) {
82
- s.stop(`Checked ${total} branches (${eligible.length} eligible)`);
83
- }
84
- if (ineligible.length > 0) {
85
- console.log("");
86
- clack.log.warn(`${ineligible.length} branch(es) ineligible:`);
87
- for (const { branch, reason } of ineligible) {
88
- statusLine("warn", branch.name, reason);
89
- }
90
- }
91
- if (eligible.length === 0) {
92
- clack.log.error("No eligible branches found.");
93
- return err(workspaceError("No eligible branches"));
94
- }
95
- statusLine("ok", `${eligible.length} eligible branch(es) ready`);
16
+ const eligible = [...context.selectedBranches];
96
17
  return ok({ ...context, eligible });
97
18
  };
@@ -7,102 +7,276 @@ import { sha256, planHash as computePlanHash } from "../utils/hash.js";
7
7
  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
+ class AsyncMutex {
11
+ queue = Promise.resolve();
12
+ async runExclusive(fn) {
13
+ let release;
14
+ const wait = new Promise((r) => (release = r));
15
+ const previous = this.queue;
16
+ this.queue = wait;
17
+ await previous;
18
+ try {
19
+ return await fn();
20
+ }
21
+ finally {
22
+ release();
23
+ }
24
+ }
25
+ }
10
26
  export const planChangesStage = async (context, deps) => {
11
27
  const templates = context.templates || (context.activeTemplate ? [context.activeTemplate] : []);
12
- if (templates.length === 0 || !context.eligible || !context.project) {
28
+ const branches = context.eligible || context.selectedBranches || [];
29
+ if (templates.length === 0 || branches.length === 0 || !context.project) {
13
30
  return err(workspaceError("Prerequisites missing for planning"));
14
31
  }
15
- sectionHeader(`Planning Changes across ${context.eligible.length} branch(es) & ${templates.length} template(s)`);
16
- const plans = [];
32
+ const total = branches.length;
33
+ const isBatchMode = total > 6;
17
34
  const isCi = context.input.ci ?? false;
18
- for (const branch of context.eligible) {
19
- const s = clack.spinner();
20
- s.start(`Planning ${branch.name}…`);
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);
35
+ const promptMutex = new AsyncMutex();
36
+ sectionHeader(`Planning & Verifying across ${total} branch(es) & ${templates.length} file(s)`);
37
+ const s = clack.spinner();
38
+ if (isBatchMode) {
39
+ s.start(`Planning changes for ${total} branches…`);
40
+ }
41
+ const branchPlansMap = new Map();
42
+ let completed = 0;
43
+ let currentIndex = 0;
44
+ const concurrency = Math.min(16, total);
45
+ const workers = Array.from({ length: concurrency }, async () => {
46
+ while (currentIndex < total) {
47
+ const idx = currentIndex++;
48
+ const branch = branches[idx];
49
+ const branchPlans = [];
50
+ if (!isBatchMode) {
51
+ s.start(`Planning ${branch.name}…`);
52
+ }
53
+ // 1. Read branch's .env to extract variables and dynamic path values
54
+ const envRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, ".env");
55
+ const envMap = new Map();
56
+ if (envRead.ok) {
57
+ const parsedEnv = parseEnvFile(envRead.value);
58
+ if (parsedEnv.ok) {
59
+ for (const [key, entry] of parsedEnv.value.map) {
60
+ envMap.set(key, entry.value);
61
+ envMap.set(key.toLowerCase(), entry.value);
62
+ }
29
63
  }
30
64
  }
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;
65
+ // Resolve branch-level servicename
66
+ let branchServiceName = envMap.get("servicename") ||
67
+ envMap.get("service_name") ||
68
+ envMap.get("SERVICE_NAME") ||
69
+ envMap.get("SERVICENAME");
70
+ // Fallback: check if an env template in context statically defines servicename
71
+ if (!branchServiceName) {
72
+ const envTmpl = templates.find((t) => t.engine === "env-reconcile");
73
+ if (envTmpl?.parsedEnv) {
74
+ const assign = envTmpl.parsedEnv.assignments.find((a) => a.key === "servicename" ||
75
+ a.key === "SERVICE_NAME" ||
76
+ a.key.toLowerCase() === "servicename");
77
+ if (assign && assign.isStatic && assign.value) {
78
+ branchServiceName = assign.value;
79
+ }
46
80
  }
47
81
  }
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) {
82
+ let branchChangedCount = 0;
83
+ let branchErrorCount = 0;
84
+ for (const template of templates) {
85
+ let targetPath = template.path;
86
+ // Dynamic path substitution (e.g. {servicename}/requirements.txt)
87
+ if (template.isDynamic || targetPath.includes("{servicename}")) {
88
+ const varName = template.dynamicVar || "servicename";
89
+ const val = branchServiceName ||
90
+ envMap.get(varName) ||
91
+ envMap.get(varName.toLowerCase()) ||
92
+ envMap.get(varName.toUpperCase());
93
+ if (!val) {
94
+ branchErrorCount++;
95
+ const errorReason = `Missing '${varName}' in branch .env required for dynamic path ${targetPath}`;
96
+ branchPlans.push({
97
+ branch,
98
+ baseCommitSha: branch.sha,
99
+ path: targetPath,
100
+ beforeContent: "",
101
+ beforeContentHash: sha256(""),
102
+ afterContent: "",
103
+ afterContentHash: sha256(""),
104
+ diff: "",
105
+ planHash: computePlanHash(branch.name, branch.sha, targetPath, sha256(""), sha256("")),
106
+ status: "error",
107
+ reason: errorReason,
108
+ addedKeys: [],
109
+ updatedKeys: [],
110
+ });
111
+ continue;
112
+ }
113
+ targetPath = targetPath.replace(`{${varName}}`, val);
114
+ }
115
+ const isEnv = targetPath === ".env" || targetPath.startsWith(".env.");
116
+ const engine = template.engine ?? (isEnv ? "env-reconcile" : "source-of-truth");
117
+ const templateRaw = template.raw;
118
+ // ─────────────────────────────────────────────────────────────────
119
+ // Source-of-Truth Engine
120
+ // ─────────────────────────────────────────────────────────────────
121
+ if (engine === "source-of-truth") {
122
+ const targetRead = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, targetPath);
123
+ const isNewFile = !targetRead.ok;
124
+ const beforeContent = targetRead.ok ? targetRead.value : "";
125
+ const beforeContentHash = sha256(beforeContent);
126
+ if (branchServiceName && !envMap.has("servicename")) {
127
+ envMap.set("servicename", branchServiceName);
128
+ envMap.set("SERVICE_NAME", branchServiceName);
129
+ }
130
+ const interpolated = interpolateTemplate(templateRaw, envMap, {
131
+ branchName: branch.name,
132
+ repoName: context.project.id,
133
+ commitSha: branch.sha,
134
+ });
135
+ if (!interpolated.ok) {
136
+ branchErrorCount++;
137
+ branchPlans.push({
138
+ branch,
139
+ baseCommitSha: branch.sha,
140
+ path: targetPath,
141
+ beforeContent,
142
+ beforeContentHash,
143
+ afterContent: beforeContent,
144
+ afterContentHash: beforeContentHash,
145
+ diff: "",
146
+ planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
147
+ status: "error",
148
+ reason: interpolated.error.message,
149
+ addedKeys: [],
150
+ updatedKeys: [],
151
+ isNewFile,
152
+ });
153
+ continue;
154
+ }
155
+ const { content: afterContent, missingVars } = interpolated.value;
156
+ if (missingVars.length > 0) {
157
+ branchErrorCount++;
158
+ const errorReason = `Missing required variable(s) from .env: ${missingVars.join(", ")}`;
159
+ branchPlans.push({
160
+ branch,
161
+ baseCommitSha: branch.sha,
162
+ path: targetPath,
163
+ beforeContent,
164
+ beforeContentHash,
165
+ afterContent: beforeContent,
166
+ afterContentHash: beforeContentHash,
167
+ diff: "",
168
+ planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
169
+ status: "error",
170
+ reason: errorReason,
171
+ addedKeys: [],
172
+ updatedKeys: [],
173
+ missingVars,
174
+ isNewFile,
175
+ });
176
+ continue;
177
+ }
178
+ const afterContentHash = sha256(afterContent);
179
+ const changed = afterContent !== beforeContent;
180
+ const diff = computeDiff(beforeContent, afterContent, targetPath);
181
+ const hash = computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, afterContentHash);
182
+ if (changed) {
183
+ branchChangedCount++;
184
+ }
185
+ branchPlans.push({
186
+ branch,
187
+ baseCommitSha: branch.sha,
188
+ path: targetPath,
189
+ beforeContent,
190
+ beforeContentHash,
191
+ afterContent,
192
+ afterContentHash,
193
+ diff,
194
+ planHash: hash,
195
+ status: changed ? "changed" : "unchanged",
196
+ addedKeys: isNewFile ? [targetPath] : [],
197
+ updatedKeys: !isNewFile && changed ? [targetPath] : [],
198
+ isNewFile,
199
+ });
200
+ continue;
201
+ }
202
+ // ─────────────────────────────────────────────────────────────────
203
+ // Env-Reconcile Engine
204
+ // ─────────────────────────────────────────────────────────────────
205
+ const readResult = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, targetPath);
206
+ const beforeContent = readResult.ok ? readResult.value : "";
207
+ const beforeContentHash = sha256(beforeContent);
208
+ const existingParse = parseEnvFile(beforeContent);
209
+ if (!existingParse.ok) {
61
210
  branchErrorCount++;
62
- const errorReason = `Missing '${varName}' in branch .env required for dynamic path ${targetPath}`;
63
- plans.push({
211
+ branchPlans.push({
64
212
  branch,
65
213
  baseCommitSha: branch.sha,
66
214
  path: targetPath,
67
- beforeContent: "",
68
- beforeContentHash: sha256(""),
69
- afterContent: "",
70
- afterContentHash: sha256(""),
215
+ beforeContent,
216
+ beforeContentHash,
217
+ afterContent: beforeContent,
218
+ afterContentHash: beforeContentHash,
71
219
  diff: "",
72
- planHash: computePlanHash(branch.name, branch.sha, targetPath, sha256(""), sha256("")),
220
+ planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
73
221
  status: "error",
74
- reason: errorReason,
222
+ reason: existingParse.error.message,
75
223
  addedKeys: [],
76
224
  updatedKeys: [],
77
225
  });
78
226
  continue;
79
227
  }
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);
228
+ const existingMap = existingParse.value.map;
229
+ const branchValues = new Map();
230
+ let hadError = false;
231
+ let errorReason = "";
232
+ const envTemplate = template.parsedEnv ?? context.template;
233
+ if (envTemplate) {
234
+ for (const assignment of envTemplate.assignments) {
235
+ if (!assignment.isPlaceholder || !assignment.placeholderName)
236
+ continue;
237
+ if (existingMap.has(assignment.key)) {
238
+ branchValues.set(assignment.key, existingMap.get(assignment.key).value);
239
+ }
240
+ else {
241
+ if (isCi) {
242
+ hadError = true;
243
+ errorReason = `Missing value for '${assignment.key}' in CI mode`;
244
+ break;
245
+ }
246
+ // Interactive prompt under mutex
247
+ await promptMutex.runExclusive(async () => {
248
+ s.stop(`${branch.name} — needs input`);
249
+ console.log("");
250
+ console.log(chalk.bold(` ${sym.branch} Branch: ${chalk.cyan(branch.name)}`));
251
+ console.log(chalk.yellow(` Missing key in ${targetPath}: ${chalk.bold(assignment.key)}`));
252
+ const value = await clack.text({
253
+ message: `Value for ${chalk.bold(assignment.key)} in ${chalk.cyan(branch.name)}:`,
254
+ placeholder: `enter value for ${assignment.key}`,
255
+ validate: (v) => {
256
+ if (!v || v.trim() === "")
257
+ return `Value cannot be empty`;
258
+ },
259
+ });
260
+ if (clack.isCancel(value)) {
261
+ hadError = true;
262
+ errorReason = "User cancelled value input";
263
+ s.stop(chalk.yellow(`${branch.name} — skipped`));
264
+ }
265
+ else {
266
+ branchValues.set(assignment.key, value);
267
+ }
268
+ if (isBatchMode) {
269
+ s.start(`Planning changes (${completed}/${total})…`);
270
+ }
271
+ });
272
+ if (hadError)
273
+ break;
274
+ }
275
+ }
97
276
  }
98
- const interpolated = interpolateTemplate(templateRaw, envMap, {
99
- branchName: branch.name,
100
- repoName: context.project.id,
101
- commitSha: branch.sha,
102
- });
103
- if (!interpolated.ok) {
277
+ if (hadError) {
104
278
  branchErrorCount++;
105
- plans.push({
279
+ branchPlans.push({
106
280
  branch,
107
281
  baseCommitSha: branch.sha,
108
282
  path: targetPath,
@@ -113,18 +287,16 @@ export const planChangesStage = async (context, deps) => {
113
287
  diff: "",
114
288
  planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
115
289
  status: "error",
116
- reason: interpolated.error.message,
290
+ reason: errorReason,
117
291
  addedKeys: [],
118
292
  updatedKeys: [],
119
- isNewFile,
120
293
  });
121
294
  continue;
122
295
  }
123
- const { content: afterContent, missingVars } = interpolated.value;
124
- if (missingVars.length > 0) {
296
+ const transformResult = transformEnv(beforeContent, envTemplate, branchValues);
297
+ if (!transformResult.ok) {
125
298
  branchErrorCount++;
126
- const errorReason = `Missing required variable(s) from .env: ${missingVars.join(", ")}`;
127
- plans.push({
299
+ branchPlans.push({
128
300
  branch,
129
301
  baseCommitSha: branch.sha,
130
302
  path: targetPath,
@@ -135,22 +307,20 @@ export const planChangesStage = async (context, deps) => {
135
307
  diff: "",
136
308
  planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
137
309
  status: "error",
138
- reason: errorReason,
310
+ reason: transformResult.error.message,
139
311
  addedKeys: [],
140
312
  updatedKeys: [],
141
- missingVars,
142
- isNewFile,
143
313
  });
144
314
  continue;
145
315
  }
316
+ const { content: afterContent, changed, addedKeys, updatedKeys } = transformResult.value;
146
317
  const afterContentHash = sha256(afterContent);
147
- const changed = afterContent !== beforeContent;
148
318
  const diff = computeDiff(beforeContent, afterContent, targetPath);
149
319
  const hash = computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, afterContentHash);
150
320
  if (changed) {
151
321
  branchChangedCount++;
152
322
  }
153
- plans.push({
323
+ branchPlans.push({
154
324
  branch,
155
325
  baseCommitSha: branch.sha,
156
326
  path: targetPath,
@@ -161,154 +331,44 @@ export const planChangesStage = async (context, deps) => {
161
331
  diff,
162
332
  planHash: hash,
163
333
  status: changed ? "changed" : "unchanged",
164
- addedKeys: isNewFile ? [targetPath] : [],
165
- updatedKeys: !isNewFile && changed ? [targetPath] : [],
166
- isNewFile,
334
+ addedKeys,
335
+ updatedKeys,
167
336
  });
168
- continue;
169
337
  }
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 : "";
175
- const beforeContentHash = sha256(beforeContent);
176
- const existingParse = parseEnvFile(beforeContent);
177
- if (!existingParse.ok) {
178
- branchErrorCount++;
179
- plans.push({
180
- branch,
181
- baseCommitSha: branch.sha,
182
- path: targetPath,
183
- beforeContent,
184
- beforeContentHash,
185
- afterContent: beforeContent,
186
- afterContentHash: beforeContentHash,
187
- diff: "",
188
- planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
189
- status: "error",
190
- reason: existingParse.error.message,
191
- addedKeys: [],
192
- updatedKeys: [],
193
- });
194
- continue;
338
+ branchPlansMap.set(idx, branchPlans);
339
+ completed++;
340
+ if (isBatchMode) {
341
+ s.message(`Planning & verifying (${completed}/${total})…`);
195
342
  }
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
- }
343
+ else {
344
+ if (branchErrorCount > 0) {
345
+ s.stop(chalk.red(`${branch.name} — ${branchErrorCount} error(s)`));
346
+ }
347
+ else if (branchChangedCount > 0) {
348
+ s.stop(chalk.green(`${branch.name} — ${branchChangedCount} file(s) changed`));
349
+ }
350
+ else {
351
+ s.stop(chalk.gray(`${branch.name} — no changes`));
235
352
  }
236
353
  }
237
- if (hadError) {
238
- branchErrorCount++;
239
- plans.push({
240
- branch,
241
- baseCommitSha: branch.sha,
242
- path: targetPath,
243
- beforeContent,
244
- beforeContentHash,
245
- afterContent: beforeContent,
246
- afterContentHash: beforeContentHash,
247
- diff: "",
248
- planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
249
- status: "error",
250
- reason: errorReason,
251
- addedKeys: [],
252
- updatedKeys: [],
253
- });
254
- continue;
255
- }
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;
277
- const afterContentHash = sha256(afterContent);
278
- const diff = computeDiff(beforeContent, afterContent, targetPath);
279
- const hash = computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, afterContentHash);
280
- if (changed) {
281
- branchChangedCount++;
282
- }
283
- plans.push({
284
- branch,
285
- baseCommitSha: branch.sha,
286
- path: targetPath,
287
- beforeContent,
288
- beforeContentHash,
289
- afterContent,
290
- afterContentHash,
291
- diff,
292
- planHash: hash,
293
- status: changed ? "changed" : "unchanged",
294
- addedKeys,
295
- updatedKeys,
296
- });
297
- }
298
- if (branchErrorCount > 0) {
299
- s.stop(chalk.red(`${branch.name} — ${branchErrorCount} error(s)`));
300
- }
301
- else if (branchChangedCount > 0) {
302
- s.stop(chalk.green(`${branch.name} — ${branchChangedCount} file(s) changed`));
303
- }
304
- else {
305
- s.stop(chalk.gray(`${branch.name} — no changes`));
306
354
  }
355
+ });
356
+ await Promise.all(workers);
357
+ // Flatten plans in deterministic branch order
358
+ const allPlans = [];
359
+ for (let i = 0; i < total; i++) {
360
+ const list = branchPlansMap.get(i) || [];
361
+ allPlans.push(...list);
362
+ }
363
+ const changed = allPlans.filter((p) => p.status === "changed").length;
364
+ const unchanged = allPlans.filter((p) => p.status === "unchanged").length;
365
+ const errors = allPlans.filter((p) => p.status === "error").length;
366
+ if (isBatchMode) {
367
+ s.stop(`Planned ${total} branch(es) — ${changed} changed, ${unchanged} unchanged, ${errors} error(s)`);
368
+ }
369
+ else {
370
+ console.log("");
371
+ statusLine("ok", `Planning complete — ${changed} file change(s) across branches (${unchanged} unchanged, ${errors} error(s))`);
307
372
  }
308
- const changed = plans.filter((p) => p.status === "changed").length;
309
- const unchanged = plans.filter((p) => p.status === "unchanged").length;
310
- const errors = plans.filter((p) => p.status === "error").length;
311
- console.log("");
312
- statusLine("ok", `Planning complete — ${changed} file change(s) across branches (${unchanged} unchanged, ${errors} error(s))`);
313
- return ok({ ...context, plans });
373
+ return ok({ ...context, plans: allPlans });
314
374
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samaksh/git-worker",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Safe, composable TypeScript CLI/TUI for applying reviewed file changes across many Git branches",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",