@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.
Files changed (115) hide show
  1. package/GIT_WORKER_PROJECT_PLAN.md +499 -0
  2. package/README.md +114 -2
  3. package/demo/README.md +58 -0
  4. package/demo/index.ts +526 -0
  5. package/dist/app.d.ts +2 -0
  6. package/dist/app.js +48 -0
  7. package/dist/cli/commands.d.ts +6 -0
  8. package/dist/cli/commands.js +111 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +115 -0
  11. package/dist/config/defaults.d.ts +2 -0
  12. package/dist/config/defaults.js +44 -0
  13. package/dist/config/load.d.ts +3 -0
  14. package/dist/config/load.js +65 -0
  15. package/dist/config/schema.d.ts +200 -0
  16. package/dist/config/schema.js +45 -0
  17. package/dist/core/errors.d.ts +12 -0
  18. package/dist/core/errors.js +34 -0
  19. package/dist/core/pipeline.d.ts +3 -0
  20. package/dist/core/pipeline.js +14 -0
  21. package/dist/core/types.d.ts +153 -0
  22. package/dist/core/types.js +16 -0
  23. package/dist/services/editor/index.d.ts +9 -0
  24. package/dist/services/editor/index.js +59 -0
  25. package/dist/services/git/ops.d.ts +30 -0
  26. package/dist/services/git/ops.js +139 -0
  27. package/dist/services/git/reader.d.ts +14 -0
  28. package/dist/services/git/reader.js +124 -0
  29. package/dist/services/report/index.d.ts +18 -0
  30. package/dist/services/report/index.js +68 -0
  31. package/dist/services/review/index.d.ts +30 -0
  32. package/dist/services/review/index.js +59 -0
  33. package/dist/services/state/index.d.ts +20 -0
  34. package/dist/services/state/index.js +49 -0
  35. package/dist/services/template/env.d.ts +23 -0
  36. package/dist/services/template/env.js +146 -0
  37. package/dist/services/template/interpolator.d.ts +30 -0
  38. package/dist/services/template/interpolator.js +75 -0
  39. package/dist/services/template/transform.d.ts +22 -0
  40. package/dist/services/template/transform.js +75 -0
  41. package/dist/services/template/validator.d.ts +5 -0
  42. package/dist/services/template/validator.js +68 -0
  43. package/dist/services/workspace/cache.d.ts +32 -0
  44. package/dist/services/workspace/cache.js +107 -0
  45. package/dist/services/workspace/manager.d.ts +44 -0
  46. package/dist/services/workspace/manager.js +301 -0
  47. package/dist/stages/apply.d.ts +3 -0
  48. package/dist/stages/apply.js +161 -0
  49. package/dist/stages/edit-template.d.ts +3 -0
  50. package/dist/stages/edit-template.js +198 -0
  51. package/dist/stages/eligibility.d.ts +3 -0
  52. package/dist/stages/eligibility.js +44 -0
  53. package/dist/stages/load-template.d.ts +3 -0
  54. package/dist/stages/load-template.js +168 -0
  55. package/dist/stages/plan.d.ts +3 -0
  56. package/dist/stages/plan.js +270 -0
  57. package/dist/stages/push.d.ts +3 -0
  58. package/dist/stages/push.js +99 -0
  59. package/dist/stages/report.d.ts +3 -0
  60. package/dist/stages/report.js +103 -0
  61. package/dist/stages/resolve-project.d.ts +3 -0
  62. package/dist/stages/resolve-project.js +294 -0
  63. package/dist/stages/review.d.ts +3 -0
  64. package/dist/stages/review.js +197 -0
  65. package/dist/stages/select.d.ts +13 -0
  66. package/dist/stages/select.js +250 -0
  67. package/dist/stages/sync.d.ts +3 -0
  68. package/dist/stages/sync.js +47 -0
  69. package/dist/utils/hash.d.ts +2 -0
  70. package/dist/utils/hash.js +7 -0
  71. package/dist/utils/ui.d.ts +21 -0
  72. package/dist/utils/ui.js +113 -0
  73. package/dist/utils/which.d.ts +5 -0
  74. package/dist/utils/which.js +16 -0
  75. package/docs/FEATURES.md +84 -0
  76. package/docs/SERVICES.md +57 -0
  77. package/docs/architecture.md +38 -0
  78. package/docs/usage.md +337 -0
  79. package/package.json +48 -4
  80. package/src/app.ts +69 -0
  81. package/src/cli/commands.ts +142 -0
  82. package/src/cli/index.ts +123 -0
  83. package/src/config/defaults.ts +46 -0
  84. package/src/config/load.ts +70 -0
  85. package/src/config/schema.ts +48 -0
  86. package/src/core/errors.ts +45 -0
  87. package/src/core/pipeline.ts +17 -0
  88. package/src/core/types.ts +188 -0
  89. package/src/services/editor/index.ts +85 -0
  90. package/src/services/git/ops.ts +170 -0
  91. package/src/services/git/reader.ts +148 -0
  92. package/src/services/report/index.ts +104 -0
  93. package/src/services/review/index.ts +80 -0
  94. package/src/services/state/index.ts +77 -0
  95. package/src/services/template/env.ts +169 -0
  96. package/src/services/template/interpolator.ts +109 -0
  97. package/src/services/template/transform.ts +97 -0
  98. package/src/services/template/validator.ts +75 -0
  99. package/src/services/workspace/cache.ts +144 -0
  100. package/src/services/workspace/manager.ts +370 -0
  101. package/src/stages/apply.ts +194 -0
  102. package/src/stages/edit-template.ts +227 -0
  103. package/src/stages/eligibility.ts +67 -0
  104. package/src/stages/load-template.ts +188 -0
  105. package/src/stages/plan.ts +362 -0
  106. package/src/stages/push.ts +120 -0
  107. package/src/stages/report.ts +126 -0
  108. package/src/stages/resolve-project.ts +341 -0
  109. package/src/stages/review.ts +228 -0
  110. package/src/stages/select.ts +302 -0
  111. package/src/stages/sync.ts +63 -0
  112. package/src/utils/hash.ts +15 -0
  113. package/src/utils/ui.ts +137 -0
  114. package/src/utils/which.ts +18 -0
  115. package/tsconfig.json +18 -0
@@ -0,0 +1,370 @@
1
+ import { Result, ok, err, ManagedProject } 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
+ import { AppConfig } from "../../config/schema.js";
10
+
11
+ export type RepoInfo = {
12
+ isRepo: boolean;
13
+ remoteUrl?: string;
14
+ remoteName?: string;
15
+ };
16
+
17
+ export type WorkspaceManager = {
18
+ resolveProject(sourceRepoPath: string, remote: string | undefined, config: AppConfig): Promise<Result<ManagedProject>>;
19
+ checkLocalRepo(dirPath: string, preferredRemote?: string): Promise<RepoInfo>;
20
+ ensureManagedClone(project: ManagedProject): Promise<Result<{ isCached: boolean }>>;
21
+ verifyOwnership(project: ManagedProject): Result<void>;
22
+ acquireLock(project: ManagedProject, timeoutMs: number): Promise<Result<() => void>>;
23
+ releaseLock(project: ManagedProject): void;
24
+ };
25
+
26
+ type MarkerData = {
27
+ id: string;
28
+ name?: string;
29
+ sourceRepoPath: string;
30
+ remoteUrl: string;
31
+ createdAt: string;
32
+ gitWorkerVersion: string;
33
+ };
34
+
35
+ const GIT_WORKER_VERSION = "0.1.0";
36
+ const LOCK_FILE = ".git-worker-lock";
37
+
38
+ /**
39
+ * Extract a human-readable repository name from a remote URL or file path.
40
+ * e.g. "https://github.com/org/repo.git" -> "repo"
41
+ * e.g. "git@github.com:org/repo.git" -> "repo"
42
+ * e.g. "/path/to/my-repo" -> "my-repo"
43
+ */
44
+ export function extractRepoName(urlOrPath?: string, fallbackId?: string): string {
45
+ if (!urlOrPath) return fallbackId || "unnamed-repo";
46
+ const clean = urlOrPath.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
47
+ const lastSlash = Math.max(
48
+ clean.lastIndexOf("/"),
49
+ clean.lastIndexOf(":"),
50
+ clean.lastIndexOf("\\")
51
+ );
52
+ if (lastSlash >= 0 && lastSlash < clean.length - 1) {
53
+ return clean.slice(lastSlash + 1);
54
+ }
55
+ return clean || fallbackId || "unnamed-repo";
56
+ }
57
+
58
+ export function getWorkspaceRoot(config?: AppConfig): string {
59
+ // Use config.workspace.root if it looks absolute or explicitly specified
60
+ const root = config?.workspace?.root;
61
+ if (root && (root.startsWith("/") || root.startsWith("~"))) {
62
+ return root.replace(/^~/, homedir());
63
+ }
64
+ // Store directly in ~/.git-worker in user's home directory
65
+ return join(homedir(), ".git-worker");
66
+ }
67
+
68
+ function projectIdFromRemote(remoteUrl: string): string {
69
+ return createHash("sha256").update(remoteUrl).digest("hex").slice(0, 16);
70
+ }
71
+
72
+ /**
73
+ * Verify that the given path is inside our workspace and the marker file is valid.
74
+ * This is the ownership guard that must run before any destructive ops.
75
+ */
76
+ export function verifyOwnership(project: ManagedProject): Result<void> {
77
+ if (!existsSync(project.markerPath)) {
78
+ return err(workspaceError(`Ownership marker not found at '${project.markerPath}'. Refusing destructive operation.`));
79
+ }
80
+
81
+ try {
82
+ const raw = readFileSync(project.markerPath, "utf-8");
83
+ const marker = JSON.parse(raw) as MarkerData;
84
+ if (marker.id !== project.id) {
85
+ return err(workspaceError(`Marker ID mismatch: expected '${project.id}', found '${marker.id}'. Refusing destructive operation.`));
86
+ }
87
+
88
+ // Path escape check: managed repo must be inside the project dir
89
+ const projectDir = join(project.managedRepoPath, "..");
90
+ const normalizedRepo = normalize(resolve(project.managedRepoPath));
91
+ const normalizedProject = normalize(resolve(projectDir));
92
+ if (!normalizedRepo.startsWith(normalizedProject)) {
93
+ return err(workspaceError(`Managed repo path '${normalizedRepo}' escapes project directory '${normalizedProject}'.`));
94
+ }
95
+
96
+ return ok(undefined);
97
+ } catch (e: any) {
98
+ return err(workspaceError(`Failed to read ownership marker: ${e.message}`));
99
+ }
100
+ }
101
+
102
+ export async function checkLocalRepo(
103
+ dirPath: string,
104
+ preferredRemote: string = "origin"
105
+ ): Promise<RepoInfo> {
106
+ try {
107
+ const resolved = resolve(dirPath);
108
+ const git = simpleGit(resolved);
109
+ let isRepo = await git.checkIsRepo().catch(() => false);
110
+ if (!isRepo) {
111
+ isRepo = await git.checkIsRepo(CheckRepoActions.BARE).catch(() => false);
112
+ }
113
+ if (!isRepo) {
114
+ return { isRepo: false };
115
+ }
116
+
117
+ try {
118
+ const remotes = await git.getRemotes(true);
119
+ const target = remotes.find((r) => r.name === preferredRemote) || remotes[0];
120
+ if (target?.refs?.fetch) {
121
+ return { isRepo: true, remoteUrl: target.refs.fetch, remoteName: target.name };
122
+ }
123
+ } catch {
124
+ // no remotes
125
+ }
126
+ return { isRepo: true };
127
+ } catch {
128
+ return { isRepo: false };
129
+ }
130
+ }
131
+
132
+ export async function resolveProject(
133
+ sourceRepoPath: string,
134
+ remote: string | undefined,
135
+ config: AppConfig
136
+ ): Promise<Result<ManagedProject>> {
137
+ try {
138
+ // Check if this looks like a remote URL (http/git/ssh)
139
+ const isRemoteUrl =
140
+ sourceRepoPath.startsWith("http://") ||
141
+ sourceRepoPath.startsWith("https://") ||
142
+ sourceRepoPath.startsWith("git@") ||
143
+ sourceRepoPath.startsWith("ssh://") ||
144
+ sourceRepoPath.startsWith("git://");
145
+
146
+ const sourceLocation = isRemoteUrl ? sourceRepoPath : resolve(sourceRepoPath);
147
+ let remoteUrl: string | undefined;
148
+
149
+ if (isRemoteUrl) {
150
+ // Treat the supplied path directly as the remote URL
151
+ remoteUrl = sourceRepoPath;
152
+ } else {
153
+ // It's a local path — verify it's a git repo and discover its remote
154
+ const git = simpleGit(sourceLocation);
155
+
156
+ let isRepo = await git.checkIsRepo().catch(() => false);
157
+ if (!isRepo) {
158
+ isRepo = await git.checkIsRepo(CheckRepoActions.BARE).catch(() => false);
159
+ }
160
+ if (!isRepo) {
161
+ return err(gitError(`'${sourceLocation}' is not a git repository`));
162
+ }
163
+
164
+ const targetRemoteName = remote || config.git.remote;
165
+ try {
166
+ const remotes = await git.getRemotes(true);
167
+ const targetRemote =
168
+ remotes.find((r) => r.name === targetRemoteName) || remotes[0];
169
+ if (targetRemote?.refs?.fetch) {
170
+ remoteUrl = targetRemote.refs.fetch;
171
+ }
172
+ } catch {
173
+ // ignore
174
+ }
175
+
176
+ // If bare repo with no remotes: use its own path as the remote URL
177
+ if (!remoteUrl) {
178
+ const isBare = await git.checkIsRepo(CheckRepoActions.BARE).catch(() => false);
179
+ if (isBare) {
180
+ remoteUrl = sourceLocation;
181
+ }
182
+ }
183
+ }
184
+
185
+ if (!remoteUrl && config.safety.rejectNoRemote) {
186
+ return err(
187
+ workspaceError(
188
+ `No remote found in '${sourceLocation}'. ` +
189
+ `Git Worker requires a remote to create a safe managed checkout. ` +
190
+ `Disable reject-no-remote in config to allow local-only repos (not recommended).`
191
+ )
192
+ );
193
+ }
194
+
195
+ const id = projectIdFromRemote(remoteUrl || sourceLocation);
196
+ const root = getWorkspaceRoot(config);
197
+ const projectDir = join(root, "projects", id);
198
+ const managedRepoPath = join(projectDir, "repo");
199
+ const stagingDir = join(projectDir, "staging");
200
+ const stateDir = join(projectDir, "state");
201
+ const reportsDir = join(projectDir, "reports");
202
+ const markerPath = join(projectDir, ".git-worker-marker");
203
+
204
+ await mkdir(projectDir, { recursive: true });
205
+ await mkdir(managedRepoPath, { recursive: true });
206
+ await mkdir(stagingDir, { recursive: true });
207
+ await mkdir(stateDir, { recursive: true });
208
+ await mkdir(reportsDir, { recursive: true });
209
+
210
+ const markerData: MarkerData = {
211
+ id,
212
+ name: extractRepoName(remoteUrl || sourceLocation, id),
213
+ sourceRepoPath: sourceLocation,
214
+ remoteUrl: remoteUrl || "",
215
+ createdAt: new Date().toISOString(),
216
+ gitWorkerVersion: GIT_WORKER_VERSION,
217
+ };
218
+ await writeFile(markerPath, JSON.stringify(markerData, null, 2), "utf-8");
219
+
220
+ // Check if an existing managed clone is already present for this remote URL
221
+ let isCached = false;
222
+ if (existsSync(managedRepoPath)) {
223
+ try {
224
+ const git = simpleGit(managedRepoPath);
225
+ const isRepo = await git.checkIsRepo().catch(() => false);
226
+ if (isRepo) {
227
+ const remotes = await git.getRemotes(true);
228
+ const origin = remotes.find((r) => r.name === "origin") || remotes[0];
229
+ if (origin?.refs?.fetch === remoteUrl) {
230
+ isCached = true;
231
+ }
232
+ }
233
+ } catch {
234
+ // ignore
235
+ }
236
+ }
237
+
238
+ return ok({
239
+ id,
240
+ sourceRepoPath: sourceLocation,
241
+ remoteUrl,
242
+ managedRepoPath,
243
+ stagingDir,
244
+ stateDir,
245
+ reportsDir,
246
+ markerPath,
247
+ isCached,
248
+ });
249
+ } catch (e: any) {
250
+ return err(workspaceError(`Failed to resolve project: ${e.message}`, e));
251
+ }
252
+ }
253
+
254
+ export async function ensureManagedClone(
255
+ project: ManagedProject
256
+ ): Promise<Result<{ isCached: boolean }>> {
257
+ try {
258
+ const git = simpleGit(project.managedRepoPath);
259
+ const isRepo = await git.checkIsRepo().catch(() => false);
260
+ if (isRepo) {
261
+ try {
262
+ const remotes = await git.getRemotes(true);
263
+ const origin = remotes.find((r) => r.name === "origin") || remotes[0];
264
+ if (origin?.refs?.fetch === project.remoteUrl) {
265
+ return ok({ isCached: true });
266
+ }
267
+ } catch {
268
+ // remote mismatch, re-clone below
269
+ }
270
+ // Remote URL changed — clean and re-clone
271
+ await rm(project.managedRepoPath, { recursive: true, force: true });
272
+ }
273
+
274
+ if (!project.remoteUrl) {
275
+ return err(gitError("Cannot clone: no remote URL available"));
276
+ }
277
+
278
+ // Clone into 'repo' subdirectory
279
+ const parentGit = simpleGit(join(project.managedRepoPath, ".."));
280
+ await parentGit.clone(project.remoteUrl, "repo", ["--no-local"]);
281
+ return ok({ isCached: false });
282
+ } catch (e: any) {
283
+ return err(gitError(`Failed to clone managed repo: ${e.message}`, e));
284
+ }
285
+ }
286
+
287
+ export function releaseLock(project: ManagedProject): void {
288
+ const lockPath = join(project.managedRepoPath, "..", LOCK_FILE);
289
+ try {
290
+ if (existsSync(lockPath)) {
291
+ unlinkSync(lockPath);
292
+ }
293
+ } catch {
294
+ // best effort
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Acquire a file-based lock on the managed checkout.
300
+ * Returns a release function to call when done.
301
+ * Checks for and cleans up stale locks from terminated processes.
302
+ * Waits up to timeoutMs for the lock to become available.
303
+ */
304
+ export async function acquireLock(
305
+ project: ManagedProject,
306
+ timeoutMs: number
307
+ ): Promise<Result<() => void>> {
308
+ const lockPath = join(project.managedRepoPath, "..", LOCK_FILE);
309
+ const deadline = Date.now() + timeoutMs;
310
+ const pid = process.pid;
311
+
312
+ while (Date.now() < deadline) {
313
+ // Check if existing lock is stale (process no longer exists)
314
+ if (existsSync(lockPath)) {
315
+ try {
316
+ const raw = readFileSync(lockPath, "utf-8");
317
+ const data = JSON.parse(raw);
318
+ if (data.pid && typeof data.pid === "number") {
319
+ try {
320
+ process.kill(data.pid, 0); // signal 0 tests process existence
321
+ } catch (err: any) {
322
+ if (err.code === "ESRCH") {
323
+ // Holding process died — remove stale lock immediately
324
+ releaseLock(project);
325
+ }
326
+ }
327
+ }
328
+ } catch {
329
+ // Malformed lock — clear it
330
+ releaseLock(project);
331
+ }
332
+ }
333
+
334
+ try {
335
+ // O_EXCL ensures atomic create — fails if file exists
336
+ const fh = await open(lockPath, "wx");
337
+ await fh.writeFile(JSON.stringify({ pid, acquiredAt: new Date().toISOString() }));
338
+ await fh.close();
339
+
340
+ const release = (): void => {
341
+ releaseLock(project);
342
+ };
343
+ return ok(release);
344
+ } catch (e: any) {
345
+ if (e.code !== "EEXIST") {
346
+ return err(workspaceError(`Failed to acquire lock: ${e.message}`));
347
+ }
348
+ // Lock held by an active process — wait and retry
349
+ await new Promise((r) => setTimeout(r, 200));
350
+ }
351
+ }
352
+
353
+ return err(
354
+ workspaceError(
355
+ `Could not acquire workspace lock within ${timeoutMs}ms. ` +
356
+ `Another git-worker process may be running. Delete '${lockPath}' to force-release.`
357
+ )
358
+ );
359
+ }
360
+
361
+ export function createWorkspaceManager(): WorkspaceManager {
362
+ return {
363
+ resolveProject,
364
+ checkLocalRepo,
365
+ ensureManagedClone,
366
+ verifyOwnership,
367
+ acquireLock,
368
+ releaseLock,
369
+ };
370
+ }
@@ -0,0 +1,194 @@
1
+ import { Stage } from "../core/pipeline.js";
2
+ import { RunContext, AppDeps, BranchResult, ok, err } from "../core/types.js";
3
+ import { workspaceError } from "../core/errors.js";
4
+ import { clack, sectionHeader, statusLine, sym } from "../utils/ui.js";
5
+ import { sha256 } from "../utils/hash.js";
6
+ import chalk from "chalk";
7
+
8
+ export const applyChangesStage: Stage<RunContext, AppDeps> = async (context, deps) => {
9
+ if (!context.plans || !context.approvals || !context.project) {
10
+ return err(workspaceError("Prerequisites missing for apply"));
11
+ }
12
+
13
+ sectionHeader("Applying Changes");
14
+
15
+ const results: BranchResult[] = [];
16
+ const approvedPlans = context.plans.filter((p) => {
17
+ const approval = context.approvals!.find((a) => a.branchName === p.branch.name);
18
+ return approval?.approved === true;
19
+ });
20
+
21
+ // Handle unapproved/error plans first
22
+ for (const plan of context.plans) {
23
+ const approval = context.approvals.find((a) => a.branchName === plan.branch.name);
24
+ if (approval?.approved) continue; // Will be handled below
25
+
26
+ if (plan.status === "unchanged") {
27
+ results.push({ branch: plan.branch, status: "unchanged", reason: "No changes" });
28
+ } else if (plan.status === "error") {
29
+ results.push({ branch: plan.branch, status: "failed", reason: plan.reason });
30
+ } else {
31
+ results.push({
32
+ branch: plan.branch,
33
+ status: approval ? "rejected" : "skipped",
34
+ reason: approval ? "User rejected" : "Not reviewed",
35
+ });
36
+ }
37
+ }
38
+
39
+ if (approvedPlans.length === 0) {
40
+ statusLine("info", "No approved changes to apply");
41
+ return ok({ ...context, results });
42
+ }
43
+
44
+ for (const plan of approvedPlans) {
45
+ const s = clack.spinner();
46
+ s.start(`Applying ${plan.branch.name}…`);
47
+
48
+ try {
49
+ // Verify ownership before any destructive op
50
+ const ownerCheck = deps.services.workspace.verifyOwnership(context.project);
51
+ if (!ownerCheck.ok) {
52
+ s.stop(chalk.red(`${plan.branch.name} — ownership verification failed`));
53
+ results.push({ branch: plan.branch, status: "failed", reason: ownerCheck.error.message });
54
+ continue;
55
+ }
56
+
57
+ // ── Stale-plan guard ────────────────────────────────────────────
58
+ // Fetch latest SHA and verify it matches the reviewed plan
59
+ const currentShaResult = await deps.services.gitOps.getBranchSha(
60
+ context.project.managedRepoPath,
61
+ plan.branch.isRemote && plan.branch.remote
62
+ ? `${plan.branch.remote}/${plan.branch.name}`
63
+ : plan.branch.name
64
+ );
65
+
66
+ if (!currentShaResult.ok) {
67
+ s.stop(chalk.red(`${plan.branch.name} — could not verify branch tip`));
68
+ results.push({ branch: plan.branch, status: "failed", reason: "Could not verify branch tip" });
69
+ // Clean up before next branch
70
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => {});
71
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => {});
72
+ continue;
73
+ }
74
+
75
+ if (currentShaResult.value !== plan.baseCommitSha) {
76
+ s.stop(chalk.yellow(`${plan.branch.name} — branch advanced since review (stale plan)`));
77
+ results.push({
78
+ branch: plan.branch,
79
+ status: "failed",
80
+ reason: `Branch advanced: reviewed at ${plan.baseCommitSha.slice(0, 8)}, now at ${currentShaResult.value.slice(0, 8)}. Re-run to replan.`,
81
+ });
82
+ continue;
83
+ }
84
+
85
+ // ── Clean before checkout ───────────────────────────────────────
86
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath);
87
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode);
88
+
89
+ // ── Checkout branch ─────────────────────────────────────────────
90
+ const checkoutResult = await deps.services.gitOps.checkoutBranch(
91
+ context.project.managedRepoPath,
92
+ plan.branch.name
93
+ );
94
+ if (!checkoutResult.ok) {
95
+ s.stop(chalk.red(`${plan.branch.name} — checkout failed`));
96
+ results.push({ branch: plan.branch, status: "failed", reason: checkoutResult.error.message });
97
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => {});
98
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => {});
99
+ continue;
100
+ }
101
+
102
+ // ── Clean after checkout ─────────────────────────────────────────
103
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath);
104
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode);
105
+
106
+ // ── Verify before-content hash ──────────────────────────────────
107
+ const currentFileResult = await deps.services.git.readFileFromBranch(
108
+ context.project.managedRepoPath,
109
+ plan.branch,
110
+ plan.path
111
+ );
112
+ const currentContent = currentFileResult.ok ? currentFileResult.value : "";
113
+ const currentHash = sha256(currentContent);
114
+ if (currentHash !== plan.beforeContentHash) {
115
+ s.stop(chalk.yellow(`${plan.branch.name} — file content changed since review`));
116
+ results.push({
117
+ branch: plan.branch,
118
+ status: "failed",
119
+ reason: "File content changed since review. Re-run to replan.",
120
+ });
121
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => {});
122
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => {});
123
+ continue;
124
+ }
125
+
126
+ // ── Write and stage ─────────────────────────────────────────────
127
+ const writeResult = await deps.services.gitOps.writeAndStage(
128
+ context.project.managedRepoPath,
129
+ plan.path,
130
+ plan.afterContent
131
+ );
132
+ if (!writeResult.ok) {
133
+ s.stop(chalk.red(`${plan.branch.name} — write failed`));
134
+ results.push({ branch: plan.branch, status: "failed", reason: writeResult.error.message });
135
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => {});
136
+ continue;
137
+ }
138
+
139
+ // ── Verify only the intended path is staged ─────────────────────
140
+ const stagedResult = await deps.services.gitOps.getStagedFiles(context.project.managedRepoPath);
141
+ if (stagedResult.ok) {
142
+ const staged = stagedResult.value;
143
+ const unexpected = staged.filter((f) => f !== plan.path);
144
+ if (unexpected.length > 0) {
145
+ s.stop(chalk.red(`${plan.branch.name} — unexpected staged files`));
146
+ results.push({
147
+ branch: plan.branch,
148
+ status: "failed",
149
+ reason: `Unexpected staged files: ${unexpected.join(", ")}. Aborting commit for safety.`,
150
+ });
151
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => {});
152
+ continue;
153
+ }
154
+ if (staged.length === 0) {
155
+ // No-op commit — already matches
156
+ s.stop(chalk.gray(`${plan.branch.name} — no staged changes (already matches)`));
157
+ results.push({ branch: plan.branch, status: "unchanged", reason: "Content identical after write" });
158
+ continue;
159
+ }
160
+ }
161
+
162
+ // ── Commit ──────────────────────────────────────────────────────
163
+ const commitMsg = deps.config.git.commitMessage;
164
+ const commitResult = await deps.services.gitOps.commit(context.project.managedRepoPath, commitMsg);
165
+ if (!commitResult.ok) {
166
+ s.stop(chalk.red(`${plan.branch.name} — commit failed`));
167
+ results.push({ branch: plan.branch, status: "failed", reason: commitResult.error.message });
168
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => {});
169
+ continue;
170
+ }
171
+
172
+ s.stop(chalk.green(`${plan.branch.name} — committed ${commitResult.value.slice(0, 8)}`));
173
+ results.push({
174
+ branch: plan.branch,
175
+ status: "committed",
176
+ commitSha: commitResult.value,
177
+ });
178
+ } catch (e: any) {
179
+ s.stop(chalk.red(`${plan.branch.name} — unexpected error`));
180
+ results.push({ branch: plan.branch, status: "failed", reason: e.message });
181
+ // Clean up managed checkout before next branch
182
+ await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => {});
183
+ await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => {});
184
+ }
185
+ }
186
+
187
+ const committed = results.filter((r) => r.status === "committed").length;
188
+ const failed = results.filter((r) => r.status === "failed").length;
189
+
190
+ console.log("");
191
+ statusLine(committed > 0 ? "ok" : "info", `Apply complete — ${committed} committed, ${failed} failed`);
192
+
193
+ return ok({ ...context, results });
194
+ };