@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,301 @@
1
+ import { ok, err } from "../../core/types.js";
2
+ import { workspaceError, gitError } from "../../core/errors.js";
3
+ import { existsSync, readFileSync, unlinkSync } from "fs";
4
+ import { mkdir, writeFile, rm, open } from "fs/promises";
5
+ import { join, resolve, normalize } from "path";
6
+ import { simpleGit, CheckRepoActions } from "simple-git";
7
+ import { homedir } from "os";
8
+ import { createHash } from "crypto";
9
+ const GIT_WORKER_VERSION = "0.2.0";
10
+ const LOCK_FILE = ".git-worker-lock";
11
+ /**
12
+ * Extract a human-readable repository name from a remote URL or file path.
13
+ * e.g. "https://github.com/org/repo.git" -> "repo"
14
+ * e.g. "git@github.com:org/repo.git" -> "repo"
15
+ * e.g. "/path/to/my-repo" -> "my-repo"
16
+ */
17
+ export function extractRepoName(urlOrPath, fallbackId) {
18
+ if (!urlOrPath)
19
+ return fallbackId || "unnamed-repo";
20
+ const clean = urlOrPath.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
21
+ const lastSlash = Math.max(clean.lastIndexOf("/"), clean.lastIndexOf(":"), clean.lastIndexOf("\\"));
22
+ if (lastSlash >= 0 && lastSlash < clean.length - 1) {
23
+ return clean.slice(lastSlash + 1);
24
+ }
25
+ return clean || fallbackId || "unnamed-repo";
26
+ }
27
+ export function getWorkspaceRoot(config) {
28
+ // Use config.workspace.root if it looks absolute or explicitly specified
29
+ const root = config?.workspace?.root;
30
+ if (root && (root.startsWith("/") || root.startsWith("~"))) {
31
+ return root.replace(/^~/, homedir());
32
+ }
33
+ // Store directly in ~/.git-worker in user's home directory
34
+ return join(homedir(), ".git-worker");
35
+ }
36
+ function projectIdFromRemote(remoteUrl) {
37
+ return createHash("sha256").update(remoteUrl).digest("hex").slice(0, 16);
38
+ }
39
+ /**
40
+ * Verify that the given path is inside our workspace and the marker file is valid.
41
+ * This is the ownership guard that must run before any destructive ops.
42
+ */
43
+ export function verifyOwnership(project) {
44
+ if (!existsSync(project.markerPath)) {
45
+ return err(workspaceError(`Ownership marker not found at '${project.markerPath}'. Refusing destructive operation.`));
46
+ }
47
+ try {
48
+ const raw = readFileSync(project.markerPath, "utf-8");
49
+ const marker = JSON.parse(raw);
50
+ if (marker.id !== project.id) {
51
+ return err(workspaceError(`Marker ID mismatch: expected '${project.id}', found '${marker.id}'. Refusing destructive operation.`));
52
+ }
53
+ // Path escape check: managed repo must be inside the project dir
54
+ const projectDir = join(project.managedRepoPath, "..");
55
+ const normalizedRepo = normalize(resolve(project.managedRepoPath));
56
+ const normalizedProject = normalize(resolve(projectDir));
57
+ if (!normalizedRepo.startsWith(normalizedProject)) {
58
+ return err(workspaceError(`Managed repo path '${normalizedRepo}' escapes project directory '${normalizedProject}'.`));
59
+ }
60
+ return ok(undefined);
61
+ }
62
+ catch (e) {
63
+ return err(workspaceError(`Failed to read ownership marker: ${e.message}`));
64
+ }
65
+ }
66
+ export async function checkLocalRepo(dirPath, preferredRemote = "origin") {
67
+ try {
68
+ const resolved = resolve(dirPath);
69
+ const git = simpleGit(resolved);
70
+ let isRepo = await git.checkIsRepo().catch(() => false);
71
+ if (!isRepo) {
72
+ isRepo = await git.checkIsRepo(CheckRepoActions.BARE).catch(() => false);
73
+ }
74
+ if (!isRepo) {
75
+ return { isRepo: false };
76
+ }
77
+ try {
78
+ const remotes = await git.getRemotes(true);
79
+ const target = remotes.find((r) => r.name === preferredRemote) || remotes[0];
80
+ if (target?.refs?.fetch) {
81
+ return { isRepo: true, remoteUrl: target.refs.fetch, remoteName: target.name };
82
+ }
83
+ }
84
+ catch {
85
+ // no remotes
86
+ }
87
+ return { isRepo: true };
88
+ }
89
+ catch {
90
+ return { isRepo: false };
91
+ }
92
+ }
93
+ export async function resolveProject(sourceRepoPath, remote, config) {
94
+ try {
95
+ // Check if this looks like a remote URL (http/git/ssh)
96
+ const isRemoteUrl = sourceRepoPath.startsWith("http://") ||
97
+ sourceRepoPath.startsWith("https://") ||
98
+ sourceRepoPath.startsWith("git@") ||
99
+ sourceRepoPath.startsWith("ssh://") ||
100
+ sourceRepoPath.startsWith("git://");
101
+ const sourceLocation = isRemoteUrl ? sourceRepoPath : resolve(sourceRepoPath);
102
+ let remoteUrl;
103
+ if (isRemoteUrl) {
104
+ // Treat the supplied path directly as the remote URL
105
+ remoteUrl = sourceRepoPath;
106
+ }
107
+ else {
108
+ // It's a local path — verify it's a git repo and discover its remote
109
+ const git = simpleGit(sourceLocation);
110
+ let isRepo = await git.checkIsRepo().catch(() => false);
111
+ if (!isRepo) {
112
+ isRepo = await git.checkIsRepo(CheckRepoActions.BARE).catch(() => false);
113
+ }
114
+ if (!isRepo) {
115
+ return err(gitError(`'${sourceLocation}' is not a git repository`));
116
+ }
117
+ const targetRemoteName = remote || config.git.remote;
118
+ try {
119
+ const remotes = await git.getRemotes(true);
120
+ const targetRemote = remotes.find((r) => r.name === targetRemoteName) || remotes[0];
121
+ if (targetRemote?.refs?.fetch) {
122
+ remoteUrl = targetRemote.refs.fetch;
123
+ }
124
+ }
125
+ catch {
126
+ // ignore
127
+ }
128
+ // If bare repo with no remotes: use its own path as the remote URL
129
+ if (!remoteUrl) {
130
+ const isBare = await git.checkIsRepo(CheckRepoActions.BARE).catch(() => false);
131
+ if (isBare) {
132
+ remoteUrl = sourceLocation;
133
+ }
134
+ }
135
+ }
136
+ if (!remoteUrl && config.safety.rejectNoRemote) {
137
+ return err(workspaceError(`No remote found in '${sourceLocation}'. ` +
138
+ `Git Worker requires a remote to create a safe managed checkout. ` +
139
+ `Disable reject-no-remote in config to allow local-only repos (not recommended).`));
140
+ }
141
+ const id = projectIdFromRemote(remoteUrl || sourceLocation);
142
+ const root = getWorkspaceRoot(config);
143
+ const projectDir = join(root, "projects", id);
144
+ const managedRepoPath = join(projectDir, "repo");
145
+ const stagingDir = join(projectDir, "staging");
146
+ const stateDir = join(projectDir, "state");
147
+ const reportsDir = join(projectDir, "reports");
148
+ const markerPath = join(projectDir, ".git-worker-marker");
149
+ await mkdir(projectDir, { recursive: true });
150
+ await mkdir(managedRepoPath, { recursive: true });
151
+ await mkdir(stagingDir, { recursive: true });
152
+ await mkdir(stateDir, { recursive: true });
153
+ await mkdir(reportsDir, { recursive: true });
154
+ const markerData = {
155
+ id,
156
+ name: extractRepoName(remoteUrl || sourceLocation, id),
157
+ sourceRepoPath: sourceLocation,
158
+ remoteUrl: remoteUrl || "",
159
+ createdAt: new Date().toISOString(),
160
+ gitWorkerVersion: GIT_WORKER_VERSION,
161
+ };
162
+ await writeFile(markerPath, JSON.stringify(markerData, null, 2), "utf-8");
163
+ // Check if an existing managed clone is already present for this remote URL
164
+ let isCached = false;
165
+ if (existsSync(managedRepoPath)) {
166
+ try {
167
+ const git = simpleGit(managedRepoPath);
168
+ const isRepo = await git.checkIsRepo().catch(() => false);
169
+ if (isRepo) {
170
+ const remotes = await git.getRemotes(true);
171
+ const origin = remotes.find((r) => r.name === "origin") || remotes[0];
172
+ if (origin?.refs?.fetch === remoteUrl) {
173
+ isCached = true;
174
+ }
175
+ }
176
+ }
177
+ catch {
178
+ // ignore
179
+ }
180
+ }
181
+ return ok({
182
+ id,
183
+ sourceRepoPath: sourceLocation,
184
+ remoteUrl,
185
+ managedRepoPath,
186
+ stagingDir,
187
+ stateDir,
188
+ reportsDir,
189
+ markerPath,
190
+ isCached,
191
+ });
192
+ }
193
+ catch (e) {
194
+ return err(workspaceError(`Failed to resolve project: ${e.message}`, e));
195
+ }
196
+ }
197
+ export async function ensureManagedClone(project) {
198
+ try {
199
+ const git = simpleGit(project.managedRepoPath);
200
+ const isRepo = await git.checkIsRepo().catch(() => false);
201
+ if (isRepo) {
202
+ try {
203
+ const remotes = await git.getRemotes(true);
204
+ const origin = remotes.find((r) => r.name === "origin") || remotes[0];
205
+ if (origin?.refs?.fetch === project.remoteUrl) {
206
+ return ok({ isCached: true });
207
+ }
208
+ }
209
+ catch {
210
+ // remote mismatch, re-clone below
211
+ }
212
+ // Remote URL changed — clean and re-clone
213
+ await rm(project.managedRepoPath, { recursive: true, force: true });
214
+ }
215
+ if (!project.remoteUrl) {
216
+ return err(gitError("Cannot clone: no remote URL available"));
217
+ }
218
+ // Clone into 'repo' subdirectory
219
+ const parentGit = simpleGit(join(project.managedRepoPath, ".."));
220
+ await parentGit.clone(project.remoteUrl, "repo", ["--no-local"]);
221
+ return ok({ isCached: false });
222
+ }
223
+ catch (e) {
224
+ return err(gitError(`Failed to clone managed repo: ${e.message}`, e));
225
+ }
226
+ }
227
+ export function releaseLock(project) {
228
+ const lockPath = join(project.managedRepoPath, "..", LOCK_FILE);
229
+ try {
230
+ if (existsSync(lockPath)) {
231
+ unlinkSync(lockPath);
232
+ }
233
+ }
234
+ catch {
235
+ // best effort
236
+ }
237
+ }
238
+ /**
239
+ * Acquire a file-based lock on the managed checkout.
240
+ * Returns a release function to call when done.
241
+ * Checks for and cleans up stale locks from terminated processes.
242
+ * Waits up to timeoutMs for the lock to become available.
243
+ */
244
+ export async function acquireLock(project, timeoutMs) {
245
+ const lockPath = join(project.managedRepoPath, "..", LOCK_FILE);
246
+ const deadline = Date.now() + timeoutMs;
247
+ const pid = process.pid;
248
+ while (Date.now() < deadline) {
249
+ // Check if existing lock is stale (process no longer exists)
250
+ if (existsSync(lockPath)) {
251
+ try {
252
+ const raw = readFileSync(lockPath, "utf-8");
253
+ const data = JSON.parse(raw);
254
+ if (data.pid && typeof data.pid === "number") {
255
+ try {
256
+ process.kill(data.pid, 0); // signal 0 tests process existence
257
+ }
258
+ catch (err) {
259
+ if (err.code === "ESRCH") {
260
+ // Holding process died — remove stale lock immediately
261
+ releaseLock(project);
262
+ }
263
+ }
264
+ }
265
+ }
266
+ catch {
267
+ // Malformed lock — clear it
268
+ releaseLock(project);
269
+ }
270
+ }
271
+ try {
272
+ // O_EXCL ensures atomic create — fails if file exists
273
+ const fh = await open(lockPath, "wx");
274
+ await fh.writeFile(JSON.stringify({ pid, acquiredAt: new Date().toISOString() }));
275
+ await fh.close();
276
+ const release = () => {
277
+ releaseLock(project);
278
+ };
279
+ return ok(release);
280
+ }
281
+ catch (e) {
282
+ if (e.code !== "EEXIST") {
283
+ return err(workspaceError(`Failed to acquire lock: ${e.message}`));
284
+ }
285
+ // Lock held by an active process — wait and retry
286
+ await new Promise((r) => setTimeout(r, 200));
287
+ }
288
+ }
289
+ return err(workspaceError(`Could not acquire workspace lock within ${timeoutMs}ms. ` +
290
+ `Another git-worker process may be running. Delete '${lockPath}' to force-release.`));
291
+ }
292
+ export function createWorkspaceManager() {
293
+ return {
294
+ resolveProject,
295
+ checkLocalRepo,
296
+ ensureManagedClone,
297
+ verifyOwnership,
298
+ acquireLock,
299
+ releaseLock,
300
+ };
301
+ }
@@ -0,0 +1,3 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps } from "../core/types.js";
3
+ export declare const applyChangesStage: Stage<RunContext, AppDeps>;
@@ -0,0 +1,161 @@
1
+ import { ok, err } from "../core/types.js";
2
+ import { workspaceError } from "../core/errors.js";
3
+ import { clack, sectionHeader, statusLine } from "../utils/ui.js";
4
+ import { sha256 } from "../utils/hash.js";
5
+ import chalk from "chalk";
6
+ export const applyChangesStage = async (context, deps) => {
7
+ if (!context.plans || !context.approvals || !context.project) {
8
+ return err(workspaceError("Prerequisites missing for apply"));
9
+ }
10
+ sectionHeader("Applying Changes");
11
+ const results = [];
12
+ const approvedPlans = context.plans.filter((p) => {
13
+ const approval = context.approvals.find((a) => a.branchName === p.branch.name);
14
+ return approval?.approved === true;
15
+ });
16
+ // Handle unapproved/error plans first
17
+ for (const plan of context.plans) {
18
+ const approval = context.approvals.find((a) => a.branchName === plan.branch.name);
19
+ if (approval?.approved)
20
+ continue; // Will be handled below
21
+ if (plan.status === "unchanged") {
22
+ results.push({ branch: plan.branch, status: "unchanged", reason: "No changes" });
23
+ }
24
+ else if (plan.status === "error") {
25
+ results.push({ branch: plan.branch, status: "failed", reason: plan.reason });
26
+ }
27
+ else {
28
+ results.push({
29
+ branch: plan.branch,
30
+ status: approval ? "rejected" : "skipped",
31
+ reason: approval ? "User rejected" : "Not reviewed",
32
+ });
33
+ }
34
+ }
35
+ if (approvedPlans.length === 0) {
36
+ statusLine("info", "No approved changes to apply");
37
+ return ok({ ...context, results });
38
+ }
39
+ for (const plan of approvedPlans) {
40
+ const s = clack.spinner();
41
+ s.start(`Applying ${plan.branch.name}…`);
42
+ try {
43
+ // Verify ownership before any destructive op
44
+ const ownerCheck = deps.services.workspace.verifyOwnership(context.project);
45
+ if (!ownerCheck.ok) {
46
+ s.stop(chalk.red(`${plan.branch.name} — ownership verification failed`));
47
+ results.push({ branch: plan.branch, status: "failed", reason: ownerCheck.error.message });
48
+ continue;
49
+ }
50
+ // ── Stale-plan guard ────────────────────────────────────────────
51
+ // Fetch latest SHA and verify it matches the reviewed plan
52
+ const currentShaResult = await deps.services.gitOps.getBranchSha(context.project.managedRepoPath, plan.branch.isRemote && plan.branch.remote
53
+ ? `${plan.branch.remote}/${plan.branch.name}`
54
+ : plan.branch.name);
55
+ if (!currentShaResult.ok) {
56
+ s.stop(chalk.red(`${plan.branch.name} — could not verify branch tip`));
57
+ results.push({ branch: plan.branch, status: "failed", reason: "Could not verify branch tip" });
58
+ // Clean up before next branch
59
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
60
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
61
+ continue;
62
+ }
63
+ if (currentShaResult.value !== plan.baseCommitSha) {
64
+ s.stop(chalk.yellow(`${plan.branch.name} — branch advanced since review (stale plan)`));
65
+ results.push({
66
+ branch: plan.branch,
67
+ status: "failed",
68
+ reason: `Branch advanced: reviewed at ${plan.baseCommitSha.slice(0, 8)}, now at ${currentShaResult.value.slice(0, 8)}. Re-run to replan.`,
69
+ });
70
+ continue;
71
+ }
72
+ // ── Clean before checkout ───────────────────────────────────────
73
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath);
74
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode);
75
+ // ── Checkout branch ─────────────────────────────────────────────
76
+ const checkoutResult = await deps.services.gitOps.checkoutBranch(context.project.managedRepoPath, plan.branch.name);
77
+ if (!checkoutResult.ok) {
78
+ s.stop(chalk.red(`${plan.branch.name} — checkout failed`));
79
+ results.push({ branch: plan.branch, status: "failed", reason: checkoutResult.error.message });
80
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
81
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
82
+ continue;
83
+ }
84
+ // ── Clean after checkout ─────────────────────────────────────────
85
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath);
86
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode);
87
+ // ── Verify before-content hash ──────────────────────────────────
88
+ const currentFileResult = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, plan.branch, plan.path);
89
+ const currentContent = currentFileResult.ok ? currentFileResult.value : "";
90
+ const currentHash = sha256(currentContent);
91
+ if (currentHash !== plan.beforeContentHash) {
92
+ s.stop(chalk.yellow(`${plan.branch.name} — file content changed since review`));
93
+ results.push({
94
+ branch: plan.branch,
95
+ status: "failed",
96
+ reason: "File content changed since review. Re-run to replan.",
97
+ });
98
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
99
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
100
+ continue;
101
+ }
102
+ // ── Write and stage ─────────────────────────────────────────────
103
+ const writeResult = await deps.services.gitOps.writeAndStage(context.project.managedRepoPath, plan.path, plan.afterContent);
104
+ if (!writeResult.ok) {
105
+ s.stop(chalk.red(`${plan.branch.name} — write failed`));
106
+ results.push({ branch: plan.branch, status: "failed", reason: writeResult.error.message });
107
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
108
+ continue;
109
+ }
110
+ // ── Verify only the intended path is staged ─────────────────────
111
+ const stagedResult = await deps.services.gitOps.getStagedFiles(context.project.managedRepoPath);
112
+ if (stagedResult.ok) {
113
+ const staged = stagedResult.value;
114
+ const unexpected = staged.filter((f) => f !== plan.path);
115
+ if (unexpected.length > 0) {
116
+ s.stop(chalk.red(`${plan.branch.name} — unexpected staged files`));
117
+ results.push({
118
+ branch: plan.branch,
119
+ status: "failed",
120
+ reason: `Unexpected staged files: ${unexpected.join(", ")}. Aborting commit for safety.`,
121
+ });
122
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
123
+ continue;
124
+ }
125
+ if (staged.length === 0) {
126
+ // No-op commit — already matches
127
+ s.stop(chalk.gray(`${plan.branch.name} — no staged changes (already matches)`));
128
+ results.push({ branch: plan.branch, status: "unchanged", reason: "Content identical after write" });
129
+ continue;
130
+ }
131
+ }
132
+ // ── Commit ──────────────────────────────────────────────────────
133
+ const commitMsg = deps.config.git.commitMessage;
134
+ const commitResult = await deps.services.gitOps.commit(context.project.managedRepoPath, commitMsg);
135
+ if (!commitResult.ok) {
136
+ s.stop(chalk.red(`${plan.branch.name} — commit failed`));
137
+ results.push({ branch: plan.branch, status: "failed", reason: commitResult.error.message });
138
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
139
+ continue;
140
+ }
141
+ s.stop(chalk.green(`${plan.branch.name} — committed ${commitResult.value.slice(0, 8)}`));
142
+ results.push({
143
+ branch: plan.branch,
144
+ status: "committed",
145
+ commitSha: commitResult.value,
146
+ });
147
+ }
148
+ catch (e) {
149
+ s.stop(chalk.red(`${plan.branch.name} — unexpected error`));
150
+ results.push({ branch: plan.branch, status: "failed", reason: e.message });
151
+ // Clean up managed checkout before next branch
152
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
153
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
154
+ }
155
+ }
156
+ const committed = results.filter((r) => r.status === "committed").length;
157
+ const failed = results.filter((r) => r.status === "failed").length;
158
+ console.log("");
159
+ statusLine(committed > 0 ? "ok" : "info", `Apply complete — ${committed} committed, ${failed} failed`);
160
+ return ok({ ...context, results });
161
+ };
@@ -0,0 +1,3 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps } from "../core/types.js";
3
+ export declare const editTemplateStage: Stage<RunContext, AppDeps>;
@@ -0,0 +1,198 @@
1
+ import { ok, err } from "../core/types.js";
2
+ import { workspaceError, inputCancelledError } from "../core/errors.js";
3
+ import { parseEnvTemplate } from "../services/template/env.js";
4
+ import { extractTemplateVariables } from "../services/template/interpolator.js";
5
+ import { validateTemplateSyntax } from "../services/template/validator.js";
6
+ import { clack, sectionHeader, statusLine } from "../utils/ui.js";
7
+ import { writeFile, readFile, mkdtemp, rm } from "fs/promises";
8
+ import { tmpdir } from "os";
9
+ import { join } from "path";
10
+ import chalk from "chalk";
11
+ const HEADER_SENTINEL = "Git Worker — Template Editor";
12
+ function buildInstructionHeader(targetPath) {
13
+ const isEnv = targetPath === ".env" || targetPath.startsWith(".env.");
14
+ const isJson = targetPath.endsWith(".json");
15
+ // Avoid inserting comments into JSON files
16
+ if (isJson)
17
+ return "";
18
+ if (isEnv) {
19
+ return [
20
+ "# ─────────────────────────────────────────────────────────────────",
21
+ `# ${HEADER_SENTINEL}`,
22
+ "# Use KEY=value → static value enforced on every branch",
23
+ "# Use KEY={KEY} → dynamic: reads existing value from each branch",
24
+ "# Lines starting with # are comments and are preserved in the file",
25
+ "# ─────────────────────────────────────────────────────────────────",
26
+ "",
27
+ ].join("\n");
28
+ }
29
+ return [
30
+ "# ─────────────────────────────────────────────────────────────────",
31
+ `# ${HEADER_SENTINEL}`,
32
+ "# This template is the declarative SOURCE OF TRUTH for this file.",
33
+ "# Variables: ${VAR} or ${VAR:-default} are resolved from branch .env",
34
+ "# Built-ins: ${BRANCH_NAME}, ${BRANCH_SLUG}, ${REPO_NAME}, ${COMMIT_SHA}",
35
+ "# ─────────────────────────────────────────────────────────────────",
36
+ "",
37
+ ].join("\n");
38
+ }
39
+ function stripInstructionHeader(content) {
40
+ if (!content.includes(HEADER_SENTINEL)) {
41
+ return content;
42
+ }
43
+ const lines = content.split("\n");
44
+ let headerEnd = 0;
45
+ for (let i = 0; i < lines.length; i++) {
46
+ if (lines[i].includes(HEADER_SENTINEL)) {
47
+ let j = i + 1;
48
+ while (j < lines.length && (lines[j].startsWith("#") || lines[j].trim() === "")) {
49
+ j++;
50
+ }
51
+ headerEnd = j;
52
+ break;
53
+ }
54
+ }
55
+ return lines.slice(headerEnd).join("\n");
56
+ }
57
+ export const editTemplateStage = async (context, deps) => {
58
+ if (!context.template && !context.activeTemplate) {
59
+ return err(workspaceError("No template loaded"));
60
+ }
61
+ const targetPath = context.target?.path ?? ".env";
62
+ const isEnv = targetPath === ".env" || targetPath.startsWith(".env.");
63
+ let currentRaw = context.activeTemplate?.raw ?? context.template?.raw ?? "";
64
+ // In CI mode, validate syntax and proceed without interactive editor
65
+ if (context.input.ci) {
66
+ statusLine("info", "CI mode — skipping template editor");
67
+ const validation = validateTemplateSyntax(targetPath, currentRaw);
68
+ if (!validation.ok) {
69
+ clack.log.error(`Template syntax error: ${validation.error.message}`);
70
+ return validation;
71
+ }
72
+ return ok(context);
73
+ }
74
+ sectionHeader("Edit Template");
75
+ const tmpDir = await mkdtemp(join(tmpdir(), "git-worker-"));
76
+ const templateFileName = targetPath.split("/").pop() ?? "template.env";
77
+ const templateFile = join(tmpDir, templateFileName);
78
+ try {
79
+ while (true) {
80
+ const header = buildInstructionHeader(targetPath);
81
+ const content = header ? `${header}${currentRaw}` : currentRaw;
82
+ await writeFile(templateFile, content, "utf-8");
83
+ const editorName = context.input.editor || deps.config.editor.name;
84
+ statusLine("info", `Opening ${targetPath} in ${editorName} — save and close when done`);
85
+ console.log(chalk.gray(` File: ${templateFile}`));
86
+ if (!header) {
87
+ console.log(chalk.gray(` Tip: Use \${VAR} or \${VAR:-default} to resolve variables from each branch's .env`));
88
+ }
89
+ console.log("");
90
+ const editResult = await deps.services.editor.openForEdit(templateFile, deps.config);
91
+ if (!editResult.ok) {
92
+ clack.log.warn(editResult.error.message);
93
+ const proceed = await clack.confirm({
94
+ message: "Editor not available. Continue with current template?",
95
+ initialValue: true,
96
+ });
97
+ if (clack.isCancel(proceed) || !proceed) {
98
+ return err(inputCancelledError("Template edit cancelled — editor unavailable"));
99
+ }
100
+ break;
101
+ }
102
+ // Read edited content
103
+ const edited = await readFile(templateFile, "utf-8");
104
+ const stripped = stripInstructionHeader(edited).trimEnd() + "\n";
105
+ // 1. Validate syntax immediately
106
+ const validation = validateTemplateSyntax(targetPath, stripped);
107
+ if (!validation.ok) {
108
+ console.log("");
109
+ clack.log.error(`Syntax Validation Failed:\n ${chalk.red(validation.error.message)}`);
110
+ console.log("");
111
+ const fixChoice = await clack.select({
112
+ message: "How would you like to handle this syntax error?",
113
+ options: [
114
+ { value: "re-edit", label: "✏️ Re-edit template to fix the error" },
115
+ { value: "discard", label: "❌ Discard changes and cancel" },
116
+ ],
117
+ });
118
+ if (clack.isCancel(fixChoice) || fixChoice === "discard") {
119
+ return err(inputCancelledError("Template edit cancelled due to syntax error"));
120
+ }
121
+ currentRaw = stripped;
122
+ continue;
123
+ }
124
+ // 2. Syntax is valid!
125
+ currentRaw = stripped;
126
+ statusLine("ok", "Template syntax validated successfully");
127
+ // 3. Post-edit menu
128
+ const actionChoice = await clack.select({
129
+ message: "Template ready. What would you like to do next?",
130
+ options: [
131
+ {
132
+ value: "proceed",
133
+ label: "➜ Proceed to plan changes",
134
+ hint: "generate diffs across target branches",
135
+ },
136
+ {
137
+ value: "re-edit",
138
+ label: "✏️ Edit template again",
139
+ hint: "re-open in editor",
140
+ },
141
+ {
142
+ value: "discard",
143
+ label: "❌ Discard changes and cancel",
144
+ },
145
+ ],
146
+ });
147
+ if (clack.isCancel(actionChoice) || actionChoice === "discard") {
148
+ return err(inputCancelledError("Template edit cancelled"));
149
+ }
150
+ if (actionChoice === "re-edit") {
151
+ continue;
152
+ }
153
+ break;
154
+ }
155
+ // Update context with the validated template
156
+ if (isEnv) {
157
+ const parseResult = parseEnvTemplate(currentRaw);
158
+ if (!parseResult.ok)
159
+ return parseResult;
160
+ const activeTemplate = {
161
+ path: targetPath,
162
+ engine: "env-reconcile",
163
+ raw: currentRaw,
164
+ parsedEnv: parseResult.value,
165
+ };
166
+ statusLine("ok", `Template ready — ${parseResult.value.assignments.length} assignment(s)`);
167
+ return ok({
168
+ ...context,
169
+ template: parseResult.value,
170
+ activeTemplate,
171
+ templates: [activeTemplate],
172
+ });
173
+ }
174
+ else {
175
+ const variables = extractTemplateVariables(currentRaw);
176
+ const activeTemplate = {
177
+ path: targetPath,
178
+ engine: "source-of-truth",
179
+ raw: currentRaw,
180
+ variables,
181
+ };
182
+ statusLine("ok", `Template ready — ${variables.length} variable placeholder(s) detected`);
183
+ return ok({
184
+ ...context,
185
+ template: {
186
+ raw: currentRaw,
187
+ assignments: [],
188
+ placeholders: variables.map((v) => v.name),
189
+ },
190
+ activeTemplate,
191
+ templates: [activeTemplate],
192
+ });
193
+ }
194
+ }
195
+ finally {
196
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => { });
197
+ }
198
+ };
@@ -0,0 +1,3 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps } from "../core/types.js";
3
+ export declare const checkEligibilityStage: Stage<RunContext, AppDeps>;