@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.
Files changed (78) hide show
  1. package/README.md +114 -2
  2. package/dist/app.d.ts +2 -0
  3. package/dist/app.js +48 -0
  4. package/dist/cli/commands.d.ts +6 -0
  5. package/dist/cli/commands.js +111 -0
  6. package/dist/cli/index.d.ts +2 -0
  7. package/dist/cli/index.js +115 -0
  8. package/dist/config/defaults.d.ts +2 -0
  9. package/dist/config/defaults.js +44 -0
  10. package/dist/config/load.d.ts +3 -0
  11. package/dist/config/load.js +65 -0
  12. package/dist/config/schema.d.ts +200 -0
  13. package/dist/config/schema.js +45 -0
  14. package/dist/core/errors.d.ts +12 -0
  15. package/dist/core/errors.js +34 -0
  16. package/dist/core/pipeline.d.ts +3 -0
  17. package/dist/core/pipeline.js +14 -0
  18. package/dist/core/types.d.ts +153 -0
  19. package/dist/core/types.js +16 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +3 -0
  22. package/dist/services/editor/index.d.ts +9 -0
  23. package/dist/services/editor/index.js +59 -0
  24. package/dist/services/git/ops.d.ts +30 -0
  25. package/dist/services/git/ops.js +139 -0
  26. package/dist/services/git/reader.d.ts +14 -0
  27. package/dist/services/git/reader.js +195 -0
  28. package/dist/services/report/index.d.ts +18 -0
  29. package/dist/services/report/index.js +68 -0
  30. package/dist/services/review/index.d.ts +30 -0
  31. package/dist/services/review/index.js +59 -0
  32. package/dist/services/state/index.d.ts +20 -0
  33. package/dist/services/state/index.js +49 -0
  34. package/dist/services/template/env.d.ts +23 -0
  35. package/dist/services/template/env.js +146 -0
  36. package/dist/services/template/interpolator.d.ts +30 -0
  37. package/dist/services/template/interpolator.js +75 -0
  38. package/dist/services/template/transform.d.ts +22 -0
  39. package/dist/services/template/transform.js +75 -0
  40. package/dist/services/template/validator.d.ts +5 -0
  41. package/dist/services/template/validator.js +68 -0
  42. package/dist/services/workspace/cache.d.ts +32 -0
  43. package/dist/services/workspace/cache.js +107 -0
  44. package/dist/services/workspace/manager.d.ts +44 -0
  45. package/dist/services/workspace/manager.js +301 -0
  46. package/dist/stages/apply.d.ts +3 -0
  47. package/dist/stages/apply.js +161 -0
  48. package/dist/stages/edit-template.d.ts +3 -0
  49. package/dist/stages/edit-template.js +198 -0
  50. package/dist/stages/eligibility.d.ts +3 -0
  51. package/dist/stages/eligibility.js +68 -0
  52. package/dist/stages/load-template.d.ts +3 -0
  53. package/dist/stages/load-template.js +168 -0
  54. package/dist/stages/plan.d.ts +3 -0
  55. package/dist/stages/plan.js +270 -0
  56. package/dist/stages/push.d.ts +3 -0
  57. package/dist/stages/push.js +99 -0
  58. package/dist/stages/report.d.ts +3 -0
  59. package/dist/stages/report.js +103 -0
  60. package/dist/stages/resolve-project.d.ts +3 -0
  61. package/dist/stages/resolve-project.js +294 -0
  62. package/dist/stages/review.d.ts +3 -0
  63. package/dist/stages/review.js +197 -0
  64. package/dist/stages/select.d.ts +13 -0
  65. package/dist/stages/select.js +265 -0
  66. package/dist/stages/sync.d.ts +3 -0
  67. package/dist/stages/sync.js +47 -0
  68. package/dist/utils/hash.d.ts +2 -0
  69. package/dist/utils/hash.js +7 -0
  70. package/dist/utils/ui.d.ts +21 -0
  71. package/dist/utils/ui.js +113 -0
  72. package/dist/utils/which.d.ts +5 -0
  73. package/dist/utils/which.js +16 -0
  74. package/docs/FEATURES.md +84 -0
  75. package/docs/SERVICES.md +57 -0
  76. package/docs/architecture.md +38 -0
  77. package/docs/usage.md +337 -0
  78. package/package.json +58 -4
@@ -0,0 +1,103 @@
1
+ import { ok } from "../core/types.js";
2
+ import { sectionHeader, statusLine, divider, renderBranchTable } from "../utils/ui.js";
3
+ import { saveJson, saveMarkdown } from "../services/report/index.js";
4
+ import chalk from "chalk";
5
+ export const reportResultsStage = async (context, deps) => {
6
+ sectionHeader("Run Report");
7
+ const results = context.results ?? [];
8
+ const pushResults = context.pushResults ?? [];
9
+ // Per-branch status
10
+ renderBranchTable(results.map((r) => ({
11
+ name: r.branch.name,
12
+ status: r.status,
13
+ detail: r.commitSha
14
+ ? `commit ${r.commitSha.slice(0, 8)}`
15
+ : r.reason ?? "",
16
+ })));
17
+ // Push results (only non-skipped)
18
+ const activePushResults = pushResults.filter((p) => p.status !== "skipped");
19
+ if (activePushResults.length > 0) {
20
+ console.log("");
21
+ console.log(chalk.gray(" Push:"));
22
+ for (const pr of activePushResults) {
23
+ if (pr.status === "pushed") {
24
+ statusLine("ok", pr.branch.name, "pushed");
25
+ }
26
+ else {
27
+ statusLine("fail", pr.branch.name, pr.reason ?? "push failed");
28
+ }
29
+ }
30
+ }
31
+ // Aggregate summary
32
+ const committed = results.filter((r) => r.status === "committed").length;
33
+ const failed = results.filter((r) => r.status === "failed").length;
34
+ const rejected = results.filter((r) => r.status === "rejected").length;
35
+ const unchanged = results.filter((r) => r.status === "unchanged").length;
36
+ const skipped = results.filter((r) => r.status === "skipped").length;
37
+ const pushed = pushResults.filter((p) => p.status === "pushed").length;
38
+ divider();
39
+ console.log("");
40
+ console.log(` ${chalk.bold("Total:")} ` +
41
+ [
42
+ committed > 0 ? chalk.green(`${committed} committed`) : "",
43
+ pushed > 0 ? chalk.green(`${pushed} pushed`) : "",
44
+ failed > 0 ? chalk.red(`${failed} failed`) : "",
45
+ rejected > 0 ? chalk.yellow(`${rejected} rejected`) : "",
46
+ unchanged > 0 ? chalk.gray(`${unchanged} unchanged`) : "",
47
+ skipped > 0 ? chalk.gray(`${skipped} skipped`) : "",
48
+ ].filter(Boolean).join(" "));
49
+ console.log("");
50
+ // Save template state if any commits succeeded
51
+ const rawTemplate = context.activeTemplate?.raw ?? context.template?.raw;
52
+ if (committed > 0 && context.project && context.target && rawTemplate) {
53
+ const summary = {
54
+ committed,
55
+ failed,
56
+ skipped: skipped + rejected,
57
+ total: results.length,
58
+ };
59
+ const saveResult = await deps.services.state.saveTemplate(context.project.stateDir, context.target.path, rawTemplate, summary);
60
+ if (saveResult.ok) {
61
+ statusLine("ok", `Template saved for '${context.target.path}' (ready for next run)`);
62
+ }
63
+ else {
64
+ statusLine("warn", "Could not save template state", saveResult.error.message);
65
+ }
66
+ }
67
+ else if (committed === 0 && context.project && context.target && rawTemplate) {
68
+ // No commits but everything matches — offer to save reviewed template
69
+ const { clack } = await import("../utils/ui.js");
70
+ if (!context.input.ci) {
71
+ const save = await clack.confirm({
72
+ message: "No changes committed. Save current template for next run?",
73
+ initialValue: true,
74
+ });
75
+ if (!clack.isCancel(save) && save) {
76
+ await deps.services.state.saveTemplate(context.project.stateDir, context.target.path, rawTemplate);
77
+ statusLine("ok", `Template saved for '${context.target.path}'`);
78
+ }
79
+ }
80
+ }
81
+ // Write JSON/Markdown reports if configured
82
+ if (context.project && (deps.config.report.writeJson || deps.config.report.writeMarkdown)) {
83
+ const report = {
84
+ runId: context.runId,
85
+ targetPath: context.target?.path ?? "",
86
+ startedAt: new Date().toISOString(),
87
+ completedAt: new Date().toISOString(),
88
+ plans: context.plans ?? [],
89
+ results,
90
+ pushResults,
91
+ };
92
+ await saveJson(context.project.reportsDir, report, deps.config).catch(() => { });
93
+ await saveMarkdown(context.project.reportsDir, report, deps.config).catch(() => { });
94
+ if (deps.config.report.writeJson) {
95
+ statusLine("info", `JSON report: ${context.project.reportsDir}/${context.runId}.json`);
96
+ }
97
+ if (deps.config.report.writeMarkdown) {
98
+ statusLine("info", `Markdown report: ${context.project.reportsDir}/${context.runId}.md`);
99
+ }
100
+ }
101
+ console.log("");
102
+ return ok(context);
103
+ };
@@ -0,0 +1,3 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps } from "../core/types.js";
3
+ export declare const resolveProjectStage: Stage<RunContext, AppDeps>;
@@ -0,0 +1,294 @@
1
+ import { ok, err } from "../core/types.js";
2
+ import { clack, banner, statusLine, sectionHeader, sym } from "../utils/ui.js";
3
+ import { inputCancelledError } from "../core/errors.js";
4
+ import { existsSync } from "fs";
5
+ import { resolve } from "path";
6
+ import chalk from "chalk";
7
+ import { listCachedProjects } from "../services/workspace/cache.js";
8
+ import { getWorkspaceRoot } from "../services/workspace/manager.js";
9
+ async function promptCachedRepo(cachedProjects, root) {
10
+ if (cachedProjects.length === 0) {
11
+ clack.log.info(`No cached repositories found in ${root}/projects yet.`);
12
+ return "__BACK__";
13
+ }
14
+ console.log("");
15
+ console.log(chalk.bold(` ${"Name".padEnd(20)} ${"Remote URL / Path".padEnd(46)} Cached Date`));
16
+ console.log(chalk.gray(" " + "─".repeat(80)));
17
+ for (const p of cachedProjects) {
18
+ const name = chalk.cyan(p.name.slice(0, 18).padEnd(20));
19
+ const target = (p.remoteUrl || p.sourceRepoPath || "(unknown)").slice(0, 44).padEnd(46);
20
+ const date = p.createdAt ? p.createdAt.slice(0, 10) : "unknown";
21
+ console.log(` ${name} ${chalk.white(target)} ${chalk.gray(date)}`);
22
+ }
23
+ console.log(chalk.gray(" " + "─".repeat(80)));
24
+ console.log("");
25
+ const selected = await clack.select({
26
+ message: "Select a cached repository to target:",
27
+ options: [
28
+ ...cachedProjects.map((p) => ({
29
+ value: p.remoteUrl || p.sourceRepoPath || p.id,
30
+ label: `${sym.branch} ${chalk.bold(p.name)}`,
31
+ hint: `${p.remoteUrl || p.sourceRepoPath}`,
32
+ })),
33
+ {
34
+ value: "__BACK__",
35
+ label: "← Back to repository menu",
36
+ },
37
+ ],
38
+ });
39
+ if (clack.isCancel(selected)) {
40
+ return null;
41
+ }
42
+ return selected;
43
+ }
44
+ export const resolveProjectStage = async (context, deps) => {
45
+ banner();
46
+ let targetRepo = context.input.repo;
47
+ // If repo was not supplied via CLI argument (-r / --repo)
48
+ if (!targetRepo) {
49
+ const isCi = context.input.ci ?? false;
50
+ const cwd = process.cwd();
51
+ // Check if the current working directory is a git repository
52
+ const localCheck = await deps.services.workspace.checkLocalRepo(cwd, context.input.remote || deps.config.git.remote);
53
+ if (isCi) {
54
+ // In CI mode, cannot prompt: use cwd if git repo, otherwise fail
55
+ if (localCheck.isRepo) {
56
+ targetRepo = cwd;
57
+ }
58
+ else {
59
+ clack.log.error(`Current directory '${cwd}' is not a Git repository. Pass --repo <url-or-path> in CI mode.`);
60
+ return err(inputCancelledError("Not a git repository in CI mode without --repo flag"));
61
+ }
62
+ }
63
+ else {
64
+ // Interactive TUI mode
65
+ sectionHeader("Target Repository");
66
+ const root = getWorkspaceRoot(deps.config);
67
+ while (!targetRepo) {
68
+ const cachedProjects = await listCachedProjects(deps.config);
69
+ // If user specifically requested --cached via CLI
70
+ if (context.input.cached) {
71
+ if (cachedProjects.length === 0) {
72
+ clack.log.warn(`No cached repositories found in ${root}/projects yet.`);
73
+ context.input.cached = false;
74
+ continue;
75
+ }
76
+ const selected = await promptCachedRepo(cachedProjects, root);
77
+ if (selected === null) {
78
+ return err(inputCancelledError("Repository selection cancelled"));
79
+ }
80
+ if (selected === "__BACK__") {
81
+ context.input.cached = false;
82
+ continue;
83
+ }
84
+ targetRepo = selected;
85
+ break;
86
+ }
87
+ if (localCheck.isRepo) {
88
+ // Current directory IS a git repo
89
+ const remoteInfo = localCheck.remoteUrl
90
+ ? ` (remote: ${localCheck.remoteUrl})`
91
+ : " (local-only)";
92
+ statusLine("ok", "Detected Git repository in current directory", `${cwd}${remoteInfo}`);
93
+ console.log("");
94
+ const choice = await clack.select({
95
+ message: "What repository would you like to target?",
96
+ options: [
97
+ {
98
+ value: "current",
99
+ label: "Use current repository",
100
+ hint: localCheck.remoteUrl || cwd,
101
+ },
102
+ {
103
+ value: "cached",
104
+ label: `📦 Select from cached repositories (${cachedProjects.length} available)`,
105
+ hint: "list already cloned repositories (name, remote URL)",
106
+ },
107
+ {
108
+ value: "remote",
109
+ label: "Clone a remote repository URL",
110
+ hint: "https://... or git@...",
111
+ },
112
+ {
113
+ value: "local-path",
114
+ label: "Specify a different local repository path",
115
+ },
116
+ ],
117
+ });
118
+ if (clack.isCancel(choice)) {
119
+ return err(inputCancelledError("Repository selection cancelled"));
120
+ }
121
+ if (choice === "current") {
122
+ targetRepo = cwd;
123
+ break;
124
+ }
125
+ if (choice === "cached") {
126
+ const selected = await promptCachedRepo(cachedProjects, root);
127
+ if (selected === null) {
128
+ return err(inputCancelledError("Repository selection cancelled"));
129
+ }
130
+ if (selected === "__BACK__") {
131
+ continue;
132
+ }
133
+ targetRepo = selected;
134
+ break;
135
+ }
136
+ if (choice === "remote") {
137
+ const remoteUrlInput = await clack.text({
138
+ message: "Remote repository URL:",
139
+ placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git",
140
+ validate: (v) => {
141
+ if (!v || v.trim() === "")
142
+ return "Repository URL cannot be empty";
143
+ const trimmed = v.trim();
144
+ const isUrl = trimmed.startsWith("http://") ||
145
+ trimmed.startsWith("https://") ||
146
+ trimmed.startsWith("git@") ||
147
+ trimmed.startsWith("ssh://") ||
148
+ trimmed.startsWith("git://");
149
+ if (!isUrl) {
150
+ return "Please enter a valid remote URL (e.g. https://github.com/... or git@github.com:...)";
151
+ }
152
+ },
153
+ });
154
+ if (clack.isCancel(remoteUrlInput)) {
155
+ return err(inputCancelledError("Repository selection cancelled"));
156
+ }
157
+ targetRepo = remoteUrlInput.trim();
158
+ break;
159
+ }
160
+ if (choice === "local-path") {
161
+ const pathInput = await clack.text({
162
+ message: "Path to local repository:",
163
+ placeholder: "/path/to/repo or ../other-repo",
164
+ validate: (v) => {
165
+ if (!v || v.trim() === "")
166
+ return "Path cannot be empty";
167
+ if (!existsSync(resolve(v.trim())))
168
+ return `Path does not exist: ${v.trim()}`;
169
+ },
170
+ });
171
+ if (clack.isCancel(pathInput)) {
172
+ return err(inputCancelledError("Repository selection cancelled"));
173
+ }
174
+ targetRepo = pathInput.trim();
175
+ break;
176
+ }
177
+ }
178
+ else {
179
+ // Current directory IS NOT a git repo
180
+ statusLine("info", "Current directory is not a Git repository", cwd);
181
+ console.log("");
182
+ const choice = await clack.select({
183
+ message: "How would you like to target a repository?",
184
+ options: [
185
+ ...(cachedProjects.length > 0
186
+ ? [
187
+ {
188
+ value: "cached",
189
+ label: `📦 Select from cached repositories (${cachedProjects.length} available)`,
190
+ hint: "list already cloned repositories (name, remote URL)",
191
+ },
192
+ ]
193
+ : [
194
+ {
195
+ value: "cached",
196
+ label: "📦 Cached repositories (none yet)",
197
+ hint: "view already cloned repositories in ~/.git-worker",
198
+ },
199
+ ]),
200
+ {
201
+ value: "remote",
202
+ label: "Clone a remote repository URL",
203
+ hint: "recommended — https://... or git@...",
204
+ },
205
+ {
206
+ value: "local-path",
207
+ label: "Specify path to a local Git repository",
208
+ },
209
+ ],
210
+ });
211
+ if (clack.isCancel(choice)) {
212
+ return err(inputCancelledError("Repository selection cancelled"));
213
+ }
214
+ if (choice === "cached") {
215
+ const selected = await promptCachedRepo(cachedProjects, root);
216
+ if (selected === null) {
217
+ return err(inputCancelledError("Repository selection cancelled"));
218
+ }
219
+ if (selected === "__BACK__") {
220
+ continue;
221
+ }
222
+ targetRepo = selected;
223
+ break;
224
+ }
225
+ if (choice === "remote") {
226
+ const remoteUrlInput = await clack.text({
227
+ message: "Remote repository URL:",
228
+ placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git",
229
+ validate: (v) => {
230
+ if (!v || v.trim() === "")
231
+ return "Repository URL cannot be empty";
232
+ const trimmed = v.trim();
233
+ const isUrl = trimmed.startsWith("http://") ||
234
+ trimmed.startsWith("https://") ||
235
+ trimmed.startsWith("git@") ||
236
+ trimmed.startsWith("ssh://") ||
237
+ trimmed.startsWith("git://");
238
+ if (!isUrl) {
239
+ return "Please enter a valid remote URL (e.g. https://github.com/... or git@github.com:...)";
240
+ }
241
+ },
242
+ });
243
+ if (clack.isCancel(remoteUrlInput)) {
244
+ return err(inputCancelledError("Repository selection cancelled"));
245
+ }
246
+ targetRepo = remoteUrlInput.trim();
247
+ break;
248
+ }
249
+ if (choice === "local-path") {
250
+ const pathInput = await clack.text({
251
+ message: "Path to local repository:",
252
+ placeholder: "/path/to/repo or ../other-repo",
253
+ validate: (v) => {
254
+ if (!v || v.trim() === "")
255
+ return "Path cannot be empty";
256
+ if (!existsSync(resolve(v.trim())))
257
+ return `Path does not exist: ${v.trim()}`;
258
+ },
259
+ });
260
+ if (clack.isCancel(pathInput)) {
261
+ return err(inputCancelledError("Repository selection cancelled"));
262
+ }
263
+ targetRepo = pathInput.trim();
264
+ break;
265
+ }
266
+ }
267
+ }
268
+ }
269
+ }
270
+ // Now resolve the project workspace
271
+ const s = clack.spinner();
272
+ s.start("Resolving project workspace…");
273
+ const result = await deps.services.workspace.resolveProject(targetRepo, context.input.remote, deps.config);
274
+ if (!result.ok) {
275
+ s.stop(chalk.red("Project resolution failed"));
276
+ clack.log.error(result.error.message);
277
+ return result;
278
+ }
279
+ s.stop(chalk.green("Project workspace ready"));
280
+ if (result.value.isCached) {
281
+ statusLine("info", "Using cached repository clone (already downloaded)", result.value.remoteUrl || result.value.managedRepoPath);
282
+ }
283
+ else {
284
+ statusLine("info", `Managed workspace: ${result.value.managedRepoPath}`);
285
+ }
286
+ return ok({
287
+ ...context,
288
+ input: {
289
+ ...context.input,
290
+ repo: targetRepo,
291
+ },
292
+ project: result.value,
293
+ });
294
+ };
@@ -0,0 +1,3 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps } from "../core/types.js";
3
+ export declare const reviewChangesStage: Stage<RunContext, AppDeps>;
@@ -0,0 +1,197 @@
1
+ import { ok, err } from "../core/types.js";
2
+ import { workspaceError, inputCancelledError } from "../core/errors.js";
3
+ import { writeSnapshots } from "../services/review/index.js";
4
+ import { clack, sectionHeader, statusLine, divider, renderDiff, renderBranchTable, sym } from "../utils/ui.js";
5
+ import chalk from "chalk";
6
+ export const reviewChangesStage = async (context, deps) => {
7
+ if (!context.plans || !context.project) {
8
+ return err(workspaceError("No plans to review"));
9
+ }
10
+ const isCi = context.input.ci ?? false;
11
+ const autoApprove = context.input.autoApprove || deps.config.review.autoApprove;
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");
24
+ const approvals = [];
25
+ // Auto-approve unchanged and errors
26
+ for (const plan of context.plans) {
27
+ if (plan.status !== "changed") {
28
+ approvals.push({
29
+ branchName: plan.branch.name,
30
+ approved: false,
31
+ planHash: plan.planHash,
32
+ });
33
+ }
34
+ }
35
+ if (changedPlans.length === 0) {
36
+ statusLine("info", "No changed branches to review");
37
+ return ok({ ...context, approvals });
38
+ }
39
+ // Auto-approve all
40
+ if (autoApprove) {
41
+ for (const plan of changedPlans) {
42
+ approvals.push({
43
+ branchName: plan.branch.name,
44
+ approved: true,
45
+ planHash: plan.planHash,
46
+ });
47
+ }
48
+ statusLine("ok", `Auto-approved ${changedPlans.length} branch(es)`);
49
+ return ok({ ...context, approvals });
50
+ }
51
+ 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
+ });
59
+ }
60
+ statusLine("warn", "CI mode — all branches rejected (use --auto-approve to commit in CI)");
61
+ return ok({ ...context, approvals });
62
+ }
63
+ // Interactive review
64
+ console.log("");
65
+ // Ask if user wants batch action or per-branch review
66
+ const batchChoice = await clack.select({
67
+ message: `Review ${changedPlans.length} changed branch(es):`,
68
+ options: [
69
+ { value: "per-branch", label: "Review each branch individually", hint: "recommended" },
70
+ { value: "approve-all", label: `Approve all ${changedPlans.length} branches` },
71
+ { value: "reject-all", label: "Reject all" },
72
+ ],
73
+ });
74
+ if (clack.isCancel(batchChoice)) {
75
+ return err(inputCancelledError("Review cancelled"));
76
+ }
77
+ if (batchChoice === "approve-all") {
78
+ for (const plan of changedPlans) {
79
+ approvals.push({ branchName: plan.branch.name, approved: true, planHash: plan.planHash });
80
+ }
81
+ statusLine("ok", `Approved all ${changedPlans.length} branch(es)`);
82
+ return ok({ ...context, approvals });
83
+ }
84
+ if (batchChoice === "reject-all") {
85
+ for (const plan of changedPlans) {
86
+ approvals.push({ branchName: plan.branch.name, approved: false, planHash: plan.planHash });
87
+ }
88
+ statusLine("warn", "All branches rejected");
89
+ return ok({ ...context, approvals });
90
+ }
91
+ // Per-branch review
92
+ for (const plan of changedPlans) {
93
+ console.log("");
94
+ 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"));
101
+ 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"));
118
+ }
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"));
135
+ }
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
+ }
149
+ }
150
+ else {
151
+ // No snapshots — just approve/reject
152
+ const choice = await clack.select({
153
+ message: "Action:",
154
+ options: [
155
+ { value: "approve", label: `${chalk.green("✓")} Approve` },
156
+ { value: "reject", label: `${chalk.red("✗")} Reject` },
157
+ ],
158
+ });
159
+ if (clack.isCancel(choice)) {
160
+ return err(inputCancelledError("Review cancelled"));
161
+ }
162
+ approvals.push({
163
+ branchName: plan.branch.name,
164
+ approved: choice === "approve",
165
+ planHash: plan.planHash,
166
+ });
167
+ }
168
+ }
169
+ 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`);
173
+ // Confirm before destructive apply
174
+ if (approved > 0) {
175
+ 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);
185
+ }
186
+ }
187
+ console.log("");
188
+ const confirm = await clack.confirm({
189
+ message: `Proceed to commit ${approved} branch(es)?`,
190
+ initialValue: true,
191
+ });
192
+ if (clack.isCancel(confirm) || !confirm) {
193
+ return err(inputCancelledError("Commit aborted by user"));
194
+ }
195
+ }
196
+ return ok({ ...context, approvals });
197
+ };
@@ -0,0 +1,13 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps } from "../core/types.js";
3
+ /**
4
+ * Matches a branch name against a pattern.
5
+ * Supports:
6
+ * - Regex: /uat.*\/, /^release\//, /pattern/i
7
+ * - Glob: uat/*, *orders, uat*
8
+ * - Exact name: main, develop
9
+ * - Trailing slash prefix: uat/
10
+ * - Substring fallback: orders
11
+ */
12
+ export declare function matchesBranchPattern(name: string, pattern: string): boolean;
13
+ export declare const selectTargetsStage: Stage<RunContext, AppDeps>;