git-watchtower 1.2.0 → 1.3.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.
package/README.md CHANGED
@@ -198,6 +198,28 @@ GIT_POLL_INTERVAL=10000 git-watchtower
198
198
  | `u` | Undo last branch switch |
199
199
  | `p` | Force pull current branch |
200
200
  | `f` | Fetch all branches + refresh sparklines |
201
+ | `b` | Branch actions modal (see below) |
202
+
203
+ ### Branch Actions (`b`)
204
+
205
+ Press `b` on any branch to open an interactive action modal. All actions are always visible — unavailable ones are grayed out with reasons (e.g., "Requires gh CLI", "Run: gh auth login").
206
+
207
+ | Key | Action | Requires |
208
+ |-----|--------|----------|
209
+ | `b` | Open branch on GitHub/GitLab/Bitbucket/Azure DevOps | - |
210
+ | `c` | Open Claude Code session in browser | Claude branch with session URL |
211
+ | `p` | Create PR (or view existing PR) | `gh` or `glab` CLI |
212
+ | `d` | View PR diff on GitHub/GitLab | Open PR |
213
+ | `a` | Approve pull request | `gh` or `glab` CLI + open PR |
214
+ | `m` | Merge pull request (squash + delete branch) | `gh` or `glab` CLI + open PR |
215
+ | `i` | Check CI status | `gh` or `glab` CLI |
216
+ | `Esc` | Close modal | - |
217
+
218
+ The modal opens instantly and loads PR info in the background. Results are cached per branch and invalidated when the branch receives new commits. The modal auto-detects:
219
+ - **Claude Code branches** (`claude/` prefix) and extracts session URLs from commit messages
220
+ - **Git hosting platform** from the remote URL (GitHub, GitLab, Bitbucket, Azure DevOps)
221
+ - **Existing PRs** and their review/CI status
222
+ - **CLI tool availability** — shows install/auth hints when `gh` or `glab` isn't set up
201
223
 
202
224
  ### Server Controls
203
225
  | Key | Mode | Action |
@@ -43,6 +43,7 @@
43
43
  * R - Restart dev server (command mode)
44
44
  * l - View server logs (command mode)
45
45
  * o - Open live server in browser
46
+ * b - Branch actions (open on GitHub, Claude session, create/approve/merge PR, CI)
46
47
  * f - Fetch all branches + refresh sparklines
47
48
  * s - Toggle sound notifications
48
49
  * c - Toggle casino mode (Vegas-style feedback)
@@ -65,119 +66,34 @@ const casinoSounds = require('../src/casino/sounds');
65
66
  // Gitignore utilities for file watcher
66
67
  const { loadGitignorePatterns, shouldIgnoreFile } = require('../src/utils/gitignore');
67
68
 
68
- // Package info for --version
69
- const PACKAGE_VERSION = '1.0.0';
69
+ // Extracted modules
70
+ const { formatTimeAgo } = require('../src/utils/time');
71
+ const { openInBrowser: openUrl } = require('../src/utils/browser');
72
+ const { playSound: playSoundEffect } = require('../src/utils/sound');
73
+ const { parseArgs: parseCliArgs, applyCliArgsToConfig: mergeCliArgs, getHelpText, PACKAGE_VERSION } = require('../src/cli/args');
74
+ const { parseRemoteUrl, buildBranchUrl, detectPlatform, buildWebUrl, extractSessionUrl } = require('../src/git/remote');
75
+ const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBaseBranch } = require('../src/git/pr');
70
76
 
71
77
  // ============================================================================
72
- // Security & Validation
78
+ // Security & Validation (imported from src/git/branch.js and src/git/commands.js)
73
79
  // ============================================================================
74
-
75
- // Valid git branch name pattern (conservative)
76
- const VALID_BRANCH_PATTERN = /^[a-zA-Z0-9_\-./]+$/;
77
-
78
- function isValidBranchName(name) {
79
- if (!name || typeof name !== 'string') return false;
80
- if (name.length > 255) return false;
81
- if (!VALID_BRANCH_PATTERN.test(name)) return false;
82
- // Reject dangerous patterns
83
- if (name.includes('..')) return false;
84
- if (name.startsWith('-')) return false;
85
- if (name.startsWith('/') || name.endsWith('/')) return false;
86
- return true;
87
- }
88
-
89
- function sanitizeBranchName(name) {
90
- if (!isValidBranchName(name)) {
91
- throw new Error(`Invalid branch name: ${name}`);
92
- }
93
- return name;
94
- }
95
-
96
- async function checkGitAvailable() {
97
- return new Promise((resolve) => {
98
- exec('git --version', (error) => {
99
- resolve(!error);
100
- });
101
- });
102
- }
80
+ const { isValidBranchName, sanitizeBranchName } = require('../src/git/branch');
81
+ const { isGitAvailable: checkGitAvailable } = require('../src/git/commands');
103
82
 
104
83
  // ============================================================================
105
- // Configuration File Support
84
+ // Configuration (imports from src/config/, inline wizard kept here)
106
85
  // ============================================================================
86
+ const { getDefaultConfig, migrateConfig } = require('../src/config/schema');
87
+ const { getConfigPath, loadConfig: loadConfigFile, saveConfig: saveConfigFile, CONFIG_FILE_NAME } = require('../src/config/loader');
107
88
 
108
- const CONFIG_FILE_NAME = '.watchtowerrc.json';
109
89
  const PROJECT_ROOT = process.cwd();
110
90
 
111
- function getConfigPath() {
112
- return path.join(PROJECT_ROOT, CONFIG_FILE_NAME);
113
- }
114
-
115
91
  function loadConfig() {
116
- const configPath = getConfigPath();
117
- if (fs.existsSync(configPath)) {
118
- try {
119
- const content = fs.readFileSync(configPath, 'utf8');
120
- return JSON.parse(content);
121
- } catch (e) {
122
- console.error(`Warning: Could not parse ${CONFIG_FILE_NAME}: ${e.message}`);
123
- return null;
124
- }
125
- }
126
- return null;
92
+ return loadConfigFile(PROJECT_ROOT);
127
93
  }
128
94
 
129
95
  function saveConfig(config) {
130
- const configPath = getConfigPath();
131
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
132
- }
133
-
134
- function getDefaultConfig() {
135
- return {
136
- // Server settings
137
- server: {
138
- mode: 'static', // 'static' | 'command' | 'none'
139
- staticDir: 'public', // Directory for static mode
140
- command: '', // Command for command mode (e.g., 'npm run dev')
141
- port: 3000, // Port for static mode / display for command mode
142
- restartOnSwitch: true, // Restart server on branch switch (command mode)
143
- },
144
- // Git settings
145
- remoteName: 'origin', // Git remote name
146
- autoPull: true, // Auto-pull when current branch has updates
147
- gitPollInterval: 5000, // Polling interval in ms
148
- // UI settings
149
- soundEnabled: true,
150
- visibleBranches: 7,
151
- };
152
- }
153
-
154
- // Migrate old config format to new format
155
- function migrateConfig(config) {
156
- if (config.server) return config; // Already new format
157
-
158
- // Convert old format to new
159
- const newConfig = getDefaultConfig();
160
-
161
- if (config.noServer) {
162
- newConfig.server.mode = 'none';
163
- }
164
- if (config.port) {
165
- newConfig.server.port = config.port;
166
- }
167
- if (config.staticDir) {
168
- newConfig.server.staticDir = config.staticDir;
169
- }
170
- if (config.gitPollInterval) {
171
- newConfig.gitPollInterval = config.gitPollInterval;
172
- }
173
- if (typeof config.soundEnabled === 'boolean') {
174
- newConfig.soundEnabled = config.soundEnabled;
175
- }
176
- if (config.visibleBranches) {
177
- newConfig.visibleBranches = config.visibleBranches;
178
- }
179
-
180
- return newConfig;
96
+ saveConfigFile(config, PROJECT_ROOT);
181
97
  }
182
98
 
183
99
  async function promptUser(question, defaultValue = '') {
@@ -401,7 +317,7 @@ async function ensureConfig(cliArgs) {
401
317
  // Check if --init flag was passed (force reconfiguration)
402
318
  if (cliArgs.init) {
403
319
  const config = await runConfigurationWizard();
404
- return applyCliArgsToConfig(config, cliArgs);
320
+ return mergeCliArgs(config, cliArgs);
405
321
  }
406
322
 
407
323
  // Load existing config
@@ -423,191 +339,16 @@ async function ensureConfig(cliArgs) {
423
339
  }
424
340
 
425
341
  // Merge CLI args over config (CLI takes precedence)
426
- return applyCliArgsToConfig(config, cliArgs);
427
- }
428
-
429
- function applyCliArgsToConfig(config, cliArgs) {
430
- // Server settings
431
- if (cliArgs.mode !== null) {
432
- config.server.mode = cliArgs.mode;
433
- }
434
- if (cliArgs.noServer) {
435
- config.server.mode = 'none';
436
- }
437
- if (cliArgs.port !== null) {
438
- config.server.port = cliArgs.port;
439
- }
440
- if (cliArgs.staticDir !== null) {
441
- config.server.staticDir = cliArgs.staticDir;
442
- }
443
- if (cliArgs.command !== null) {
444
- config.server.command = cliArgs.command;
445
- }
446
- if (cliArgs.restartOnSwitch !== null) {
447
- config.server.restartOnSwitch = cliArgs.restartOnSwitch;
448
- }
449
-
450
- // Git settings
451
- if (cliArgs.remote !== null) {
452
- config.remoteName = cliArgs.remote;
453
- }
454
- if (cliArgs.autoPull !== null) {
455
- config.autoPull = cliArgs.autoPull;
456
- }
457
- if (cliArgs.pollInterval !== null) {
458
- config.gitPollInterval = cliArgs.pollInterval;
459
- }
460
-
461
- // UI settings
462
- if (cliArgs.sound !== null) {
463
- config.soundEnabled = cliArgs.sound;
464
- }
465
- if (cliArgs.visibleBranches !== null) {
466
- config.visibleBranches = cliArgs.visibleBranches;
467
- }
468
-
469
- return config;
342
+ return mergeCliArgs(config, cliArgs);
470
343
  }
471
344
 
472
- // Parse CLI arguments
473
- function parseArgs() {
474
- const args = process.argv.slice(2);
475
- const result = {
476
- // Server settings
477
- mode: null,
478
- noServer: false,
479
- port: null,
480
- staticDir: null,
481
- command: null,
482
- restartOnSwitch: null,
483
- // Git settings
484
- remote: null,
485
- autoPull: null,
486
- pollInterval: null,
487
- // UI settings
488
- sound: null,
489
- visibleBranches: null,
490
- // Actions
491
- init: false,
492
- };
345
+ // mergeCliArgs imported from src/cli/args.js as mergeCliArgs
493
346
 
494
- for (let i = 0; i < args.length; i++) {
495
- // Server settings
496
- if (args[i] === '--mode' || args[i] === '-m') {
497
- const mode = args[i + 1];
498
- if (['static', 'command', 'none'].includes(mode)) {
499
- result.mode = mode;
500
- }
501
- i++;
502
- } else if (args[i] === '--port' || args[i] === '-p') {
503
- const portValue = parseInt(args[i + 1], 10);
504
- if (!isNaN(portValue) && portValue > 0 && portValue < 65536) {
505
- result.port = portValue;
506
- }
507
- i++;
508
- } else if (args[i] === '--no-server' || args[i] === '-n') {
509
- result.noServer = true;
510
- } else if (args[i] === '--static-dir') {
511
- result.staticDir = args[i + 1];
512
- i++;
513
- } else if (args[i] === '--command' || args[i] === '-c') {
514
- result.command = args[i + 1];
515
- i++;
516
- } else if (args[i] === '--restart-on-switch') {
517
- result.restartOnSwitch = true;
518
- } else if (args[i] === '--no-restart-on-switch') {
519
- result.restartOnSwitch = false;
520
- }
521
- // Git settings
522
- else if (args[i] === '--remote' || args[i] === '-r') {
523
- result.remote = args[i + 1];
524
- i++;
525
- } else if (args[i] === '--auto-pull') {
526
- result.autoPull = true;
527
- } else if (args[i] === '--no-auto-pull') {
528
- result.autoPull = false;
529
- } else if (args[i] === '--poll-interval') {
530
- const interval = parseInt(args[i + 1], 10);
531
- if (!isNaN(interval) && interval > 0) {
532
- result.pollInterval = interval;
533
- }
534
- i++;
535
- }
536
- // UI settings
537
- else if (args[i] === '--sound') {
538
- result.sound = true;
539
- } else if (args[i] === '--no-sound') {
540
- result.sound = false;
541
- } else if (args[i] === '--visible-branches') {
542
- const count = parseInt(args[i + 1], 10);
543
- if (!isNaN(count) && count > 0) {
544
- result.visibleBranches = count;
545
- }
546
- i++;
547
- }
548
- // Actions and info
549
- else if (args[i] === '--init') {
550
- result.init = true;
551
- } else if (args[i] === '--version' || args[i] === '-v') {
552
- console.log(`git-watchtower v${PACKAGE_VERSION}`);
553
- process.exit(0);
554
- } else if (args[i] === '--help' || args[i] === '-h') {
555
- console.log(`
556
- Git Watchtower v${PACKAGE_VERSION} - Branch Monitor & Dev Server
557
-
558
- Usage:
559
- git-watchtower [options]
560
-
561
- Server Options:
562
- -m, --mode <mode> Server mode: static, command, or none
563
- -p, --port <port> Server port (default: 3000)
564
- -n, --no-server Shorthand for --mode none
565
- --static-dir <dir> Directory for static file serving (default: public)
566
- -c, --command <cmd> Command to run in command mode (e.g., "npm run dev")
567
- --restart-on-switch Restart server on branch switch (default)
568
- --no-restart-on-switch Don't restart server on branch switch
569
-
570
- Git Options:
571
- -r, --remote <name> Git remote name (default: origin)
572
- --auto-pull Auto-pull on branch switch (default)
573
- --no-auto-pull Don't auto-pull on branch switch
574
- --poll-interval <ms> Git polling interval in ms (default: 5000)
575
-
576
- UI Options:
577
- --sound Enable sound notifications (default)
578
- --no-sound Disable sound notifications
579
- --visible-branches <n> Number of branches to display (default: 7)
580
-
581
- General:
582
- --init Run the configuration wizard
583
- -v, --version Show version number
584
- -h, --help Show this help message
585
-
586
- Server Modes:
587
- static Serve static files with live reload (default)
588
- command Run your own dev server (Next.js, Vite, Nuxt, etc.)
589
- none Branch monitoring only
590
-
591
- Configuration:
592
- On first run, Git Watchtower will prompt you to configure settings.
593
- Settings are saved to .watchtowerrc.json in your project directory.
594
- CLI options override config file settings for the current session.
595
-
596
- Examples:
597
- git-watchtower # Start with config or defaults
598
- git-watchtower --init # Re-run configuration wizard
599
- git-watchtower --no-server # Branch monitoring only
600
- git-watchtower -p 8080 # Override port
601
- git-watchtower -m command -c "npm run dev" # Use custom dev server
602
- git-watchtower --no-sound --poll-interval 10000
603
- `);
604
- process.exit(0);
605
- }
606
- }
607
- return result;
608
- }
609
-
610
- const cliArgs = parseArgs();
347
+ // CLI argument parsing delegated to src/cli/args.js
348
+ const cliArgs = parseCliArgs(process.argv.slice(2), {
349
+ onVersion: (v) => { console.log(`git-watchtower v${v}`); process.exit(0); },
350
+ onHelp: (v) => { console.log(getHelpText(v)); process.exit(0); },
351
+ });
611
352
 
612
353
  // Configuration - these will be set after config is loaded
613
354
  let SERVER_MODE = 'static'; // 'static' | 'command' | 'none'
@@ -674,25 +415,190 @@ function clearServerLog() {
674
415
  serverLogBuffer = [];
675
416
  }
676
417
 
677
- // Open URL in default browser (cross-platform)
418
+ // openInBrowser imported from src/utils/browser.js
678
419
  function openInBrowser(url) {
679
- const platform = process.platform;
680
- let command;
420
+ openUrl(url, (error) => {
421
+ addLog(`Failed to open browser: ${error.message}`, 'error');
422
+ });
423
+ }
681
424
 
682
- if (platform === 'darwin') {
683
- command = `open "${url}"`;
684
- } else if (platform === 'win32') {
685
- command = `start "" "${url}"`;
686
- } else {
687
- // Linux and other Unix-like systems
688
- command = `xdg-open "${url}"`;
425
+ // parseRemoteUrl, buildBranchUrl, detectPlatform, buildWebUrl, extractSessionUrl
426
+ // imported from src/git/remote.js
427
+
428
+ async function getRemoteWebUrl(branchName) {
429
+ try {
430
+ const { stdout } = await execAsync(`git remote get-url "${REMOTE_NAME}"`);
431
+ const parsed = parseRemoteUrl(stdout);
432
+ return buildWebUrl(parsed, branchName);
433
+ } catch (e) {
434
+ return null;
689
435
  }
436
+ }
690
437
 
691
- exec(command, (error) => {
692
- if (error) {
693
- addLog(`Failed to open browser: ${error.message}`, 'error');
438
+ // Extract Claude Code session URL from the most recent commit on a branch
439
+ async function getSessionUrl(branchName) {
440
+ try {
441
+ const { stdout } = await execAsync(
442
+ `git log "${REMOTE_NAME}/${branchName}" -1 --format=%B 2>/dev/null || git log "${branchName}" -1 --format=%B 2>/dev/null`
443
+ );
444
+ return extractSessionUrl(stdout);
445
+ } catch (e) {
446
+ return null;
447
+ }
448
+ }
449
+
450
+ // Check if a CLI tool is available
451
+ async function hasCommand(cmd) {
452
+ try {
453
+ await execAsync(`which ${cmd} 2>/dev/null`);
454
+ return true;
455
+ } catch (e) {
456
+ return false;
457
+ }
458
+ }
459
+
460
+ // detectPlatform imported from src/git/remote.js
461
+
462
+ // Get PR info for a branch using gh or glab CLI (parsing delegated to src/git/pr.js)
463
+ async function getPrInfo(branchName, platform, hasGh, hasGlab) {
464
+ if (platform === 'github' && hasGh) {
465
+ try {
466
+ const { stdout } = await execAsync(
467
+ `gh pr list --head "${branchName}" --state all --json number,title,state,reviewDecision,statusCheckRollup --limit 1`
468
+ );
469
+ return parseGitHubPr(JSON.parse(stdout));
470
+ } catch (e) { /* gh not authed or other error */ }
471
+ }
472
+ if (platform === 'gitlab' && hasGlab) {
473
+ try {
474
+ const { stdout } = await execAsync(
475
+ `glab mr list --source-branch="${branchName}" --state all --output json 2>/dev/null`
476
+ );
477
+ return parseGitLabMr(JSON.parse(stdout));
478
+ } catch (e) { /* glab not authed or other error */ }
479
+ }
480
+ return null;
481
+ }
482
+
483
+ // Check if gh/glab CLI is authenticated
484
+ async function checkCliAuth(cmd) {
485
+ try {
486
+ if (cmd === 'gh') {
487
+ await execAsync('gh auth status 2>&1');
488
+ } else if (cmd === 'glab') {
489
+ await execAsync('glab auth status 2>&1');
694
490
  }
695
- });
491
+ return true;
492
+ } catch (e) {
493
+ return false;
494
+ }
495
+ }
496
+
497
+ // Bulk-fetch PR statuses for all branches (parsing delegated to src/git/pr.js)
498
+ async function fetchAllPrStatuses() {
499
+ if (!cachedEnv) return null;
500
+ const { platform, hasGh, ghAuthed, hasGlab, glabAuthed } = cachedEnv;
501
+
502
+ if (platform === 'github' && hasGh && ghAuthed) {
503
+ try {
504
+ const { stdout } = await execAsync(
505
+ 'gh pr list --state all --json headRefName,number,title,state --limit 200'
506
+ );
507
+ return parseGitHubPrList(JSON.parse(stdout));
508
+ } catch (e) { /* gh error */ }
509
+ }
510
+
511
+ if (platform === 'gitlab' && hasGlab && glabAuthed) {
512
+ try {
513
+ const { stdout } = await execAsync(
514
+ 'glab mr list --state all --output json 2>/dev/null'
515
+ );
516
+ return parseGitLabMrList(JSON.parse(stdout));
517
+ } catch (e) { /* glab error */ }
518
+ }
519
+
520
+ return null;
521
+ }
522
+
523
+ // One-time environment detection (called at startup)
524
+ async function initActionCache() {
525
+ const [hasGh, hasGlab, webUrlBase] = await Promise.all([
526
+ hasCommand('gh'),
527
+ hasCommand('glab'),
528
+ getRemoteWebUrl(null), // base URL without branch
529
+ ]);
530
+
531
+ let ghAuthed = false;
532
+ let glabAuthed = false;
533
+ if (hasGh) ghAuthed = await checkCliAuth('gh');
534
+ if (hasGlab) glabAuthed = await checkCliAuth('glab');
535
+
536
+ const platform = detectPlatform(webUrlBase);
537
+
538
+ cachedEnv = { hasGh, hasGlab, ghAuthed, glabAuthed, webUrlBase, platform };
539
+ }
540
+
541
+ // Phase 1: Instant local data for the modal (no network calls)
542
+ function gatherLocalActionData(branch) {
543
+ const isClaudeBranch = /^claude\//.test(branch.name);
544
+ const env = cachedEnv || { hasGh: false, hasGlab: false, ghAuthed: false, glabAuthed: false, webUrlBase: null, platform: 'github' };
545
+
546
+ // Build branch-specific web URL from cached base
547
+ let webUrl = null;
548
+ if (env.webUrlBase) {
549
+ try {
550
+ const host = new URL(env.webUrlBase).hostname;
551
+ webUrl = buildBranchUrl(env.webUrlBase, host, branch.name);
552
+ } catch (e) { /* invalid URL, will be resolved in async phase */ }
553
+ }
554
+
555
+ // Check PR cache (instant if we've seen this branch+commit before)
556
+ const cached = prInfoCache.get(branch.name);
557
+ const prInfo = (cached && cached.commit === branch.commit) ? cached.prInfo : null;
558
+ const prLoaded = !!(cached && cached.commit === branch.commit);
559
+
560
+ return {
561
+ branch, sessionUrl: null, prInfo, webUrl, isClaudeBranch,
562
+ ...env,
563
+ prLoaded, // false means PR info still needs to be fetched
564
+ };
565
+ }
566
+
567
+ // Phase 2: Async data that requires network/git calls
568
+ async function loadAsyncActionData(branch, currentData) {
569
+ const isClaudeBranch = currentData.isClaudeBranch;
570
+
571
+ // Ensure env cache is populated (might have been in flight during Phase 1)
572
+ if (!cachedEnv) {
573
+ await initActionCache();
574
+ }
575
+ const env = cachedEnv || {};
576
+
577
+ // Resolve webUrl if it wasn't available synchronously
578
+ let webUrl = currentData.webUrl;
579
+ if (!webUrl && env.webUrlBase) {
580
+ try {
581
+ const host = new URL(env.webUrlBase).hostname;
582
+ webUrl = buildBranchUrl(env.webUrlBase, host, branch.name);
583
+ } catch (e) { /* ignore */ }
584
+ }
585
+
586
+ // Fetch session URL (local git, fast but async)
587
+ const sessionUrl = isClaudeBranch ? await getSessionUrl(branch.name) : null;
588
+
589
+ // Fetch PR info if not cached
590
+ let prInfo = currentData.prInfo;
591
+ let prLoaded = currentData.prLoaded;
592
+ if (!prLoaded) {
593
+ const canQueryPr = (env.platform === 'github' && env.hasGh && env.ghAuthed) ||
594
+ (env.platform === 'gitlab' && env.hasGlab && env.glabAuthed);
595
+ prInfo = canQueryPr ? await getPrInfo(branch.name, env.platform, env.hasGh && env.ghAuthed, env.hasGlab && env.glabAuthed) : null;
596
+ // Cache the result, keyed by branch commit
597
+ prInfoCache.set(branch.name, { commit: branch.commit, prInfo });
598
+ prLoaded = true;
599
+ }
600
+
601
+ return { ...currentData, ...env, webUrl, sessionUrl, prInfo, prLoaded };
696
602
  }
697
603
 
698
604
  // Command mode server management
@@ -811,94 +717,11 @@ let pollIntervalId = null;
811
717
  let isDetachedHead = false;
812
718
  let hasMergeConflict = false;
813
719
 
814
- // ANSI escape codes
815
- const ESC = '\x1b';
816
- const CSI = `${ESC}[`;
817
-
818
- const ansi = {
819
- // Screen
820
- clearScreen: `${CSI}2J`,
821
- clearLine: `${CSI}2K`,
822
- moveTo: (row, col) => `${CSI}${row};${col}H`,
823
- moveToTop: `${CSI}H`,
824
- hideCursor: `${CSI}?25l`,
825
- showCursor: `${CSI}?25h`,
826
- saveScreen: `${CSI}?1049h`,
827
- restoreScreen: `${CSI}?1049l`,
828
-
829
- // Colors
830
- reset: `${CSI}0m`,
831
- bold: `${CSI}1m`,
832
- dim: `${CSI}2m`,
833
- italic: `${CSI}3m`,
834
- underline: `${CSI}4m`,
835
- inverse: `${CSI}7m`,
836
- blink: `${CSI}5m`,
837
-
838
- // Foreground colors
839
- black: `${CSI}30m`,
840
- red: `${CSI}31m`,
841
- green: `${CSI}32m`,
842
- yellow: `${CSI}33m`,
843
- blue: `${CSI}34m`,
844
- magenta: `${CSI}35m`,
845
- cyan: `${CSI}36m`,
846
- white: `${CSI}37m`,
847
- gray: `${CSI}90m`,
848
-
849
- // Bright foreground colors
850
- brightRed: `${CSI}91m`,
851
- brightGreen: `${CSI}92m`,
852
- brightYellow: `${CSI}93m`,
853
- brightBlue: `${CSI}94m`,
854
- brightMagenta: `${CSI}95m`,
855
- brightCyan: `${CSI}96m`,
856
- brightWhite: `${CSI}97m`,
857
-
858
- // Background colors
859
- bgBlack: `${CSI}40m`,
860
- bgRed: `${CSI}41m`,
861
- bgGreen: `${CSI}42m`,
862
- bgYellow: `${CSI}43m`,
863
- bgBlue: `${CSI}44m`,
864
- bgMagenta: `${CSI}45m`,
865
- bgCyan: `${CSI}46m`,
866
- bgWhite: `${CSI}47m`,
867
-
868
- // Bright background colors
869
- bgBrightRed: `${CSI}101m`,
870
- bgBrightGreen: `${CSI}102m`,
871
- bgBrightYellow: `${CSI}103m`,
872
- bgBrightBlue: `${CSI}104m`,
873
- bgBrightMagenta: `${CSI}105m`,
874
- bgBrightCyan: `${CSI}106m`,
875
- bgBrightWhite: `${CSI}107m`,
876
-
877
- // 256 colors
878
- fg256: (n) => `${CSI}38;5;${n}m`,
879
- bg256: (n) => `${CSI}48;5;${n}m`,
880
- };
720
+ // ANSI escape codes and box drawing imported from src/ui/ansi.js
721
+ const { ansi, box, truncate, sparkline: uiSparkline, visibleLength, stripAnsi } = require('../src/ui/ansi');
881
722
 
882
- // Box drawing characters
883
- const box = {
884
- topLeft: '┌',
885
- topRight: '┐',
886
- bottomLeft: '└',
887
- bottomRight: '┘',
888
- horizontal: '─',
889
- vertical: '│',
890
- teeRight: '├',
891
- teeLeft: '┤',
892
- cross: '┼',
893
-
894
- // Double line for flash
895
- dTopLeft: '╔',
896
- dTopRight: '╗',
897
- dBottomLeft: '╚',
898
- dBottomRight: '╝',
899
- dHorizontal: '═',
900
- dVertical: '║',
901
- };
723
+ // Error detection utilities imported from src/utils/errors.js
724
+ const { ErrorHandler } = require('../src/utils/errors');
902
725
 
903
726
  // State
904
727
  let branches = [];
@@ -935,6 +758,27 @@ let searchMode = false;
935
758
  let searchQuery = '';
936
759
  let filteredBranches = null;
937
760
 
761
+ // Branch action modal state
762
+ let actionMode = false;
763
+ let actionData = null; // { branch, sessionUrl, prInfo, hasGh, hasGlab, webUrl, isClaudeBranch, ... }
764
+ let actionLoading = false; // true while PR info is being fetched asynchronously
765
+
766
+ // Cached environment info (populated once at startup, doesn't change during session)
767
+ let cachedEnv = null; // { hasGh, hasGlab, ghAuthed, glabAuthed, webUrlBase, platform }
768
+
769
+ // Per-branch PR info cache: Map<branchName, { commit, prInfo }>
770
+ // Invalidated when the branch's commit hash changes
771
+ const prInfoCache = new Map();
772
+
773
+ // Bulk PR status map: Map<branchName, { state: 'OPEN'|'MERGED'|'CLOSED', number, title }>
774
+ // Updated in background every PR_STATUS_POLL_INTERVAL ms
775
+ let branchPrStatusMap = new Map();
776
+ let lastPrStatusFetch = 0;
777
+ const PR_STATUS_POLL_INTERVAL = 60 * 1000; // 60 seconds
778
+ let prStatusFetchInFlight = false;
779
+
780
+ // BASE_BRANCH_RE and isBaseBranch imported from src/git/pr.js
781
+
938
782
  // Session history for undo
939
783
  const switchHistory = [];
940
784
  const MAX_HISTORY = 20;
@@ -1020,27 +864,9 @@ async function getDiffStats(fromCommit, toCommit = 'HEAD') {
1020
864
  }
1021
865
  }
1022
866
 
1023
- function formatTimeAgo(date) {
1024
- const now = new Date();
1025
- const diffMs = now - date;
1026
- const diffSec = Math.floor(diffMs / 1000);
1027
- const diffMin = Math.floor(diffSec / 60);
1028
- const diffHr = Math.floor(diffMin / 60);
1029
- const diffDay = Math.floor(diffHr / 24);
867
+ // formatTimeAgo imported from src/utils/time.js
1030
868
 
1031
- if (diffSec < 10) return 'just now';
1032
- if (diffSec < 60) return `${diffSec}s ago`;
1033
- if (diffMin < 60) return `${diffMin}m ago`;
1034
- if (diffHr < 24) return `${diffHr}h ago`;
1035
- if (diffDay === 1) return '1 day ago';
1036
- return `${diffDay} days ago`;
1037
- }
1038
-
1039
- function truncate(str, maxLen) {
1040
- if (!str) return '';
1041
- if (str.length <= maxLen) return str;
1042
- return str.substring(0, maxLen - 3) + '...';
1043
- }
869
+ // truncate imported from src/ui/ansi.js
1044
870
 
1045
871
  function padRight(str, len) {
1046
872
  if (str.length >= len) return str.substring(0, len);
@@ -1109,16 +935,10 @@ function addLog(message, type = 'info') {
1109
935
  if (activityLog.length > MAX_LOG_ENTRIES) activityLog.pop();
1110
936
  }
1111
937
 
1112
- // Sparkline characters (8 levels)
1113
- const SPARKLINE_CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
1114
-
938
+ // generateSparkline uses uiSparkline from src/ui/ansi.js
1115
939
  function generateSparkline(commitCounts) {
1116
940
  if (!commitCounts || commitCounts.length === 0) return ' ';
1117
- const max = Math.max(...commitCounts, 1);
1118
- return commitCounts.map(count => {
1119
- const level = Math.floor((count / max) * 7);
1120
- return SPARKLINE_CHARS[level];
1121
- }).join('');
941
+ return uiSparkline(commitCounts);
1122
942
  }
1123
943
 
1124
944
  async function getBranchSparkline(branchName) {
@@ -1200,30 +1020,10 @@ async function getPreviewData(branchName) {
1200
1020
  }
1201
1021
  }
1202
1022
 
1023
+ // playSound delegates to extracted src/utils/sound.js
1203
1024
  function playSound() {
1204
1025
  if (!soundEnabled) return;
1205
-
1206
- // Try to play a friendly system sound (non-blocking)
1207
- const { platform } = process;
1208
-
1209
- if (platform === 'darwin') {
1210
- // macOS: Use afplay with a gentle system sound
1211
- // Options: Glass, Pop, Ping, Purr, Submarine, Tink, Blow, Bottle, Frog, Funk, Hero, Morse, Sosumi
1212
- exec('afplay /System/Library/Sounds/Pop.aiff 2>/dev/null', { cwd: PROJECT_ROOT });
1213
- } else if (platform === 'linux') {
1214
- // Linux: Try paplay (PulseAudio) or aplay (ALSA) with a system sound
1215
- // First try freedesktop sound theme, then fall back to terminal bell
1216
- exec(
1217
- 'paplay /usr/share/sounds/freedesktop/stereo/message-new-instant.oga 2>/dev/null || ' +
1218
- 'paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || ' +
1219
- 'aplay /usr/share/sounds/sound-icons/prompt.wav 2>/dev/null || ' +
1220
- 'printf "\\a"',
1221
- { cwd: PROJECT_ROOT }
1222
- );
1223
- } else {
1224
- // Windows or other: Terminal bell
1225
- process.stdout.write('\x07');
1226
- }
1026
+ playSoundEffect({ cwd: PROJECT_ROOT });
1227
1027
  }
1228
1028
 
1229
1029
  // ============================================================================
@@ -1412,6 +1212,11 @@ function renderBranchList() {
1412
1212
  const isCurrent = branch.name === currentBranch;
1413
1213
  const timeAgo = formatTimeAgo(branch.date);
1414
1214
  const sparkline = sparklineCache.get(branch.name) || ' ';
1215
+ const prStatus = branchPrStatusMap.get(branch.name); // { state, number, title } or undefined
1216
+ // Never treat default/base branches as "merged" — they're merge targets, not sources
1217
+ const isBranchBase = isBaseBranch(branch.name);
1218
+ const isMerged = !isBranchBase && prStatus && prStatus.state === 'MERGED';
1219
+ const hasOpenPr = prStatus && prStatus.state === 'OPEN';
1415
1220
 
1416
1221
  // Branch name line
1417
1222
  write(ansi.moveTo(row, 2));
@@ -1433,6 +1238,10 @@ function renderBranchList() {
1433
1238
  if (branch.isDeleted) {
1434
1239
  write(ansi.gray + ansi.dim + displayName + ansi.reset);
1435
1240
  if (isSelected) write(ansi.inverse);
1241
+ } else if (isMerged && !isCurrent) {
1242
+ // Merged branches get dimmed styling (like deleted, but in magenta tint)
1243
+ write(ansi.dim + ansi.fg256(103) + displayName + ansi.reset);
1244
+ if (isSelected) write(ansi.inverse);
1436
1245
  } else if (isCurrent) {
1437
1246
  write(ansi.green + ansi.bold + displayName + ansi.reset);
1438
1247
  if (isSelected) write(ansi.inverse);
@@ -1448,15 +1257,33 @@ function renderBranchList() {
1448
1257
 
1449
1258
  // Sparkline (7 chars)
1450
1259
  if (isSelected) write(ansi.reset);
1451
- write(ansi.fg256(39) + sparkline + ansi.reset); // Nice blue color
1260
+ if (isMerged && !isCurrent) {
1261
+ write(ansi.dim + ansi.fg256(60) + sparkline + ansi.reset); // Dimmed sparkline for merged
1262
+ } else {
1263
+ write(ansi.fg256(39) + sparkline + ansi.reset); // Nice blue color
1264
+ }
1265
+ if (isSelected) write(ansi.inverse);
1266
+
1267
+ // PR status dot indicator (1 char)
1268
+ if (isSelected) write(ansi.reset);
1269
+ if (isMerged) {
1270
+ write(ansi.dim + ansi.magenta + '●' + ansi.reset);
1271
+ } else if (hasOpenPr) {
1272
+ write(ansi.brightGreen + '●' + ansi.reset);
1273
+ } else {
1274
+ write(' ');
1275
+ }
1452
1276
  if (isSelected) write(ansi.inverse);
1453
- write(' ');
1454
1277
 
1455
1278
  // Status badge
1456
1279
  if (branch.isDeleted) {
1457
1280
  if (isSelected) write(ansi.reset);
1458
1281
  write(ansi.red + ansi.dim + '✗ DELETED' + ansi.reset);
1459
1282
  if (isSelected) write(ansi.inverse);
1283
+ } else if (isMerged && !isCurrent && !branch.isNew && !branch.hasUpdates) {
1284
+ if (isSelected) write(ansi.reset);
1285
+ write(ansi.dim + ansi.magenta + '✓ MERGED ' + ansi.reset);
1286
+ if (isSelected) write(ansi.inverse);
1460
1287
  } else if (isCurrent) {
1461
1288
  if (isSelected) write(ansi.reset);
1462
1289
  write(ansi.green + '★ CURRENT' + ansi.reset);
@@ -1484,10 +1311,25 @@ function renderBranchList() {
1484
1311
 
1485
1312
  // Commit info line
1486
1313
  write(ansi.moveTo(row, 2));
1487
- write(' └─ ');
1488
- write(ansi.cyan + (branch.commit || '???????') + ansi.reset);
1489
- write(' ');
1490
- write(ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 22) + ansi.reset);
1314
+ if (isMerged && !isCurrent) {
1315
+ // Dimmed commit line for merged branches, with PR number
1316
+ write(ansi.dim + ' └─ ' + ansi.reset);
1317
+ write(ansi.dim + ansi.cyan + (branch.commit || '???????') + ansi.reset);
1318
+ write(ansi.dim + ' • ' + ansi.reset);
1319
+ const prTag = ansi.dim + ansi.magenta + '#' + prStatus.number + ansi.reset + ansi.dim + ' ';
1320
+ write(prTag + ansi.gray + ansi.dim + truncate(branch.subject || 'No commit message', contentWidth - 28) + ansi.reset);
1321
+ } else {
1322
+ write(' └─ ');
1323
+ write(ansi.cyan + (branch.commit || '???????') + ansi.reset);
1324
+ write(' • ');
1325
+ if (hasOpenPr) {
1326
+ // Show PR number inline for open PRs
1327
+ const prTag = ansi.brightGreen + '#' + prStatus.number + ansi.reset + ' ';
1328
+ write(prTag + ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 28) + ansi.reset);
1329
+ } else {
1330
+ write(ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 22) + ansi.reset);
1331
+ }
1332
+ }
1491
1333
 
1492
1334
  row++;
1493
1335
  }
@@ -1581,6 +1423,7 @@ function renderFooter() {
1581
1423
  write(ansi.gray + '[Enter]' + ansi.reset + ansi.bgBlack + ' Switch ');
1582
1424
  write(ansi.gray + '[h]' + ansi.reset + ansi.bgBlack + ' History ');
1583
1425
  write(ansi.gray + '[i]' + ansi.reset + ansi.bgBlack + ' Info ');
1426
+ write(ansi.gray + '[b]' + ansi.reset + ansi.bgBlack + ' Actions ');
1584
1427
 
1585
1428
  // Mode-specific keys
1586
1429
  if (!NO_SERVER) {
@@ -2015,6 +1858,222 @@ function renderInfo() {
2015
1858
  write(ansi.gray + 'Press [i] or [Esc] to close' + ansi.reset);
2016
1859
  }
2017
1860
 
1861
+ function renderActionModal() {
1862
+ if (!actionMode || !actionData) return;
1863
+
1864
+ const { branch, sessionUrl, prInfo, hasGh, hasGlab, ghAuthed, glabAuthed, webUrl, isClaudeBranch, platform, prLoaded } = actionData;
1865
+
1866
+ const width = Math.min(64, terminalWidth - 4);
1867
+ const innerW = width - 6;
1868
+
1869
+ const platformLabel = platform === 'gitlab' ? 'GitLab' : platform === 'bitbucket' ? 'Bitbucket' : platform === 'azure' ? 'Azure DevOps' : 'GitHub';
1870
+ const prLabel = platform === 'gitlab' ? 'MR' : 'PR';
1871
+ const cliTool = platform === 'gitlab' ? 'glab' : 'gh';
1872
+ const hasCli = platform === 'gitlab' ? hasGlab : hasGh;
1873
+ const cliAuthed = platform === 'gitlab' ? glabAuthed : ghAuthed;
1874
+ const cliReady = hasCli && cliAuthed;
1875
+ const loading = actionLoading; // PR info still loading
1876
+
1877
+ // Build actions list — ALL actions always shown, grayed out with reasons when unavailable
1878
+ // { key, label, available, reason, loading }
1879
+ const actions = [];
1880
+
1881
+ // Open on web
1882
+ actions.push({
1883
+ key: 'b', label: `Open branch on ${platformLabel}`,
1884
+ available: !!webUrl, reason: !webUrl ? 'Could not parse remote URL' : null,
1885
+ });
1886
+
1887
+ // Claude session — always shown so users know it exists
1888
+ actions.push({
1889
+ key: 'c', label: 'Open Claude Code session',
1890
+ available: !!sessionUrl,
1891
+ reason: !isClaudeBranch ? 'Not a Claude branch' : !sessionUrl && !loading ? 'No session URL in commits' : null,
1892
+ loading: isClaudeBranch && !sessionUrl && loading,
1893
+ });
1894
+
1895
+ // PR: create or view depending on state
1896
+ const prIsMerged = prInfo && (prInfo.state === 'MERGED' || prInfo.state === 'merged');
1897
+ const prIsOpen = prInfo && (prInfo.state === 'OPEN' || prInfo.state === 'open');
1898
+ if (prInfo) {
1899
+ actions.push({ key: 'p', label: `View ${prLabel} #${prInfo.number}`, available: !!webUrl, reason: null });
1900
+ } else {
1901
+ actions.push({
1902
+ key: 'p', label: `Create ${prLabel}`,
1903
+ available: cliReady && prLoaded,
1904
+ reason: !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : null,
1905
+ loading: cliReady && !prLoaded,
1906
+ });
1907
+ }
1908
+
1909
+ // Diff — opens on web, just needs a PR and webUrl
1910
+ actions.push({
1911
+ key: 'd', label: `View ${prLabel} diff on ${platformLabel}`,
1912
+ available: !!prInfo && !!webUrl,
1913
+ reason: !prInfo && prLoaded ? `No ${prLabel}` : !webUrl ? 'Could not parse remote URL' : null,
1914
+ loading: !prLoaded && (cliReady || !!webUrl),
1915
+ });
1916
+
1917
+ // Approve — disabled for merged PRs
1918
+ actions.push({
1919
+ key: 'a', label: `Approve ${prLabel}`,
1920
+ available: !!prInfo && prIsOpen && cliReady,
1921
+ reason: prIsMerged ? `${prLabel} already merged` : !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded ? `No open ${prLabel}` : null,
1922
+ loading: cliReady && !prLoaded,
1923
+ });
1924
+
1925
+ // Merge — disabled for already-merged PRs
1926
+ actions.push({
1927
+ key: 'm', label: `Merge ${prLabel} (squash)`,
1928
+ available: !!prInfo && prIsOpen && cliReady,
1929
+ reason: prIsMerged ? `${prLabel} already merged` : !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded ? `No open ${prLabel}` : null,
1930
+ loading: cliReady && !prLoaded,
1931
+ });
1932
+
1933
+ // CI
1934
+ actions.push({
1935
+ key: 'i', label: 'Check CI status',
1936
+ available: cliReady && (!!prInfo || platform === 'gitlab'),
1937
+ reason: !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded && platform !== 'gitlab' ? `No open ${prLabel}` : null,
1938
+ loading: cliReady && !prLoaded && platform !== 'gitlab',
1939
+ });
1940
+
1941
+ // Calculate height
1942
+ let contentLines = 0;
1943
+ contentLines += 2; // spacing + branch name
1944
+ contentLines += 1; // separator
1945
+ contentLines += actions.length;
1946
+ contentLines += 1; // separator
1947
+
1948
+ // Status info
1949
+ const statusInfoLines = [];
1950
+ if (prInfo) {
1951
+ let prStatus = `${prLabel} #${prInfo.number}: ${truncate(prInfo.title, innerW - 20)}`;
1952
+ const badges = [];
1953
+ if (prIsMerged) badges.push('merged');
1954
+ if (prInfo.approved) badges.push('approved');
1955
+ if (prInfo.checksPass) badges.push('checks pass');
1956
+ if (prInfo.checksFail) badges.push('checks fail');
1957
+ if (badges.length) prStatus += ` [${badges.join(', ')}]`;
1958
+ statusInfoLines.push({ color: prIsMerged ? 'magenta' : 'green', text: prStatus });
1959
+ } else if (loading) {
1960
+ statusInfoLines.push({ color: 'gray', text: `Loading ${prLabel} info...` });
1961
+ } else if (cliReady) {
1962
+ statusInfoLines.push({ color: 'gray', text: `No ${prLabel} for this branch` });
1963
+ }
1964
+
1965
+ if (isClaudeBranch) {
1966
+ if (sessionUrl) {
1967
+ const shortSession = sessionUrl.replace('https://claude.ai/code/', '');
1968
+ statusInfoLines.push({ color: 'magenta', text: `Session: ${truncate(shortSession, innerW - 10)}` });
1969
+ } else if (!loading) {
1970
+ statusInfoLines.push({ color: 'gray', text: 'Claude branch (no session URL in commits)' });
1971
+ }
1972
+ }
1973
+
1974
+ contentLines += statusInfoLines.length;
1975
+
1976
+ // Setup hints
1977
+ const hints = [];
1978
+ if (!hasCli) {
1979
+ if (platform === 'gitlab') {
1980
+ hints.push(`Install glab: https://gitlab.com/gitlab-org/cli`);
1981
+ hints.push(`Then run: glab auth login`);
1982
+ } else {
1983
+ hints.push(`Install gh: https://cli.github.com`);
1984
+ hints.push(`Then run: gh auth login`);
1985
+ }
1986
+ } else if (!cliAuthed) {
1987
+ hints.push(`${cliTool} is installed but not authenticated`);
1988
+ hints.push(`Run: ${cliTool} auth login`);
1989
+ }
1990
+
1991
+ if (hints.length > 0) {
1992
+ contentLines += 1;
1993
+ contentLines += hints.length;
1994
+ }
1995
+
1996
+ contentLines += 2; // blank + close instructions
1997
+
1998
+ const height = contentLines + 3;
1999
+ const col = Math.floor((terminalWidth - width) / 2);
2000
+ const row = Math.floor((terminalHeight - height) / 2);
2001
+
2002
+ // Draw box
2003
+ const borderColor = ansi.brightCyan;
2004
+ write(ansi.moveTo(row, col));
2005
+ write(borderColor + ansi.bold);
2006
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
2007
+
2008
+ for (let i = 1; i < height - 1; i++) {
2009
+ write(ansi.moveTo(row + i, col));
2010
+ write(borderColor + box.dVertical + ansi.reset + ' '.repeat(width - 2) + borderColor + box.dVertical + ansi.reset);
2011
+ }
2012
+
2013
+ write(ansi.moveTo(row + height - 1, col));
2014
+ write(borderColor + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
2015
+ write(ansi.reset);
2016
+
2017
+ // Title
2018
+ const title = ' Branch Actions ';
2019
+ write(ansi.moveTo(row, col + 2));
2020
+ write(borderColor + ansi.bold + title + ansi.reset);
2021
+
2022
+ let r = row + 2;
2023
+
2024
+ // Branch name with type indicator
2025
+ write(ansi.moveTo(r, col + 3));
2026
+ write(ansi.white + ansi.bold + truncate(branch.name, innerW - 10) + ansi.reset);
2027
+ if (isClaudeBranch) {
2028
+ write(ansi.magenta + ' [Claude]' + ansi.reset);
2029
+ }
2030
+ r++;
2031
+
2032
+ // Separator
2033
+ r++;
2034
+
2035
+ // Actions list — all always visible
2036
+ for (const action of actions) {
2037
+ write(ansi.moveTo(r, col + 3));
2038
+ if (action.loading) {
2039
+ write(ansi.gray + '[' + action.key + '] ' + action.label + ' ' + ansi.dim + ansi.cyan + 'loading...' + ansi.reset);
2040
+ } else if (action.available) {
2041
+ write(ansi.brightCyan + '[' + action.key + ']' + ansi.reset + ' ' + action.label);
2042
+ } else {
2043
+ write(ansi.gray + '[' + action.key + '] ' + action.label);
2044
+ if (action.reason) {
2045
+ write(' ' + ansi.dim + ansi.yellow + action.reason + ansi.reset);
2046
+ }
2047
+ write(ansi.reset);
2048
+ }
2049
+ r++;
2050
+ }
2051
+
2052
+ // Separator
2053
+ r++;
2054
+
2055
+ // Status info
2056
+ for (const info of statusInfoLines) {
2057
+ write(ansi.moveTo(r, col + 3));
2058
+ write(ansi[info.color] + truncate(info.text, innerW) + ansi.reset);
2059
+ r++;
2060
+ }
2061
+
2062
+ // Setup hints
2063
+ if (hints.length > 0) {
2064
+ r++;
2065
+ for (const hint of hints) {
2066
+ write(ansi.moveTo(r, col + 3));
2067
+ write(ansi.yellow + truncate(hint, innerW) + ansi.reset);
2068
+ r++;
2069
+ }
2070
+ }
2071
+
2072
+ // Close instructions
2073
+ write(ansi.moveTo(row + height - 2, col + Math.floor((width - 18) / 2)));
2074
+ write(ansi.gray + 'Press [Esc] to close' + ansi.reset);
2075
+ }
2076
+
2018
2077
  function render() {
2019
2078
  updateTerminalSize();
2020
2079
 
@@ -2120,6 +2179,10 @@ function render() {
2120
2179
  renderLogView();
2121
2180
  }
2122
2181
 
2182
+ if (actionMode) {
2183
+ renderActionModal();
2184
+ }
2185
+
2123
2186
  // Error toast renders on top of everything for maximum visibility
2124
2187
  if (errorToast) {
2125
2188
  renderErrorToast();
@@ -2640,10 +2703,16 @@ async function pollGitChanges() {
2640
2703
  // Remember which branch was selected before updating the list
2641
2704
  const previouslySelectedName = selectedBranchName || (branches[selectedIndex] ? branches[selectedIndex].name : null);
2642
2705
 
2643
- // Sort: new branches first, then by date, deleted branches at the bottom
2706
+ // Sort: new branches first, then by date, merged branches near bottom, deleted at bottom
2644
2707
  filteredBranches.sort((a, b) => {
2708
+ const aIsBase = isBaseBranch(a.name);
2709
+ const bIsBase = isBaseBranch(b.name);
2710
+ const aMerged = !aIsBase && branchPrStatusMap.has(a.name) && branchPrStatusMap.get(a.name).state === 'MERGED';
2711
+ const bMerged = !bIsBase && branchPrStatusMap.has(b.name) && branchPrStatusMap.get(b.name).state === 'MERGED';
2645
2712
  if (a.isDeleted && !b.isDeleted) return 1;
2646
2713
  if (!a.isDeleted && b.isDeleted) return -1;
2714
+ if (aMerged && !bMerged && !b.isDeleted) return 1;
2715
+ if (!aMerged && bMerged && !a.isDeleted) return -1;
2647
2716
  if (a.isNew && !b.isNew) return -1;
2648
2717
  if (!a.isNew && b.isNew) return 1;
2649
2718
  return b.date - a.date;
@@ -2668,6 +2737,22 @@ async function pollGitChanges() {
2668
2737
  selectedBranchName = branches[selectedIndex] ? branches[selectedIndex].name : null;
2669
2738
  }
2670
2739
 
2740
+ // Background PR status fetch (throttled to every PR_STATUS_POLL_INTERVAL)
2741
+ const now2 = Date.now();
2742
+ if (!prStatusFetchInFlight && cachedEnv && (now2 - lastPrStatusFetch > PR_STATUS_POLL_INTERVAL)) {
2743
+ prStatusFetchInFlight = true;
2744
+ fetchAllPrStatuses().then(map => {
2745
+ if (map) {
2746
+ branchPrStatusMap = map;
2747
+ render(); // re-render to show updated PR indicators
2748
+ }
2749
+ lastPrStatusFetch = Date.now();
2750
+ prStatusFetchInFlight = false;
2751
+ }).catch(() => {
2752
+ prStatusFetchInFlight = false;
2753
+ });
2754
+ }
2755
+
2671
2756
  // AUTO-PULL: If current branch has remote updates, pull automatically (if enabled)
2672
2757
  const currentInfo = branches.find(b => b.name === currentBranch);
2673
2758
  if (AUTO_PULL && currentInfo && currentInfo.hasUpdates && !hasMergeConflict) {
@@ -3050,6 +3135,166 @@ function setupKeyboardInput() {
3050
3135
  return; // Ignore other keys in log view mode
3051
3136
  }
3052
3137
 
3138
+ if (actionMode) {
3139
+ if (key === '\u001b') { // Escape to close
3140
+ actionMode = false;
3141
+ actionData = null;
3142
+ actionLoading = false;
3143
+ render();
3144
+ return;
3145
+ }
3146
+ if (!actionData) return;
3147
+ const { branch: aBranch, sessionUrl, prInfo, hasGh, hasGlab, ghAuthed, glabAuthed, webUrl, platform, prLoaded } = actionData;
3148
+ const cliReady = (platform === 'gitlab') ? (hasGlab && glabAuthed) : (hasGh && ghAuthed);
3149
+ const prLabel = platform === 'gitlab' ? 'MR' : 'PR';
3150
+
3151
+ // Helper to extract the base repo URL from a branch-specific URL
3152
+ const repoUrl = webUrl ? webUrl.replace(/\/tree\/.*$/, '') : null;
3153
+
3154
+ if (key === 'b' && webUrl) { // Open branch on web host
3155
+ addLog(`Opening ${webUrl}`, 'info');
3156
+ openInBrowser(webUrl);
3157
+ render();
3158
+ return;
3159
+ }
3160
+ if (key === 'c' && sessionUrl) { // Open Claude session
3161
+ addLog(`Opening Claude session...`, 'info');
3162
+ openInBrowser(sessionUrl);
3163
+ render();
3164
+ return;
3165
+ }
3166
+ if (key === 'p') { // Create or view PR
3167
+ if (prInfo && repoUrl) {
3168
+ // View existing PR on web
3169
+ const prUrl = platform === 'gitlab'
3170
+ ? `${repoUrl}/-/merge_requests/${prInfo.number}`
3171
+ : `${repoUrl}/pull/${prInfo.number}`;
3172
+ addLog(`Opening ${prLabel} #${prInfo.number}...`, 'info');
3173
+ openInBrowser(prUrl);
3174
+ } else if (!prInfo && prLoaded && cliReady) {
3175
+ // Create PR — only if we've confirmed no PR exists (prLoaded=true)
3176
+ addLog(`Creating ${prLabel} for ${aBranch.name}...`, 'update');
3177
+ render();
3178
+ try {
3179
+ let result;
3180
+ if (platform === 'gitlab') {
3181
+ result = await execAsync(`glab mr create --source-branch="${aBranch.name}" --fill --yes 2>&1`);
3182
+ } else {
3183
+ result = await execAsync(`gh pr create --head "${aBranch.name}" --fill 2>&1`);
3184
+ }
3185
+ addLog(`${prLabel} created: ${(result.stdout || '').trim().split('\n').pop()}`, 'success');
3186
+ // Invalidate cache and refresh modal data
3187
+ prInfoCache.delete(aBranch.name);
3188
+ actionData = gatherLocalActionData(aBranch);
3189
+ actionLoading = true;
3190
+ render();
3191
+ loadAsyncActionData(aBranch, actionData).then((fullData) => {
3192
+ if (actionMode && actionData && actionData.branch.name === aBranch.name) {
3193
+ actionData = fullData;
3194
+ actionLoading = false;
3195
+ render();
3196
+ }
3197
+ }).catch(() => {});
3198
+ } catch (e) {
3199
+ const msg = (e && e.stderr) || (e && e.message) || String(e);
3200
+ addLog(`Failed to create ${prLabel}: ${msg.split('\n')[0]}`, 'error');
3201
+ }
3202
+ } else if (!prLoaded) {
3203
+ addLog(`Still loading ${prLabel} info...`, 'info');
3204
+ }
3205
+ render();
3206
+ return;
3207
+ }
3208
+ if (key === 'd' && prInfo && repoUrl) { // View diff on web
3209
+ const diffUrl = platform === 'gitlab'
3210
+ ? `${repoUrl}/-/merge_requests/${prInfo.number}/diffs`
3211
+ : `${repoUrl}/pull/${prInfo.number}/files`;
3212
+ addLog(`Opening ${prLabel} #${prInfo.number} diff...`, 'info');
3213
+ openInBrowser(diffUrl);
3214
+ render();
3215
+ return;
3216
+ }
3217
+ if (key === 'a' && prInfo && cliReady) { // Approve PR
3218
+ addLog(`Approving ${prLabel} #${prInfo.number}...`, 'update');
3219
+ render();
3220
+ try {
3221
+ if (platform === 'gitlab') {
3222
+ await execAsync(`glab mr approve ${prInfo.number} 2>&1`);
3223
+ } else {
3224
+ await execAsync(`gh pr review ${prInfo.number} --approve 2>&1`);
3225
+ }
3226
+ addLog(`${prLabel} #${prInfo.number} approved`, 'success');
3227
+ // Refresh PR info to show updated status
3228
+ prInfoCache.delete(aBranch.name);
3229
+ } catch (e) {
3230
+ const msg = (e && e.stderr) || (e && e.message) || String(e);
3231
+ addLog(`Failed to approve: ${msg.split('\n')[0]}`, 'error');
3232
+ }
3233
+ render();
3234
+ return;
3235
+ }
3236
+ if (key === 'm' && prInfo && cliReady) { // Merge PR
3237
+ addLog(`Merging ${prLabel} #${prInfo.number}...`, 'update');
3238
+ render();
3239
+ try {
3240
+ if (platform === 'gitlab') {
3241
+ await execAsync(`glab mr merge ${prInfo.number} --squash --remove-source-branch --yes 2>&1`);
3242
+ } else {
3243
+ await execAsync(`gh pr merge ${prInfo.number} --squash --delete-branch 2>&1`);
3244
+ }
3245
+ addLog(`${prLabel} #${prInfo.number} merged`, 'success');
3246
+ actionMode = false;
3247
+ actionData = null;
3248
+ actionLoading = false;
3249
+ prInfoCache.delete(aBranch.name);
3250
+ // Force-refresh bulk PR statuses so inline indicators update immediately
3251
+ lastPrStatusFetch = 0;
3252
+ await pollGitChanges();
3253
+ } catch (e) {
3254
+ const msg = (e && e.stderr) || (e && e.message) || String(e);
3255
+ addLog(`Failed to merge: ${msg.split('\n')[0]}`, 'error');
3256
+ }
3257
+ render();
3258
+ return;
3259
+ }
3260
+ if (key === 'i' && cliReady) { // CI status
3261
+ addLog(`Checking CI for ${aBranch.name}...`, 'info');
3262
+ render();
3263
+ try {
3264
+ if (platform === 'gitlab') {
3265
+ const result = await execAsync(`glab ci status --branch "${aBranch.name}" 2>&1`);
3266
+ const lines = (result.stdout || '').trim().split('\n');
3267
+ for (const line of lines.slice(0, 3)) {
3268
+ addLog(line.trim(), 'info');
3269
+ }
3270
+ } else if (prInfo) {
3271
+ const result = await execAsync(`gh pr checks ${prInfo.number} 2>&1`);
3272
+ const lines = (result.stdout || '').trim().split('\n');
3273
+ for (const line of lines.slice(0, 5)) {
3274
+ addLog(line.trim(), 'info');
3275
+ }
3276
+ } else {
3277
+ addLog(`No open ${prLabel} — CI status requires an open ${prLabel} on GitHub`, 'info');
3278
+ }
3279
+ } catch (e) {
3280
+ // gh pr checks exits non-zero when checks fail — stdout still has useful info
3281
+ const output = (e && e.stdout) || '';
3282
+ if (output.trim()) {
3283
+ const lines = output.trim().split('\n');
3284
+ for (const line of lines.slice(0, 5)) {
3285
+ addLog(line.trim(), 'info');
3286
+ }
3287
+ } else {
3288
+ const msg = (e && e.stderr) || (e && e.message) || String(e);
3289
+ addLog(`CI check failed: ${msg.split('\n')[0]}`, 'error');
3290
+ }
3291
+ }
3292
+ render();
3293
+ return;
3294
+ }
3295
+ return; // Ignore other keys in action mode
3296
+ }
3297
+
3053
3298
  // Dismiss flash on any key
3054
3299
  if (flashMessage) {
3055
3300
  hideFlash();
@@ -3175,6 +3420,34 @@ function setupKeyboardInput() {
3175
3420
  }
3176
3421
  break;
3177
3422
 
3423
+ case 'b': { // Branch action modal
3424
+ const branch = displayBranches.length > 0 && selectedIndex < displayBranches.length
3425
+ ? displayBranches[selectedIndex] : null;
3426
+ if (branch) {
3427
+ // Phase 1: Open modal instantly with local/cached data
3428
+ actionData = gatherLocalActionData(branch);
3429
+ actionMode = true;
3430
+ actionLoading = !actionData.prLoaded;
3431
+ render();
3432
+
3433
+ // Phase 2: Load async data (session URL, PR info) in background
3434
+ loadAsyncActionData(branch, actionData).then((fullData) => {
3435
+ // Only update if modal is still open for the same branch
3436
+ if (actionMode && actionData && actionData.branch.name === branch.name) {
3437
+ actionData = fullData;
3438
+ actionLoading = false;
3439
+ render();
3440
+ }
3441
+ }).catch(() => {
3442
+ if (actionMode && actionData && actionData.branch.name === branch.name) {
3443
+ actionLoading = false;
3444
+ render();
3445
+ }
3446
+ });
3447
+ }
3448
+ break;
3449
+ }
3450
+
3178
3451
  case 'f':
3179
3452
  addLog('Fetching all branches...', 'update');
3180
3453
  await pollGitChanges();
@@ -3371,8 +3644,18 @@ async function start() {
3371
3644
  selectedBranchName = branches[0].name;
3372
3645
  }
3373
3646
 
3374
- // Load sparklines in background
3647
+ // Load sparklines and action cache in background
3375
3648
  refreshAllSparklines().catch(() => {});
3649
+ initActionCache().then(() => {
3650
+ // Once env is known, kick off initial PR status fetch
3651
+ fetchAllPrStatuses().then(map => {
3652
+ if (map) {
3653
+ branchPrStatusMap = map;
3654
+ lastPrStatusFetch = Date.now();
3655
+ render();
3656
+ }
3657
+ }).catch(() => {});
3658
+ }).catch(() => {});
3376
3659
 
3377
3660
  // Start server based on mode
3378
3661
  if (SERVER_MODE === 'none') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Terminal-based Git branch monitor with activity sparklines and optional dev server with live reload",
5
5
  "main": "bin/git-watchtower.js",
6
6
  "bin": {