@samaksh/git-worker 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -2
- package/dist/app.d.ts +2 -0
- package/dist/app.js +48 -0
- package/dist/cli/commands.d.ts +6 -0
- package/dist/cli/commands.js +111 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +115 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +44 -0
- package/dist/config/load.d.ts +3 -0
- package/dist/config/load.js +65 -0
- package/dist/config/schema.d.ts +200 -0
- package/dist/config/schema.js +45 -0
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/pipeline.d.ts +3 -0
- package/dist/core/pipeline.js +14 -0
- package/dist/core/types.d.ts +153 -0
- package/dist/core/types.js +16 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/services/editor/index.d.ts +9 -0
- package/dist/services/editor/index.js +59 -0
- package/dist/services/git/ops.d.ts +30 -0
- package/dist/services/git/ops.js +139 -0
- package/dist/services/git/reader.d.ts +14 -0
- package/dist/services/git/reader.js +195 -0
- package/dist/services/report/index.d.ts +18 -0
- package/dist/services/report/index.js +68 -0
- package/dist/services/review/index.d.ts +30 -0
- package/dist/services/review/index.js +59 -0
- package/dist/services/state/index.d.ts +20 -0
- package/dist/services/state/index.js +49 -0
- package/dist/services/template/env.d.ts +23 -0
- package/dist/services/template/env.js +146 -0
- package/dist/services/template/interpolator.d.ts +30 -0
- package/dist/services/template/interpolator.js +75 -0
- package/dist/services/template/transform.d.ts +22 -0
- package/dist/services/template/transform.js +75 -0
- package/dist/services/template/validator.d.ts +5 -0
- package/dist/services/template/validator.js +68 -0
- package/dist/services/workspace/cache.d.ts +32 -0
- package/dist/services/workspace/cache.js +107 -0
- package/dist/services/workspace/manager.d.ts +44 -0
- package/dist/services/workspace/manager.js +301 -0
- package/dist/stages/apply.d.ts +3 -0
- package/dist/stages/apply.js +161 -0
- package/dist/stages/edit-template.d.ts +3 -0
- package/dist/stages/edit-template.js +198 -0
- package/dist/stages/eligibility.d.ts +3 -0
- package/dist/stages/eligibility.js +68 -0
- package/dist/stages/load-template.d.ts +3 -0
- package/dist/stages/load-template.js +168 -0
- package/dist/stages/plan.d.ts +3 -0
- package/dist/stages/plan.js +270 -0
- package/dist/stages/push.d.ts +3 -0
- package/dist/stages/push.js +99 -0
- package/dist/stages/report.d.ts +3 -0
- package/dist/stages/report.js +103 -0
- package/dist/stages/resolve-project.d.ts +3 -0
- package/dist/stages/resolve-project.js +294 -0
- package/dist/stages/review.d.ts +3 -0
- package/dist/stages/review.js +197 -0
- package/dist/stages/select.d.ts +13 -0
- package/dist/stages/select.js +265 -0
- package/dist/stages/sync.d.ts +3 -0
- package/dist/stages/sync.js +47 -0
- package/dist/utils/hash.d.ts +2 -0
- package/dist/utils/hash.js +7 -0
- package/dist/utils/ui.d.ts +21 -0
- package/dist/utils/ui.js +113 -0
- package/dist/utils/which.d.ts +5 -0
- package/dist/utils/which.js +16 -0
- package/docs/FEATURES.md +84 -0
- package/docs/SERVICES.md +57 -0
- package/docs/architecture.md +38 -0
- package/docs/usage.md +337 -0
- package/package.json +58 -4
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { ok, err } from "../core/types.js";
|
|
2
|
+
import { workspaceError, inputCancelledError } from "../core/errors.js";
|
|
3
|
+
import { clack, sectionHeader, statusLine, sym } from "../utils/ui.js";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
/**
|
|
6
|
+
* Matches a branch name against a pattern.
|
|
7
|
+
* Supports:
|
|
8
|
+
* - Regex: /uat.*\/, /^release\//, /pattern/i
|
|
9
|
+
* - Glob: uat/*, *orders, uat*
|
|
10
|
+
* - Exact name: main, develop
|
|
11
|
+
* - Trailing slash prefix: uat/
|
|
12
|
+
* - Substring fallback: orders
|
|
13
|
+
*/
|
|
14
|
+
export function matchesBranchPattern(name, pattern) {
|
|
15
|
+
const trimmed = pattern.trim();
|
|
16
|
+
if (!trimmed)
|
|
17
|
+
return false;
|
|
18
|
+
// 1. Regex pattern: /regex/ or /regex/flags
|
|
19
|
+
const regexMatch = trimmed.match(/^\/(.+)\/([a-z]*)$/i);
|
|
20
|
+
if (regexMatch) {
|
|
21
|
+
try {
|
|
22
|
+
return new RegExp(regexMatch[1], regexMatch[2]).test(name);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// 2. Glob pattern with * or ?
|
|
29
|
+
if (trimmed.includes("*") || trimmed.includes("?")) {
|
|
30
|
+
const escaped = trimmed
|
|
31
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
32
|
+
.replace(/\*/g, ".*")
|
|
33
|
+
.replace(/\?/g, ".");
|
|
34
|
+
try {
|
|
35
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// 3. Exact match
|
|
42
|
+
if (name === trimmed) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
// 4. Prefix match if pattern ends with '/'
|
|
46
|
+
if (trimmed.endsWith("/") && name.startsWith(trimmed)) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
// 5. Fallback substring match
|
|
50
|
+
return name.includes(trimmed);
|
|
51
|
+
}
|
|
52
|
+
export const selectTargetsStage = async (context, deps) => {
|
|
53
|
+
if (!context.project) {
|
|
54
|
+
return err(workspaceError("Project not resolved"));
|
|
55
|
+
}
|
|
56
|
+
sectionHeader("Branch & Target Selection");
|
|
57
|
+
const s = clack.spinner();
|
|
58
|
+
s.start("Listing branches…");
|
|
59
|
+
const result = await deps.services.git.listBranches(context.project.managedRepoPath, deps.config.git.remote);
|
|
60
|
+
if (!result.ok) {
|
|
61
|
+
s.stop(chalk.red("Failed to list branches"));
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
s.stop(`Found ${result.value.length} branches`);
|
|
65
|
+
let branches = result.value;
|
|
66
|
+
// De-duplicate by name (prefer remote over local)
|
|
67
|
+
const seen = new Map();
|
|
68
|
+
for (const b of branches) {
|
|
69
|
+
if (!seen.has(b.name) || b.isRemote) {
|
|
70
|
+
seen.set(b.name, b);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
branches = Array.from(seen.values());
|
|
74
|
+
// Filter by pattern if supplied via CLI
|
|
75
|
+
if (context.input.branch) {
|
|
76
|
+
const pattern = context.input.branch;
|
|
77
|
+
const invert = Boolean(context.input.invertBranch);
|
|
78
|
+
branches = branches.filter((b) => {
|
|
79
|
+
const matched = matchesBranchPattern(b.name, pattern);
|
|
80
|
+
return invert ? !matched : matched;
|
|
81
|
+
});
|
|
82
|
+
statusLine("info", `Pattern '${pattern}'${invert ? " (inverted)" : ""} matched ${branches.length} branch(es)`);
|
|
83
|
+
}
|
|
84
|
+
// Interactive branch selection (non-CI when no --branch provided)
|
|
85
|
+
if (!context.input.ci && !context.input.branch) {
|
|
86
|
+
if (branches.length === 0) {
|
|
87
|
+
clack.log.warn("No branches found in this repository.");
|
|
88
|
+
return err(inputCancelledError("No branches available"));
|
|
89
|
+
}
|
|
90
|
+
const allBranches = branches;
|
|
91
|
+
let selectedNames = new Set();
|
|
92
|
+
while (true) {
|
|
93
|
+
const selectedCount = selectedNames.size;
|
|
94
|
+
const totalCount = allBranches.length;
|
|
95
|
+
const options = [];
|
|
96
|
+
if (selectedCount > 0) {
|
|
97
|
+
options.push({
|
|
98
|
+
value: "confirm",
|
|
99
|
+
label: `➜ Confirm and proceed with ${selectedCount} branch(es)`,
|
|
100
|
+
hint: "proceed to target file selection",
|
|
101
|
+
});
|
|
102
|
+
options.push({
|
|
103
|
+
value: "list",
|
|
104
|
+
label: "📋 Search & select branches from list",
|
|
105
|
+
hint: "type to filter, Space/Tab to toggle",
|
|
106
|
+
});
|
|
107
|
+
options.push({
|
|
108
|
+
value: "pattern",
|
|
109
|
+
label: "🔍 Filter by pattern or regex",
|
|
110
|
+
hint: "glob (uat/*) or regex (/uat.*/)",
|
|
111
|
+
});
|
|
112
|
+
options.push({
|
|
113
|
+
value: "invert",
|
|
114
|
+
label: `🔄 Invert selection (select the other ${totalCount - selectedCount} branches)`,
|
|
115
|
+
hint: "flip current selection",
|
|
116
|
+
});
|
|
117
|
+
options.push({
|
|
118
|
+
value: "all",
|
|
119
|
+
label: `🌟 Select ALL branches (${totalCount} branches)`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
options.push({
|
|
124
|
+
value: "list",
|
|
125
|
+
label: "📋 Search & select branches from list",
|
|
126
|
+
hint: "type to filter, Space/Tab to toggle",
|
|
127
|
+
});
|
|
128
|
+
options.push({
|
|
129
|
+
value: "pattern",
|
|
130
|
+
label: "🔍 Filter by pattern or regex",
|
|
131
|
+
hint: "glob (uat/*) or regex (/uat.*/)",
|
|
132
|
+
});
|
|
133
|
+
options.push({
|
|
134
|
+
value: "all",
|
|
135
|
+
label: `🌟 Select ALL branches (${totalCount} branches)`,
|
|
136
|
+
hint: "select every branch in repository",
|
|
137
|
+
});
|
|
138
|
+
options.push({
|
|
139
|
+
value: "invert",
|
|
140
|
+
label: `🔄 Invert selection (select all ${totalCount} branches)`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const previewText = selectedCount > 0
|
|
144
|
+
? `${selectedCount} of ${totalCount} selected: ${chalk.cyan(Array.from(selectedNames).slice(0, 3).join(", ") +
|
|
145
|
+
(selectedCount > 3 ? `, +${selectedCount - 3} more` : ""))}`
|
|
146
|
+
: `0 of ${totalCount} branches selected`;
|
|
147
|
+
const choice = await clack.select({
|
|
148
|
+
message: `Branch selection (${previewText}):`,
|
|
149
|
+
options,
|
|
150
|
+
});
|
|
151
|
+
if (clack.isCancel(choice)) {
|
|
152
|
+
return err(inputCancelledError("Branch selection cancelled"));
|
|
153
|
+
}
|
|
154
|
+
if (choice === "confirm") {
|
|
155
|
+
if (selectedNames.size === 0) {
|
|
156
|
+
clack.log.warn("Please select at least one branch before proceeding.");
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (choice === "all") {
|
|
162
|
+
selectedNames = new Set(allBranches.map((b) => b.name));
|
|
163
|
+
statusLine("ok", `Selected all ${totalCount} branch(es)`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (choice === "invert") {
|
|
167
|
+
const inverted = allBranches.filter((b) => !selectedNames.has(b.name));
|
|
168
|
+
selectedNames = new Set(inverted.map((b) => b.name));
|
|
169
|
+
statusLine("ok", `Inverted selection: ${selectedNames.size} of ${totalCount} branch(es) selected`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (choice === "pattern") {
|
|
173
|
+
const patternInput = await clack.text({
|
|
174
|
+
message: "Enter glob pattern (e.g. uat/*) or regex (e.g. /uat.*/):",
|
|
175
|
+
placeholder: "/uat.*/ or uat/*",
|
|
176
|
+
validate: (v) => {
|
|
177
|
+
if (!v || v.trim().length === 0) {
|
|
178
|
+
return "Please enter a pattern or regex";
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
if (clack.isCancel(patternInput)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const pattern = patternInput.trim();
|
|
186
|
+
const matched = allBranches.filter((b) => matchesBranchPattern(b.name, pattern));
|
|
187
|
+
if (matched.length === 0) {
|
|
188
|
+
clack.log.warn(`Pattern '${pattern}' matched 0 of ${totalCount} branches.`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
selectedNames = new Set(matched.map((b) => b.name));
|
|
192
|
+
statusLine("ok", `Pattern '${pattern}' matched ${matched.length} branch(es):`);
|
|
193
|
+
for (const b of matched.slice(0, 5)) {
|
|
194
|
+
console.log(chalk.gray(` • ${b.name}`));
|
|
195
|
+
}
|
|
196
|
+
if (matched.length > 5) {
|
|
197
|
+
console.log(chalk.gray(` ... and ${matched.length - 5} more`));
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (choice === "list") {
|
|
202
|
+
const promptFn = typeof clack.autocompleteMultiselect === "function"
|
|
203
|
+
? clack.autocompleteMultiselect
|
|
204
|
+
: clack.multiselect;
|
|
205
|
+
const picked = await promptFn({
|
|
206
|
+
message: "Select branches (type to filter, Space/Tab to toggle, Enter to confirm):",
|
|
207
|
+
placeholder: "Type branch name or pattern…",
|
|
208
|
+
options: allBranches.map((b) => ({
|
|
209
|
+
value: b.name,
|
|
210
|
+
label: `${sym.branch} ${b.name}`,
|
|
211
|
+
hint: b.isRemote
|
|
212
|
+
? `remote/${b.remote} ${b.sha.slice(0, 8)}`
|
|
213
|
+
: `local ${b.sha.slice(0, 8)}`,
|
|
214
|
+
})),
|
|
215
|
+
filter: (input, option) => {
|
|
216
|
+
if (!input || !input.trim())
|
|
217
|
+
return true;
|
|
218
|
+
const query = input.trim().toLowerCase();
|
|
219
|
+
const val = String(option.value).toLowerCase();
|
|
220
|
+
if (query.startsWith("/") || query.includes("*") || query.includes("?")) {
|
|
221
|
+
return matchesBranchPattern(val, query);
|
|
222
|
+
}
|
|
223
|
+
return val.includes(query);
|
|
224
|
+
},
|
|
225
|
+
initialValues: Array.from(selectedNames),
|
|
226
|
+
required: false,
|
|
227
|
+
maxItems: 12,
|
|
228
|
+
});
|
|
229
|
+
if (clack.isCancel(picked)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
selectedNames = new Set(picked);
|
|
233
|
+
statusLine("ok", `Selection updated: ${selectedNames.size} branch(es) selected`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
branches = allBranches.filter((b) => selectedNames.has(b.name));
|
|
238
|
+
}
|
|
239
|
+
if (branches.length === 0) {
|
|
240
|
+
clack.log.warn("No branches selected.");
|
|
241
|
+
return err(inputCancelledError("No branches selected"));
|
|
242
|
+
}
|
|
243
|
+
// Target file selection
|
|
244
|
+
let targetPath = context.input.file;
|
|
245
|
+
if (!targetPath && !context.input.ci) {
|
|
246
|
+
const fileInput = await clack.text({
|
|
247
|
+
message: "Target file path:",
|
|
248
|
+
placeholder: ".env",
|
|
249
|
+
defaultValue: ".env",
|
|
250
|
+
});
|
|
251
|
+
if (clack.isCancel(fileInput)) {
|
|
252
|
+
return err(inputCancelledError("File selection cancelled"));
|
|
253
|
+
}
|
|
254
|
+
targetPath = fileInput || ".env";
|
|
255
|
+
}
|
|
256
|
+
else if (!targetPath) {
|
|
257
|
+
targetPath = ".env";
|
|
258
|
+
}
|
|
259
|
+
statusLine("ok", `Selected ${branches.length} branch(es) → ${targetPath}`);
|
|
260
|
+
return ok({
|
|
261
|
+
...context,
|
|
262
|
+
selectedBranches: branches,
|
|
263
|
+
target: { path: targetPath },
|
|
264
|
+
});
|
|
265
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ok, err } from "../core/types.js";
|
|
2
|
+
import { workspaceError } from "../core/errors.js";
|
|
3
|
+
import { clack, statusLine } from "../utils/ui.js";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
export const syncRepositoryStage = async (context, deps) => {
|
|
6
|
+
if (!context.project) {
|
|
7
|
+
return err(workspaceError("Project not resolved"));
|
|
8
|
+
}
|
|
9
|
+
const s = clack.spinner();
|
|
10
|
+
s.start(context.project.isCached
|
|
11
|
+
? "Syncing cached repository with remote…"
|
|
12
|
+
: "Cloning repository into managed workspace…");
|
|
13
|
+
const cloneResult = await deps.services.workspace.ensureManagedClone(context.project);
|
|
14
|
+
if (!cloneResult.ok) {
|
|
15
|
+
s.stop(chalk.red("Clone failed"));
|
|
16
|
+
return cloneResult;
|
|
17
|
+
}
|
|
18
|
+
if (cloneResult.value.isCached) {
|
|
19
|
+
s.message("Using cached repository — fetching latest branches…");
|
|
20
|
+
}
|
|
21
|
+
// Acquire workspace lock
|
|
22
|
+
const lockResult = await deps.services.workspace.acquireLock(context.project, deps.config.workspace.lockTimeoutMs);
|
|
23
|
+
if (!lockResult.ok) {
|
|
24
|
+
s.stop(chalk.red("Could not acquire workspace lock"));
|
|
25
|
+
return lockResult;
|
|
26
|
+
}
|
|
27
|
+
// Register lock release on process exit
|
|
28
|
+
const releaseLock = lockResult.value;
|
|
29
|
+
process.on("exit", () => { releaseLock(); });
|
|
30
|
+
process.on("SIGINT", () => { releaseLock(); process.exit(130); });
|
|
31
|
+
process.on("SIGTERM", () => { releaseLock(); process.exit(143); });
|
|
32
|
+
// Fetch latest from remote
|
|
33
|
+
if (context.project.remoteUrl) {
|
|
34
|
+
const fetchResult = await deps.services.git.fetch(context.project.managedRepoPath, deps.config.git.remote, deps.config.git.fetchPrune);
|
|
35
|
+
if (!fetchResult.ok) {
|
|
36
|
+
s.stop(chalk.yellow("Fetch warning — continuing with cached refs"));
|
|
37
|
+
statusLine("warn", fetchResult.error.message);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
s.stop(chalk.green("Repository synced"));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
s.stop(chalk.green("Checkout ready (no remote to fetch)"));
|
|
45
|
+
}
|
|
46
|
+
return ok(context);
|
|
47
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
export function sha256(content) {
|
|
3
|
+
return createHash("sha256").update(content).digest("hex");
|
|
4
|
+
}
|
|
5
|
+
export function planHash(branchName, baseCommitSha, targetPath, beforeContentHash, afterContentHash) {
|
|
6
|
+
return sha256(`${branchName}|${baseCommitSha}|${targetPath}|${beforeContentHash}|${afterContentHash}`);
|
|
7
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as clack from "@clack/prompts";
|
|
2
|
+
export { clack };
|
|
3
|
+
export declare const sym: {
|
|
4
|
+
check: string;
|
|
5
|
+
cross: string;
|
|
6
|
+
skip: string;
|
|
7
|
+
dot: string;
|
|
8
|
+
warn: string;
|
|
9
|
+
arrow: string;
|
|
10
|
+
branch: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function banner(): void;
|
|
13
|
+
export declare function sectionHeader(title: string): void;
|
|
14
|
+
export declare function divider(): void;
|
|
15
|
+
export declare function statusLine(status: "ok" | "fail" | "warn" | "skip" | "info", message: string, detail?: string): void;
|
|
16
|
+
export declare function renderDiff(diff: string, redact?: boolean): void;
|
|
17
|
+
export declare function renderBranchTable(rows: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
status: string;
|
|
20
|
+
detail?: string;
|
|
21
|
+
}>): void;
|
package/dist/utils/ui.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal UI utilities using chalk for color and @clack/prompts for interactive prompts.
|
|
3
|
+
* All UX-facing output goes through this module.
|
|
4
|
+
*/
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import * as clack from "@clack/prompts";
|
|
7
|
+
export { clack };
|
|
8
|
+
// ── Symbols ────────────────────────────────────────────────────────────────
|
|
9
|
+
export const sym = {
|
|
10
|
+
check: chalk.green("✓"),
|
|
11
|
+
cross: chalk.red("✗"),
|
|
12
|
+
skip: chalk.gray("–"),
|
|
13
|
+
dot: chalk.gray("•"),
|
|
14
|
+
warn: chalk.yellow("⚠"),
|
|
15
|
+
arrow: chalk.cyan("→"),
|
|
16
|
+
branch: chalk.cyan("⎇"),
|
|
17
|
+
};
|
|
18
|
+
// ── Box / header ───────────────────────────────────────────────────────────
|
|
19
|
+
export function banner() {
|
|
20
|
+
console.log("");
|
|
21
|
+
console.log(chalk.bold.cyan(" ◆ git-worker"));
|
|
22
|
+
console.log(chalk.gray(" Safe multi-branch file sync"));
|
|
23
|
+
console.log("");
|
|
24
|
+
}
|
|
25
|
+
export function sectionHeader(title) {
|
|
26
|
+
console.log("");
|
|
27
|
+
console.log(chalk.bold(` ${title}`));
|
|
28
|
+
console.log(chalk.gray(" " + "─".repeat(Math.max(title.length, 40))));
|
|
29
|
+
}
|
|
30
|
+
export function divider() {
|
|
31
|
+
console.log(chalk.gray(" " + "─".repeat(60)));
|
|
32
|
+
}
|
|
33
|
+
// ── Status lines ──────────────────────────────────────────────────────────
|
|
34
|
+
export function statusLine(status, message, detail) {
|
|
35
|
+
const icon = status === "ok" ? sym.check :
|
|
36
|
+
status === "fail" ? sym.cross :
|
|
37
|
+
status === "warn" ? sym.warn :
|
|
38
|
+
status === "skip" ? sym.skip :
|
|
39
|
+
sym.dot;
|
|
40
|
+
const color = status === "ok" ? chalk.green :
|
|
41
|
+
status === "fail" ? chalk.red :
|
|
42
|
+
status === "warn" ? chalk.yellow :
|
|
43
|
+
status === "skip" ? chalk.gray :
|
|
44
|
+
chalk.white;
|
|
45
|
+
console.log(` ${icon} ${color(message)}`);
|
|
46
|
+
if (detail)
|
|
47
|
+
console.log(chalk.gray(` ${detail}`));
|
|
48
|
+
}
|
|
49
|
+
// ── Diff rendering ────────────────────────────────────────────────────────
|
|
50
|
+
export function renderDiff(diff, redact = false) {
|
|
51
|
+
if (!diff) {
|
|
52
|
+
console.log(chalk.gray(" (no changes)"));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const lines = diff.split("\n");
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
58
|
+
console.log(chalk.gray(` ${line}`));
|
|
59
|
+
}
|
|
60
|
+
else if (line.startsWith("@@")) {
|
|
61
|
+
console.log(chalk.cyan(` ${line}`));
|
|
62
|
+
}
|
|
63
|
+
else if (line.startsWith("+")) {
|
|
64
|
+
const display = redact ? redactLine(line) : line;
|
|
65
|
+
console.log(chalk.green(` ${display}`));
|
|
66
|
+
}
|
|
67
|
+
else if (line.startsWith("-")) {
|
|
68
|
+
const display = redact ? redactLine(line) : line;
|
|
69
|
+
console.log(chalk.red(` ${display}`));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
console.log(chalk.gray(` ${line}`));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function redactLine(line) {
|
|
77
|
+
// Redact value portion: +KEY=value → +KEY=[REDACTED]
|
|
78
|
+
return line.replace(/^([+-][A-Za-z_][A-Za-z0-9_]*)=.+$/, "$1=[REDACTED]");
|
|
79
|
+
}
|
|
80
|
+
// ── Branch status table ───────────────────────────────────────────────────
|
|
81
|
+
export function renderBranchTable(rows) {
|
|
82
|
+
const maxName = Math.max(...rows.map((r) => r.name.length), 10);
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const name = row.name.padEnd(maxName);
|
|
85
|
+
let statusStr;
|
|
86
|
+
switch (row.status) {
|
|
87
|
+
case "committed":
|
|
88
|
+
statusStr = chalk.green("committed");
|
|
89
|
+
break;
|
|
90
|
+
case "failed":
|
|
91
|
+
statusStr = chalk.red("failed ");
|
|
92
|
+
break;
|
|
93
|
+
case "rejected":
|
|
94
|
+
statusStr = chalk.yellow("rejected ");
|
|
95
|
+
break;
|
|
96
|
+
case "unchanged":
|
|
97
|
+
statusStr = chalk.gray("unchanged");
|
|
98
|
+
break;
|
|
99
|
+
case "skipped":
|
|
100
|
+
statusStr = chalk.gray("skipped ");
|
|
101
|
+
break;
|
|
102
|
+
case "changed":
|
|
103
|
+
statusStr = chalk.cyan("changed ");
|
|
104
|
+
break;
|
|
105
|
+
case "error":
|
|
106
|
+
statusStr = chalk.red("error ");
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
statusStr = chalk.gray(row.status.padEnd(9));
|
|
110
|
+
}
|
|
111
|
+
console.log(` ${sym.branch} ${chalk.white(name)} ${statusStr} ${chalk.gray(row.detail ?? "")}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const execAsync = promisify(exec);
|
|
4
|
+
/**
|
|
5
|
+
* Find the full path of an executable on PATH.
|
|
6
|
+
* Returns null if not found.
|
|
7
|
+
*/
|
|
8
|
+
export async function which(name) {
|
|
9
|
+
try {
|
|
10
|
+
const { stdout } = await execAsync(`which ${name}`);
|
|
11
|
+
return stdout.trim() || null;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/docs/FEATURES.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Features
|
|
2
|
+
|
|
3
|
+
## Core Features
|
|
4
|
+
|
|
5
|
+
### Multi-branch Operations
|
|
6
|
+
- Target multiple branches simultaneously via glob patterns (`uat/*`) or regex patterns (`/uat.*/`)
|
|
7
|
+
- Interactive branch selector with **Select All**, **Invert Selection**, **Pattern Filter**, and manual checkbox list
|
|
8
|
+
- Command-line pattern inversion support via `--invert-branch` (`-i`)
|
|
9
|
+
- No persistent worktree per branch - uses git object reads for planning
|
|
10
|
+
- Per-branch fault isolation - failures don't affect other branches
|
|
11
|
+
|
|
12
|
+
### Safe .env Template System
|
|
13
|
+
- **Static values**: `KEY=value` enforced on every selected branch
|
|
14
|
+
- **Dynamic placeholders**: `KEY={KEY}` reads existing value from each branch
|
|
15
|
+
- **Missing values**: Prompted individually per branch during planning
|
|
16
|
+
- **Unmanaged keys preserved**: Keys not in template are never touched
|
|
17
|
+
- **Comment preservation**: Comments and formatting preserved
|
|
18
|
+
- **Export support**: Handles `export KEY=value` syntax
|
|
19
|
+
- **Quoted values**: Preserves quoted values and spaces correctly
|
|
20
|
+
- **Empty values**: `KEY=` treated as present with empty value
|
|
21
|
+
- **Duplicate detection**: Fails safely if duplicates found
|
|
22
|
+
|
|
23
|
+
### Declarative Source-of-Truth Templates (Dockerfile, JSON, YAML)
|
|
24
|
+
- **Full File Override / Creation**: The template represents the authoritative source of truth.
|
|
25
|
+
- **Variable Substitution**: Replaces `${VAR}` placeholders with values resolved from each branch's `.env`.
|
|
26
|
+
- **Fallback Defaults**: Supports `${VAR:-default}` syntax (e.g. `${PORT:-8080}`) to use fallback values when missing.
|
|
27
|
+
- **Built-in Branch Variables**: Injects `${BRANCH_NAME}`, `${BRANCH_SLUG}`, `${REPO_NAME}`, `${COMMIT_SHA}`, and `${SHORT_SHA}`.
|
|
28
|
+
- **Pre-Plan Syntax Validation**: Validates JSON syntax, YAML indentation/structure, and template delimiters before planning.
|
|
29
|
+
- **State Persistence**: Applied templates are saved in `~/.git-worker/projects/<id>/state/` and automatically restored on subsequent runs.
|
|
30
|
+
|
|
31
|
+
### Review Workflow
|
|
32
|
+
- Plan all changes before any mutations
|
|
33
|
+
- Preview diffs per branch in terminal or external viewer (VS Code/Vim)
|
|
34
|
+
- Approve/reject branches individually
|
|
35
|
+
- Pre-destructive checkout confirmation
|
|
36
|
+
- Branch tip revalidation before apply (stale plans skipped)
|
|
37
|
+
|
|
38
|
+
### Safe Application
|
|
39
|
+
- Uses managed disposable checkout in `~/.git-worker` (separate from original repo)
|
|
40
|
+
- **Requires remote** - refuses repos without remotes by default
|
|
41
|
+
- Workspace ownership verification
|
|
42
|
+
- Cross-process locking
|
|
43
|
+
- Resets/cleans only in managed checkout
|
|
44
|
+
- Stages only target paths
|
|
45
|
+
- Commits per branch independently
|
|
46
|
+
- Push with policy (prompt/always/never), respects CI mode
|
|
47
|
+
|
|
48
|
+
### Repository Discovery & Remote Clones
|
|
49
|
+
- Automatically detects if current directory is a Git repository
|
|
50
|
+
- Lists and selects from already cloned and cached repositories (`name`, `remote-url`)
|
|
51
|
+
- If run outside a repository, prompts to select from cache, clone a remote URL (HTTP/HTTPS/SSH), or provide a local path
|
|
52
|
+
- Direct command-line targeting via `--repo <url-or-path>` or `--cached` (`-c`)
|
|
53
|
+
|
|
54
|
+
### Cache Management & Notifications
|
|
55
|
+
- Centralized storage in `~/.git-worker/projects/<project-id>`
|
|
56
|
+
- Detects existing clones and notifies user of cached repository reuse
|
|
57
|
+
- Interactive cached repository picker showing Name and Remote URL
|
|
58
|
+
- `git-worker cache list` (or `ls`) displays all cached workspaces in a formatted table
|
|
59
|
+
- `git-worker cache clean [target]` (or `clear`) removes specific projects or provides an interactive removal menu
|
|
60
|
+
- `git-worker cache clean --all -y` batch cleans all workspaces safely
|
|
61
|
+
|
|
62
|
+
### State Persistence
|
|
63
|
+
- Saves last successfully applied template
|
|
64
|
+
- Saves on partial success (after first commit or explicit approval)
|
|
65
|
+
- Loaded on subsequent runs
|
|
66
|
+
- No persistent branch-specific bindings
|
|
67
|
+
- Loaded on subsequent runs
|
|
68
|
+
- No persistent branch-specific bindings
|
|
69
|
+
|
|
70
|
+
## Interactive TUI
|
|
71
|
+
- Modern prompts with @clack/prompts
|
|
72
|
+
- Multi-select for branches
|
|
73
|
+
- Per-branch value input
|
|
74
|
+
- Diff review with viewer options
|
|
75
|
+
- Confirmation before destructive operations
|
|
76
|
+
|
|
77
|
+
## CI Mode
|
|
78
|
+
- Non-interactive operation
|
|
79
|
+
- Missing values cause explicit failure/skip (no hanging)
|
|
80
|
+
- Auto-approve or explicit flags required
|
|
81
|
+
- Push policy enforced
|
|
82
|
+
|
|
83
|
+
## Demo Suite
|
|
84
|
+
Run `npm run demo` to test with self-contained multi-branch scenarios.
|
package/docs/SERVICES.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Services Documentation
|
|
2
|
+
|
|
3
|
+
Git Worker uses dependency injection with services for all side effects. Each service is created via a factory function.
|
|
4
|
+
|
|
5
|
+
## Core Services
|
|
6
|
+
|
|
7
|
+
### Git Reader (`services/git/reader.ts`)
|
|
8
|
+
- `listBranches(repoPath)` - Lists all local and remote branches
|
|
9
|
+
- `readFileFromBranch(repoPath, branch, filePath)` - Reads file content from a branch without checkout using `git show`
|
|
10
|
+
- `getBranchSha(repoPath, branchName)` - Gets current SHA of a branch
|
|
11
|
+
- `fetch(repoPath, remote)` - Fetches from remote with prune
|
|
12
|
+
- `getCurrentBranch(repoPath)` - Gets current branch name
|
|
13
|
+
|
|
14
|
+
### Git Ops (`services/git/ops.ts`)
|
|
15
|
+
- `ensureRepo(repoPath)` - Validates it's a git repo
|
|
16
|
+
- `checkoutBranch(repoPath, branchName)` - Checks out a branch
|
|
17
|
+
- `resetHard(repoPath)` - Hard reset (managed checkout only)
|
|
18
|
+
- `clean(repoPath)` - Clean untracked files/dirs
|
|
19
|
+
- `writeAndStage(repoPath, filePath, content)` - Write file and stage it
|
|
20
|
+
- `commit(repoPath, message)` - Create commit
|
|
21
|
+
- `push(repoPath, remote, branchName)` - Push to remote
|
|
22
|
+
- `verifyOwnership(managedRepoPath, markerPath)` - Verifies workspace ownership via marker
|
|
23
|
+
|
|
24
|
+
### Workspace Manager (`services/workspace/manager.ts`)
|
|
25
|
+
- `resolveProject(sourceRepoPath, remote?)` - Resolves/creates project, **requires remote** (fails if no remote by default)
|
|
26
|
+
- `ensureManagedClone(project)` - Clones or ensures managed repo exists
|
|
27
|
+
- `acquireLock(project)` - Acquires per-project lock with stale PID detection
|
|
28
|
+
- `releaseLock(project)` - Releases lock
|
|
29
|
+
|
|
30
|
+
### Template Service (`services/template/`)
|
|
31
|
+
**env.ts**: Parsing and validation
|
|
32
|
+
- `parseEnvTemplate(raw)` - Parses template, validates syntax, detects duplicates, extracts placeholders
|
|
33
|
+
|
|
34
|
+
**transform.ts**: Transformation logic
|
|
35
|
+
- `parseEnvFile(content)` - Parses existing .env file
|
|
36
|
+
- `transformEnv(branchContent, template, branchValues)` - Applies template: enforces static values, preserves dynamic values from branch, prompts for missing
|
|
37
|
+
|
|
38
|
+
### State Service (`services/state/index.ts`)
|
|
39
|
+
Persists templates per project+target. Saves on success/partial success per configured policy.
|
|
40
|
+
- `loadState(project, targetPath)` - Load saved template
|
|
41
|
+
- `saveState(project, targetPath, template)` - Save template
|
|
42
|
+
- `getStatePath(project, targetPath)` - Get state file path
|
|
43
|
+
|
|
44
|
+
### Editor Service (`services/editor/index.ts`)
|
|
45
|
+
Opens files in VS Code or Vim.
|
|
46
|
+
- `editFile(filePath, editorName)` - Opens editor and waits for close
|
|
47
|
+
- `diffFiles(before, after, editorName)` - Opens diff viewer
|
|
48
|
+
|
|
49
|
+
### Review Service (`services/review/index.ts`)
|
|
50
|
+
Manages review snapshots and diff generation.
|
|
51
|
+
- `createSnapshot(content, branch, targetPath)` - Creates before/after snapshots in staging
|
|
52
|
+
- `generateDiff(before, after)` - Generates unified diff
|
|
53
|
+
|
|
54
|
+
### Report Service (`services/report/index.ts`)
|
|
55
|
+
Generates reports. **Redacts values by default** for exported reports.
|
|
56
|
+
- `generateReport(context)` - Creates report object
|
|
57
|
+
- `writeReport(project, report)` - Writes JSON/Markdown reports (values redacted)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Git Worker follows a functional core, imperative shell pattern with a pipeline composition model.
|
|
4
|
+
|
|
5
|
+
## Core Concepts
|
|
6
|
+
|
|
7
|
+
- **Pipeline**: Stages compose sequentially, each receiving context and deps, returning `Result<T>`
|
|
8
|
+
- **Context**: Single evolving typed object passed through all stages
|
|
9
|
+
- **Services**: Injected dependencies for side effects (git, fs, editor, prompts)
|
|
10
|
+
- **Pure Functions**: Template parsing, transformation, validation are pure
|
|
11
|
+
|
|
12
|
+
## Directory Structure
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
src/
|
|
16
|
+
cli/ # CLI entry point
|
|
17
|
+
config/ # Configuration schema, defaults, loading
|
|
18
|
+
core/ # Types, errors, pipeline
|
|
19
|
+
services/ # Side effect implementations
|
|
20
|
+
git/ # Git operations (read, write)
|
|
21
|
+
workspace/ # Managed workspace management
|
|
22
|
+
template/ # .env parsing and transformation
|
|
23
|
+
stages/ # Pipeline stages
|
|
24
|
+
utils/ # Utilities
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Safety Model
|
|
28
|
+
|
|
29
|
+
1. **No mutation of original repo** - All changes go through managed checkout
|
|
30
|
+
2. **Remote required** - Rejects repos without remotes by default
|
|
31
|
+
3. **Per-branch isolation** - Failures don't cascade
|
|
32
|
+
4. **Tip revalidation** - Branch tips checked before apply (stale plans skipped)
|
|
33
|
+
5. **Path-scoped staging** - Only target files staged
|
|
34
|
+
6. **Read-only diff review by default** - Snapshots not editable in MVP
|
|
35
|
+
|
|
36
|
+
## Decisions
|
|
37
|
+
|
|
38
|
+
See [README](../README.md) for all chosen defaults.
|