@samaksh/git-worker 0.2.2 → 0.3.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.
@@ -10,19 +10,43 @@ export const reviewChangesStage = async (context, deps) => {
10
10
  const isCi = context.input.ci ?? false;
11
11
  const autoApprove = context.input.autoApprove || deps.config.review.autoApprove;
12
12
  sectionHeader("Review Changes");
13
- // Show summary table first
14
- renderBranchTable(context.plans.map((p) => ({
15
- name: p.branch.name,
16
- status: p.status,
17
- detail: p.status === "changed"
18
- ? `${p.updatedKeys.length} updated, ${p.addedKeys.length} added`
19
- : p.status === "error"
20
- ? p.reason ?? "error"
21
- : "no changes",
22
- })));
23
- const changedPlans = context.plans.filter((p) => p.status === "changed");
13
+ // Group plans by branch
14
+ const branchMap = new Map();
15
+ for (const plan of context.plans) {
16
+ const list = branchMap.get(plan.branch.name) || [];
17
+ list.push(plan);
18
+ branchMap.set(plan.branch.name, list);
19
+ }
20
+ // Show summary table grouped by branch
21
+ const tableRows = Array.from(branchMap.entries()).map(([branchName, plans]) => {
22
+ const hasError = plans.some((p) => p.status === "error");
23
+ const changedPlans = plans.filter((p) => p.status === "changed");
24
+ if (hasError) {
25
+ const errPlan = plans.find((p) => p.status === "error");
26
+ return {
27
+ name: branchName,
28
+ status: "error",
29
+ detail: `${errPlan?.path}: ${errPlan?.reason ?? "error"}`,
30
+ };
31
+ }
32
+ if (changedPlans.length > 0) {
33
+ const fileSummary = changedPlans.map((p) => p.path).join(", ");
34
+ return {
35
+ name: branchName,
36
+ status: "changed",
37
+ detail: `${changedPlans.length} file(s) modified (${fileSummary})`,
38
+ };
39
+ }
40
+ return {
41
+ name: branchName,
42
+ status: "unchanged",
43
+ detail: "no changes across files",
44
+ };
45
+ });
46
+ renderBranchTable(tableRows);
47
+ const changedBranches = Array.from(branchMap.entries()).filter(([_, plans]) => plans.some((p) => p.status === "changed") && !plans.some((p) => p.status === "error"));
24
48
  const approvals = [];
25
- // Auto-approve unchanged and errors
49
+ // Auto-record non-changed or errored plans as unapproved
26
50
  for (const plan of context.plans) {
27
51
  if (plan.status !== "changed") {
28
52
  approvals.push({
@@ -32,42 +56,48 @@ export const reviewChangesStage = async (context, deps) => {
32
56
  });
33
57
  }
34
58
  }
35
- if (changedPlans.length === 0) {
59
+ if (changedBranches.length === 0) {
36
60
  statusLine("info", "No changed branches to review");
37
61
  return ok({ ...context, approvals });
38
62
  }
39
63
  // Auto-approve all
40
64
  if (autoApprove) {
41
- for (const plan of changedPlans) {
42
- approvals.push({
43
- branchName: plan.branch.name,
44
- approved: true,
45
- planHash: plan.planHash,
46
- });
65
+ for (const [_, plans] of changedBranches) {
66
+ for (const plan of plans) {
67
+ if (plan.status === "changed") {
68
+ approvals.push({
69
+ branchName: plan.branch.name,
70
+ approved: true,
71
+ planHash: plan.planHash,
72
+ });
73
+ }
74
+ }
47
75
  }
48
- statusLine("ok", `Auto-approved ${changedPlans.length} branch(es)`);
76
+ statusLine("ok", `Auto-approved ${changedBranches.length} branch(es)`);
49
77
  return ok({ ...context, approvals });
50
78
  }
51
79
  if (isCi) {
52
- // CI without auto-approve: reject all
53
- for (const plan of changedPlans) {
54
- approvals.push({
55
- branchName: plan.branch.name,
56
- approved: false,
57
- planHash: plan.planHash,
58
- });
80
+ for (const [_, plans] of changedBranches) {
81
+ for (const plan of plans) {
82
+ if (plan.status === "changed") {
83
+ approvals.push({
84
+ branchName: plan.branch.name,
85
+ approved: false,
86
+ planHash: plan.planHash,
87
+ });
88
+ }
89
+ }
59
90
  }
60
91
  statusLine("warn", "CI mode — all branches rejected (use --auto-approve to commit in CI)");
61
92
  return ok({ ...context, approvals });
62
93
  }
63
94
  // Interactive review
64
95
  console.log("");
65
- // Ask if user wants batch action or per-branch review
66
96
  const batchChoice = await clack.select({
67
- message: `Review ${changedPlans.length} changed branch(es):`,
97
+ message: `Review ${changedBranches.length} changed branch(es):`,
68
98
  options: [
69
99
  { value: "per-branch", label: "Review each branch individually", hint: "recommended" },
70
- { value: "approve-all", label: `Approve all ${changedPlans.length} branches` },
100
+ { value: "approve-all", label: `Approve all ${changedBranches.length} branches` },
71
101
  { value: "reject-all", label: "Reject all" },
72
102
  ],
73
103
  });
@@ -75,118 +105,106 @@ export const reviewChangesStage = async (context, deps) => {
75
105
  return err(inputCancelledError("Review cancelled"));
76
106
  }
77
107
  if (batchChoice === "approve-all") {
78
- for (const plan of changedPlans) {
79
- approvals.push({ branchName: plan.branch.name, approved: true, planHash: plan.planHash });
108
+ for (const [_, plans] of changedBranches) {
109
+ for (const plan of plans) {
110
+ if (plan.status === "changed") {
111
+ approvals.push({ branchName: plan.branch.name, approved: true, planHash: plan.planHash });
112
+ }
113
+ }
80
114
  }
81
- statusLine("ok", `Approved all ${changedPlans.length} branch(es)`);
115
+ statusLine("ok", `Approved all ${changedBranches.length} branch(es)`);
82
116
  return ok({ ...context, approvals });
83
117
  }
84
118
  if (batchChoice === "reject-all") {
85
- for (const plan of changedPlans) {
86
- approvals.push({ branchName: plan.branch.name, approved: false, planHash: plan.planHash });
119
+ for (const [_, plans] of changedBranches) {
120
+ for (const plan of plans) {
121
+ if (plan.status === "changed") {
122
+ approvals.push({ branchName: plan.branch.name, approved: false, planHash: plan.planHash });
123
+ }
124
+ }
87
125
  }
88
126
  statusLine("warn", "All branches rejected");
89
127
  return ok({ ...context, approvals });
90
128
  }
91
129
  // Per-branch review
92
- for (const plan of changedPlans) {
130
+ for (const [branchName, plans] of changedBranches) {
131
+ const branchPlansToCommit = plans.filter((p) => p.status === "changed");
132
+ const firstPlan = branchPlansToCommit[0];
93
133
  console.log("");
94
134
  divider();
95
- console.log(` ${sym.branch} ${chalk.bold.cyan(plan.branch.name)} ${chalk.gray(plan.baseCommitSha.slice(0, 8))}`);
96
- console.log(` ${chalk.gray(plan.path)}`);
97
- console.log("");
98
- // Show inline diff
99
- if (deps.config.report.warnOnSecrets) {
100
- console.log(chalk.yellow(" ⚠ Diff may contain secret values — treat with care"));
135
+ console.log(` ${sym.branch} ${chalk.bold.cyan(branchName)} ${chalk.gray(firstPlan.baseCommitSha.slice(0, 8))} (${branchPlansToCommit.length} file(s) changed)`);
136
+ // Show diffs for each file on this branch
137
+ for (const plan of branchPlansToCommit) {
101
138
  console.log("");
102
- }
103
- renderDiff(plan.diff, false);
104
- // Write snapshots for external diff tool
105
- const snapResult = await writeSnapshots(context.project.stagingDir, context.runId, plan);
106
- let openDiff = false;
107
- if (snapResult.ok) {
108
- const choice = await clack.select({
109
- message: "Action:",
110
- options: [
111
- { value: "approve", label: `${chalk.green("✓")} Approve`, hint: "commit this branch" },
112
- { value: "reject", label: `${chalk.red("✗")} Reject`, hint: "skip this branch" },
113
- { value: "diff", label: `${chalk.cyan("⊞")} Open in diff viewer`, hint: deps.config.editor.name },
114
- ],
115
- });
116
- if (clack.isCancel(choice)) {
117
- return err(inputCancelledError("Review cancelled"));
139
+ console.log(` ${chalk.bold("File:")} ${chalk.yellow(plan.path)}`);
140
+ if (deps.config.report.warnOnSecrets) {
141
+ console.log(chalk.yellow(" ⚠ Diff may contain secret values — treat with care"));
118
142
  }
119
- if (choice === "diff") {
120
- const { beforePath, afterPath } = snapResult.value;
121
- const diffResult = await deps.services.editor.openForDiff(beforePath, afterPath, deps.config);
122
- if (!diffResult.ok) {
123
- clack.log.warn(diffResult.error.message);
124
- }
125
- // Ask again after opening diff
126
- const afterDiff = await clack.select({
127
- message: "After reviewing the diff:",
128
- options: [
129
- { value: "approve", label: `${chalk.green("✓")} Approve` },
130
- { value: "reject", label: `${chalk.red("✗")} Reject` },
131
- ],
132
- });
133
- if (clack.isCancel(afterDiff)) {
134
- return err(inputCancelledError("Review cancelled"));
143
+ renderDiff(plan.diff, false);
144
+ }
145
+ const choice = await clack.select({
146
+ message: `Action for branch ${chalk.cyan(branchName)}:`,
147
+ options: [
148
+ { value: "approve", label: `${chalk.green("✓")} Approve branch bundle`, hint: `commit ${branchPlansToCommit.length} file(s)` },
149
+ { value: "reject", label: `${chalk.red("✗")} Reject`, hint: "skip this branch" },
150
+ { value: "diff", label: `${chalk.cyan("⊞")} Open in external diff viewer`, hint: deps.config.editor.name },
151
+ ],
152
+ });
153
+ if (clack.isCancel(choice)) {
154
+ return err(inputCancelledError("Review cancelled"));
155
+ }
156
+ if (choice === "diff") {
157
+ for (const plan of branchPlansToCommit) {
158
+ const snapResult = await writeSnapshots(context.project.stagingDir, context.runId, plan);
159
+ if (snapResult.ok) {
160
+ await deps.services.editor.openForDiff(snapResult.value.beforePath, snapResult.value.afterPath, deps.config);
135
161
  }
136
- approvals.push({
137
- branchName: plan.branch.name,
138
- approved: afterDiff === "approve",
139
- planHash: plan.planHash,
140
- });
141
- }
142
- else {
143
- approvals.push({
144
- branchName: plan.branch.name,
145
- approved: choice === "approve",
146
- planHash: plan.planHash,
147
- });
148
162
  }
149
- }
150
- else {
151
- // No snapshots — just approve/reject
152
- const choice = await clack.select({
153
- message: "Action:",
163
+ const afterDiff = await clack.select({
164
+ message: `After reviewing diffs for ${branchName}:`,
154
165
  options: [
155
166
  { value: "approve", label: `${chalk.green("✓")} Approve` },
156
167
  { value: "reject", label: `${chalk.red("✗")} Reject` },
157
168
  ],
158
169
  });
159
- if (clack.isCancel(choice)) {
170
+ if (clack.isCancel(afterDiff)) {
160
171
  return err(inputCancelledError("Review cancelled"));
161
172
  }
162
- approvals.push({
163
- branchName: plan.branch.name,
164
- approved: choice === "approve",
165
- planHash: plan.planHash,
166
- });
173
+ const isApproved = afterDiff === "approve";
174
+ for (const plan of branchPlansToCommit) {
175
+ approvals.push({ branchName, approved: isApproved, planHash: plan.planHash });
176
+ }
177
+ }
178
+ else {
179
+ const isApproved = choice === "approve";
180
+ for (const plan of branchPlansToCommit) {
181
+ approvals.push({ branchName, approved: isApproved, planHash: plan.planHash });
182
+ }
167
183
  }
168
184
  }
169
185
  divider();
170
- const approved = approvals.filter((a) => a.approved).length;
171
- const rejected = approvals.filter((a) => !a.approved && context.plans.find((p) => p.branch.name === a.branchName)?.status === "changed").length;
172
- statusLine("ok", `Review complete — ${approved} approved, ${rejected} rejected`);
186
+ const approvedCount = new Set(approvals.filter((a) => a.approved).map((a) => a.branchName)).size;
187
+ const rejectedCount = new Set(approvals
188
+ .filter((a) => !a.approved && changedBranches.some(([b]) => b === a.branchName))
189
+ .map((a) => a.branchName)).size;
190
+ statusLine("ok", `Review complete — ${approvedCount} branch(es) approved, ${rejectedCount} rejected`);
173
191
  // Confirm before destructive apply
174
- if (approved > 0) {
192
+ if (approvedCount > 0) {
175
193
  console.log("");
176
- console.log(chalk.bold(" Summary of approved changes:"));
177
- for (const plan of changedPlans) {
178
- const a = approvals.find((ap) => ap.branchName === plan.branch.name);
179
- if (a?.approved) {
180
- const detail = [
181
- plan.updatedKeys.length > 0 ? `update: ${plan.updatedKeys.join(", ")}` : "",
182
- plan.addedKeys.length > 0 ? `add: ${plan.addedKeys.join(", ")}` : "",
183
- ].filter(Boolean).join(" | ");
184
- statusLine("ok", plan.branch.name, detail);
194
+ console.log(chalk.bold(" Summary of approved branch commits:"));
195
+ for (const [branchName, plans] of changedBranches) {
196
+ const isApp = approvals.some((a) => a.branchName === branchName && a.approved);
197
+ if (isApp) {
198
+ const fileNames = plans
199
+ .filter((p) => p.status === "changed")
200
+ .map((p) => p.path)
201
+ .join(", ");
202
+ statusLine("ok", branchName, `files: ${fileNames}`);
185
203
  }
186
204
  }
187
205
  console.log("");
188
206
  const confirm = await clack.confirm({
189
- message: `Proceed to commit ${approved} branch(es)?`,
207
+ message: `Proceed to commit ${approvedCount} branch(es)?`,
190
208
  initialValue: true,
191
209
  });
192
210
  if (clack.isCancel(confirm) || !confirm) {
@@ -3,6 +3,7 @@ import { workspaceError, inputCancelledError } from "../core/errors.js";
3
3
  import { clack, sectionHeader, statusLine } from "../utils/ui.js";
4
4
  import inquirer from "inquirer";
5
5
  import chalk from "chalk";
6
+ import { detectPreset } from "../config/repo-presets.js";
6
7
  /**
7
8
  * Matches a branch name against a pattern.
8
9
  * Supports:
@@ -253,7 +254,7 @@ export const selectTargetsStage = async (context, deps) => {
253
254
  {
254
255
  type: "checkbox",
255
256
  name: "picked",
256
- message: "Select branches to process:",
257
+ message: "Select branches to process (Space: toggle • a: select all • i: invert • Enter: confirm):",
257
258
  pageSize: 15,
258
259
  choices,
259
260
  },
@@ -325,26 +326,125 @@ export const selectTargetsStage = async (context, deps) => {
325
326
  clack.log.warn("No branches selected.");
326
327
  return err(inputCancelledError("No branches selected"));
327
328
  }
328
- // Target file selection
329
- let targetPath = context.input.file;
330
- if (!targetPath && !context.input.ci) {
331
- const fileInput = await clack.text({
332
- message: "Target file path:",
333
- placeholder: ".env",
334
- defaultValue: ".env",
329
+ // ── Target file(s) selection ──────────────────────────────────────────
330
+ let targets = [];
331
+ if (context.input.file) {
332
+ const preset = detectPreset(context.project.remoteUrl, context.project.id);
333
+ const rawPaths = context.input.file
334
+ .split(",")
335
+ .map((p) => p.trim())
336
+ .filter(Boolean);
337
+ targets = rawPaths.map((p) => {
338
+ const presetFile = preset?.files.find((pf) => pf.path === p);
339
+ if (presetFile) {
340
+ return {
341
+ path: presetFile.path,
342
+ description: presetFile.description,
343
+ isDynamic: presetFile.isDynamic,
344
+ dynamicVar: presetFile.dynamicVar,
345
+ };
346
+ }
347
+ return { path: p };
335
348
  });
336
- if (clack.isCancel(fileInput)) {
337
- return err(inputCancelledError("File selection cancelled"));
349
+ }
350
+ else if (!context.input.ci) {
351
+ const preset = detectPreset(context.project.remoteUrl, context.project.id);
352
+ if (preset) {
353
+ statusLine("ok", `Detected repository preset: ${chalk.bold.cyan(preset.name)}`);
354
+ const CUSTOM_PATH_VAL = "__CUSTOM_PATH__";
355
+ try {
356
+ const { selectedPaths } = await inquirer.prompt([
357
+ {
358
+ type: "checkbox",
359
+ name: "selectedPaths",
360
+ message: "Select template file(s) to edit and sync across branches:",
361
+ pageSize: 15,
362
+ choices: [
363
+ ...preset.files.map((f) => ({
364
+ name: `${f.path.padEnd(32)} ${chalk.gray(f.description)}${f.isDynamic ? chalk.yellow(" [dynamic]") : ""}`,
365
+ value: f.path,
366
+ checked: f.path === ".env",
367
+ })),
368
+ new inquirer.Separator(),
369
+ {
370
+ name: chalk.cyan("➕ [Add custom file path...]"),
371
+ value: CUSTOM_PATH_VAL,
372
+ checked: false,
373
+ },
374
+ ],
375
+ validate: (ans) => ans.length > 0 || "Please select at least one file",
376
+ },
377
+ ]);
378
+ const chosenPaths = selectedPaths.filter((p) => p !== CUSTOM_PATH_VAL);
379
+ if (selectedPaths.includes(CUSTOM_PATH_VAL)) {
380
+ const customInput = await clack.text({
381
+ message: "Enter custom target file path:",
382
+ placeholder: "config/settings.json",
383
+ validate: (v) => {
384
+ if (!v || v.trim().length === 0)
385
+ return "Please enter a valid path";
386
+ },
387
+ });
388
+ if (!clack.isCancel(customInput)) {
389
+ chosenPaths.push(customInput.trim());
390
+ }
391
+ }
392
+ targets = chosenPaths.map((p) => {
393
+ const presetFile = preset.files.find((pf) => pf.path === p);
394
+ if (presetFile) {
395
+ return {
396
+ path: presetFile.path,
397
+ description: presetFile.description,
398
+ isDynamic: presetFile.isDynamic,
399
+ dynamicVar: presetFile.dynamicVar,
400
+ };
401
+ }
402
+ return { path: p };
403
+ });
404
+ }
405
+ catch (e) {
406
+ if (e.isTtyError ||
407
+ e.name === "ExitPromptError" ||
408
+ e.message?.includes("force closed") ||
409
+ e.message?.includes("User force closed")) {
410
+ return err(inputCancelledError("Target file selection cancelled"));
411
+ }
412
+ targets = [{ path: ".env" }];
413
+ }
338
414
  }
339
- targetPath = fileInput || ".env";
415
+ else {
416
+ // Generic repository target selection
417
+ const fileInput = await clack.text({
418
+ message: "Target file path:",
419
+ placeholder: ".env",
420
+ defaultValue: ".env",
421
+ });
422
+ if (clack.isCancel(fileInput)) {
423
+ return err(inputCancelledError("File selection cancelled"));
424
+ }
425
+ const rawPath = fileInput || ".env";
426
+ targets = rawPath
427
+ .split(",")
428
+ .map((p) => p.trim())
429
+ .filter(Boolean)
430
+ .map((p) => ({ path: p }));
431
+ }
432
+ }
433
+ else {
434
+ targets = [{ path: ".env" }];
340
435
  }
341
- else if (!targetPath) {
342
- targetPath = ".env";
436
+ if (targets.length === 0) {
437
+ targets = [{ path: ".env" }];
343
438
  }
344
- statusLine("ok", `Selected ${branches.length} branch(es) → ${targetPath}`);
439
+ const primaryTarget = targets[0];
440
+ const targetSummary = targets.length === 1
441
+ ? targets[0].path
442
+ : `${targets.length} files (${targets.map((t) => t.path).join(", ")})`;
443
+ statusLine("ok", `Selected ${branches.length} branch(es) → ${targetSummary}`);
345
444
  return ok({
346
445
  ...context,
347
446
  selectedBranches: branches,
348
- target: { path: targetPath },
447
+ target: primaryTarget,
448
+ targets,
349
449
  });
350
450
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samaksh/git-worker",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Safe, composable TypeScript CLI/TUI for applying reviewed file changes across many Git branches",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,11 +15,9 @@
15
15
  "files": [
16
16
  "dist",
17
17
  "README.md",
18
+ "LICENSE",
18
19
  "docs"
19
20
  ],
20
- "licenses": [
21
- "MIT"
22
- ],
23
21
  "scripts": {
24
22
  "build": "tsc",
25
23
  "prepublishOnly": "npm run build",
@@ -38,7 +36,7 @@
38
36
  "branches",
39
37
  "multi-branch"
40
38
  ],
41
- "author": "",
39
+ "author": "Samaksh Grover <samaksh@bsure.live>",
42
40
  "license": "MIT",
43
41
  "dependencies": {
44
42
  "@clack/prompts": "^1.8.1",