ralphblaster-agent 1.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.
@@ -0,0 +1,239 @@
1
+ #!/bin/bash
2
+ # Ralph Wiggum - Long-running AI agent loop
3
+ # Usage: ./ralph.sh [max_iterations]
4
+
5
+ set -e
6
+
7
+ MAX_ITERATIONS=${1:-10}
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ PRD_FILE="$SCRIPT_DIR/prd.json"
10
+ PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
11
+ ARCHIVE_DIR="$SCRIPT_DIR/archive"
12
+ LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch"
13
+
14
+ # Worktree configuration - auto-detect git repository
15
+ # Navigate up from instance directory to find git root
16
+ CURRENT_DIR="$SCRIPT_DIR"
17
+ while [ "$CURRENT_DIR" != "/" ]; do
18
+ if [ -d "$CURRENT_DIR/.git" ] || git -C "$CURRENT_DIR" rev-parse --git-dir >/dev/null 2>&1; then
19
+ MAIN_REPO_PATH="$(cd "$CURRENT_DIR" && git rev-parse --show-toplevel)"
20
+ break
21
+ fi
22
+ CURRENT_DIR="$(dirname "$CURRENT_DIR")"
23
+ done
24
+
25
+ if [ -z "$MAIN_REPO_PATH" ]; then
26
+ echo "Error: Could not find git repository. Ralph instances must be within a git repository."
27
+ exit 1
28
+ fi
29
+
30
+ # Derive worktree parent directory (sibling to repo)
31
+ REPO_NAME="$(basename "$MAIN_REPO_PATH")"
32
+ REPO_PARENT="$(dirname "$MAIN_REPO_PATH")"
33
+ WORKTREE_PARENT="$REPO_PARENT/${REPO_NAME}-worktrees"
34
+ WORKTREE_PATH_FILE="$SCRIPT_DIR/.worktree-path"
35
+
36
+ # Setup worktree for the branch specified in prd.json
37
+ setup_worktree() {
38
+ local branch_name="$1"
39
+
40
+ if [ -z "$branch_name" ]; then
41
+ echo "Error: No branch name provided to setup_worktree"
42
+ return 1
43
+ fi
44
+
45
+ # If RALPH_WORKTREE_PATH is set and valid, use it
46
+ if [ -n "$RALPH_WORKTREE_PATH" ] && [ -d "$RALPH_WORKTREE_PATH" ]; then
47
+ if git -C "$RALPH_WORKTREE_PATH" rev-parse --git-dir >/dev/null 2>&1; then
48
+ echo "✓ Using provided worktree: $RALPH_WORKTREE_PATH"
49
+ echo "$RALPH_WORKTREE_PATH" > "$WORKTREE_PATH_FILE"
50
+ export RALPH_WORKTREE_PATH
51
+ return 0
52
+ fi
53
+ fi
54
+
55
+ # Fall back to calculating worktree path (existing logic)
56
+ # Derive worktree path (strip ralph/ prefix if present)
57
+ local worktree_name=$(echo "$branch_name" | sed 's|^ralph/||')
58
+ local worktree_path="$WORKTREE_PARENT/$worktree_name"
59
+
60
+ # Create parent directory if it doesn't exist
61
+ mkdir -p "$WORKTREE_PARENT"
62
+
63
+ # Check if worktree already exists
64
+ if [ -d "$worktree_path" ]; then
65
+ # Verify it's a valid git worktree
66
+ if git -C "$worktree_path" rev-parse --git-dir >/dev/null 2>&1; then
67
+ echo "✓ Using existing worktree: $worktree_path"
68
+ echo "$worktree_path" > "$WORKTREE_PATH_FILE"
69
+ return 0
70
+ else
71
+ echo "⚠ Invalid worktree found, removing: $worktree_path"
72
+ rm -rf "$worktree_path"
73
+ fi
74
+ fi
75
+
76
+ # Create new worktree
77
+ echo "Creating worktree for branch: $branch_name"
78
+ cd "$MAIN_REPO_PATH"
79
+
80
+ # Check if branch exists
81
+ if git show-ref --verify --quiet "refs/heads/$branch_name"; then
82
+ # Branch exists, checkout to worktree
83
+ git worktree add "$worktree_path" "$branch_name"
84
+ else
85
+ # Branch doesn't exist, create from main
86
+ git worktree add "$worktree_path" -b "$branch_name" main
87
+ fi
88
+
89
+ if [ $? -eq 0 ]; then
90
+ echo "✓ Worktree created: $worktree_path"
91
+ echo "$worktree_path" > "$WORKTREE_PATH_FILE"
92
+ return 0
93
+ else
94
+ echo "✗ Failed to create worktree"
95
+ return 1
96
+ fi
97
+ }
98
+
99
+ # Archive previous run if branch changed
100
+ if [ -f "$PRD_FILE" ] && [ -f "$LAST_BRANCH_FILE" ]; then
101
+ CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
102
+ LAST_BRANCH=$(cat "$LAST_BRANCH_FILE" 2>/dev/null || echo "")
103
+
104
+ if [ -n "$CURRENT_BRANCH" ] && [ -n "$LAST_BRANCH" ] && [ "$CURRENT_BRANCH" != "$LAST_BRANCH" ]; then
105
+ # Archive the previous run
106
+ DATE=$(date +%Y-%m-%d)
107
+ # Strip "ralph/" prefix from branch name for folder
108
+ FOLDER_NAME=$(echo "$LAST_BRANCH" | sed 's|^ralph/||')
109
+ ARCHIVE_FOLDER="$ARCHIVE_DIR/$DATE-$FOLDER_NAME"
110
+
111
+ echo "Archiving previous run: $LAST_BRANCH"
112
+ mkdir -p "$ARCHIVE_FOLDER"
113
+ [ -f "$PRD_FILE" ] && cp "$PRD_FILE" "$ARCHIVE_FOLDER/"
114
+ [ -f "$PROGRESS_FILE" ] && cp "$PROGRESS_FILE" "$ARCHIVE_FOLDER/"
115
+ echo " Archived to: $ARCHIVE_FOLDER"
116
+
117
+ # Reset progress file for new run
118
+ echo "# Ralph Progress Log" > "$PROGRESS_FILE"
119
+ echo "Started: $(date)" >> "$PROGRESS_FILE"
120
+ echo "---" >> "$PROGRESS_FILE"
121
+ fi
122
+ fi
123
+
124
+ # Track current branch
125
+ if [ -f "$PRD_FILE" ]; then
126
+ CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
127
+ if [ -n "$CURRENT_BRANCH" ]; then
128
+ echo "$CURRENT_BRANCH" > "$LAST_BRANCH_FILE"
129
+ fi
130
+ fi
131
+
132
+ # Initialize progress file if it doesn't exist
133
+ if [ ! -f "$PROGRESS_FILE" ]; then
134
+ echo "# Ralph Progress Log" > "$PROGRESS_FILE"
135
+ echo "Started: $(date)" >> "$PROGRESS_FILE"
136
+ echo "---" >> "$PROGRESS_FILE"
137
+ fi
138
+
139
+ # Setup worktree before starting iterations
140
+ if [ -f "$PRD_FILE" ]; then
141
+ BRANCH_NAME=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
142
+ if [ -n "$BRANCH_NAME" ]; then
143
+ setup_worktree "$BRANCH_NAME"
144
+ if [ $? -ne 0 ]; then
145
+ echo "Failed to setup worktree"
146
+ exit 1
147
+ fi
148
+ else
149
+ echo "Error: No branchName found in prd.json"
150
+ exit 1
151
+ fi
152
+ else
153
+ echo "Error: prd.json not found at $PRD_FILE"
154
+ exit 1
155
+ fi
156
+
157
+ # Export environment variables for Claude
158
+ WORKTREE_PATH=$(cat "$WORKTREE_PATH_FILE" 2>/dev/null || echo "")
159
+ if [ -z "$WORKTREE_PATH" ]; then
160
+ echo "Error: Worktree path not found"
161
+ exit 1
162
+ fi
163
+
164
+ export RALPH_WORKTREE_PATH="$WORKTREE_PATH"
165
+ export RALPH_INSTANCE_DIR="$SCRIPT_DIR"
166
+ export RALPH_MAIN_REPO="$MAIN_REPO_PATH"
167
+
168
+ echo "Starting Ralph - Max iterations: $MAX_ITERATIONS"
169
+ echo "Worktree: $RALPH_WORKTREE_PATH"
170
+ echo "Instance: $RALPH_INSTANCE_DIR"
171
+
172
+ for i in $(seq 1 $MAX_ITERATIONS); do
173
+ echo ""
174
+ echo "═══════════════════════════════════════════════════════"
175
+ echo " Ralph Iteration $i of $MAX_ITERATIONS"
176
+ echo "═══════════════════════════════════════════════════════"
177
+ echo "Started: $(date '+%H:%M:%S')"
178
+ echo ""
179
+ echo "💬 Executing Claude..."
180
+ echo ""
181
+
182
+ # Start tailing progress file in background to show real-time updates
183
+ if [ -f "$PROGRESS_FILE" ]; then
184
+ # Get current line count to show only new additions
185
+ PROGRESS_LINES_BEFORE=$(wc -l < "$PROGRESS_FILE" 2>/dev/null || echo "0")
186
+ tail -f "$PROGRESS_FILE" &
187
+ TAIL_PID=$!
188
+ fi
189
+
190
+ # Run claude with the ralph prompt from the instance directory
191
+ # Use --continue for iterations after the first to maintain context
192
+ # Use stdbuf to unbuffer output for real-time visibility
193
+ if [ "$i" -eq 1 ]; then
194
+ OUTPUT=$(cd "$SCRIPT_DIR" && cat prompt.md | stdbuf -oL -eL claude --dangerously-skip-permissions 2>&1 | tee /dev/stderr) || true
195
+ else
196
+ OUTPUT=$(cd "$SCRIPT_DIR" && cat prompt.md | stdbuf -oL -eL claude --dangerously-skip-permissions --continue 2>&1 | tee /dev/stderr) || true
197
+ fi
198
+
199
+ # Stop tailing progress file
200
+ if [ -n "$TAIL_PID" ]; then
201
+ kill $TAIL_PID 2>/dev/null || true
202
+ wait $TAIL_PID 2>/dev/null || true
203
+ fi
204
+
205
+ # Check for completion signal
206
+ if echo "$OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then
207
+ echo ""
208
+ echo "✅ Ralph completed all tasks!"
209
+ echo "Completed at iteration $i of $MAX_ITERATIONS ($(date '+%H:%M:%S'))"
210
+ exit 0
211
+ fi
212
+
213
+ echo ""
214
+ echo "✓ Iteration $i complete at $(date '+%H:%M:%S')"
215
+
216
+ # Show progress summary from this iteration
217
+ if [ -f "$PROGRESS_FILE" ]; then
218
+ PROGRESS_LINES_AFTER=$(wc -l < "$PROGRESS_FILE" 2>/dev/null || echo "0")
219
+ NEW_LINES=$((PROGRESS_LINES_AFTER - PROGRESS_LINES_BEFORE))
220
+ if [ "$NEW_LINES" -gt 0 ]; then
221
+ echo ""
222
+ echo "📝 Progress update ($NEW_LINES new lines in $PROGRESS_FILE):"
223
+ echo "---"
224
+ tail -n "$NEW_LINES" "$PROGRESS_FILE" | head -20
225
+ if [ "$NEW_LINES" -gt 20 ]; then
226
+ echo "... (showing last 20 of $NEW_LINES new lines)"
227
+ fi
228
+ echo "---"
229
+ fi
230
+ fi
231
+
232
+ echo "Continuing in 2 seconds..."
233
+ sleep 2
234
+ done
235
+
236
+ echo ""
237
+ echo "Ralph reached max iterations ($MAX_ITERATIONS) without completing all tasks."
238
+ echo "Check $PROGRESS_FILE for status."
239
+ exit 1
@@ -0,0 +1,171 @@
1
+ const fs = require('fs').promises;
2
+ const path = require('path');
3
+ const { exec } = require('child_process');
4
+ const { promisify } = require('util');
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ /**
9
+ * Manages Ralph agent instances within git worktrees
10
+ * Creates instance directories, converts PRDs, and manages progress tracking
11
+ */
12
+ class RalphInstanceManager {
13
+ /**
14
+ * Creates a Ralph instance directory with all required files
15
+ * @param {string} worktreePath - Path to the git worktree
16
+ * @param {string} prompt - The PRD or feature description in markdown
17
+ * @param {string} jobId - Unique job identifier
18
+ * @returns {Promise<string>} Path to the created instance directory
19
+ */
20
+ async createInstance(worktreePath, prompt, jobId) {
21
+ // Create instance directory inside the worktree
22
+ const instancePath = path.join(worktreePath, 'ralph-instance');
23
+ await fs.mkdir(instancePath, { recursive: true });
24
+
25
+ // Copy bundled ralph.sh and prompt.md to instance
26
+ const bundledRalphDir = path.join(__dirname, 'ralph');
27
+ const ralphShPath = path.join(bundledRalphDir, 'ralph.sh');
28
+ const promptMdPath = path.join(bundledRalphDir, 'prompt.md');
29
+
30
+ await fs.copyFile(ralphShPath, path.join(instancePath, 'ralph.sh'));
31
+ await fs.copyFile(promptMdPath, path.join(instancePath, 'prompt.md'));
32
+
33
+ // Make ralph.sh executable
34
+ await fs.chmod(path.join(instancePath, 'ralph.sh'), 0o755);
35
+
36
+ // Convert prompt to prd.json
37
+ await this.convertPrdToJson(instancePath, prompt);
38
+
39
+ // Initialize progress.txt
40
+ const progressContent = `# Ralph Progress Log - Job ${jobId}
41
+ Started: ${new Date().toISOString()}
42
+ ---
43
+
44
+ `;
45
+ await fs.writeFile(path.join(instancePath, 'progress.txt'), progressContent);
46
+
47
+ return instancePath;
48
+ }
49
+
50
+ /**
51
+ * Converts markdown PRD to prd.json using Claude /ralph skill
52
+ * @param {string} instancePath - Path to the Ralph instance directory
53
+ * @param {string} prompt - The PRD markdown content
54
+ * @returns {Promise<void>}
55
+ */
56
+ async convertPrdToJson(instancePath, prompt) {
57
+ // Write the prompt to a temporary file
58
+ const promptFilePath = path.join(instancePath, 'input-prd.md');
59
+ await fs.writeFile(promptFilePath, prompt);
60
+
61
+ try {
62
+ // Run claude /ralph to convert the PRD
63
+ const { stdout, stderr } = await execAsync(
64
+ `claude /ralph < "${promptFilePath}"`,
65
+ {
66
+ cwd: instancePath,
67
+ env: {
68
+ ...process.env,
69
+ // Ensure Claude CLI can access the instance directory
70
+ PWD: instancePath
71
+ },
72
+ maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large outputs
73
+ }
74
+ );
75
+
76
+ // Verify prd.json was created
77
+ const prdJsonPath = path.join(instancePath, 'prd.json');
78
+ try {
79
+ await fs.access(prdJsonPath);
80
+
81
+ // Validate prd.json structure
82
+ const prdContent = await fs.readFile(prdJsonPath, 'utf8');
83
+ const prd = JSON.parse(prdContent);
84
+
85
+ if (!prd.branchName || !prd.userStories) {
86
+ throw new Error('Invalid prd.json structure: missing branchName or userStories');
87
+ }
88
+ } catch (error) {
89
+ throw new Error(`prd.json validation failed: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`);
90
+ }
91
+
92
+ // Clean up temporary prompt file
93
+ await fs.unlink(promptFilePath).catch(() => {});
94
+ } catch (error) {
95
+ throw new Error(`Failed to convert PRD to JSON: ${error.message}`);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Returns environment variables required for ralph.sh execution
101
+ * @param {string} worktreePath - Path to the git worktree
102
+ * @param {string} instancePath - Path to the Ralph instance directory
103
+ * @param {string} mainRepoPath - Path to the main repository
104
+ * @returns {Object} Environment variables object
105
+ */
106
+ getEnvVars(worktreePath, instancePath, mainRepoPath) {
107
+ return {
108
+ ...process.env,
109
+ RALPH_WORKTREE_PATH: worktreePath,
110
+ RALPH_INSTANCE_DIR: instancePath,
111
+ RALPH_MAIN_REPO: mainRepoPath
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Reads and parses the progress summary from progress.txt
117
+ * @param {string} instancePath - Path to the Ralph instance directory
118
+ * @returns {Promise<string>} Summary of progress
119
+ */
120
+ async readProgressSummary(instancePath) {
121
+ const progressPath = path.join(instancePath, 'progress.txt');
122
+
123
+ try {
124
+ const content = await fs.readFile(progressPath, 'utf8');
125
+
126
+ // Extract meaningful summary from progress file
127
+ const lines = content.split('\n');
128
+ const summaryLines = [];
129
+
130
+ for (const line of lines) {
131
+ // Skip header and empty lines
132
+ if (line.startsWith('#') || line.startsWith('Started:') || line === '---' || !line.trim()) {
133
+ continue;
134
+ }
135
+
136
+ summaryLines.push(line);
137
+ }
138
+
139
+ return summaryLines.join('\n').trim() || 'No progress recorded yet';
140
+ } catch (error) {
141
+ return `Failed to read progress: ${error.message}`;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Checks if Ralph has completed successfully by looking for completion signal
147
+ * @param {string} output - The output from ralph.sh execution
148
+ * @returns {boolean} True if completion signal found
149
+ */
150
+ hasCompletionSignal(output) {
151
+ return output.includes('<promise>COMPLETE</promise>');
152
+ }
153
+
154
+ /**
155
+ * Extracts the branch name from prd.json
156
+ * @param {string} instancePath - Path to the Ralph instance directory
157
+ * @returns {Promise<string|null>} Branch name or null if not found
158
+ */
159
+ async getBranchName(instancePath) {
160
+ try {
161
+ const prdJsonPath = path.join(instancePath, 'prd.json');
162
+ const content = await fs.readFile(prdJsonPath, 'utf8');
163
+ const prd = JSON.parse(content);
164
+ return prd.branchName || null;
165
+ } catch (error) {
166
+ return null;
167
+ }
168
+ }
169
+ }
170
+
171
+ module.exports = RalphInstanceManager;
@@ -0,0 +1,170 @@
1
+ const { spawn } = require('child_process');
2
+ const path = require('path');
3
+ const fs = require('fs').promises;
4
+ const logger = require('./logger');
5
+
6
+ /**
7
+ * WorktreeManager - Manages git worktrees for parallel job execution
8
+ *
9
+ * Each job gets an isolated worktree in .ralph-worktrees/job-{id}/
10
+ * with a unique branch: ralph/ticket-{task_id}/job-{job_id}
11
+ */
12
+ class WorktreeManager {
13
+ /**
14
+ * Create a new worktree for a job
15
+ * @param {Object} job - The job object with id, task, and project
16
+ * @returns {Promise<string>} - The absolute path to the worktree
17
+ */
18
+ async createWorktree(job) {
19
+ const worktreePath = this.getWorktreePath(job)
20
+ const branchName = this.getBranchName(job)
21
+ const systemPath = job.project.system_path
22
+
23
+ logger.info(`Creating worktree for job ${job.id}`, {
24
+ worktreePath,
25
+ branchName,
26
+ systemPath
27
+ })
28
+
29
+ try {
30
+ // Verify git is available
31
+ await this.execGit(systemPath, ['--version'], 5000)
32
+
33
+ // Check if worktree already exists (from a previous failed run)
34
+ try {
35
+ await fs.access(worktreePath)
36
+ logger.warn(`Worktree already exists at ${worktreePath}, removing stale worktree`)
37
+ await this.removeWorktree(job)
38
+ } catch (err) {
39
+ // Worktree doesn't exist, which is expected
40
+ }
41
+
42
+ // Create the worktree with a new branch
43
+ // -b creates a new branch, --detach would checkout without branch
44
+ await this.execGit(
45
+ systemPath,
46
+ ['worktree', 'add', '-b', branchName, worktreePath, 'HEAD'],
47
+ 30000
48
+ )
49
+
50
+ logger.info(`Created worktree: ${worktreePath}`)
51
+ return worktreePath
52
+ } catch (error) {
53
+ logger.error(`Failed to create worktree for job ${job.id}`, {
54
+ error: error.message,
55
+ worktreePath,
56
+ branchName
57
+ })
58
+ throw new Error(`Failed to create worktree: ${error.message}`)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Remove a worktree after job completion
64
+ * @param {Object} job - The job object
65
+ */
66
+ async removeWorktree(job) {
67
+ const worktreePath = this.getWorktreePath(job)
68
+ const branchName = this.getBranchName(job)
69
+ const systemPath = job.project.system_path
70
+
71
+ logger.info(`Removing worktree for job ${job.id}`, {
72
+ worktreePath,
73
+ branchName
74
+ })
75
+
76
+ try {
77
+ // Remove the worktree (--force removes even if dirty)
78
+ await this.execGit(
79
+ systemPath,
80
+ ['worktree', 'remove', worktreePath, '--force'],
81
+ 30000
82
+ )
83
+
84
+ logger.info(`Removed worktree: ${worktreePath}`)
85
+ } catch (error) {
86
+ // Log error but don't throw - cleanup is best-effort
87
+ logger.error(`Failed to remove worktree for job ${job.id}`, {
88
+ error: error.message,
89
+ worktreePath
90
+ })
91
+ }
92
+
93
+ // Note: We intentionally don't delete the branch here
94
+ // The branch remains in the repo for history/inspection
95
+ }
96
+
97
+ /**
98
+ * Get the absolute path where the worktree should be created
99
+ * @param {Object} job - The job object
100
+ * @returns {string} - Absolute path to worktree
101
+ */
102
+ getWorktreePath(job) {
103
+ const systemPath = job.project.system_path
104
+ return path.join(systemPath, '.ralph-worktrees', `job-${job.id}`)
105
+ }
106
+
107
+ /**
108
+ * Get the branch name for this job
109
+ * @param {Object} job - The job object
110
+ * @returns {string} - Branch name in format ralph/ticket-{task_id}/job-{job_id}
111
+ */
112
+ getBranchName(job) {
113
+ return `ralph/ticket-${job.task_id}/job-${job.id}`
114
+ }
115
+
116
+ /**
117
+ * Execute a git command safely
118
+ * @param {string} cwd - Working directory
119
+ * @param {string[]} args - Git command arguments
120
+ * @param {number} timeout - Timeout in milliseconds (default: 30s)
121
+ * @returns {Promise<{stdout: string, stderr: string}>}
122
+ */
123
+ async execGit(cwd, args, timeout = 30000) {
124
+ return new Promise((resolve, reject) => {
125
+ const process = spawn('git', args, {
126
+ cwd,
127
+ stdio: ['ignore', 'pipe', 'pipe'],
128
+ shell: false // Security: Don't use shell to prevent injection
129
+ })
130
+
131
+ let stdout = ''
132
+ let stderr = ''
133
+ let timedOut = false
134
+
135
+ const timer = setTimeout(() => {
136
+ timedOut = true
137
+ process.kill('SIGTERM')
138
+ reject(new Error(`Git command timed out after ${timeout}ms`))
139
+ }, timeout)
140
+
141
+ process.stdout.on('data', (data) => {
142
+ stdout += data.toString()
143
+ })
144
+
145
+ process.stderr.on('data', (data) => {
146
+ stderr += data.toString()
147
+ })
148
+
149
+ process.on('error', (err) => {
150
+ clearTimeout(timer)
151
+ if (!timedOut) {
152
+ reject(new Error(`Failed to execute git: ${err.message}`))
153
+ }
154
+ })
155
+
156
+ process.on('close', (code) => {
157
+ clearTimeout(timer)
158
+ if (timedOut) return // Already rejected
159
+
160
+ if (code === 0) {
161
+ resolve({ stdout, stderr })
162
+ } else {
163
+ reject(new Error(`Git command failed (exit code ${code}): ${stderr || stdout}`))
164
+ }
165
+ })
166
+ })
167
+ }
168
+ }
169
+
170
+ module.exports = WorktreeManager;