jettypod 4.2.11 → 4.4.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.
@@ -19,6 +19,23 @@ function getSessionFilePath() {
19
19
  return path.join(process.cwd(), '.claude', 'session.md');
20
20
  }
21
21
 
22
+ /**
23
+ * Validate that we're in a worktree, not the root directory
24
+ * @throws {Error} If in root directory
25
+ */
26
+ function validateWorktreeLocation() {
27
+ const cwd = process.cwd();
28
+
29
+ // Check if current directory is inside .jettypod-work/
30
+ if (!cwd.includes('.jettypod-work')) {
31
+ throw new Error(
32
+ 'Cannot create session.md in root directory.\n' +
33
+ 'Session files should only be created in worktrees (.jettypod-work/).\n' +
34
+ 'Use "jettypod work start <id>" to create a worktree for this work item.'
35
+ );
36
+ }
37
+ }
38
+
22
39
  /**
23
40
  * Ensure .claude directory exists
24
41
  */
@@ -69,6 +86,9 @@ function writeSessionFile(workItem, mode, options = {}) {
69
86
  throw new Error('Work item must have a string status');
70
87
  }
71
88
 
89
+ // Validate we're in a worktree, not root directory
90
+ validateWorktreeLocation();
91
+
72
92
  ensureClaudeDir();
73
93
 
74
94
  const skillName = modeToSkillName(mode);
@@ -15,6 +15,29 @@
15
15
  const worktreeManager = require('./worktree-manager');
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
+ const { getDb } = require('./database');
19
+
20
+ /**
21
+ * Check if a worktree already exists for a work item
22
+ *
23
+ * @param {Object} db - Database connection
24
+ * @param {number} workItemId - Work item ID
25
+ * @returns {Promise<Object|null>} Existing worktree or null
26
+ */
27
+ function checkExistingWorktree(db, workItemId) {
28
+ return new Promise((resolve, reject) => {
29
+ db.get(
30
+ `SELECT * FROM worktrees
31
+ WHERE work_item_id = ?
32
+ AND status = 'active'`,
33
+ [workItemId],
34
+ (err, row) => {
35
+ if (err) return reject(err);
36
+ resolve(row || null);
37
+ }
38
+ );
39
+ });
40
+ }
18
41
 
19
42
  /**
20
43
  * Start work on a work item with graceful degradation
@@ -33,6 +56,7 @@ async function startWork(workItem, options = {}) {
33
56
  }
34
57
 
35
58
  const repoPath = options.repoPath || process.cwd();
59
+ const db = options.db || getDb();
36
60
  const result = {
37
61
  mode: null, // 'worktree' or 'main'
38
62
  path: null, // Working directory path
@@ -41,6 +65,25 @@ async function startWork(workItem, options = {}) {
41
65
  warnings: [] // Non-fatal warnings
42
66
  };
43
67
 
68
+ // Explicit check for existing worktree (defensive programming)
69
+ try {
70
+ const existingWorktree = await checkExistingWorktree(db, workItem.id);
71
+ if (existingWorktree) {
72
+ // Another instance is already working on this - fall back to main
73
+ result.mode = 'main';
74
+ result.path = repoPath;
75
+ result.error = {
76
+ message: `Work item #${workItem.id} already has active worktree at ${existingWorktree.worktree_path}`,
77
+ reason: 'duplicate_worktree_prevented'
78
+ };
79
+ result.warnings.push('Another instance may be working on this chore');
80
+ return result;
81
+ }
82
+ } catch (checkErr) {
83
+ // If check fails, continue anyway - database constraint will catch duplicates
84
+ result.warnings.push(`Warning: Could not check for existing worktree: ${checkErr.message}`);
85
+ }
86
+
44
87
  // Attempt to create worktree
45
88
  try {
46
89
  const worktree = await worktreeManager.createWorktree(workItem, options);
@@ -14,6 +14,9 @@
14
14
  // Database will be required lazily when implementing actual functions
15
15
  // const getDb = require('./database');
16
16
 
17
+ // Safe delete module - ALWAYS use this for deletions
18
+ const safeDelete = require('./safe-delete');
19
+
17
20
  /**
18
21
  * Valid worktree statuses
19
22
  */
@@ -44,9 +47,16 @@ async function createWorktree(workItem, options = {}) {
44
47
  return Promise.reject(new Error('SAFETY: repoPath must be explicitly provided to createWorktree'));
45
48
  }
46
49
 
50
+ // SAFETY CHECK 0: Verify we're not running from within a worktree
51
+ try {
52
+ safeDelete.ensureNotInWorktree();
53
+ } catch (err) {
54
+ return Promise.reject(err);
55
+ }
56
+
47
57
  const gitRoot = options.repoPath;
48
58
 
49
- // SAFETY CHECK: Verify gitRoot is actually the main repository
59
+ // SAFETY CHECK 1: Verify gitRoot is actually the main repository
50
60
  try {
51
61
  const gitRootCheck = execSync('git rev-parse --show-toplevel', {
52
62
  cwd: gitRoot,
@@ -205,7 +215,11 @@ async function createWorktree(workItem, options = {}) {
205
215
  }
206
216
  } else {
207
217
  // It's a directory - remove it and create symlink
208
- fs.rmSync(jettypodLink, { recursive: true, force: true });
218
+ // SAFETY: Use safe delete with gitRoot validation
219
+ const deleteResult = safeDelete.safeRmRecursive(jettypodLink, { gitRoot, force: true });
220
+ if (!deleteResult.success) {
221
+ throw new Error(`Failed to remove .jettypod directory: ${deleteResult.error}`);
222
+ }
209
223
  fs.symlinkSync(jettypodTarget, jettypodLink, 'dir');
210
224
  }
211
225
  } else {
@@ -324,10 +338,10 @@ async function createWorktree(workItem, options = {}) {
324
338
 
325
339
  // Cleanup directory
326
340
  if (fs.existsSync(worktreePath)) {
327
- try {
328
- fs.rmSync(worktreePath, { recursive: true, force: true });
329
- } catch (fsErr) {
330
- console.error(`Failed to cleanup worktree directory: ${fsErr.message}`);
341
+ // SAFETY: Use safe delete with gitRoot validation
342
+ const deleteResult = safeDelete.safeRmRecursive(worktreePath, { gitRoot, force: true });
343
+ if (!deleteResult.success) {
344
+ console.error(`Failed to cleanup worktree directory: ${deleteResult.error}`);
331
345
  }
332
346
  }
333
347
 
@@ -478,12 +492,27 @@ async function removeDirectoryResilient(dirPath, gitRoot) {
478
492
  const fs = require('fs');
479
493
  const { execSync } = require('child_process');
480
494
 
495
+ // CRITICAL SAFETY: Validate path before ANY deletion attempt
496
+ const validation = safeDelete.validatePath(dirPath, gitRoot);
497
+ if (!validation.safe) {
498
+ throw new Error(`SAFETY: Cannot remove directory - ${validation.reason}`);
499
+ }
500
+
501
+ // Additional safety: Ensure dirPath is within .jettypod-work
502
+ const path = require('path');
503
+ const resolvedDir = path.resolve(dirPath);
504
+ const worktreeBase = path.resolve(gitRoot, '.jettypod-work');
505
+ if (!resolvedDir.startsWith(worktreeBase)) {
506
+ throw new Error(`SAFETY: Can only remove directories within .jettypod-work/. Got: ${dirPath}`);
507
+ }
508
+
481
509
  // Stage 1: Try standard git worktree remove (silent)
482
510
  try {
483
511
  execSync(`git worktree remove "${dirPath}"`, {
484
512
  cwd: gitRoot,
485
513
  stdio: 'pipe'
486
514
  });
515
+ safeDelete.logDeletion(dirPath, 'git-worktree-remove', true);
487
516
  return; // Success - exit silently
488
517
  } catch (stage1Err) {
489
518
  // Stage 1 failed - escalate with logging
@@ -497,16 +526,18 @@ async function removeDirectoryResilient(dirPath, gitRoot) {
497
526
  stdio: 'pipe'
498
527
  });
499
528
  console.log(`✓ Forced git removal succeeded`);
529
+ safeDelete.logDeletion(dirPath, 'git-worktree-remove-force', true);
500
530
  return;
501
531
  } catch (stage2Err) {
502
- console.log(`⚠️ Forced git removal failed, escalating to filesystem removal...`);
532
+ console.log(`⚠️ Forced git removal failed, escalating to safe filesystem removal...`);
503
533
  }
504
534
 
505
- // Stage 3: Try filesystem removal with force
535
+ // Stage 3: Try SAFE filesystem removal (validates path again)
506
536
  if (fs.existsSync(dirPath)) {
507
- try {
508
- fs.rmSync(dirPath, { recursive: true, force: true });
509
- console.log(`✓ Filesystem removal succeeded`);
537
+ const deleteResult = safeDelete.safeRmRecursive(dirPath, { gitRoot, force: true });
538
+
539
+ if (deleteResult.success) {
540
+ console.log(`✓ Safe filesystem removal succeeded`);
510
541
 
511
542
  // Clean up git metadata
512
543
  try {
@@ -515,25 +546,14 @@ async function removeDirectoryResilient(dirPath, gitRoot) {
515
546
  // Non-fatal
516
547
  }
517
548
  return;
518
- } catch (stage3Err) {
519
- console.log(`⚠️ Filesystem removal failed, escalating to manual deletion...`);
549
+ } else {
550
+ console.log(`⚠️ Safe filesystem removal failed: ${deleteResult.error}`);
551
+ throw new Error(`Directory removal failed: ${deleteResult.error}`);
520
552
  }
521
553
  }
522
554
 
523
- // Stage 4: Manual recursive deletion (last resort)
524
- try {
525
- await manualRecursiveDelete(dirPath);
526
- console.log(`✓ Manual recursive deletion succeeded`);
527
-
528
- // Clean up git metadata
529
- try {
530
- execSync('git worktree prune', { cwd: gitRoot, stdio: 'pipe' });
531
- } catch (pruneErr) {
532
- // Non-fatal
533
- }
534
- } catch (stage4Err) {
535
- throw new Error(`All removal strategies failed: ${stage4Err.message}`);
536
- }
555
+ // If we get here, directory doesn't exist - that's fine
556
+ console.log(`✓ Directory already removed or doesn't exist`);
537
557
  }
538
558
 
539
559
  /**
@@ -566,21 +586,14 @@ async function cleanupWorktree(worktreeId, options = {}) {
566
586
  const db = options.db || getDb();
567
587
  const deleteBranch = options.deleteBranch || false;
568
588
 
569
- // SAFETY CHECK 1: Verify we're not running from within a worktree
570
- try {
571
- const currentGitDir = execSync('git rev-parse --git-dir', {
572
- cwd: process.cwd(),
573
- encoding: 'utf8'
574
- }).trim();
575
-
576
- if (currentGitDir.includes('.jettypod-work') || currentGitDir !== '.git') {
577
- return Promise.reject(new Error('SAFETY: Cannot run cleanup from within a worktree'));
578
- }
579
- } catch (err) {
580
- // Ignore - might not be in a git repo
581
- }
589
+ // NOTE: We intentionally do NOT check ensureNotInWorktree() here.
590
+ // The merge workflow (jettypod work merge) is designed to be run from
591
+ // within the worktree being merged. Safety comes from:
592
+ // 1. Requiring explicit repoPath (not relying on process.cwd())
593
+ // 2. Verifying repoPath is the main repo (check below)
594
+ // 3. Path validation in removeDirectoryResilient()
582
595
 
583
- // SAFETY CHECK 2: Verify gitRoot is actually the main repository
596
+ // SAFETY CHECK: Verify gitRoot is actually the main repository
584
597
  try {
585
598
  const gitRootCheck = execSync('git rev-parse --show-toplevel', {
586
599
  cwd: gitRoot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jettypod",
3
- "version": "4.2.11",
3
+ "version": "4.4.0",
4
4
  "description": "AI-powered development workflow manager with TDD, BDD, and automatic test generation",
5
5
  "main": "jettypod.js",
6
6
  "bin": {
@@ -1,84 +0,0 @@
1
- /**
2
- * Migration: Create worktree_sessions table
3
- *
4
- * Purpose: Track which work item is active in each worktree to support multiple
5
- * concurrent Claude Code instances working on different features simultaneously.
6
- *
7
- * Why this is critical:
8
- * - Enable true concurrent development with multiple worktrees
9
- * - Prevent confusion when Claude switches between worktrees
10
- * - Provide centralized visibility of what's active where
11
- * - Support automatic cleanup when work items are completed
12
- */
13
-
14
- module.exports = {
15
- id: '016-worktree-sessions-table',
16
- description: 'Create worktree_sessions table for multi-worktree current work tracking',
17
-
18
- async up(db) {
19
- return new Promise((resolve, reject) => {
20
- db.run(`
21
- CREATE TABLE IF NOT EXISTS worktree_sessions (
22
- id INTEGER PRIMARY KEY AUTOINCREMENT,
23
- worktree_path TEXT UNIQUE NOT NULL,
24
- work_item_id INTEGER NOT NULL,
25
- branch_name TEXT NOT NULL,
26
- last_activity DATETIME DEFAULT CURRENT_TIMESTAMP,
27
- FOREIGN KEY (work_item_id) REFERENCES work_items(id)
28
- )
29
- `, (err) => {
30
- if (err) {
31
- return reject(err);
32
- }
33
-
34
- // Create index on worktree_path for fast lookups
35
- db.run(`
36
- CREATE INDEX IF NOT EXISTS idx_worktree_sessions_path
37
- ON worktree_sessions(worktree_path)
38
- `, (err) => {
39
- if (err) {
40
- return reject(err);
41
- }
42
-
43
- // Create index on work_item_id for reverse lookups
44
- db.run(`
45
- CREATE INDEX IF NOT EXISTS idx_worktree_sessions_work_item
46
- ON worktree_sessions(work_item_id)
47
- `, (err) => {
48
- if (err) {
49
- return reject(err);
50
- }
51
-
52
- resolve();
53
- });
54
- });
55
- });
56
- });
57
- },
58
-
59
- async down(db) {
60
- return new Promise((resolve, reject) => {
61
- // Drop indexes first
62
- db.run('DROP INDEX IF EXISTS idx_worktree_sessions_work_item', (err) => {
63
- if (err) {
64
- return reject(err);
65
- }
66
-
67
- db.run('DROP INDEX IF EXISTS idx_worktree_sessions_path', (err) => {
68
- if (err) {
69
- return reject(err);
70
- }
71
-
72
- // Drop table
73
- db.run('DROP TABLE IF EXISTS worktree_sessions', (err) => {
74
- if (err) {
75
- return reject(err);
76
- }
77
-
78
- resolve();
79
- });
80
- });
81
- });
82
- });
83
- }
84
- };
@@ -1,186 +0,0 @@
1
- /**
2
- * Worktree Session Management
3
- *
4
- * CRUD operations for tracking which work item is active in each worktree.
5
- * Enables multiple concurrent Claude Code instances working in different worktrees.
6
- */
7
-
8
- const { getDb } = require('./database');
9
-
10
- /**
11
- * Create or update a worktree session
12
- *
13
- * @param {string} worktreePath - Absolute path to the worktree
14
- * @param {number} workItemId - ID of the work item
15
- * @param {string} branchName - Git branch name
16
- * @returns {Promise<void>}
17
- */
18
- function createOrUpdateSession(worktreePath, workItemId, branchName) {
19
- const db = getDb();
20
-
21
- return new Promise((resolve, reject) => {
22
- db.run(
23
- `INSERT OR REPLACE INTO worktree_sessions
24
- (worktree_path, work_item_id, branch_name, last_activity)
25
- VALUES (?, ?, ?, datetime('now'))`,
26
- [worktreePath, workItemId, branchName],
27
- (err) => {
28
- if (err) {
29
- // Provide user-friendly error messages based on error type
30
- if (err.code === 'SQLITE_BUSY') {
31
- return reject(new Error('Database is locked by another process. Please try again in a moment.'));
32
- } else if (err.code === 'SQLITE_CORRUPT') {
33
- return reject(new Error('Database is corrupted. Please run: jettypod init --reset'));
34
- } else if (err.code === 'SQLITE_CANTOPEN') {
35
- return reject(new Error('Cannot open database. Check file permissions or run: jettypod init'));
36
- } else if (err.code === 'SQLITE_READONLY') {
37
- return reject(new Error('Database is read-only. Check file permissions.'));
38
- }
39
- // Generic database error
40
- console.error('Database error in createOrUpdateSession:', err);
41
- return reject(new Error(`Failed to create/update worktree session: ${err.message}`));
42
- }
43
- resolve();
44
- }
45
- );
46
- });
47
- }
48
-
49
- /**
50
- * Get session by worktree path
51
- *
52
- * @param {string} worktreePath - Absolute path to the worktree
53
- * @returns {Promise<object|null>} Session object or null if not found
54
- */
55
- function getSessionByWorktreePath(worktreePath) {
56
- const db = getDb();
57
-
58
- return new Promise((resolve, reject) => {
59
- db.get(
60
- `SELECT * FROM worktree_sessions WHERE worktree_path = ?`,
61
- [worktreePath],
62
- (err, row) => {
63
- if (err) {
64
- // Provide user-friendly error messages
65
- if (err.code === 'SQLITE_BUSY') {
66
- return reject(new Error('Database is locked. Please try again in a moment.'));
67
- } else if (err.code === 'SQLITE_CORRUPT') {
68
- return reject(new Error('Database is corrupted. Please run: jettypod init --reset'));
69
- } else if (err.code === 'SQLITE_CANTOPEN') {
70
- return reject(new Error('Cannot open database. Run: jettypod init'));
71
- }
72
- console.error('Database error in getSessionByWorktreePath:', err);
73
- return reject(new Error(`Failed to get worktree session: ${err.message}`));
74
- }
75
- resolve(row || null);
76
- }
77
- );
78
- });
79
- }
80
-
81
- /**
82
- * Get all active worktree sessions
83
- *
84
- * @returns {Promise<Array>} Array of session objects
85
- */
86
- function getAllActiveSessions() {
87
- const db = getDb();
88
-
89
- return new Promise((resolve, reject) => {
90
- db.all(
91
- `SELECT * FROM worktree_sessions ORDER BY last_activity DESC`,
92
- [],
93
- (err, rows) => {
94
- if (err) {
95
- // Provide user-friendly error messages
96
- if (err.code === 'SQLITE_BUSY') {
97
- return reject(new Error('Database is locked. Please try again in a moment.'));
98
- } else if (err.code === 'SQLITE_CORRUPT') {
99
- return reject(new Error('Database is corrupted. Please run: jettypod init --reset'));
100
- } else if (err.code === 'SQLITE_CANTOPEN') {
101
- return reject(new Error('Cannot open database. Run: jettypod init'));
102
- }
103
- console.error('Database error in getAllActiveSessions:', err);
104
- return reject(new Error(`Failed to get active sessions: ${err.message}`));
105
- }
106
- resolve(rows || []);
107
- }
108
- );
109
- });
110
- }
111
-
112
- /**
113
- * Delete session by worktree path
114
- *
115
- * @param {string} worktreePath - Absolute path to the worktree
116
- * @returns {Promise<void>}
117
- */
118
- function deleteSession(worktreePath) {
119
- const db = getDb();
120
-
121
- return new Promise((resolve, reject) => {
122
- db.run(
123
- `DELETE FROM worktree_sessions WHERE worktree_path = ?`,
124
- [worktreePath],
125
- (err) => {
126
- if (err) {
127
- // Provide user-friendly error messages
128
- if (err.code === 'SQLITE_BUSY') {
129
- return reject(new Error('Database is locked. Please try again in a moment.'));
130
- } else if (err.code === 'SQLITE_CORRUPT') {
131
- return reject(new Error('Database is corrupted. Please run: jettypod init --reset'));
132
- } else if (err.code === 'SQLITE_CANTOPEN') {
133
- return reject(new Error('Cannot open database. Run: jettypod init'));
134
- } else if (err.code === 'SQLITE_READONLY') {
135
- return reject(new Error('Database is read-only. Check file permissions.'));
136
- }
137
- console.error('Database error in deleteSession:', err);
138
- return reject(new Error(`Failed to delete worktree session: ${err.message}`));
139
- }
140
- resolve();
141
- }
142
- );
143
- });
144
- }
145
-
146
- /**
147
- * Delete session by work item ID
148
- *
149
- * @param {number} workItemId - ID of the work item
150
- * @returns {Promise<void>}
151
- */
152
- function deleteSessionByWorkItem(workItemId) {
153
- const db = getDb();
154
-
155
- return new Promise((resolve, reject) => {
156
- db.run(
157
- `DELETE FROM worktree_sessions WHERE work_item_id = ?`,
158
- [workItemId],
159
- (err) => {
160
- if (err) {
161
- // Provide user-friendly error messages
162
- if (err.code === 'SQLITE_BUSY') {
163
- return reject(new Error('Database is locked. Please try again in a moment.'));
164
- } else if (err.code === 'SQLITE_CORRUPT') {
165
- return reject(new Error('Database is corrupted. Please run: jettypod init --reset'));
166
- } else if (err.code === 'SQLITE_CANTOPEN') {
167
- return reject(new Error('Cannot open database. Run: jettypod init'));
168
- } else if (err.code === 'SQLITE_READONLY') {
169
- return reject(new Error('Database is read-only. Check file permissions.'));
170
- }
171
- console.error('Database error in deleteSessionByWorkItem:', err);
172
- return reject(new Error(`Failed to delete session by work item: ${err.message}`));
173
- }
174
- resolve();
175
- }
176
- );
177
- });
178
- }
179
-
180
- module.exports = {
181
- createOrUpdateSession,
182
- getSessionByWorktreePath,
183
- getAllActiveSessions,
184
- deleteSession,
185
- deleteSessionByWorkItem
186
- };