@samaksh/git-worker 0.3.1 → 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 +2 -2
- package/dist/config/editor-resolver.d.ts +15 -0
- package/dist/config/editor-resolver.js +160 -0
- package/dist/config/load.js +7 -2
- package/dist/stages/eligibility.d.ts +8 -0
- package/dist/stages/eligibility.js +10 -89
- package/dist/stages/plan.js +288 -228
- package/package.json +1 -1
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.
|
|
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 <
|
|
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
|
+
}
|
package/dist/config/load.js
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/stages/plan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
16
|
-
const
|
|
32
|
+
const total = branches.length;
|
|
33
|
+
const isBatchMode = total > 6;
|
|
17
34
|
const isCi = context.input.ci ?? false;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
plans.push({
|
|
211
|
+
branchPlans.push({
|
|
64
212
|
branch,
|
|
65
213
|
baseCommitSha: branch.sha,
|
|
66
214
|
path: targetPath,
|
|
67
|
-
beforeContent
|
|
68
|
-
beforeContentHash
|
|
69
|
-
afterContent:
|
|
70
|
-
afterContentHash:
|
|
215
|
+
beforeContent,
|
|
216
|
+
beforeContentHash,
|
|
217
|
+
afterContent: beforeContent,
|
|
218
|
+
afterContentHash: beforeContentHash,
|
|
71
219
|
diff: "",
|
|
72
|
-
planHash: computePlanHash(branch.name, branch.sha, targetPath,
|
|
220
|
+
planHash: computePlanHash(branch.name, branch.sha, targetPath, beforeContentHash, beforeContentHash),
|
|
73
221
|
status: "error",
|
|
74
|
-
reason:
|
|
222
|
+
reason: existingParse.error.message,
|
|
75
223
|
addedKeys: [],
|
|
76
224
|
updatedKeys: [],
|
|
77
225
|
});
|
|
78
226
|
continue;
|
|
79
227
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
290
|
+
reason: errorReason,
|
|
117
291
|
addedKeys: [],
|
|
118
292
|
updatedKeys: [],
|
|
119
|
-
isNewFile,
|
|
120
293
|
});
|
|
121
294
|
continue;
|
|
122
295
|
}
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
296
|
+
const transformResult = transformEnv(beforeContent, envTemplate, branchValues);
|
|
297
|
+
if (!transformResult.ok) {
|
|
125
298
|
branchErrorCount++;
|
|
126
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
165
|
-
updatedKeys
|
|
166
|
-
isNewFile,
|
|
334
|
+
addedKeys,
|
|
335
|
+
updatedKeys,
|
|
167
336
|
});
|
|
168
|
-
continue;
|
|
169
337
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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