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.
@@ -1,7 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { getDb } = require('./database');
4
- const { getSessionByWorktreePath, getAllActiveSessions } = require('./worktree-sessions');
5
4
 
6
5
  /**
7
6
  * Get path to current-work.json file
@@ -12,35 +11,62 @@ function getCurrentWorkPath() {
12
11
  }
13
12
 
14
13
  /**
15
- * Get current work item from worktree session database
14
+ * Get current work item based on status='in_progress'
15
+ * Extracts work item ID from worktree branch name if in a worktree,
16
+ * otherwise returns first in_progress item
16
17
  * @returns {Object|null} Current work item or null if not set
17
18
  */
18
19
  async function getCurrentWork() {
19
20
  const worktreePath = process.cwd();
20
21
  const db = getDb();
22
+ const { execSync } = require('child_process');
23
+
24
+ // Try to extract work item ID from branch name (worktree naming: "feature/work-123-some-title" or "123-some-title")
25
+ let workItemId = null;
26
+ try {
27
+ const branchName = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
28
+ // Match either "feature/work-123-" or "123-" at start
29
+ const match = branchName.match(/(?:feature\/work-|^)(\d+)-/);
30
+ if (match) {
31
+ workItemId = parseInt(match[1]);
32
+ }
33
+ } catch (err) {
34
+ // Not in a git repo or other git error - that's ok
35
+ }
21
36
 
22
- // Query worktree session and join with work_items
37
+ // Query for in_progress work item
23
38
  let row;
24
39
  try {
25
40
  row = await new Promise((resolve, reject) => {
26
- db.get(
27
- `SELECT
28
- wi.id, wi.title, wi.type, wi.status, wi.parent_id, wi.epic_id,
29
- parent.title as parent_title,
30
- epic.title as epic_title
31
- FROM worktree_sessions ws
32
- JOIN work_items wi ON ws.work_item_id = wi.id
33
- LEFT JOIN work_items parent ON wi.parent_id = parent.id
34
- LEFT JOIN work_items epic ON wi.epic_id = epic.id
35
- WHERE ws.worktree_path = ?`,
36
- [worktreePath],
37
- (err, row) => {
38
- if (err) {
39
- return reject(err);
40
- }
41
- resolve(row);
41
+ const query = workItemId
42
+ ? // If we have an ID from branch name, get that specific item
43
+ `SELECT
44
+ wi.id, wi.title, wi.type, wi.status, wi.parent_id, wi.epic_id,
45
+ parent.title as parent_title,
46
+ epic.title as epic_title
47
+ FROM work_items wi
48
+ LEFT JOIN work_items parent ON wi.parent_id = parent.id
49
+ LEFT JOIN work_items epic ON wi.epic_id = epic.id
50
+ WHERE wi.id = ? AND wi.status = 'in_progress'`
51
+ : // Otherwise get any in_progress item (for main branch)
52
+ `SELECT
53
+ wi.id, wi.title, wi.type, wi.status, wi.parent_id, wi.epic_id,
54
+ parent.title as parent_title,
55
+ epic.title as epic_title
56
+ FROM work_items wi
57
+ LEFT JOIN work_items parent ON wi.parent_id = parent.id
58
+ LEFT JOIN work_items epic ON wi.epic_id = epic.id
59
+ WHERE wi.status = 'in_progress'
60
+ LIMIT 1`;
61
+
62
+ const params = workItemId ? [workItemId] : [];
63
+
64
+ db.get(query, params, (err, row) => {
65
+ if (err) {
66
+ return reject(err);
42
67
  }
43
- );
68
+ resolve(row);
69
+ });
44
70
  });
45
71
  } catch (dbErr) {
46
72
  // Handle database errors gracefully - log but don't crash
@@ -58,53 +84,6 @@ async function getCurrentWork() {
58
84
  return row;
59
85
  }
60
86
 
61
- // No session found - check if we should warn about main branch
62
- try {
63
- const allSessions = await getAllActiveSessions();
64
- if (allSessions.length > 0) {
65
- // Get all work items in ONE query using IN clause
66
- const ids = allSessions.map(s => s.work_item_id);
67
- const placeholders = ids.map(() => '?').join(',');
68
-
69
- let workItems;
70
- try {
71
- workItems = await new Promise((resolve, reject) => {
72
- db.all(
73
- `SELECT id, title FROM work_items WHERE id IN (${placeholders})`,
74
- ids,
75
- (err, rows) => {
76
- if (err) reject(err);
77
- else resolve(rows || []);
78
- }
79
- );
80
- });
81
- } catch (workItemErr) {
82
- // Database error getting work items - log but continue
83
- console.error('Database error getting work items:', workItemErr.message);
84
- workItems = [];
85
- }
86
-
87
- // Create map of id -> title
88
- const titleMap = {};
89
- for (const wi of workItems) {
90
- titleMap[wi.id] = wi.title;
91
- }
92
-
93
- // Display warnings
94
- console.warn('No active work in main branch');
95
- console.warn('\nActive work in other worktrees:');
96
- for (const session of allSessions) {
97
- const title = titleMap[session.work_item_id];
98
- if (title) {
99
- console.warn(` • ${title} (${session.worktree_path})`);
100
- }
101
- }
102
- }
103
- } catch (listErr) {
104
- // Ignore listing errors - gracefully fail
105
- console.error('Error listing active sessions:', listErr.message);
106
- }
107
-
108
87
  return null;
109
88
  }
110
89
 
@@ -136,41 +115,75 @@ function validateWorkItem(workItem) {
136
115
  }
137
116
 
138
117
  /**
139
- * Set current work item in worktree session database
118
+ * Set current work item by updating status to in_progress
140
119
  * @param {Object} workItem - Work item to set as current
141
120
  * @throws {Error} If workItem is invalid
142
121
  */
143
122
  async function setCurrentWork(workItem) {
144
123
  validateWorkItem(workItem);
145
124
 
146
- const { createOrUpdateSession } = require('./worktree-sessions');
147
- const { execSync } = require('child_process');
148
- const worktreePath = process.cwd();
125
+ const db = getDb();
149
126
 
150
- // Get current branch name
151
- let branchName;
127
+ // Update work item status to in_progress
152
128
  try {
153
- branchName = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
129
+ await new Promise((resolve, reject) => {
130
+ db.run(
131
+ 'UPDATE work_items SET status = ? WHERE id = ?',
132
+ ['in_progress', workItem.id],
133
+ (err) => {
134
+ if (err) {
135
+ return reject(new Error(`Failed to set work item status: ${err.message}`));
136
+ }
137
+ resolve();
138
+ }
139
+ );
140
+ });
154
141
  } catch (err) {
155
- branchName = 'unknown';
142
+ throw new Error(`Failed to set current work: ${err.message}`);
156
143
  }
157
-
158
- // Create or update worktree session in database
159
- await createOrUpdateSession(worktreePath, workItem.id, branchName);
160
144
  }
161
145
 
162
146
  /**
163
- * Clear current work item from worktree session database
164
- * @throws {Error} If session cannot be deleted
147
+ * Clear current work item by extracting ID from branch and setting status to backlog
148
+ * @throws {Error} If work item cannot be found or updated
165
149
  */
166
150
  async function clearCurrentWork() {
167
- const { deleteSession } = require('./worktree-sessions');
168
- const worktreePath = process.cwd();
151
+ const { execSync } = require('child_process');
152
+ const db = getDb();
169
153
 
154
+ // Extract work item ID from branch name
155
+ let workItemId = null;
170
156
  try {
171
- await deleteSession(worktreePath);
157
+ const branchName = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
158
+ // Match either "feature/work-123-" or "123-" at start
159
+ const match = branchName.match(/(?:feature\/work-|^)(\d+)-/);
160
+ if (match) {
161
+ workItemId = parseInt(match[1]);
162
+ }
163
+ } catch (err) {
164
+ throw new Error('Cannot determine work item from branch name');
165
+ }
166
+
167
+ if (!workItemId) {
168
+ throw new Error('Cannot clear current work: no work item ID in branch name');
169
+ }
170
+
171
+ // Update work item status back to backlog
172
+ try {
173
+ await new Promise((resolve, reject) => {
174
+ db.run(
175
+ 'UPDATE work_items SET status = ? WHERE id = ?',
176
+ ['backlog', workItemId],
177
+ (err) => {
178
+ if (err) {
179
+ return reject(new Error(`Failed to clear work item status: ${err.message}`));
180
+ }
181
+ resolve();
182
+ }
183
+ );
184
+ });
172
185
  } catch (err) {
173
- throw new Error(`Failed to clear current work session: ${err.message}`);
186
+ throw new Error(`Failed to clear current work: ${err.message}`);
174
187
  }
175
188
  }
176
189
 
@@ -8,16 +8,41 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const { execSync } = require('child_process');
11
+ const safeDelete = require('./safe-delete');
12
+
13
+ /**
14
+ * Get the global backup directory path (in home directory)
15
+ * @returns {string} Path to global backup directory
16
+ */
17
+ function getGlobalBackupDir() {
18
+ const os = require('os');
19
+ return path.join(os.homedir(), '.jettypod-backups');
20
+ }
21
+
22
+ /**
23
+ * Get project-specific identifier for global backups
24
+ * @param {string} gitRoot - Path to git repository root
25
+ * @returns {string} Project identifier
26
+ */
27
+ function getProjectId(gitRoot) {
28
+ // Use the directory name and a hash of the full path for uniqueness
29
+ const dirname = path.basename(gitRoot);
30
+ const hash = require('crypto').createHash('md5').update(gitRoot).digest('hex').slice(0, 8);
31
+ return `${dirname}-${hash}`;
32
+ }
11
33
 
12
34
  /**
13
35
  * Create a backup of the .jettypod directory
14
36
  *
15
37
  * @param {string} gitRoot - Absolute path to git repository root
16
38
  * @param {string} reason - Human-readable reason for backup (e.g., "cleanup-worktree-1234")
39
+ * @param {Object} options - Backup options
40
+ * @param {boolean} options.global - Store backup in home directory (default: false)
17
41
  * @returns {Promise<Object>} Result with success status and backup path
18
42
  */
19
- async function createBackup(gitRoot, reason = 'unknown') {
43
+ async function createBackup(gitRoot, reason = 'unknown', options = {}) {
20
44
  const jettypodPath = path.join(gitRoot, '.jettypod');
45
+ const useGlobal = options.global || false;
21
46
 
22
47
  // Verify .jettypod exists
23
48
  if (!fs.existsSync(jettypodPath)) {
@@ -28,8 +53,17 @@ async function createBackup(gitRoot, reason = 'unknown') {
28
53
  };
29
54
  }
30
55
 
56
+ // Determine backup location
57
+ let backupBaseDir;
58
+ if (useGlobal) {
59
+ const globalDir = getGlobalBackupDir();
60
+ const projectId = getProjectId(gitRoot);
61
+ backupBaseDir = path.join(globalDir, projectId);
62
+ } else {
63
+ backupBaseDir = path.join(gitRoot, '.git', 'jettypod-backups');
64
+ }
65
+
31
66
  // Create backup directory if it doesn't exist
32
- const backupBaseDir = path.join(gitRoot, '.git', 'jettypod-backups');
33
67
  if (!fs.existsSync(backupBaseDir)) {
34
68
  fs.mkdirSync(backupBaseDir, { recursive: true });
35
69
  }
@@ -89,7 +123,16 @@ async function cleanupOldBackups(backupDir, keepCount = 10) {
89
123
  // Delete old backups
90
124
  const toDelete = backups.slice(keepCount);
91
125
  for (const backup of toDelete) {
92
- fs.rmSync(backup.path, { recursive: true, force: true });
126
+ // SAFETY: Validate backup path before deletion
127
+ const resolvedPath = path.resolve(backup.path);
128
+ const isValidBackupPath = (resolvedPath.includes('.git/jettypod-backups') ||
129
+ resolvedPath.includes('.jettypod-backups')) &&
130
+ backup.name.startsWith('jettypod-');
131
+ if (!isValidBackupPath) {
132
+ console.warn(`Warning: Skipping suspicious backup path: ${backup.path}`);
133
+ continue;
134
+ }
135
+ fs.rmSync(backup.path, { recursive: true });
93
136
  }
94
137
  } catch (err) {
95
138
  // Non-fatal - just log warning
@@ -98,12 +141,77 @@ async function cleanupOldBackups(backupDir, keepCount = 10) {
98
141
  }
99
142
 
100
143
  /**
101
- * List available backups
144
+ * List available backups from both local and global locations
102
145
  *
103
146
  * @param {string} gitRoot - Absolute path to git repository root
104
- * @returns {Array} List of backup objects with name, path, timestamp
147
+ * @param {Object} options - Options
148
+ * @param {boolean} options.includeGlobal - Include global backups (default: true)
149
+ * @returns {Array} List of backup objects with name, path, timestamp, location
105
150
  */
106
- function listBackups(gitRoot) {
151
+ function listBackups(gitRoot, options = {}) {
152
+ const includeGlobal = options.includeGlobal !== false;
153
+ const allBackups = [];
154
+
155
+ // Local backups
156
+ const localDir = path.join(gitRoot, '.git', 'jettypod-backups');
157
+ if (fs.existsSync(localDir)) {
158
+ try {
159
+ const localBackups = fs.readdirSync(localDir)
160
+ .filter(name => name.startsWith('jettypod-'))
161
+ .map(name => {
162
+ const backupPath = path.join(localDir, name);
163
+ const stat = fs.statSync(backupPath);
164
+ return {
165
+ name: name,
166
+ path: backupPath,
167
+ created: stat.mtime,
168
+ size: getDirectorySize(backupPath),
169
+ location: 'local'
170
+ };
171
+ });
172
+ allBackups.push(...localBackups);
173
+ } catch (err) {
174
+ console.error(`Error reading local backups: ${err.message}`);
175
+ }
176
+ }
177
+
178
+ // Global backups
179
+ if (includeGlobal) {
180
+ const globalDir = getGlobalBackupDir();
181
+ const projectId = getProjectId(gitRoot);
182
+ const projectBackupDir = path.join(globalDir, projectId);
183
+
184
+ if (fs.existsSync(projectBackupDir)) {
185
+ try {
186
+ const globalBackups = fs.readdirSync(projectBackupDir)
187
+ .filter(name => name.startsWith('jettypod-'))
188
+ .map(name => {
189
+ const backupPath = path.join(projectBackupDir, name);
190
+ const stat = fs.statSync(backupPath);
191
+ return {
192
+ name: name,
193
+ path: backupPath,
194
+ created: stat.mtime,
195
+ size: getDirectorySize(backupPath),
196
+ location: 'global'
197
+ };
198
+ });
199
+ allBackups.push(...globalBackups);
200
+ } catch (err) {
201
+ console.error(`Error reading global backups: ${err.message}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ // Sort all backups by date, newest first
207
+ return allBackups.sort((a, b) => b.created - a.created);
208
+ }
209
+
210
+ /**
211
+ * List available backups (legacy function for backwards compatibility)
212
+ * @deprecated Use listBackups with options instead
213
+ */
214
+ function listBackupsLegacy(gitRoot) {
107
215
  const backupBaseDir = path.join(gitRoot, '.git', 'jettypod-backups');
108
216
 
109
217
  if (!fs.existsSync(backupBaseDir)) {
@@ -182,7 +290,13 @@ async function restoreBackup(gitRoot, backupName = 'latest') {
182
290
 
183
291
  // Remove current .jettypod
184
292
  if (fs.existsSync(jettypodPath)) {
185
- fs.rmSync(jettypodPath, { recursive: true, force: true });
293
+ // SAFETY: Validate jettypod path before deletion
294
+ const resolvedPath = path.resolve(jettypodPath);
295
+ const resolvedGitRoot = path.resolve(gitRoot);
296
+ if (!resolvedPath.startsWith(resolvedGitRoot) || !resolvedPath.endsWith('.jettypod')) {
297
+ throw new Error(`SAFETY: Refusing to delete ${jettypodPath} - not a valid .jettypod directory`);
298
+ }
299
+ fs.rmSync(jettypodPath, { recursive: true });
186
300
  }
187
301
 
188
302
  // Copy backup to .jettypod
@@ -234,5 +348,7 @@ function getDirectorySize(dirPath) {
234
348
  module.exports = {
235
349
  createBackup,
236
350
  listBackups,
237
- restoreBackup
351
+ restoreBackup,
352
+ getGlobalBackupDir,
353
+ getProjectId
238
354
  };
package/lib/merge-lock.js CHANGED
@@ -36,11 +36,20 @@ async function acquireMergeLock(db, workItemId, instanceId = null, options = {})
36
36
 
37
37
  const maxWait = options.maxWait || 60000; // 1 minute default
38
38
  const pollInterval = options.pollInterval || 1500; // 1.5 seconds
39
+ const staleThreshold = options.staleThreshold || 120000; // 2 minutes (reduced from 5 minutes)
39
40
  const startTime = Date.now();
40
41
 
41
42
  // Generate instance identifier
42
43
  const lockedBy = instanceId || `${os.hostname()}-${process.pid}`;
43
44
 
45
+ // Proactive cleanup of stale locks before attempting acquisition
46
+ try {
47
+ await cleanupStaleLocks(db, staleThreshold);
48
+ } catch (cleanupErr) {
49
+ // Log but don't fail - cleanup is best-effort
50
+ console.warn(`Warning: Failed to cleanup stale locks: ${cleanupErr.message}`);
51
+ }
52
+
44
53
  while (Date.now() - startTime < maxWait) {
45
54
  // Check if lock exists
46
55
  const existingLock = await checkExistingLock(db);
@@ -102,6 +111,27 @@ function insertLock(db, workItemId, lockedBy) {
102
111
  });
103
112
  }
104
113
 
114
+ /**
115
+ * Clean up stale locks that are older than the threshold
116
+ *
117
+ * @param {Object} db - SQLite database connection
118
+ * @param {number} staleThresholdMs - Age threshold in milliseconds (default: 120000 = 2 minutes)
119
+ * @returns {Promise<void>}
120
+ */
121
+ function cleanupStaleLocks(db, staleThresholdMs = 120000) {
122
+ return new Promise((resolve, reject) => {
123
+ db.run(
124
+ `DELETE FROM merge_locks
125
+ WHERE (julianday('now') - julianday(locked_at)) * 86400000 > ?`,
126
+ [staleThresholdMs],
127
+ (err) => {
128
+ if (err) return reject(err);
129
+ resolve();
130
+ }
131
+ );
132
+ });
133
+ }
134
+
105
135
  /**
106
136
  * Create lock handle object with release function
107
137
  *