agileflow 2.79.0 → 2.81.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.
@@ -17,6 +17,7 @@ const { execSync, spawnSync } = require('child_process');
17
17
  const { c } = require('../lib/colors');
18
18
  const { getProjectRoot } = require('../lib/paths');
19
19
  const { safeReadJSON } = require('../lib/errors');
20
+ const { isValidBranchName, isValidSessionNickname } = require('../lib/validate');
20
21
 
21
22
  const ROOT = getProjectRoot();
22
23
  const SESSIONS_DIR = path.join(ROOT, '.agileflow', 'sessions');
@@ -225,6 +226,23 @@ function createSession(options = {}) {
225
226
  const nickname = options.nickname || null;
226
227
  const branchName = options.branch || `session-${sessionId}`;
227
228
  const dirName = nickname || sessionId;
229
+
230
+ // SECURITY: Validate branch name to prevent command injection
231
+ if (!isValidBranchName(branchName)) {
232
+ return {
233
+ success: false,
234
+ error: `Invalid branch name: "${branchName}". Use only letters, numbers, hyphens, underscores, and forward slashes.`,
235
+ };
236
+ }
237
+
238
+ // SECURITY: Validate nickname if provided
239
+ if (nickname && !isValidSessionNickname(nickname)) {
240
+ return {
241
+ success: false,
242
+ error: `Invalid nickname: "${nickname}". Use only letters, numbers, hyphens, and underscores.`,
243
+ };
244
+ }
245
+
228
246
  const worktreePath = path.resolve(ROOT, '..', `${projectName}-${dirName}`);
229
247
 
230
248
  // Check if directory already exists
@@ -235,27 +253,42 @@ function createSession(options = {}) {
235
253
  };
236
254
  }
237
255
 
238
- // Create branch if it doesn't exist
239
- try {
240
- execSync(`git show-ref --verify --quiet refs/heads/${branchName}`, { cwd: ROOT });
241
- } catch (e) {
242
- // Branch doesn't exist, create it
243
- try {
244
- execSync(`git branch ${branchName}`, { cwd: ROOT, encoding: 'utf8' });
245
- } catch (e2) {
246
- return { success: false, error: `Failed to create branch: ${e2.message}` };
256
+ // Create branch if it doesn't exist (using spawnSync for safety)
257
+ const checkRef = spawnSync(
258
+ 'git',
259
+ ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`],
260
+ {
261
+ cwd: ROOT,
262
+ encoding: 'utf8',
247
263
  }
248
- }
264
+ );
249
265
 
250
- // Create worktree
251
- try {
252
- execSync(`git worktree add "${worktreePath}" ${branchName}`, {
266
+ if (checkRef.status !== 0) {
267
+ // Branch doesn't exist, create it
268
+ const createBranch = spawnSync('git', ['branch', branchName], {
253
269
  cwd: ROOT,
254
270
  encoding: 'utf8',
255
- stdio: 'pipe',
256
271
  });
257
- } catch (e) {
258
- return { success: false, error: `Failed to create worktree: ${e.message}` };
272
+
273
+ if (createBranch.status !== 0) {
274
+ return {
275
+ success: false,
276
+ error: `Failed to create branch: ${createBranch.stderr || 'unknown error'}`,
277
+ };
278
+ }
279
+ }
280
+
281
+ // Create worktree (using spawnSync for safety)
282
+ const createWorktree = spawnSync('git', ['worktree', 'add', worktreePath, branchName], {
283
+ cwd: ROOT,
284
+ encoding: 'utf8',
285
+ });
286
+
287
+ if (createWorktree.status !== 0) {
288
+ return {
289
+ success: false,
290
+ error: `Failed to create worktree: ${createWorktree.stderr || 'unknown error'}`,
291
+ };
259
292
  }
260
293
 
261
294
  // Register session
@@ -346,6 +379,298 @@ function deleteSession(sessionId, removeWorktree = false) {
346
379
  return { success: true };
347
380
  }
348
381
 
382
+ // Get main branch name (main or master)
383
+ function getMainBranch() {
384
+ const checkMain = spawnSync('git', ['show-ref', '--verify', '--quiet', 'refs/heads/main'], {
385
+ cwd: ROOT,
386
+ encoding: 'utf8',
387
+ });
388
+
389
+ if (checkMain.status === 0) return 'main';
390
+
391
+ const checkMaster = spawnSync('git', ['show-ref', '--verify', '--quiet', 'refs/heads/master'], {
392
+ cwd: ROOT,
393
+ encoding: 'utf8',
394
+ });
395
+
396
+ if (checkMaster.status === 0) return 'master';
397
+
398
+ return 'main'; // Default fallback
399
+ }
400
+
401
+ // Check if session branch is mergeable to main
402
+ function checkMergeability(sessionId) {
403
+ const registry = loadRegistry();
404
+ const session = registry.sessions[sessionId];
405
+
406
+ if (!session) {
407
+ return { success: false, error: `Session ${sessionId} not found` };
408
+ }
409
+
410
+ if (session.is_main) {
411
+ return { success: false, error: 'Cannot merge main session' };
412
+ }
413
+
414
+ const branchName = session.branch;
415
+ const mainBranch = getMainBranch();
416
+
417
+ // Check for uncommitted changes in the session worktree
418
+ const statusResult = spawnSync('git', ['status', '--porcelain'], {
419
+ cwd: session.path,
420
+ encoding: 'utf8',
421
+ });
422
+
423
+ if (statusResult.stdout && statusResult.stdout.trim()) {
424
+ return {
425
+ success: true,
426
+ mergeable: false,
427
+ reason: 'uncommitted_changes',
428
+ details: statusResult.stdout.trim(),
429
+ branchName,
430
+ mainBranch,
431
+ };
432
+ }
433
+
434
+ // Check if branch has commits ahead of main
435
+ const aheadBehind = spawnSync(
436
+ 'git',
437
+ ['rev-list', '--left-right', '--count', `${mainBranch}...${branchName}`],
438
+ {
439
+ cwd: ROOT,
440
+ encoding: 'utf8',
441
+ }
442
+ );
443
+
444
+ const [behind, ahead] = (aheadBehind.stdout || '0\t0').trim().split('\t').map(Number);
445
+
446
+ if (ahead === 0) {
447
+ return {
448
+ success: true,
449
+ mergeable: false,
450
+ reason: 'no_changes',
451
+ details: 'Branch has no commits ahead of main',
452
+ branchName,
453
+ mainBranch,
454
+ commitsAhead: 0,
455
+ commitsBehind: behind,
456
+ };
457
+ }
458
+
459
+ // Try merge --no-commit --no-ff to check for conflicts (dry run)
460
+ // First, stash any changes in ROOT and ensure we're on main
461
+ const currentBranch = getCurrentBranch();
462
+
463
+ // Checkout main in ROOT for the test merge
464
+ const checkoutMain = spawnSync('git', ['checkout', mainBranch], {
465
+ cwd: ROOT,
466
+ encoding: 'utf8',
467
+ });
468
+
469
+ if (checkoutMain.status !== 0) {
470
+ return {
471
+ success: false,
472
+ error: `Failed to checkout ${mainBranch}: ${checkoutMain.stderr}`,
473
+ };
474
+ }
475
+
476
+ // Try the merge
477
+ const testMerge = spawnSync('git', ['merge', '--no-commit', '--no-ff', branchName], {
478
+ cwd: ROOT,
479
+ encoding: 'utf8',
480
+ });
481
+
482
+ const hasConflicts = testMerge.status !== 0;
483
+
484
+ // Abort the test merge
485
+ spawnSync('git', ['merge', '--abort'], { cwd: ROOT, encoding: 'utf8' });
486
+
487
+ // Go back to original branch if different
488
+ if (currentBranch && currentBranch !== mainBranch) {
489
+ spawnSync('git', ['checkout', currentBranch], { cwd: ROOT, encoding: 'utf8' });
490
+ }
491
+
492
+ return {
493
+ success: true,
494
+ mergeable: !hasConflicts,
495
+ branchName,
496
+ mainBranch,
497
+ commitsAhead: ahead,
498
+ commitsBehind: behind,
499
+ hasConflicts,
500
+ conflictDetails: hasConflicts ? testMerge.stderr : null,
501
+ };
502
+ }
503
+
504
+ // Get merge preview (commits and files to be merged)
505
+ function getMergePreview(sessionId) {
506
+ const registry = loadRegistry();
507
+ const session = registry.sessions[sessionId];
508
+
509
+ if (!session) {
510
+ return { success: false, error: `Session ${sessionId} not found` };
511
+ }
512
+
513
+ if (session.is_main) {
514
+ return { success: false, error: 'Cannot preview merge for main session' };
515
+ }
516
+
517
+ const branchName = session.branch;
518
+ const mainBranch = getMainBranch();
519
+
520
+ // Get commits that would be merged
521
+ const logResult = spawnSync('git', ['log', '--oneline', `${mainBranch}..${branchName}`], {
522
+ cwd: ROOT,
523
+ encoding: 'utf8',
524
+ });
525
+
526
+ const commits = (logResult.stdout || '').trim().split('\n').filter(Boolean);
527
+
528
+ // Get files changed
529
+ const diffResult = spawnSync('git', ['diff', '--name-status', `${mainBranch}...${branchName}`], {
530
+ cwd: ROOT,
531
+ encoding: 'utf8',
532
+ });
533
+
534
+ const filesChanged = (diffResult.stdout || '').trim().split('\n').filter(Boolean);
535
+
536
+ return {
537
+ success: true,
538
+ branchName,
539
+ mainBranch,
540
+ nickname: session.nickname,
541
+ commits,
542
+ commitCount: commits.length,
543
+ filesChanged,
544
+ fileCount: filesChanged.length,
545
+ };
546
+ }
547
+
548
+ // Execute merge operation
549
+ function integrateSession(sessionId, options = {}) {
550
+ const {
551
+ strategy = 'squash',
552
+ deleteBranch = true,
553
+ deleteWorktree = true,
554
+ message = null,
555
+ } = options;
556
+
557
+ const registry = loadRegistry();
558
+ const session = registry.sessions[sessionId];
559
+
560
+ if (!session) {
561
+ return { success: false, error: `Session ${sessionId} not found` };
562
+ }
563
+
564
+ if (session.is_main) {
565
+ return { success: false, error: 'Cannot merge main session' };
566
+ }
567
+
568
+ const branchName = session.branch;
569
+ const mainBranch = getMainBranch();
570
+
571
+ // Ensure we're on main branch in ROOT
572
+ const checkoutMain = spawnSync('git', ['checkout', mainBranch], {
573
+ cwd: ROOT,
574
+ encoding: 'utf8',
575
+ });
576
+
577
+ if (checkoutMain.status !== 0) {
578
+ return { success: false, error: `Failed to checkout ${mainBranch}: ${checkoutMain.stderr}` };
579
+ }
580
+
581
+ // Pull latest main (optional, for safety) - ignore errors for local-only repos
582
+ spawnSync('git', ['pull', '--ff-only'], { cwd: ROOT, encoding: 'utf8' });
583
+
584
+ // Build commit message
585
+ const commitMessage =
586
+ message ||
587
+ `Merge session ${sessionId}${session.nickname ? ` "${session.nickname}"` : ''}: ${branchName}`;
588
+
589
+ // Execute merge based on strategy
590
+ let mergeResult;
591
+
592
+ if (strategy === 'squash') {
593
+ mergeResult = spawnSync('git', ['merge', '--squash', branchName], {
594
+ cwd: ROOT,
595
+ encoding: 'utf8',
596
+ });
597
+
598
+ if (mergeResult.status === 0) {
599
+ // Create the squash commit
600
+ const commitResult = spawnSync('git', ['commit', '-m', commitMessage], {
601
+ cwd: ROOT,
602
+ encoding: 'utf8',
603
+ });
604
+
605
+ if (commitResult.status !== 0) {
606
+ return { success: false, error: `Failed to create squash commit: ${commitResult.stderr}` };
607
+ }
608
+ }
609
+ } else {
610
+ // Regular merge commit
611
+ mergeResult = spawnSync('git', ['merge', '--no-ff', '-m', commitMessage, branchName], {
612
+ cwd: ROOT,
613
+ encoding: 'utf8',
614
+ });
615
+ }
616
+
617
+ if (mergeResult.status !== 0) {
618
+ // Abort if merge failed
619
+ spawnSync('git', ['merge', '--abort'], { cwd: ROOT, encoding: 'utf8' });
620
+ return { success: false, error: `Merge failed: ${mergeResult.stderr}`, hasConflicts: true };
621
+ }
622
+
623
+ const result = {
624
+ success: true,
625
+ merged: true,
626
+ strategy,
627
+ branchName,
628
+ mainBranch,
629
+ commitMessage,
630
+ mainPath: ROOT,
631
+ };
632
+
633
+ // Delete worktree first (before branch, as worktree holds ref)
634
+ if (deleteWorktree && session.path !== ROOT && fs.existsSync(session.path)) {
635
+ try {
636
+ execSync(`git worktree remove "${session.path}"`, { cwd: ROOT, encoding: 'utf8' });
637
+ result.worktreeDeleted = true;
638
+ } catch (e) {
639
+ try {
640
+ execSync(`git worktree remove --force "${session.path}"`, { cwd: ROOT, encoding: 'utf8' });
641
+ result.worktreeDeleted = true;
642
+ } catch (e2) {
643
+ result.worktreeDeleted = false;
644
+ result.worktreeError = e2.message;
645
+ }
646
+ }
647
+ }
648
+
649
+ // Delete branch if requested
650
+ if (deleteBranch) {
651
+ const deleteBranchResult = spawnSync('git', ['branch', '-d', branchName], {
652
+ cwd: ROOT,
653
+ encoding: 'utf8',
654
+ });
655
+ result.branchDeleted = deleteBranchResult.status === 0;
656
+ if (!result.branchDeleted) {
657
+ // Try force delete if normal delete fails
658
+ const forceDelete = spawnSync('git', ['branch', '-D', branchName], {
659
+ cwd: ROOT,
660
+ encoding: 'utf8',
661
+ });
662
+ result.branchDeleted = forceDelete.status === 0;
663
+ }
664
+ }
665
+
666
+ // Remove from registry
667
+ removeLock(sessionId);
668
+ delete registry.sessions[sessionId];
669
+ saveRegistry(registry);
670
+
671
+ return result;
672
+ }
673
+
349
674
  // Format sessions for display
350
675
  function formatSessionsTable(sessions) {
351
676
  const lines = [];
@@ -394,10 +719,24 @@ function main() {
394
719
 
395
720
  case 'create': {
396
721
  const options = {};
397
- for (let i = 1; i < args.length; i += 2) {
398
- const key = args[i].replace('--', '');
399
- const value = args[i + 1];
400
- options[key] = value;
722
+ // SECURITY: Only accept whitelisted option keys
723
+ const allowedKeys = ['nickname', 'branch'];
724
+ for (let i = 1; i < args.length; i++) {
725
+ const arg = args[i];
726
+ if (arg.startsWith('--')) {
727
+ const key = arg.slice(2).split('=')[0];
728
+ if (!allowedKeys.includes(key)) {
729
+ console.log(JSON.stringify({ success: false, error: `Unknown option: --${key}` }));
730
+ return;
731
+ }
732
+ // Handle --key=value or --key value formats
733
+ const eqIndex = arg.indexOf('=');
734
+ if (eqIndex !== -1) {
735
+ options[key] = arg.slice(eqIndex + 1);
736
+ } else if (args[i + 1] && !args[i + 1].startsWith('--')) {
737
+ options[key] = args[++i];
738
+ }
739
+ }
401
740
  }
402
741
  const result = createSession(options);
403
742
  console.log(JSON.stringify(result));
@@ -447,6 +786,65 @@ function main() {
447
786
  break;
448
787
  }
449
788
 
789
+ case 'check-merge': {
790
+ const sessionId = args[1];
791
+ if (!sessionId) {
792
+ console.log(JSON.stringify({ success: false, error: 'Session ID required' }));
793
+ return;
794
+ }
795
+ const result = checkMergeability(sessionId);
796
+ console.log(JSON.stringify(result));
797
+ break;
798
+ }
799
+
800
+ case 'merge-preview': {
801
+ const sessionId = args[1];
802
+ if (!sessionId) {
803
+ console.log(JSON.stringify({ success: false, error: 'Session ID required' }));
804
+ return;
805
+ }
806
+ const result = getMergePreview(sessionId);
807
+ console.log(JSON.stringify(result));
808
+ break;
809
+ }
810
+
811
+ case 'integrate': {
812
+ const sessionId = args[1];
813
+ if (!sessionId) {
814
+ console.log(JSON.stringify({ success: false, error: 'Session ID required' }));
815
+ return;
816
+ }
817
+ const options = {};
818
+ const allowedKeys = ['strategy', 'deleteBranch', 'deleteWorktree', 'message'];
819
+ for (let i = 2; i < args.length; i++) {
820
+ const arg = args[i];
821
+ if (arg.startsWith('--')) {
822
+ const eqIndex = arg.indexOf('=');
823
+ let key, value;
824
+ if (eqIndex !== -1) {
825
+ key = arg.slice(2, eqIndex);
826
+ value = arg.slice(eqIndex + 1);
827
+ } else {
828
+ key = arg.slice(2);
829
+ value = args[++i];
830
+ }
831
+ if (!allowedKeys.includes(key)) {
832
+ console.log(JSON.stringify({ success: false, error: `Unknown option: --${key}` }));
833
+ return;
834
+ }
835
+ // Convert boolean strings
836
+ if (key === 'deleteBranch' || key === 'deleteWorktree') {
837
+ options[key] = value !== 'false';
838
+ } else {
839
+ options[key] = value;
840
+ }
841
+ }
842
+ }
843
+ const result = integrateSession(sessionId, options);
844
+ console.log(JSON.stringify(result));
845
+ break;
846
+ }
847
+
450
848
  case 'help':
451
849
  default:
452
850
  console.log(`
@@ -460,13 +858,24 @@ ${c.cyan}Commands:${c.reset}
460
858
  count Count other active sessions
461
859
  delete <id> [--remove-worktree] Delete session
462
860
  status Get current session status
861
+ check-merge <id> Check if session is mergeable to main
862
+ merge-preview <id> Preview commits/files to be merged
863
+ integrate <id> [opts] Merge session to main and cleanup
463
864
  help Show this help
464
865
 
866
+ ${c.cyan}Integrate Options:${c.reset}
867
+ --strategy=squash|merge Merge strategy (default: squash)
868
+ --deleteBranch=true|false Delete branch after merge (default: true)
869
+ --deleteWorktree=true|false Delete worktree after merge (default: true)
870
+ --message="..." Custom commit message
871
+
465
872
  ${c.cyan}Examples:${c.reset}
466
873
  node session-manager.js register
467
874
  node session-manager.js create --nickname auth
468
875
  node session-manager.js list
469
876
  node session-manager.js delete 2 --remove-worktree
877
+ node session-manager.js check-merge 2
878
+ node session-manager.js integrate 2 --strategy=squash
470
879
  `);
471
880
  }
472
881
  }
@@ -483,6 +892,11 @@ module.exports = {
483
892
  deleteSession,
484
893
  isSessionActive,
485
894
  cleanupStaleLocks,
895
+ // Merge operations
896
+ getMainBranch,
897
+ checkMergeability,
898
+ getMergePreview,
899
+ integrateSession,
486
900
  };
487
901
 
488
902
  // Run CLI if executed directly