s9n-devops-agent 1.6.2 → 1.7.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.
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Enhanced Session Cleanup Tool with Dual Merge Support
5
+ * Safely closes and cleans up DevOps agent sessions with hierarchical and target branch merging
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { execSync } = require('child_process');
11
+ const readline = require('readline');
12
+
13
+ // Configuration
14
+ const CONFIG = {
15
+ colors: {
16
+ reset: '\x1b[0m',
17
+ bright: '\x1b[1m',
18
+ red: '\x1b[31m',
19
+ green: '\x1b[32m',
20
+ yellow: '\x1b[33m',
21
+ blue: '\x1b[34m',
22
+ cyan: '\x1b[36m',
23
+ dim: '\x1b[2m'
24
+ }
25
+ };
26
+
27
+ class EnhancedSessionCloser {
28
+ constructor() {
29
+ this.repoRoot = this.getRepoRoot();
30
+ this.locksPath = path.join(this.repoRoot, 'local_deploy', 'session-locks');
31
+ this.worktreesPath = path.join(this.repoRoot, 'local_deploy', 'worktrees');
32
+ this.projectSettingsPath = path.join(this.repoRoot, 'local_deploy', 'project-settings.json');
33
+ this.projectSettings = this.loadProjectSettings();
34
+ }
35
+
36
+ getRepoRoot() {
37
+ try {
38
+ return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
39
+ } catch (error) {
40
+ console.error(`${CONFIG.colors.red}Error: Not in a git repository${CONFIG.colors.reset}`);
41
+ process.exit(1);
42
+ }
43
+ }
44
+
45
+ loadProjectSettings() {
46
+ try {
47
+ if (fs.existsSync(this.projectSettingsPath)) {
48
+ return JSON.parse(fs.readFileSync(this.projectSettingsPath, 'utf8'));
49
+ }
50
+ } catch (error) {
51
+ console.warn(`${CONFIG.colors.yellow}Warning: Could not load project settings${CONFIG.colors.reset}`);
52
+ }
53
+ return {};
54
+ }
55
+
56
+ /**
57
+ * Get current daily branch name
58
+ */
59
+ getDailyBranch() {
60
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
61
+ return `daily/${today}`;
62
+ }
63
+
64
+ /**
65
+ * Check if a branch exists
66
+ */
67
+ async branchExists(branchName) {
68
+ try {
69
+ execSync(`git show-ref --verify --quiet refs/heads/${branchName}`, { stdio: 'ignore' });
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Create a branch if it doesn't exist
78
+ */
79
+ async ensureBranch(branchName, baseBranch = 'main') {
80
+ if (await this.branchExists(branchName)) {
81
+ return true;
82
+ }
83
+
84
+ try {
85
+ console.log(`${CONFIG.colors.blue}Creating branch: ${branchName} from ${baseBranch}${CONFIG.colors.reset}`);
86
+ execSync(`git checkout -b ${branchName} ${baseBranch}`, { stdio: 'ignore' });
87
+ execSync(`git push -u origin ${branchName}`, { stdio: 'ignore' });
88
+ return true;
89
+ } catch (error) {
90
+ console.error(`${CONFIG.colors.red}Failed to create branch ${branchName}: ${error.message}${CONFIG.colors.reset}`);
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Merge one branch into another
97
+ */
98
+ async mergeBranch(sourceBranch, targetBranch) {
99
+ try {
100
+ console.log(`${CONFIG.colors.blue}Merging ${sourceBranch} → ${targetBranch}${CONFIG.colors.reset}`);
101
+
102
+ // Ensure target branch exists
103
+ await this.ensureBranch(targetBranch);
104
+
105
+ // Checkout target branch
106
+ execSync(`git checkout ${targetBranch}`, { stdio: 'ignore' });
107
+
108
+ // Attempt merge
109
+ execSync(`git merge --no-ff ${sourceBranch} -m "Merge session branch ${sourceBranch}"`, { stdio: 'ignore' });
110
+
111
+ // Push the merge
112
+ execSync(`git push origin ${targetBranch}`, { stdio: 'ignore' });
113
+
114
+ console.log(`${CONFIG.colors.green}✓ Successfully merged ${sourceBranch} → ${targetBranch}${CONFIG.colors.reset}`);
115
+ return true;
116
+ } catch (error) {
117
+ console.error(`${CONFIG.colors.red}✗ Failed to merge ${sourceBranch} → ${targetBranch}${CONFIG.colors.reset}`);
118
+ console.error(`${CONFIG.colors.dim}Error: ${error.message}${CONFIG.colors.reset}`);
119
+
120
+ // Reset any partial merge state
121
+ try {
122
+ execSync('git merge --abort', { stdio: 'ignore' });
123
+ } catch {}
124
+
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Perform dual merge: session → daily AND session → target
131
+ */
132
+ async performDualMerge(sessionData) {
133
+ const sessionBranch = sessionData.branchName;
134
+ const dailyBranch = this.getDailyBranch();
135
+ const targetBranch = this.projectSettings?.branchManagement?.defaultMergeTarget || 'main';
136
+ const mergeStrategy = this.projectSettings?.branchManagement?.mergeStrategy || 'hierarchical-first';
137
+
138
+ console.log(`\n${CONFIG.colors.bright}Performing dual merge with strategy: ${mergeStrategy}${CONFIG.colors.reset}`);
139
+ console.log(`Session: ${sessionBranch}`);
140
+ console.log(`Daily: ${dailyBranch}`);
141
+ console.log(`Target: ${targetBranch}\n`);
142
+
143
+ let dailySuccess = false;
144
+ let targetSuccess = false;
145
+
146
+ try {
147
+ if (mergeStrategy === 'hierarchical-first') {
148
+ // Merge to daily first, then to target
149
+ dailySuccess = await this.mergeBranch(sessionBranch, dailyBranch);
150
+ if (dailySuccess) {
151
+ targetSuccess = await this.mergeBranch(sessionBranch, targetBranch);
152
+ }
153
+ } else if (mergeStrategy === 'target-first') {
154
+ // Merge to target first, then to daily
155
+ targetSuccess = await this.mergeBranch(sessionBranch, targetBranch);
156
+ if (targetSuccess) {
157
+ dailySuccess = await this.mergeBranch(sessionBranch, dailyBranch);
158
+ }
159
+ } else {
160
+ // Parallel merge (both at once)
161
+ dailySuccess = await this.mergeBranch(sessionBranch, dailyBranch);
162
+ targetSuccess = await this.mergeBranch(sessionBranch, targetBranch);
163
+ }
164
+
165
+ // Report results
166
+ if (dailySuccess && targetSuccess) {
167
+ console.log(`${CONFIG.colors.green}✅ Dual merge completed successfully${CONFIG.colors.reset}`);
168
+ return true;
169
+ } else if (dailySuccess || targetSuccess) {
170
+ console.log(`${CONFIG.colors.yellow}⚠️ Partial merge completed:${CONFIG.colors.reset}`);
171
+ console.log(` Daily merge: ${dailySuccess ? '✓' : '✗'}`);
172
+ console.log(` Target merge: ${targetSuccess ? '✓' : '✗'}`);
173
+ return true; // Partial success is still success
174
+ } else {
175
+ console.log(`${CONFIG.colors.red}❌ Both merges failed${CONFIG.colors.reset}`);
176
+ return false;
177
+ }
178
+ } catch (error) {
179
+ console.error(`${CONFIG.colors.red}❌ Dual merge failed: ${error.message}${CONFIG.colors.reset}`);
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Perform single merge to configured target or daily branch
186
+ */
187
+ async performSingleMerge(sessionData) {
188
+ const sessionBranch = sessionData.branchName;
189
+ const targetBranch = this.projectSettings?.branchManagement?.defaultMergeTarget || this.getDailyBranch();
190
+
191
+ console.log(`\n${CONFIG.colors.bright}Performing single merge${CONFIG.colors.reset}`);
192
+ console.log(`Session: ${sessionBranch} → ${targetBranch}\n`);
193
+
194
+ return await this.mergeBranch(sessionBranch, targetBranch);
195
+ }
196
+
197
+ /**
198
+ * Clean up session files and worktree
199
+ */
200
+ async cleanupSessionFiles(sessionData) {
201
+ const sessionId = sessionData.sessionId;
202
+ const branchName = sessionData.branchName;
203
+ const worktreePath = sessionData.worktreePath;
204
+
205
+ try {
206
+ // Remove worktree if it exists
207
+ if (worktreePath && fs.existsSync(worktreePath)) {
208
+ console.log(`${CONFIG.colors.blue}Removing worktree: ${worktreePath}${CONFIG.colors.reset}`);
209
+ execSync(`git worktree remove --force "${worktreePath}"`, { stdio: 'ignore' });
210
+ }
211
+
212
+ // Delete session branch
213
+ if (branchName && await this.branchExists(branchName)) {
214
+ console.log(`${CONFIG.colors.blue}Deleting session branch: ${branchName}${CONFIG.colors.reset}`);
215
+ execSync(`git branch -D ${branchName}`, { stdio: 'ignore' });
216
+
217
+ // Also delete remote branch if it exists
218
+ try {
219
+ execSync(`git push origin --delete ${branchName}`, { stdio: 'ignore' });
220
+ } catch {
221
+ // Remote branch might not exist, ignore
222
+ }
223
+ }
224
+
225
+ // Remove session lock file
226
+ const lockFile = path.join(this.locksPath, `${sessionId}.lock`);
227
+ if (fs.existsSync(lockFile)) {
228
+ console.log(`${CONFIG.colors.blue}Removing session lock file${CONFIG.colors.reset}`);
229
+ fs.unlinkSync(lockFile);
230
+ }
231
+
232
+ // Remove any commit message files
233
+ const commitMsgFile = path.join(this.repoRoot, `.devops-commit-${sessionId}.msg`);
234
+ if (fs.existsSync(commitMsgFile)) {
235
+ fs.unlinkSync(commitMsgFile);
236
+ }
237
+
238
+ console.log(`${CONFIG.colors.green}✓ Session cleanup completed${CONFIG.colors.reset}`);
239
+ } catch (error) {
240
+ console.error(`${CONFIG.colors.red}✗ Session cleanup failed: ${error.message}${CONFIG.colors.reset}`);
241
+ throw error;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * List all active sessions
247
+ */
248
+ listSessions() {
249
+ if (!fs.existsSync(this.locksPath)) {
250
+ console.log(`${CONFIG.colors.yellow}No active sessions found${CONFIG.colors.reset}`);
251
+ return [];
252
+ }
253
+
254
+ const sessions = [];
255
+ const lockFiles = fs.readdirSync(this.locksPath).filter(f => f.endsWith('.lock'));
256
+
257
+ lockFiles.forEach(file => {
258
+ const lockPath = path.join(this.locksPath, file);
259
+ const sessionData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
260
+ sessions.push(sessionData);
261
+ });
262
+
263
+ return sessions;
264
+ }
265
+
266
+ /**
267
+ * Display sessions for selection
268
+ */
269
+ async selectSession() {
270
+ const sessions = this.listSessions();
271
+
272
+ if (sessions.length === 0) {
273
+ console.log(`${CONFIG.colors.yellow}No active sessions to close${CONFIG.colors.reset}`);
274
+ return null;
275
+ }
276
+
277
+ console.log(`\n${CONFIG.colors.bright}Active Sessions:${CONFIG.colors.reset}\n`);
278
+
279
+ sessions.forEach((session, index) => {
280
+ const status = session.status === 'active' ?
281
+ `${CONFIG.colors.green}●${CONFIG.colors.reset}` :
282
+ `${CONFIG.colors.yellow}○${CONFIG.colors.reset}`;
283
+
284
+ console.log(`${status} ${CONFIG.colors.bright}${index + 1})${CONFIG.colors.reset} ${session.sessionId}`);
285
+ console.log(` Task: ${session.task}`);
286
+ console.log(` Branch: ${session.branchName}`);
287
+ console.log(` Created: ${session.created}`);
288
+ console.log();
289
+ });
290
+
291
+ const rl = readline.createInterface({
292
+ input: process.stdin,
293
+ output: process.stdout
294
+ });
295
+
296
+ return new Promise((resolve) => {
297
+ rl.question(`Select session to close (1-${sessions.length}) or 'q' to quit: `, (answer) => {
298
+ rl.close();
299
+
300
+ if (answer.toLowerCase() === 'q') {
301
+ resolve(null);
302
+ } else {
303
+ const index = parseInt(answer) - 1;
304
+ if (index >= 0 && index < sessions.length) {
305
+ resolve(sessions[index]);
306
+ } else {
307
+ console.log(`${CONFIG.colors.red}Invalid selection${CONFIG.colors.reset}`);
308
+ resolve(null);
309
+ }
310
+ }
311
+ });
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Prompt for close action
317
+ */
318
+ async promptForCloseAction() {
319
+ const rl = readline.createInterface({
320
+ input: process.stdin,
321
+ output: process.stdout
322
+ });
323
+
324
+ return new Promise((resolve) => {
325
+ console.log('\nWhat would you like to do with this session?');
326
+ console.log(` ${CONFIG.colors.green}(m) Merge changes and cleanup${CONFIG.colors.reset}`);
327
+ console.log(` ${CONFIG.colors.yellow}(k) Keep session active, just cleanup worktree${CONFIG.colors.reset}`);
328
+ console.log(` ${CONFIG.colors.red}(d) Delete session and all changes${CONFIG.colors.reset}`);
329
+ console.log(` ${CONFIG.colors.dim}(c) Cancel${CONFIG.colors.reset}`);
330
+
331
+ rl.question('\nChoose action (m/k/d/c): ', (answer) => {
332
+ rl.close();
333
+ const action = answer.toLowerCase();
334
+ if (['m', 'k', 'd', 'c'].includes(action)) {
335
+ resolve(action);
336
+ } else {
337
+ console.log('Invalid choice, defaulting to cancel');
338
+ resolve('c');
339
+ }
340
+ });
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Close a session with the specified action
346
+ */
347
+ async closeSession(sessionData) {
348
+ console.log(`\n${CONFIG.colors.bright}Closing session: ${sessionData.sessionId}${CONFIG.colors.reset}`);
349
+ console.log(`Task: ${sessionData.task}`);
350
+ console.log(`Branch: ${sessionData.branchName}`);
351
+
352
+ const action = await this.promptForCloseAction();
353
+
354
+ switch (action) {
355
+ case 'm':
356
+ await this.mergeAndCleanup(sessionData);
357
+ break;
358
+ case 'k':
359
+ await this.keepSessionCleanupWorktree(sessionData);
360
+ break;
361
+ case 'd':
362
+ await this.deleteSession(sessionData);
363
+ break;
364
+ case 'c':
365
+ console.log('Session close cancelled');
366
+ return;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Merge session and cleanup
372
+ */
373
+ async mergeAndCleanup(sessionData) {
374
+ console.log(`\n${CONFIG.colors.bright}Merging and cleaning up session...${CONFIG.colors.reset}`);
375
+
376
+ try {
377
+ const enableDualMerge = this.projectSettings?.branchManagement?.enableDualMerge;
378
+ const targetBranch = this.projectSettings?.branchManagement?.defaultMergeTarget;
379
+
380
+ let mergeSuccess = false;
381
+
382
+ if (enableDualMerge && targetBranch) {
383
+ console.log(`${CONFIG.colors.blue}Dual merge enabled${CONFIG.colors.reset}`);
384
+ mergeSuccess = await this.performDualMerge(sessionData);
385
+ } else {
386
+ console.log(`${CONFIG.colors.blue}Single merge mode${CONFIG.colors.reset}`);
387
+ mergeSuccess = await this.performSingleMerge(sessionData);
388
+ }
389
+
390
+ if (mergeSuccess) {
391
+ await this.cleanupSessionFiles(sessionData);
392
+ console.log(`\n${CONFIG.colors.green}✅ Session successfully merged and cleaned up${CONFIG.colors.reset}`);
393
+ } else {
394
+ console.log(`\n${CONFIG.colors.red}❌ Merge failed. Session preserved for manual resolution.${CONFIG.colors.reset}`);
395
+ console.log(`${CONFIG.colors.dim}You can manually resolve conflicts and try again later.${CONFIG.colors.reset}`);
396
+ }
397
+ } catch (error) {
398
+ console.error(`\n${CONFIG.colors.red}❌ Error during merge and cleanup: ${error.message}${CONFIG.colors.reset}`);
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Keep session active but cleanup worktree
404
+ */
405
+ async keepSessionCleanupWorktree(sessionData) {
406
+ console.log(`\n${CONFIG.colors.bright}Keeping session active, cleaning up worktree...${CONFIG.colors.reset}`);
407
+
408
+ try {
409
+ // Only remove worktree, keep branch and session
410
+ if (sessionData.worktreePath && fs.existsSync(sessionData.worktreePath)) {
411
+ console.log(`${CONFIG.colors.blue}Removing worktree: ${sessionData.worktreePath}${CONFIG.colors.reset}`);
412
+ execSync(`git worktree remove --force "${sessionData.worktreePath}"`, { stdio: 'ignore' });
413
+ }
414
+
415
+ // Update session status
416
+ const lockFile = path.join(this.locksPath, `${sessionData.sessionId}.lock`);
417
+ if (fs.existsSync(lockFile)) {
418
+ sessionData.status = 'inactive';
419
+ sessionData.worktreePath = null;
420
+ sessionData.inactiveAt = new Date().toISOString();
421
+ fs.writeFileSync(lockFile, JSON.stringify(sessionData, null, 2));
422
+ }
423
+
424
+ console.log(`\n${CONFIG.colors.green}✅ Session marked as inactive, worktree cleaned up${CONFIG.colors.reset}`);
425
+ console.log(`${CONFIG.colors.dim}Session branch ${sessionData.branchName} is preserved${CONFIG.colors.reset}`);
426
+ } catch (error) {
427
+ console.error(`\n${CONFIG.colors.red}❌ Error during worktree cleanup: ${error.message}${CONFIG.colors.reset}`);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Delete session completely
433
+ */
434
+ async deleteSession(sessionData) {
435
+ console.log(`\n${CONFIG.colors.bright}Deleting session completely...${CONFIG.colors.reset}`);
436
+ console.log(`${CONFIG.colors.red}⚠️ This will permanently delete all work in this session!${CONFIG.colors.reset}`);
437
+
438
+ const rl = readline.createInterface({
439
+ input: process.stdin,
440
+ output: process.stdout
441
+ });
442
+
443
+ const confirmed = await new Promise((resolve) => {
444
+ rl.question('Are you sure? Type "DELETE" to confirm: ', (answer) => {
445
+ rl.close();
446
+ resolve(answer === 'DELETE');
447
+ });
448
+ });
449
+
450
+ if (!confirmed) {
451
+ console.log('Deletion cancelled');
452
+ return;
453
+ }
454
+
455
+ try {
456
+ await this.cleanupSessionFiles(sessionData);
457
+ console.log(`\n${CONFIG.colors.green}✅ Session completely deleted${CONFIG.colors.reset}`);
458
+ } catch (error) {
459
+ console.error(`\n${CONFIG.colors.red}❌ Error during session deletion: ${error.message}${CONFIG.colors.reset}`);
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Main execution function
465
+ */
466
+ async run() {
467
+ console.log(`\n${CONFIG.colors.bright}${CONFIG.colors.blue}DevOps Agent - Enhanced Session Closer${CONFIG.colors.reset}`);
468
+ console.log(`${CONFIG.colors.dim}Repository: ${this.repoRoot}${CONFIG.colors.reset}\n`);
469
+
470
+ // Show current configuration
471
+ const enableDualMerge = this.projectSettings?.branchManagement?.enableDualMerge;
472
+ const targetBranch = this.projectSettings?.branchManagement?.defaultMergeTarget;
473
+
474
+ console.log(`${CONFIG.colors.bright}Current Configuration:${CONFIG.colors.reset}`);
475
+ console.log(` Dual merge: ${enableDualMerge ? CONFIG.colors.green + 'enabled' : CONFIG.colors.yellow + 'disabled'}${CONFIG.colors.reset}`);
476
+ console.log(` Target branch: ${targetBranch || CONFIG.colors.dim + 'not set'}${CONFIG.colors.reset}`);
477
+ console.log(` Daily branch: ${this.getDailyBranch()}`);
478
+
479
+ const sessionData = await this.selectSession();
480
+ if (sessionData) {
481
+ await this.closeSession(sessionData);
482
+ }
483
+ }
484
+ }
485
+
486
+ // CLI execution
487
+ if (require.main === module) {
488
+ const closer = new EnhancedSessionCloser();
489
+ closer.run().catch(console.error);
490
+ }
491
+
492
+ module.exports = EnhancedSessionCloser;