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 +22 -0
- package/bin/git-watchtower.js +725 -442
- package/package.json +1 -1
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 |
|
package/bin/git-watchtower.js
CHANGED
|
@@ -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
|
-
//
|
|
69
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
//
|
|
418
|
+
// openInBrowser imported from src/utils/browser.js
|
|
678
419
|
function openInBrowser(url) {
|
|
679
|
-
|
|
680
|
-
|
|
420
|
+
openUrl(url, (error) => {
|
|
421
|
+
addLog(`Failed to open browser: ${error.message}`, 'error');
|
|
422
|
+
});
|
|
423
|
+
}
|
|
681
424
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
|
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
|
-
//
|
|
883
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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,
|
|
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