@samaksh/git-worker 0.0.0-stage → 0.1.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/GIT_WORKER_PROJECT_PLAN.md +499 -0
- package/README.md +114 -2
- package/demo/README.md +58 -0
- package/demo/index.ts +526 -0
- 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/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 +124 -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 +44 -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 +250 -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 +48 -4
- package/src/app.ts +69 -0
- package/src/cli/commands.ts +142 -0
- package/src/cli/index.ts +123 -0
- package/src/config/defaults.ts +46 -0
- package/src/config/load.ts +70 -0
- package/src/config/schema.ts +48 -0
- package/src/core/errors.ts +45 -0
- package/src/core/pipeline.ts +17 -0
- package/src/core/types.ts +188 -0
- package/src/services/editor/index.ts +85 -0
- package/src/services/git/ops.ts +170 -0
- package/src/services/git/reader.ts +148 -0
- package/src/services/report/index.ts +104 -0
- package/src/services/review/index.ts +80 -0
- package/src/services/state/index.ts +77 -0
- package/src/services/template/env.ts +169 -0
- package/src/services/template/interpolator.ts +109 -0
- package/src/services/template/transform.ts +97 -0
- package/src/services/template/validator.ts +75 -0
- package/src/services/workspace/cache.ts +144 -0
- package/src/services/workspace/manager.ts +370 -0
- package/src/stages/apply.ts +194 -0
- package/src/stages/edit-template.ts +227 -0
- package/src/stages/eligibility.ts +67 -0
- package/src/stages/load-template.ts +188 -0
- package/src/stages/plan.ts +362 -0
- package/src/stages/push.ts +120 -0
- package/src/stages/report.ts +126 -0
- package/src/stages/resolve-project.ts +341 -0
- package/src/stages/review.ts +228 -0
- package/src/stages/select.ts +302 -0
- package/src/stages/sync.ts +63 -0
- package/src/utils/hash.ts +15 -0
- package/src/utils/ui.ts +137 -0
- package/src/utils/which.ts +18 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { Stage } from "../core/pipeline.js";
|
|
2
|
+
import { RunContext, AppDeps, BranchRef, ok, err } from "../core/types.js";
|
|
3
|
+
import { workspaceError, inputCancelledError } from "../core/errors.js";
|
|
4
|
+
import { clack, sectionHeader, statusLine, sym } from "../utils/ui.js";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Matches a branch name against a pattern.
|
|
9
|
+
* Supports:
|
|
10
|
+
* - Regex: /uat.*\/, /^release\//, /pattern/i
|
|
11
|
+
* - Glob: uat/*, *orders, uat*
|
|
12
|
+
* - Exact name: main, develop
|
|
13
|
+
* - Trailing slash prefix: uat/
|
|
14
|
+
* - Substring fallback: orders
|
|
15
|
+
*/
|
|
16
|
+
export function matchesBranchPattern(name: string, pattern: string): boolean {
|
|
17
|
+
const trimmed = pattern.trim();
|
|
18
|
+
if (!trimmed) return false;
|
|
19
|
+
|
|
20
|
+
// 1. Regex pattern: /regex/ or /regex/flags
|
|
21
|
+
const regexMatch = trimmed.match(/^\/(.+)\/([a-z]*)$/i);
|
|
22
|
+
if (regexMatch) {
|
|
23
|
+
try {
|
|
24
|
+
return new RegExp(regexMatch[1], regexMatch[2]).test(name);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2. Glob pattern with * or ?
|
|
31
|
+
if (trimmed.includes("*") || trimmed.includes("?")) {
|
|
32
|
+
const escaped = trimmed
|
|
33
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
34
|
+
.replace(/\*/g, ".*")
|
|
35
|
+
.replace(/\?/g, ".");
|
|
36
|
+
try {
|
|
37
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 3. Exact match
|
|
44
|
+
if (name === trimmed) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 4. Prefix match if pattern ends with '/'
|
|
49
|
+
if (trimmed.endsWith("/") && name.startsWith(trimmed)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 5. Fallback substring match
|
|
54
|
+
return name.includes(trimmed);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const selectTargetsStage: Stage<RunContext, AppDeps> = async (context, deps) => {
|
|
58
|
+
if (!context.project) {
|
|
59
|
+
return err(workspaceError("Project not resolved"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
sectionHeader("Branch & Target Selection");
|
|
63
|
+
|
|
64
|
+
const s = clack.spinner();
|
|
65
|
+
s.start("Listing branches…");
|
|
66
|
+
|
|
67
|
+
const result = await deps.services.git.listBranches(
|
|
68
|
+
context.project.managedRepoPath,
|
|
69
|
+
deps.config.git.remote
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (!result.ok) {
|
|
73
|
+
s.stop(chalk.red("Failed to list branches"));
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
s.stop(`Found ${result.value.length} branches`);
|
|
78
|
+
|
|
79
|
+
let branches = result.value;
|
|
80
|
+
|
|
81
|
+
// De-duplicate by name (prefer remote over local)
|
|
82
|
+
const seen = new Map<string, BranchRef>();
|
|
83
|
+
for (const b of branches) {
|
|
84
|
+
if (!seen.has(b.name) || b.isRemote) {
|
|
85
|
+
seen.set(b.name, b);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
branches = Array.from(seen.values());
|
|
89
|
+
|
|
90
|
+
// Filter by pattern if supplied via CLI
|
|
91
|
+
if (context.input.branch) {
|
|
92
|
+
const pattern = context.input.branch;
|
|
93
|
+
const invert = Boolean(context.input.invertBranch);
|
|
94
|
+
branches = branches.filter((b) => {
|
|
95
|
+
const matched = matchesBranchPattern(b.name, pattern);
|
|
96
|
+
return invert ? !matched : matched;
|
|
97
|
+
});
|
|
98
|
+
statusLine(
|
|
99
|
+
"info",
|
|
100
|
+
`Pattern '${pattern}'${invert ? " (inverted)" : ""} matched ${branches.length} branch(es)`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Interactive branch selection (non-CI when no --branch provided)
|
|
105
|
+
if (!context.input.ci && !context.input.branch) {
|
|
106
|
+
if (branches.length === 0) {
|
|
107
|
+
clack.log.warn("No branches found in this repository.");
|
|
108
|
+
return err(inputCancelledError("No branches available"));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const allBranches = branches;
|
|
112
|
+
let selectedNames = new Set<string>();
|
|
113
|
+
|
|
114
|
+
while (true) {
|
|
115
|
+
const selectedCount = selectedNames.size;
|
|
116
|
+
const totalCount = allBranches.length;
|
|
117
|
+
|
|
118
|
+
type BranchAction = "confirm" | "pattern" | "all" | "invert" | "list";
|
|
119
|
+
|
|
120
|
+
const options: { value: BranchAction; label: string; hint?: string }[] = [];
|
|
121
|
+
|
|
122
|
+
if (selectedCount > 0) {
|
|
123
|
+
options.push({
|
|
124
|
+
value: "confirm",
|
|
125
|
+
label: `➜ Confirm and proceed with ${selectedCount} branch(es)`,
|
|
126
|
+
hint: "proceed to target file selection",
|
|
127
|
+
});
|
|
128
|
+
options.push({
|
|
129
|
+
value: "invert",
|
|
130
|
+
label: `🔄 Invert selection (select the other ${totalCount - selectedCount} branches)`,
|
|
131
|
+
hint: "flip current selection",
|
|
132
|
+
});
|
|
133
|
+
options.push({
|
|
134
|
+
value: "pattern",
|
|
135
|
+
label: "🔍 Filter by pattern or regex",
|
|
136
|
+
hint: "glob (uat/*) or regex (/uat.*/)",
|
|
137
|
+
});
|
|
138
|
+
options.push({
|
|
139
|
+
value: "list",
|
|
140
|
+
label: "📋 Customize selection from list",
|
|
141
|
+
hint: "toggle individual checkboxes",
|
|
142
|
+
});
|
|
143
|
+
options.push({
|
|
144
|
+
value: "all",
|
|
145
|
+
label: `🌟 Select ALL branches (${totalCount} branches)`,
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
options.push({
|
|
149
|
+
value: "pattern",
|
|
150
|
+
label: "🔍 Filter by pattern or regex",
|
|
151
|
+
hint: "glob (uat/*) or regex (/uat.*/)",
|
|
152
|
+
});
|
|
153
|
+
options.push({
|
|
154
|
+
value: "all",
|
|
155
|
+
label: `🌟 Select ALL branches (${totalCount} branches)`,
|
|
156
|
+
hint: "select every branch in repository",
|
|
157
|
+
});
|
|
158
|
+
options.push({
|
|
159
|
+
value: "list",
|
|
160
|
+
label: "📋 Choose individually from list",
|
|
161
|
+
hint: "select branches with checkboxes",
|
|
162
|
+
});
|
|
163
|
+
options.push({
|
|
164
|
+
value: "invert",
|
|
165
|
+
label: `🔄 Invert selection (select all ${totalCount} branches)`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const previewText =
|
|
170
|
+
selectedCount > 0
|
|
171
|
+
? `${selectedCount} of ${totalCount} selected: ${chalk.cyan(
|
|
172
|
+
Array.from(selectedNames).slice(0, 3).join(", ") +
|
|
173
|
+
(selectedCount > 3 ? `, +${selectedCount - 3} more` : "")
|
|
174
|
+
)}`
|
|
175
|
+
: `0 of ${totalCount} branches selected`;
|
|
176
|
+
|
|
177
|
+
const choice = await clack.select<BranchAction>({
|
|
178
|
+
message: `Branch selection (${previewText}):`,
|
|
179
|
+
options,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (clack.isCancel(choice)) {
|
|
183
|
+
return err(inputCancelledError("Branch selection cancelled"));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (choice === "confirm") {
|
|
187
|
+
if (selectedNames.size === 0) {
|
|
188
|
+
clack.log.warn("Please select at least one branch before proceeding.");
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (choice === "all") {
|
|
195
|
+
selectedNames = new Set(allBranches.map((b) => b.name));
|
|
196
|
+
statusLine("ok", `Selected all ${totalCount} branch(es)`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (choice === "invert") {
|
|
201
|
+
const inverted = allBranches.filter((b) => !selectedNames.has(b.name));
|
|
202
|
+
selectedNames = new Set(inverted.map((b) => b.name));
|
|
203
|
+
statusLine(
|
|
204
|
+
"ok",
|
|
205
|
+
`Inverted selection: ${selectedNames.size} of ${totalCount} branch(es) selected`
|
|
206
|
+
);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (choice === "pattern") {
|
|
211
|
+
const patternInput = await clack.text({
|
|
212
|
+
message: "Enter glob pattern (e.g. uat/*) or regex (e.g. /uat.*/):",
|
|
213
|
+
placeholder: "/uat.*/ or uat/*",
|
|
214
|
+
validate: (v) => {
|
|
215
|
+
if (!v || v.trim().length === 0) {
|
|
216
|
+
return "Please enter a pattern or regex";
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (clack.isCancel(patternInput)) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const pattern = (patternInput as string).trim();
|
|
226
|
+
const matched = allBranches.filter((b) => matchesBranchPattern(b.name, pattern));
|
|
227
|
+
|
|
228
|
+
if (matched.length === 0) {
|
|
229
|
+
clack.log.warn(`Pattern '${pattern}' matched 0 of ${totalCount} branches.`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
selectedNames = new Set(matched.map((b) => b.name));
|
|
234
|
+
statusLine(
|
|
235
|
+
"ok",
|
|
236
|
+
`Pattern '${pattern}' matched ${matched.length} branch(es):`
|
|
237
|
+
);
|
|
238
|
+
for (const b of matched.slice(0, 5)) {
|
|
239
|
+
console.log(chalk.gray(` • ${b.name}`));
|
|
240
|
+
}
|
|
241
|
+
if (matched.length > 5) {
|
|
242
|
+
console.log(chalk.gray(` ... and ${matched.length - 5} more`));
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (choice === "list") {
|
|
248
|
+
const picked = await clack.multiselect<string>({
|
|
249
|
+
message: "Select branches to process (Space to toggle, Enter to confirm):",
|
|
250
|
+
options: allBranches.map((b) => ({
|
|
251
|
+
value: b.name,
|
|
252
|
+
label: `${sym.branch} ${b.name}`,
|
|
253
|
+
hint: b.isRemote
|
|
254
|
+
? `remote/${b.remote} ${b.sha.slice(0, 8)}`
|
|
255
|
+
: `local ${b.sha.slice(0, 8)}`,
|
|
256
|
+
})),
|
|
257
|
+
initialValues: Array.from(selectedNames),
|
|
258
|
+
required: false,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (clack.isCancel(picked)) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
selectedNames = new Set(picked as string[]);
|
|
266
|
+
statusLine("ok", `Selection updated: ${selectedNames.size} branch(es) selected`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
branches = allBranches.filter((b) => selectedNames.has(b.name));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (branches.length === 0) {
|
|
275
|
+
clack.log.warn("No branches selected.");
|
|
276
|
+
return err(inputCancelledError("No branches selected"));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Target file selection
|
|
280
|
+
let targetPath = context.input.file;
|
|
281
|
+
if (!targetPath && !context.input.ci) {
|
|
282
|
+
const fileInput = await clack.text({
|
|
283
|
+
message: "Target file path:",
|
|
284
|
+
placeholder: ".env",
|
|
285
|
+
defaultValue: ".env",
|
|
286
|
+
});
|
|
287
|
+
if (clack.isCancel(fileInput)) {
|
|
288
|
+
return err(inputCancelledError("File selection cancelled"));
|
|
289
|
+
}
|
|
290
|
+
targetPath = (fileInput as string) || ".env";
|
|
291
|
+
} else if (!targetPath) {
|
|
292
|
+
targetPath = ".env";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
statusLine("ok", `Selected ${branches.length} branch(es) → ${targetPath}`);
|
|
296
|
+
|
|
297
|
+
return ok({
|
|
298
|
+
...context,
|
|
299
|
+
selectedBranches: branches,
|
|
300
|
+
target: { path: targetPath },
|
|
301
|
+
});
|
|
302
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Stage } from "../core/pipeline.js";
|
|
2
|
+
import { RunContext, AppDeps, ok, err } from "../core/types.js";
|
|
3
|
+
import { workspaceError } from "../core/errors.js";
|
|
4
|
+
import { clack, statusLine } from "../utils/ui.js";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
|
|
7
|
+
export const syncRepositoryStage: Stage<RunContext, AppDeps> = async (context, deps) => {
|
|
8
|
+
if (!context.project) {
|
|
9
|
+
return err(workspaceError("Project not resolved"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const s = clack.spinner();
|
|
13
|
+
s.start(
|
|
14
|
+
context.project.isCached
|
|
15
|
+
? "Syncing cached repository with remote…"
|
|
16
|
+
: "Cloning repository into managed workspace…"
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const cloneResult = await deps.services.workspace.ensureManagedClone(context.project);
|
|
20
|
+
if (!cloneResult.ok) {
|
|
21
|
+
s.stop(chalk.red("Clone failed"));
|
|
22
|
+
return cloneResult;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (cloneResult.value.isCached) {
|
|
26
|
+
s.message("Using cached repository — fetching latest branches…");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Acquire workspace lock
|
|
30
|
+
const lockResult = await deps.services.workspace.acquireLock(
|
|
31
|
+
context.project,
|
|
32
|
+
deps.config.workspace.lockTimeoutMs
|
|
33
|
+
);
|
|
34
|
+
if (!lockResult.ok) {
|
|
35
|
+
s.stop(chalk.red("Could not acquire workspace lock"));
|
|
36
|
+
return lockResult;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Register lock release on process exit
|
|
40
|
+
const releaseLock = lockResult.value;
|
|
41
|
+
process.on("exit", () => { releaseLock(); });
|
|
42
|
+
process.on("SIGINT", () => { releaseLock(); process.exit(130); });
|
|
43
|
+
process.on("SIGTERM", () => { releaseLock(); process.exit(143); });
|
|
44
|
+
|
|
45
|
+
// Fetch latest from remote
|
|
46
|
+
if (context.project.remoteUrl) {
|
|
47
|
+
const fetchResult = await deps.services.git.fetch(
|
|
48
|
+
context.project.managedRepoPath,
|
|
49
|
+
deps.config.git.remote,
|
|
50
|
+
deps.config.git.fetchPrune
|
|
51
|
+
);
|
|
52
|
+
if (!fetchResult.ok) {
|
|
53
|
+
s.stop(chalk.yellow("Fetch warning — continuing with cached refs"));
|
|
54
|
+
statusLine("warn", fetchResult.error.message);
|
|
55
|
+
} else {
|
|
56
|
+
s.stop(chalk.green("Repository synced"));
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
s.stop(chalk.green("Checkout ready (no remote to fetch)"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return ok(context);
|
|
63
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
|
|
3
|
+
export function sha256(content: string): string {
|
|
4
|
+
return createHash("sha256").update(content).digest("hex");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function planHash(
|
|
8
|
+
branchName: string,
|
|
9
|
+
baseCommitSha: string,
|
|
10
|
+
targetPath: string,
|
|
11
|
+
beforeContentHash: string,
|
|
12
|
+
afterContentHash: string
|
|
13
|
+
): string {
|
|
14
|
+
return sha256(`${branchName}|${baseCommitSha}|${targetPath}|${beforeContentHash}|${afterContentHash}`);
|
|
15
|
+
}
|
package/src/utils/ui.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
|
|
8
|
+
export { clack };
|
|
9
|
+
|
|
10
|
+
// ── Symbols ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export const sym = {
|
|
13
|
+
check: chalk.green("✓"),
|
|
14
|
+
cross: chalk.red("✗"),
|
|
15
|
+
skip: chalk.gray("–"),
|
|
16
|
+
dot: chalk.gray("•"),
|
|
17
|
+
warn: chalk.yellow("⚠"),
|
|
18
|
+
arrow: chalk.cyan("→"),
|
|
19
|
+
branch: chalk.cyan("⎇"),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// ── Box / header ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export function banner(): void {
|
|
25
|
+
console.log("");
|
|
26
|
+
console.log(chalk.bold.cyan(" ◆ git-worker"));
|
|
27
|
+
console.log(chalk.gray(" Safe multi-branch file sync"));
|
|
28
|
+
console.log("");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sectionHeader(title: string): void {
|
|
32
|
+
console.log("");
|
|
33
|
+
console.log(chalk.bold(` ${title}`));
|
|
34
|
+
console.log(chalk.gray(" " + "─".repeat(Math.max(title.length, 40))));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function divider(): void {
|
|
38
|
+
console.log(chalk.gray(" " + "─".repeat(60)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Status lines ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export function statusLine(
|
|
44
|
+
status: "ok" | "fail" | "warn" | "skip" | "info",
|
|
45
|
+
message: string,
|
|
46
|
+
detail?: string
|
|
47
|
+
): void {
|
|
48
|
+
const icon =
|
|
49
|
+
status === "ok" ? sym.check :
|
|
50
|
+
status === "fail" ? sym.cross :
|
|
51
|
+
status === "warn" ? sym.warn :
|
|
52
|
+
status === "skip" ? sym.skip :
|
|
53
|
+
sym.dot;
|
|
54
|
+
|
|
55
|
+
const color =
|
|
56
|
+
status === "ok" ? chalk.green :
|
|
57
|
+
status === "fail" ? chalk.red :
|
|
58
|
+
status === "warn" ? chalk.yellow :
|
|
59
|
+
status === "skip" ? chalk.gray :
|
|
60
|
+
chalk.white;
|
|
61
|
+
|
|
62
|
+
console.log(` ${icon} ${color(message)}`);
|
|
63
|
+
if (detail) console.log(chalk.gray(` ${detail}`));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Diff rendering ────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export function renderDiff(diff: string, redact = false): void {
|
|
69
|
+
if (!diff) {
|
|
70
|
+
console.log(chalk.gray(" (no changes)"));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const lines = diff.split("\n");
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
77
|
+
console.log(chalk.gray(` ${line}`));
|
|
78
|
+
} else if (line.startsWith("@@")) {
|
|
79
|
+
console.log(chalk.cyan(` ${line}`));
|
|
80
|
+
} else if (line.startsWith("+")) {
|
|
81
|
+
const display = redact ? redactLine(line) : line;
|
|
82
|
+
console.log(chalk.green(` ${display}`));
|
|
83
|
+
} else if (line.startsWith("-")) {
|
|
84
|
+
const display = redact ? redactLine(line) : line;
|
|
85
|
+
console.log(chalk.red(` ${display}`));
|
|
86
|
+
} else {
|
|
87
|
+
console.log(chalk.gray(` ${line}`));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function redactLine(line: string): string {
|
|
93
|
+
// Redact value portion: +KEY=value → +KEY=[REDACTED]
|
|
94
|
+
return line.replace(/^([+-][A-Za-z_][A-Za-z0-9_]*)=.+$/, "$1=[REDACTED]");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Branch status table ───────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export function renderBranchTable(
|
|
100
|
+
rows: Array<{
|
|
101
|
+
name: string;
|
|
102
|
+
status: string;
|
|
103
|
+
detail?: string;
|
|
104
|
+
}>
|
|
105
|
+
): void {
|
|
106
|
+
const maxName = Math.max(...rows.map((r) => r.name.length), 10);
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
const name = row.name.padEnd(maxName);
|
|
109
|
+
let statusStr: string;
|
|
110
|
+
switch (row.status) {
|
|
111
|
+
case "committed":
|
|
112
|
+
statusStr = chalk.green("committed");
|
|
113
|
+
break;
|
|
114
|
+
case "failed":
|
|
115
|
+
statusStr = chalk.red("failed ");
|
|
116
|
+
break;
|
|
117
|
+
case "rejected":
|
|
118
|
+
statusStr = chalk.yellow("rejected ");
|
|
119
|
+
break;
|
|
120
|
+
case "unchanged":
|
|
121
|
+
statusStr = chalk.gray("unchanged");
|
|
122
|
+
break;
|
|
123
|
+
case "skipped":
|
|
124
|
+
statusStr = chalk.gray("skipped ");
|
|
125
|
+
break;
|
|
126
|
+
case "changed":
|
|
127
|
+
statusStr = chalk.cyan("changed ");
|
|
128
|
+
break;
|
|
129
|
+
case "error":
|
|
130
|
+
statusStr = chalk.red("error ");
|
|
131
|
+
break;
|
|
132
|
+
default:
|
|
133
|
+
statusStr = chalk.gray(row.status.padEnd(9));
|
|
134
|
+
}
|
|
135
|
+
console.log(` ${sym.branch} ${chalk.white(name)} ${statusStr} ${chalk.gray(row.detail ?? "")}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { exec } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Find the full path of an executable on PATH.
|
|
9
|
+
* Returns null if not found.
|
|
10
|
+
*/
|
|
11
|
+
export async function which(name: string): Promise<string | null> {
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await execAsync(`which ${name}`);
|
|
14
|
+
return stdout.trim() || null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"types": ["node"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src",
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"allowSyntheticDefaultImports": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|