ralph-cli-sandboxed 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,7 @@ ralph docker run
44
44
  | `ralph clean` | Remove all passing entries from PRD |
45
45
  | `ralph fix-prd [opts]` | Validate and recover corrupted PRD file |
46
46
  | `ralph prompt [opts]` | Display resolved prompt |
47
+ | `ralph branch <sub>` | Manage PRD branches (list, merge, pr, delete) |
47
48
  | `ralph docker <sub>` | Manage Docker sandbox environment |
48
49
  | `ralph daemon <sub>` | Manage host daemon for sandbox notifications |
49
50
  | `ralph notify [msg]` | Send notification (from sandbox to host) |
@@ -427,6 +428,20 @@ The PRD (`prd.json`) is an array of requirements:
427
428
 
428
429
  Categories: `setup`, `feature`, `bugfix`, `refactor`, `docs`, `test`, `release`, `config`, `ui`
429
430
 
431
+ ### Branching
432
+
433
+ PRD items can be tagged with a `branch` field to group work onto separate git branches. Ralph uses git worktrees to isolate branch work from the main checkout, so the host's working directory stays untouched.
434
+
435
+ ```yaml
436
+ - category: feature
437
+ description: Add login page
438
+ branch: feat/auth
439
+ steps: [...]
440
+ passes: false
441
+ ```
442
+
443
+ See [docs/BRANCHING.md](docs/BRANCHING.md) for the full architecture, configuration, and branch management commands.
444
+
430
445
  ### Advanced: File References
431
446
 
432
447
  PRD steps can include file contents using the `@{filepath}` syntax:
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Main branch command dispatcher.
3
+ */
4
+ export declare function branch(args: string[]): Promise<void>;
@@ -0,0 +1,408 @@
1
+ import { execSync } from "child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "fs";
3
+ import { extname, join } from "path";
4
+ import { getRalphDir, getPrdFiles, loadBranchState, getProjectName } from "../utils/config.js";
5
+ import { readPrdFile, writePrdAuto } from "../utils/prd-validator.js";
6
+ import { promptConfirm } from "../utils/prompt.js";
7
+ import YAML from "yaml";
8
+ /**
9
+ * Converts a branch name to a worktree directory name, prefixed with the project name.
10
+ * e.g., "feat/login" -> "myproject_feat-login"
11
+ * The project prefix avoids conflicts when multiple projects share the same worktrees directory.
12
+ */
13
+ function branchToWorktreeName(branch) {
14
+ const projectName = getProjectName();
15
+ return `${projectName}_${branch.replace(/\//g, "-")}`;
16
+ }
17
+ /**
18
+ * Gets the worktrees base path from config or defaults to /worktrees.
19
+ */
20
+ function getWorktreesBase() {
21
+ return "/worktrees";
22
+ }
23
+ /**
24
+ * Loads PRD entries from the primary PRD file.
25
+ */
26
+ function loadPrdEntries() {
27
+ const prdFiles = getPrdFiles();
28
+ if (!prdFiles.primary) {
29
+ console.error("\x1b[31mError: No PRD file found. Run 'ralph init' first.\x1b[0m");
30
+ return null;
31
+ }
32
+ const parsed = readPrdFile(prdFiles.primary);
33
+ if (!parsed || !Array.isArray(parsed.content)) {
34
+ console.error("\x1b[31mError: PRD file is corrupted. Run 'ralph fix-prd' to repair.\x1b[0m");
35
+ return null;
36
+ }
37
+ return { entries: parsed.content, prdPath: prdFiles.primary };
38
+ }
39
+ /**
40
+ * Gets the base branch (the branch that /workspace is on).
41
+ */
42
+ function getBaseBranch() {
43
+ try {
44
+ return execSync("git -C /workspace rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
45
+ }
46
+ catch {
47
+ return "main";
48
+ }
49
+ }
50
+ /**
51
+ * Checks if a git branch exists.
52
+ */
53
+ function branchExists(branch) {
54
+ try {
55
+ execSync(`git rev-parse --verify "${branch}"`, { stdio: "pipe" });
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * List all branches referenced in the PRD and their status.
64
+ * Shows item counts, pass/fail status, worktree existence, and active branch indicator.
65
+ */
66
+ function branchList() {
67
+ const result = loadPrdEntries();
68
+ if (!result)
69
+ return;
70
+ const { entries } = result;
71
+ const worktreesBase = getWorktreesBase();
72
+ const activeBranch = loadBranchState();
73
+ // Group items by branch
74
+ const branchGroups = new Map();
75
+ const noBranchItems = [];
76
+ for (const entry of entries) {
77
+ if (entry.branch) {
78
+ const group = branchGroups.get(entry.branch) || [];
79
+ group.push(entry);
80
+ branchGroups.set(entry.branch, group);
81
+ }
82
+ else {
83
+ noBranchItems.push(entry);
84
+ }
85
+ }
86
+ if (branchGroups.size === 0 && noBranchItems.length === 0) {
87
+ console.log("No PRD items found.");
88
+ return;
89
+ }
90
+ console.log("\x1b[1mBranches:\x1b[0m\n");
91
+ // Sort branches alphabetically
92
+ const sortedBranches = [...branchGroups.keys()].sort();
93
+ for (const branchName of sortedBranches) {
94
+ const items = branchGroups.get(branchName);
95
+ const passing = items.filter((e) => e.passes).length;
96
+ const total = items.length;
97
+ const allPassing = passing === total;
98
+ // Check if worktree exists on disk
99
+ const dirName = branchToWorktreeName(branchName);
100
+ const worktreePath = join(worktreesBase, dirName);
101
+ const hasWorktree = existsSync(worktreePath);
102
+ // Check if this is the active branch
103
+ const isActive = activeBranch?.currentBranch === branchName;
104
+ // Build the line
105
+ const statusIcon = allPassing ? "\x1b[32m✅\x1b[0m" : "\x1b[33m○\x1b[0m";
106
+ const activeIndicator = isActive ? " \x1b[36m◀ active\x1b[0m" : "";
107
+ const worktreeStatus = hasWorktree ? " \x1b[32m[worktree]\x1b[0m" : "";
108
+ const countStr = `${passing}/${total}`;
109
+ console.log(` ${statusIcon} \x1b[1m${branchName}\x1b[0m ${countStr}${worktreeStatus}${activeIndicator}`);
110
+ }
111
+ // Show no-branch group
112
+ if (noBranchItems.length > 0) {
113
+ const passing = noBranchItems.filter((e) => e.passes).length;
114
+ const total = noBranchItems.length;
115
+ const allPassing = passing === total;
116
+ const statusIcon = allPassing ? "\x1b[32m✅\x1b[0m" : "\x1b[33m○\x1b[0m";
117
+ const countStr = `${passing}/${total}`;
118
+ console.log(` ${statusIcon} \x1b[2m(no branch)\x1b[0m ${countStr}`);
119
+ }
120
+ console.log();
121
+ }
122
+ /**
123
+ * Merge a completed branch worktree back into the base branch.
124
+ * Handles merge conflicts by aborting and showing conflicting files.
125
+ */
126
+ async function branchMerge(args) {
127
+ const branchName = args[0];
128
+ if (!branchName) {
129
+ console.error("Usage: ralph branch merge <branch-name>");
130
+ console.error("\nExample: ralph branch merge feat/login");
131
+ process.exit(1);
132
+ }
133
+ // Verify the branch exists
134
+ if (!branchExists(branchName)) {
135
+ console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
136
+ process.exit(1);
137
+ }
138
+ const baseBranch = getBaseBranch();
139
+ const worktreesBase = getWorktreesBase();
140
+ const dirName = branchToWorktreeName(branchName);
141
+ const worktreePath = join(worktreesBase, dirName);
142
+ console.log(`Branch: ${branchName}`);
143
+ console.log(`Base branch: ${baseBranch}`);
144
+ if (existsSync(worktreePath)) {
145
+ console.log(`Worktree: ${worktreePath}`);
146
+ }
147
+ console.log();
148
+ // Ask for confirmation
149
+ const confirmed = await promptConfirm(`Merge "${branchName}" into "${baseBranch}"?`, true);
150
+ if (!confirmed) {
151
+ console.log("Merge cancelled.");
152
+ return;
153
+ }
154
+ // Perform the merge from /workspace (which is on the base branch)
155
+ try {
156
+ console.log(`\nMerging "${branchName}" into "${baseBranch}"...`);
157
+ execSync(`git -C /workspace merge "${branchName}" --no-edit`, { stdio: "pipe" });
158
+ console.log(`\x1b[32mSuccessfully merged "${branchName}" into "${baseBranch}".\x1b[0m`);
159
+ }
160
+ catch (err) {
161
+ // Check if this is a merge conflict
162
+ let conflictingFiles = [];
163
+ try {
164
+ const status = execSync("git -C /workspace status --porcelain", { encoding: "utf-8" });
165
+ conflictingFiles = status
166
+ .split("\n")
167
+ .filter((line) => line.startsWith("UU") || line.startsWith("AA") || line.startsWith("DD") || line.startsWith("AU") || line.startsWith("UA") || line.startsWith("DU") || line.startsWith("UD"))
168
+ .map((line) => line.substring(3).trim());
169
+ }
170
+ catch {
171
+ // Ignore status errors
172
+ }
173
+ if (conflictingFiles.length > 0) {
174
+ // Merge conflict detected - abort and report
175
+ console.error(`\n\x1b[31mMerge conflict detected!\x1b[0m`);
176
+ console.error(`\nConflicting files:`);
177
+ for (const file of conflictingFiles) {
178
+ console.error(` \x1b[33m${file}\x1b[0m`);
179
+ }
180
+ // Abort the merge
181
+ try {
182
+ execSync("git -C /workspace merge --abort", { stdio: "pipe" });
183
+ console.error(`\n\x1b[36mMerge aborted.\x1b[0m`);
184
+ }
185
+ catch {
186
+ console.error("\n\x1b[33mWarning: Could not abort merge. You may need to run 'git merge --abort' manually.\x1b[0m");
187
+ }
188
+ console.error(`\nTo resolve:`);
189
+ console.error(` 1. Resolve conflicts manually and merge again`);
190
+ console.error(` 2. Or add a PRD item to resolve the conflicts:`);
191
+ console.error(` ralph prd add # describe the conflict resolution needed`);
192
+ process.exit(1);
193
+ }
194
+ else {
195
+ // Some other merge error
196
+ const message = err instanceof Error ? err.message : String(err);
197
+ console.error(`\x1b[31mMerge failed: ${message}\x1b[0m`);
198
+ // Try to abort in case merge is in progress
199
+ try {
200
+ execSync("git -C /workspace merge --abort", { stdio: "pipe" });
201
+ }
202
+ catch {
203
+ // Ignore if nothing to abort
204
+ }
205
+ process.exit(1);
206
+ }
207
+ }
208
+ // Clean up worktree if it exists
209
+ if (existsSync(worktreePath)) {
210
+ console.log(`\nCleaning up worktree at ${worktreePath}...`);
211
+ try {
212
+ execSync(`git -C /workspace worktree remove "${worktreePath}"`, { stdio: "pipe" });
213
+ console.log(`\x1b[32mWorktree removed.\x1b[0m`);
214
+ }
215
+ catch (err) {
216
+ const message = err instanceof Error ? err.message : String(err);
217
+ console.warn(`\x1b[33mWarning: Could not remove worktree: ${message}\x1b[0m`);
218
+ console.warn("You can remove it manually with: git worktree remove " + worktreePath);
219
+ }
220
+ }
221
+ // Clean up the branch itself (optional - merged branches can be deleted)
222
+ console.log(`\n\x1b[32mDone!\x1b[0m Branch "${branchName}" has been merged into "${baseBranch}".`);
223
+ }
224
+ /**
225
+ * Gets the PRD file path, preferring the primary if it exists.
226
+ */
227
+ function getPrdPath() {
228
+ const prdFiles = getPrdFiles();
229
+ if (prdFiles.primary) {
230
+ return prdFiles.primary;
231
+ }
232
+ return join(getRalphDir(), "prd.json");
233
+ }
234
+ /**
235
+ * Parses a PRD file (YAML or JSON) and returns the entries.
236
+ */
237
+ function parsePrdFile(path) {
238
+ const content = readFileSync(path, "utf-8");
239
+ const ext = extname(path).toLowerCase();
240
+ try {
241
+ let result;
242
+ if (ext === ".yaml" || ext === ".yml") {
243
+ result = YAML.parse(content);
244
+ }
245
+ else {
246
+ result = JSON.parse(content);
247
+ }
248
+ return result ?? [];
249
+ }
250
+ catch {
251
+ console.error(`Error parsing ${path}. Run 'ralph fix-prd' to attempt automatic repair.`);
252
+ process.exit(1);
253
+ }
254
+ }
255
+ /**
256
+ * Saves PRD entries to the PRD file (YAML or JSON based on extension).
257
+ */
258
+ function savePrd(entries) {
259
+ const path = getPrdPath();
260
+ const ext = extname(path).toLowerCase();
261
+ if (ext === ".yaml" || ext === ".yml") {
262
+ writeFileSync(path, YAML.stringify(entries));
263
+ }
264
+ else {
265
+ writeFileSync(path, JSON.stringify(entries, null, 2) + "\n");
266
+ }
267
+ }
268
+ /**
269
+ * Create a PRD item to open a pull request for a branch.
270
+ */
271
+ function branchPr(args) {
272
+ const branchName = args[0];
273
+ if (!branchName) {
274
+ console.error("Usage: ralph branch pr <branch-name>");
275
+ console.error("\nExample: ralph branch pr feat/login");
276
+ process.exit(1);
277
+ }
278
+ // Verify the branch exists
279
+ if (!branchExists(branchName)) {
280
+ console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
281
+ process.exit(1);
282
+ }
283
+ const baseBranch = getBaseBranch();
284
+ const entry = {
285
+ category: "feature",
286
+ description: `Create a pull request from \`${branchName}\` into \`${baseBranch}\``,
287
+ steps: [
288
+ `Ensure all changes on \`${branchName}\` are committed`,
289
+ `Push \`${branchName}\` to the remote if not already pushed`,
290
+ `Create a pull request from \`${branchName}\` into \`${baseBranch}\` using the appropriate tool (e.g. gh pr create)`,
291
+ "Include a descriptive title and summary of the changes in the PR",
292
+ ],
293
+ passes: false,
294
+ branch: branchName,
295
+ };
296
+ const prdPath = getPrdPath();
297
+ const prd = parsePrdFile(prdPath);
298
+ prd.push(entry);
299
+ savePrd(prd);
300
+ console.log(`Added PRD entry #${prd.length}: Create PR for ${branchName} → ${baseBranch}`);
301
+ console.log(`Branch field set to: ${branchName}`);
302
+ console.log("Run 'ralph run' or 'ralph once' to execute.");
303
+ }
304
+ /**
305
+ * Delete a branch: remove worktree, delete git branch, and untag PRD items.
306
+ * Asks for confirmation before proceeding.
307
+ */
308
+ async function branchDelete(args) {
309
+ const branchName = args[0];
310
+ if (!branchName) {
311
+ console.error("Usage: ralph branch delete <branch-name>");
312
+ console.error("\nExample: ralph branch delete feat/old-branch");
313
+ process.exit(1);
314
+ }
315
+ // Verify the branch exists
316
+ if (!branchExists(branchName)) {
317
+ console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
318
+ process.exit(1);
319
+ }
320
+ const worktreesBase = getWorktreesBase();
321
+ const dirName = branchToWorktreeName(branchName);
322
+ const worktreePath = join(worktreesBase, dirName);
323
+ const hasWorktree = existsSync(worktreePath);
324
+ // Load PRD to check for tagged items
325
+ const result = loadPrdEntries();
326
+ const taggedCount = result
327
+ ? result.entries.filter((e) => e.branch === branchName).length
328
+ : 0;
329
+ console.log(`Branch: ${branchName}`);
330
+ if (hasWorktree) {
331
+ console.log(`Worktree: ${worktreePath}`);
332
+ }
333
+ if (taggedCount > 0) {
334
+ console.log(`PRD items tagged: ${taggedCount}`);
335
+ }
336
+ console.log();
337
+ // Ask for confirmation
338
+ const confirmed = await promptConfirm(`Delete branch "${branchName}"${hasWorktree ? " and its worktree" : ""}?`, false);
339
+ if (!confirmed) {
340
+ console.log("Delete cancelled.");
341
+ return;
342
+ }
343
+ // Step 1: Remove worktree if it exists
344
+ if (hasWorktree) {
345
+ console.log(`\nRemoving worktree at ${worktreePath}...`);
346
+ try {
347
+ execSync(`git -C /workspace worktree remove "${worktreePath}" --force`, { stdio: "pipe" });
348
+ console.log(`\x1b[32mWorktree removed.\x1b[0m`);
349
+ }
350
+ catch (err) {
351
+ const message = err instanceof Error ? err.message : String(err);
352
+ console.warn(`\x1b[33mWarning: Could not remove worktree: ${message}\x1b[0m`);
353
+ console.warn("You can remove it manually with: git worktree remove " + worktreePath);
354
+ }
355
+ }
356
+ // Step 2: Delete the git branch
357
+ console.log(`Deleting branch "${branchName}"...`);
358
+ try {
359
+ execSync(`git -C /workspace branch -D "${branchName}"`, { stdio: "pipe" });
360
+ console.log(`\x1b[32mBranch deleted.\x1b[0m`);
361
+ }
362
+ catch (err) {
363
+ const message = err instanceof Error ? err.message : String(err);
364
+ console.error(`\x1b[31mError deleting branch: ${message}\x1b[0m`);
365
+ }
366
+ // Step 3: Remove branch tag from PRD items
367
+ if (result && taggedCount > 0) {
368
+ console.log(`Removing branch tag from ${taggedCount} PRD item(s)...`);
369
+ const updatedEntries = result.entries.map((entry) => {
370
+ if (entry.branch === branchName) {
371
+ const { branch: _, ...rest } = entry;
372
+ return rest;
373
+ }
374
+ return entry;
375
+ });
376
+ writePrdAuto(result.prdPath, updatedEntries);
377
+ console.log(`\x1b[32mPRD items updated.\x1b[0m`);
378
+ }
379
+ console.log(`\n\x1b[32mDone!\x1b[0m Branch "${branchName}" has been deleted.`);
380
+ }
381
+ /**
382
+ * Main branch command dispatcher.
383
+ */
384
+ export async function branch(args) {
385
+ const subcommand = args[0];
386
+ switch (subcommand) {
387
+ case "list":
388
+ branchList();
389
+ break;
390
+ case "merge":
391
+ await branchMerge(args.slice(1));
392
+ break;
393
+ case "delete":
394
+ await branchDelete(args.slice(1));
395
+ break;
396
+ case "pr":
397
+ branchPr(args.slice(1));
398
+ break;
399
+ default:
400
+ console.error("Usage: ralph branch <subcommand>");
401
+ console.error("\nSubcommands:");
402
+ console.error(" list List all branches and their status");
403
+ console.error(" merge <name> Merge a branch worktree into the base branch");
404
+ console.error(" delete <name> Delete a branch and its worktree");
405
+ console.error(" pr <name> Create a PRD item to open a PR for a branch");
406
+ process.exit(1);
407
+ }
408
+ }