git-watchtower 1.0.0 → 1.2.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
@@ -1,5 +1,9 @@
1
1
  # Git Watchtower
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/git-watchtower.svg)](https://www.npmjs.com/package/git-watchtower)
4
+ [![npm downloads](https://img.shields.io/npm/dm/git-watchtower.svg)](https://www.npmjs.com/package/git-watchtower)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
3
7
  Monitor and switch between git branches in real-time. Built for working with web based AI coding agents, like Claude Code Web & Codex.
4
8
 
5
9
  - **Live branch monitoring** - Watches your remote for new commits, branches, and deletions
@@ -89,6 +93,40 @@ git-watchtower --help
89
93
  ### Static Site Mode (Default)
90
94
  Serves static files with automatic live reload. Good for static HTML/CSS/JS sites, projects without a build step, quick prototyping.
91
95
 
96
+ #### Live Reload
97
+
98
+ The static server includes automatic live reload powered by Server-Sent Events (SSE). When you save a file, all connected browsers refresh instantly.
99
+
100
+ **How it works:**
101
+ 1. A small script is automatically injected into HTML pages
102
+ 2. The script opens an SSE connection to `/livereload`
103
+ 3. When files change in your static directory, the server notifies all browsers
104
+ 4. Browsers automatically reload to show your changes
105
+
106
+ **File watching behavior:**
107
+ - Uses Node.js native `fs.watch()` with recursive watching
108
+ - Changes are debounced (100ms) to prevent rapid reloads during saves
109
+ - Press `r` to manually trigger a reload for all connected browsers
110
+
111
+ **Ignored files:**
112
+
113
+ The file watcher automatically ignores certain files to prevent unnecessary reloads:
114
+
115
+ | Ignored | Reason |
116
+ |---------|--------|
117
+ | `.git/` directory | Git internals change frequently during commits, fetches, etc. |
118
+ | `.gitignore` patterns | Respects your project's ignore rules |
119
+
120
+ If a `.gitignore` file exists in your static directory (or project root), those patterns are used to filter file change events. This means changes to `node_modules/`, build artifacts, log files, and other ignored paths won't trigger reloads.
121
+
122
+ **Supported `.gitignore` patterns:**
123
+ - Simple filenames: `foo.txt`
124
+ - Wildcards: `*.log`, `file?.txt`
125
+ - Globstar: `**/logs`, `logs/**/*.log`
126
+ - Directory patterns: `node_modules/`, `dist/`
127
+ - Anchored patterns: `/build` (root only)
128
+ - Comments and blank lines are ignored
129
+
92
130
  ### Custom Server Command Mode
93
131
  Runs your own dev server command (`next dev`, `npm run dev`, `vite`, etc.). Press `l` to view server logs, `R` to restart the server.
94
132
 
@@ -198,7 +236,7 @@ GIT_POLL_INTERVAL=10000 git-watchtower
198
236
 
199
237
  ## Requirements
200
238
 
201
- - **Node.js** 14.0.0 or higher
239
+ - **Node.js** 18.0.0 or higher
202
240
  - **Git** installed and in PATH
203
241
  - **Git remote** configured (any name, defaults to `origin`)
204
242
  - **Terminal** with ANSI color support
@@ -17,11 +17,14 @@
17
17
  * - Network failure detection with offline indicator
18
18
  * - Graceful shutdown handling
19
19
  * - Support for custom dev server commands (Next.js, Vite, etc.)
20
+ * - Casino Mode: Vegas-style feedback with slot reels, marquee lights,
21
+ * and win celebrations based on diff size (toggle with 'c' or --casino)
20
22
  *
21
23
  * Usage:
22
24
  * git-watchtower # Run with config or defaults
23
25
  * git-watchtower --port 8080 # Override port
24
26
  * git-watchtower --no-server # Branch monitoring only
27
+ * git-watchtower --casino # Enable casino mode
25
28
  * git-watchtower --init # Run configuration wizard
26
29
  * git-watchtower --version # Show version
27
30
  *
@@ -39,8 +42,10 @@
39
42
  * r - Force reload all browsers (static mode)
40
43
  * R - Restart dev server (command mode)
41
44
  * l - View server logs (command mode)
45
+ * o - Open live server in browser
42
46
  * f - Fetch all branches + refresh sparklines
43
47
  * s - Toggle sound notifications
48
+ * c - Toggle casino mode (Vegas-style feedback)
44
49
  * i - Show server info (port, connections)
45
50
  * 1-0 - Set visible branch count (1-10)
46
51
  * +/- - Increase/decrease visible branches
@@ -50,9 +55,16 @@
50
55
  const http = require('http');
51
56
  const fs = require('fs');
52
57
  const path = require('path');
53
- const { exec, spawn } = require('child_process');
58
+ const { exec, execSync, spawn } = require('child_process');
54
59
  const readline = require('readline');
55
60
 
61
+ // Casino mode - Vegas-style feedback effects
62
+ const casino = require('../src/casino');
63
+ const casinoSounds = require('../src/casino/sounds');
64
+
65
+ // Gitignore utilities for file watcher
66
+ const { loadGitignorePatterns, shouldIgnoreFile } = require('../src/utils/gitignore');
67
+
56
68
  // Package info for --version
57
69
  const PACKAGE_VERSION = '1.0.0';
58
70
 
@@ -267,9 +279,124 @@ async function runConfigurationWizard() {
267
279
  console.log('\nāœ“ Configuration saved to ' + CONFIG_FILE_NAME);
268
280
  console.log(' You can edit this file manually or delete it to reconfigure.\n');
269
281
 
282
+ // Ask user how to handle the new config file in git
283
+ await promptConfigFileHandling();
284
+
270
285
  return config;
271
286
  }
272
287
 
288
+ /**
289
+ * After creating .watchtowerrc.json, ask the user how to handle it in git.
290
+ * This prevents the new config file from dirtying the working directory
291
+ * and blocking branch switching.
292
+ */
293
+ async function promptConfigFileHandling() {
294
+ // Check if we're in a git repo
295
+ try {
296
+ execSync('git rev-parse --git-dir', { cwd: PROJECT_ROOT, stdio: 'pipe' });
297
+ } catch {
298
+ return; // Not a git repo, nothing to do
299
+ }
300
+
301
+ console.log('How should the config file be handled in git?\n');
302
+ console.log(' 1. Keep local (ignore via .git/info/exclude) [recommended]');
303
+ console.log(' 2. Track in repo (commit ' + CONFIG_FILE_NAME + ')');
304
+ console.log(' 3. Add to .gitignore (you\'ll need to commit .gitignore)');
305
+ console.log(' 4. Do nothing (handle manually)\n');
306
+
307
+ const answer = await promptUser('Choice', '1');
308
+
309
+ switch (answer) {
310
+ case '1':
311
+ handleConfigExcludeLocal();
312
+ break;
313
+ case '2':
314
+ await handleConfigCommit();
315
+ break;
316
+ case '3':
317
+ handleConfigGitignore();
318
+ break;
319
+ case '4':
320
+ default:
321
+ console.log(' Skipped. Note: the config file may block branch switching until handled.\n');
322
+ break;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Add .watchtowerrc.json to .git/info/exclude (local-only gitignore)
328
+ */
329
+ function handleConfigExcludeLocal() {
330
+ try {
331
+ const gitDir = execSync('git rev-parse --git-dir', { cwd: PROJECT_ROOT, encoding: 'utf8' }).trim();
332
+ const excludePath = path.join(PROJECT_ROOT, gitDir, 'info', 'exclude');
333
+
334
+ // Ensure the info directory exists
335
+ const infoDir = path.dirname(excludePath);
336
+ if (!fs.existsSync(infoDir)) {
337
+ fs.mkdirSync(infoDir, { recursive: true });
338
+ }
339
+
340
+ // Check if already excluded
341
+ if (fs.existsSync(excludePath)) {
342
+ const content = fs.readFileSync(excludePath, 'utf8');
343
+ if (content.includes(CONFIG_FILE_NAME)) {
344
+ console.log(' Already excluded in .git/info/exclude.\n');
345
+ return;
346
+ }
347
+ }
348
+
349
+ // Append the exclusion
350
+ const line = '\n# Git Watchtower config (local)\n' + CONFIG_FILE_NAME + '\n';
351
+ fs.appendFileSync(excludePath, line, 'utf8');
352
+ console.log(' āœ“ Added ' + CONFIG_FILE_NAME + ' to .git/info/exclude');
353
+ console.log(' Config file will be ignored locally without affecting the repo.\n');
354
+ } catch (e) {
355
+ console.error(' Warning: Could not update .git/info/exclude: ' + e.message);
356
+ console.log(' You may need to handle the config file manually.\n');
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Stage and commit .watchtowerrc.json
362
+ */
363
+ async function handleConfigCommit() {
364
+ try {
365
+ execSync(`git add "${CONFIG_FILE_NAME}"`, { cwd: PROJECT_ROOT, stdio: 'pipe' });
366
+ execSync(`git commit -m "Add git-watchtower configuration"`, { cwd: PROJECT_ROOT, stdio: 'pipe' });
367
+ console.log(' āœ“ Committed ' + CONFIG_FILE_NAME + ' to the repository.\n');
368
+ } catch (e) {
369
+ console.error(' Warning: Could not commit config file: ' + (e.message || 'unknown error'));
370
+ console.log(' You may need to commit it manually: git add ' + CONFIG_FILE_NAME + ' && git commit\n');
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Add .watchtowerrc.json to .gitignore
376
+ */
377
+ function handleConfigGitignore() {
378
+ try {
379
+ const gitignorePath = path.join(PROJECT_ROOT, '.gitignore');
380
+
381
+ // Check if already in .gitignore
382
+ if (fs.existsSync(gitignorePath)) {
383
+ const content = fs.readFileSync(gitignorePath, 'utf8');
384
+ if (content.includes(CONFIG_FILE_NAME)) {
385
+ console.log(' Already listed in .gitignore.\n');
386
+ return;
387
+ }
388
+ }
389
+
390
+ const line = '\n# Git Watchtower config\n' + CONFIG_FILE_NAME + '\n';
391
+ fs.appendFileSync(gitignorePath, line, 'utf8');
392
+ console.log(' āœ“ Added ' + CONFIG_FILE_NAME + ' to .gitignore');
393
+ console.log(' Note: You\'ll need to commit the .gitignore change.\n');
394
+ } catch (e) {
395
+ console.error(' Warning: Could not update .gitignore: ' + e.message);
396
+ console.log(' You may need to add it manually.\n');
397
+ }
398
+ }
399
+
273
400
  async function ensureConfig(cliArgs) {
274
401
  // Check if --init flag was passed (force reconfiguration)
275
402
  if (cliArgs.init) {
@@ -498,6 +625,7 @@ const MAX_SERVER_LOG_LINES = 500;
498
625
  // Dynamic settings
499
626
  let visibleBranchCount = 7;
500
627
  let soundEnabled = true;
628
+ let casinoModeEnabled = false;
501
629
 
502
630
  // Server process management (for command mode)
503
631
  let serverProcess = null;
@@ -525,6 +653,12 @@ function applyConfig(config) {
525
653
  // UI settings
526
654
  visibleBranchCount = config.visibleBranches || 7;
527
655
  soundEnabled = config.soundEnabled !== false;
656
+
657
+ // Casino mode
658
+ casinoModeEnabled = config.casinoMode === true;
659
+ if (casinoModeEnabled) {
660
+ casino.enable();
661
+ }
528
662
  }
529
663
 
530
664
  // Server log management
@@ -540,6 +674,27 @@ function clearServerLog() {
540
674
  serverLogBuffer = [];
541
675
  }
542
676
 
677
+ // Open URL in default browser (cross-platform)
678
+ function openInBrowser(url) {
679
+ const platform = process.platform;
680
+ let command;
681
+
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}"`;
689
+ }
690
+
691
+ exec(command, (error) => {
692
+ if (error) {
693
+ addLog(`Failed to open browser: ${error.message}`, 'error');
694
+ }
695
+ });
696
+ }
697
+
543
698
  // Command mode server management
544
699
  function startServerProcess() {
545
700
  if (SERVER_MODE !== 'command' || !SERVER_COMMAND) return;
@@ -678,6 +833,7 @@ const ansi = {
678
833
  italic: `${CSI}3m`,
679
834
  underline: `${CSI}4m`,
680
835
  inverse: `${CSI}7m`,
836
+ blink: `${CSI}5m`,
681
837
 
682
838
  // Foreground colors
683
839
  black: `${CSI}30m`,
@@ -690,6 +846,15 @@ const ansi = {
690
846
  white: `${CSI}37m`,
691
847
  gray: `${CSI}90m`,
692
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
+
693
858
  // Background colors
694
859
  bgBlack: `${CSI}40m`,
695
860
  bgRed: `${CSI}41m`,
@@ -700,6 +865,15 @@ const ansi = {
700
865
  bgCyan: `${CSI}46m`,
701
866
  bgWhite: `${CSI}47m`,
702
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
+
703
877
  // 256 colors
704
878
  fg256: (n) => `${CSI}38;5;${n}m`,
705
879
  bg256: (n) => `${CSI}48;5;${n}m`,
@@ -820,6 +994,32 @@ function execAsync(command, options = {}) {
820
994
  });
821
995
  }
822
996
 
997
+ /**
998
+ * Get diff stats between two commits
999
+ * @param {string} fromCommit - Starting commit
1000
+ * @param {string} toCommit - Ending commit (default HEAD)
1001
+ * @returns {Promise<{added: number, deleted: number}>}
1002
+ */
1003
+ async function getDiffStats(fromCommit, toCommit = 'HEAD') {
1004
+ try {
1005
+ const { stdout } = await execAsync(`git diff --stat ${fromCommit}..${toCommit}`);
1006
+ // Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)"
1007
+ const match = stdout.match(/(\d+) insertions?\(\+\).*?(\d+) deletions?\(-\)/);
1008
+ if (match) {
1009
+ return { added: parseInt(match[1], 10), deleted: parseInt(match[2], 10) };
1010
+ }
1011
+ // Try to match just insertions or just deletions
1012
+ const insertMatch = stdout.match(/(\d+) insertions?\(\+\)/);
1013
+ const deleteMatch = stdout.match(/(\d+) deletions?\(-\)/);
1014
+ return {
1015
+ added: insertMatch ? parseInt(insertMatch[1], 10) : 0,
1016
+ deleted: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
1017
+ };
1018
+ } catch (e) {
1019
+ return { added: 0, deleted: 0 };
1020
+ }
1021
+ }
1022
+
823
1023
  function formatTimeAgo(date) {
824
1024
  const now = new Date();
825
1025
  const diffMs = now - date;
@@ -859,6 +1059,47 @@ function padLeft(str, len) {
859
1059
  return ' '.repeat(len - str.length) + str;
860
1060
  }
861
1061
 
1062
+ // Casino mode funny messages
1063
+ const CASINO_WIN_MESSAGES = [
1064
+ "Here's your dopamine hit! šŸŽ°",
1065
+ "The house always wins... and this is YOUR house!",
1066
+ "Cha-ching! Fresh code incoming!",
1067
+ "šŸŽ² Lucky roll! New commits detected!",
1068
+ "Jackpot! Someone's been busy coding!",
1069
+ "šŸ’° Cashing out some fresh changes!",
1070
+ "The slot gods smile upon you!",
1071
+ "Winner winner, chicken dinner! šŸ—",
1072
+ "Your patience has been rewarded!",
1073
+ "šŸŽÆ Bullseye! Updates acquired!",
1074
+ "Dopamine delivery service! šŸ“¦",
1075
+ "The code fairy visited while you waited!",
1076
+ "🌟 Wish granted: new commits!",
1077
+ "Variable reward unlocked! šŸ”“",
1078
+ ];
1079
+
1080
+ const CASINO_PULL_MESSAGES = [
1081
+ "Pulling the lever... šŸŽ°",
1082
+ "Spinning the reels of fate...",
1083
+ "Checking if luck is on your side...",
1084
+ "Rolling the dice on git fetch...",
1085
+ "Summoning the code spirits...",
1086
+ "Consulting the commit oracle...",
1087
+ ];
1088
+
1089
+ const CASINO_LOSS_MESSAGES = [
1090
+ "Better luck next merge!",
1091
+ "šŸŽ² Snake eyes! Conflict detected!",
1092
+ "Busted! Time to resolve manually.",
1093
+ "The git gods are displeased...",
1094
+ ];
1095
+
1096
+ function getCasinoMessage(type) {
1097
+ const messages = type === 'win' ? CASINO_WIN_MESSAGES
1098
+ : type === 'pull' ? CASINO_PULL_MESSAGES
1099
+ : CASINO_LOSS_MESSAGES;
1100
+ return messages[Math.floor(Math.random() * messages.length)];
1101
+ }
1102
+
862
1103
  function addLog(message, type = 'info') {
863
1104
  const timestamp = new Date().toLocaleTimeString();
864
1105
  const icons = { info: 'ā—‹', success: 'āœ“', warning: 'ā—', error: 'āœ—', update: '⟳' };
@@ -1043,6 +1284,9 @@ function clearArea(row, col, width, height) {
1043
1284
 
1044
1285
  function renderHeader() {
1045
1286
  const width = terminalWidth;
1287
+ // Header row: 1 normally, 2 when casino mode (row 1 is marquee)
1288
+ const headerRow = casinoModeEnabled ? 2 : 1;
1289
+
1046
1290
  let statusIcon = { idle: ansi.green + 'ā—', fetching: ansi.yellow + '⟳', error: ansi.red + 'ā—' }[pollingStatus];
1047
1291
 
1048
1292
  // Override status for special states
@@ -1053,7 +1297,7 @@ function renderHeader() {
1053
1297
  const soundIcon = soundEnabled ? ansi.green + 'šŸ””' : ansi.gray + 'šŸ”•';
1054
1298
  const projectName = path.basename(PROJECT_ROOT);
1055
1299
 
1056
- write(ansi.moveTo(1, 1));
1300
+ write(ansi.moveTo(headerRow, 1));
1057
1301
  write(ansi.bgBlue + ansi.white + ansi.bold);
1058
1302
 
1059
1303
  // Left side: Title + separator + project name
@@ -1065,6 +1309,9 @@ function renderHeader() {
1065
1309
  // Warning badges (center area)
1066
1310
  let badges = '';
1067
1311
  let badgesVisibleLen = 0;
1312
+
1313
+ // Casino mode slot display moved to its own row below header (row 3)
1314
+
1068
1315
  if (SERVER_MODE === 'command' && serverCrashed) {
1069
1316
  const label = ' CRASHED ';
1070
1317
  badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
@@ -1124,7 +1371,8 @@ function renderHeader() {
1124
1371
  }
1125
1372
 
1126
1373
  function renderBranchList() {
1127
- const startRow = 3;
1374
+ // Start row: 3 normally, 4 when casino mode (row 1 is marquee, row 2 is header)
1375
+ const startRow = casinoModeEnabled ? 4 : 3;
1128
1376
  const boxWidth = terminalWidth;
1129
1377
  const contentWidth = boxWidth - 4; // Space between borders
1130
1378
  const height = Math.min(visibleBranchCount * 2 + 4, Math.floor(terminalHeight * 0.5));
@@ -1278,6 +1526,49 @@ function renderActivityLog(startRow) {
1278
1526
  return startRow + height;
1279
1527
  }
1280
1528
 
1529
+ function renderCasinoStats(startRow) {
1530
+ if (!casinoModeEnabled) return startRow;
1531
+
1532
+ const boxWidth = terminalWidth;
1533
+ const height = 6; // Box with two content lines
1534
+
1535
+ // Don't draw if not enough space
1536
+ if (startRow + height > terminalHeight - 3) return startRow;
1537
+
1538
+ drawBox(startRow, 1, boxWidth, height, 'šŸŽ° CASINO WINNINGS šŸŽ°', ansi.brightMagenta);
1539
+
1540
+ // Clear content area
1541
+ for (let i = 1; i < height - 1; i++) {
1542
+ write(ansi.moveTo(startRow + i, 2));
1543
+ write(' '.repeat(boxWidth - 2));
1544
+ }
1545
+
1546
+ const stats = casino.getStats();
1547
+
1548
+ // Net winnings color
1549
+ const netColor = stats.netWinnings >= 0 ? ansi.brightGreen : ansi.brightRed;
1550
+ const netSign = stats.netWinnings >= 0 ? '+' : '';
1551
+
1552
+ // Line 1: Line Changes | Poll Cost | Net Earnings
1553
+ write(ansi.moveTo(startRow + 2, 3));
1554
+ write('šŸ“ Line Changes: ');
1555
+ write(ansi.brightGreen + '+' + stats.totalLinesAdded + ansi.reset);
1556
+ write(' / ');
1557
+ write(ansi.brightRed + '-' + stats.totalLinesDeleted + ansi.reset);
1558
+ write(' = ' + ansi.brightYellow + '$' + stats.totalLines + ansi.reset);
1559
+ write(' | šŸ’ø Poll Cost: ' + ansi.brightRed + '$' + stats.totalPolls + ansi.reset);
1560
+ write(' | šŸ’° Net Earnings: ' + netColor + netSign + '$' + stats.netWinnings + ansi.reset);
1561
+
1562
+ // Line 2: House Edge | Vibes Quality | Luck Meter | Dopamine Hits
1563
+ write(ansi.moveTo(startRow + 3, 3));
1564
+ write('šŸŽ° House Edge: ' + ansi.brightCyan + stats.houseEdge + '%' + ansi.reset);
1565
+ write(' | šŸ˜Ž Vibes: ' + stats.vibesQuality);
1566
+ write(' | šŸŽ² Luck: ' + ansi.brightYellow + stats.luckMeter + '%' + ansi.reset);
1567
+ write(' | 🧠 Dopamine Hits: ' + ansi.brightGreen + stats.dopamineHits + ansi.reset);
1568
+
1569
+ return startRow + height;
1570
+ }
1571
+
1281
1572
  function renderFooter() {
1282
1573
  const row = terminalHeight - 1;
1283
1574
 
@@ -1294,6 +1585,7 @@ function renderFooter() {
1294
1585
  // Mode-specific keys
1295
1586
  if (!NO_SERVER) {
1296
1587
  write(ansi.gray + '[l]' + ansi.reset + ansi.bgBlack + ' Logs ');
1588
+ write(ansi.gray + '[o]' + ansi.reset + ansi.bgBlack + ' Open ');
1297
1589
  }
1298
1590
  if (SERVER_MODE === 'static') {
1299
1591
  write(ansi.gray + '[r]' + ansi.reset + ansi.bgBlack + ' Reload ');
@@ -1302,6 +1594,14 @@ function renderFooter() {
1302
1594
  }
1303
1595
 
1304
1596
  write(ansi.gray + '[±]' + ansi.reset + ansi.bgBlack + ' List:' + ansi.cyan + visibleBranchCount + ansi.reset + ansi.bgBlack + ' ');
1597
+
1598
+ // Casino mode toggle indicator
1599
+ if (casinoModeEnabled) {
1600
+ write(ansi.brightMagenta + '[c]' + ansi.reset + ansi.bgBlack + ' šŸŽ° ');
1601
+ } else {
1602
+ write(ansi.gray + '[c]' + ansi.reset + ansi.bgBlack + ' Casino ');
1603
+ }
1604
+
1305
1605
  write(ansi.gray + '[q]' + ansi.reset + ansi.bgBlack + ' Quit ');
1306
1606
  write(ansi.reset);
1307
1607
  }
@@ -1722,11 +2022,84 @@ function render() {
1722
2022
  write(ansi.moveToTop);
1723
2023
  write(ansi.clearScreen);
1724
2024
 
2025
+ // Casino mode: top marquee border
2026
+ if (casinoModeEnabled) {
2027
+ write(ansi.moveTo(1, 1));
2028
+ write(casino.renderMarqueeLine(terminalWidth, 'top'));
2029
+ }
2030
+
1725
2031
  renderHeader();
1726
2032
  const logStart = renderBranchList();
1727
- renderActivityLog(logStart);
2033
+ const statsStart = renderActivityLog(logStart);
2034
+ renderCasinoStats(statsStart);
1728
2035
  renderFooter();
1729
2036
 
2037
+ // Casino mode: full border (top, bottom, left, right)
2038
+ if (casinoModeEnabled) {
2039
+ // Bottom marquee border
2040
+ write(ansi.moveTo(terminalHeight, 1));
2041
+ write(casino.renderMarqueeLine(terminalWidth, 'bottom'));
2042
+
2043
+ // Left and right side borders
2044
+ for (let row = 2; row < terminalHeight; row++) {
2045
+ // Left side
2046
+ write(ansi.moveTo(row, 1));
2047
+ write(casino.getMarqueeSideChar(row, terminalHeight, 'left'));
2048
+ // Right side
2049
+ write(ansi.moveTo(row, terminalWidth));
2050
+ write(casino.getMarqueeSideChar(row, terminalHeight, 'right'));
2051
+ }
2052
+ }
2053
+
2054
+ // Casino mode: slot reels on row 3 (below header) when polling or showing result
2055
+ if (casinoModeEnabled && casino.isSlotsActive()) {
2056
+ const slotDisplay = casino.getSlotReelDisplay();
2057
+ if (slotDisplay) {
2058
+ // Row 3: below header (row 1 is marquee, row 2 is header)
2059
+ const resultLabel = casino.getSlotResultLabel();
2060
+ let leftLabel, rightLabel;
2061
+
2062
+ if (casino.isSlotSpinning()) {
2063
+ leftLabel = ansi.bgBrightYellow + ansi.black + ansi.bold + ' POLLING ' + ansi.reset;
2064
+ rightLabel = '';
2065
+ } else if (resultLabel) {
2066
+ leftLabel = ansi.bgBrightGreen + ansi.black + ansi.bold + ' RESULT ' + ansi.reset;
2067
+ // Flash effect for jackpots, use result color for text
2068
+ const flash = resultLabel.isJackpot && (Math.floor(Date.now() / 150) % 2 === 0);
2069
+ const bgColor = flash ? ansi.bgBrightYellow : ansi.bgWhite;
2070
+ rightLabel = ' ' + bgColor + resultLabel.color + ansi.bold + ' ' + resultLabel.text + ' ' + ansi.reset;
2071
+ } else {
2072
+ leftLabel = ansi.bgBrightGreen + ansi.black + ansi.bold + ' RESULT ' + ansi.reset;
2073
+ rightLabel = '';
2074
+ }
2075
+
2076
+ const fullDisplay = leftLabel + ' ' + slotDisplay + rightLabel;
2077
+ const col = Math.floor((terminalWidth - 70) / 2); // Center the display
2078
+ write(ansi.moveTo(3, Math.max(2, col)));
2079
+ write(fullDisplay);
2080
+ }
2081
+ }
2082
+
2083
+ // Casino mode: win animation overlay
2084
+ if (casinoModeEnabled && casino.isWinAnimating()) {
2085
+ const winDisplay = casino.getWinDisplay(terminalWidth);
2086
+ if (winDisplay) {
2087
+ const row = Math.floor(terminalHeight / 2);
2088
+ write(ansi.moveTo(row, 1));
2089
+ write(winDisplay);
2090
+ }
2091
+ }
2092
+
2093
+ // Casino mode: loss animation overlay
2094
+ if (casinoModeEnabled && casino.isLossAnimating()) {
2095
+ const lossDisplay = casino.getLossDisplay(terminalWidth);
2096
+ if (lossDisplay) {
2097
+ const row = Math.floor(terminalHeight / 2);
2098
+ write(ansi.moveTo(row, 1));
2099
+ write(lossDisplay);
2100
+ }
2101
+ }
2102
+
1730
2103
  if (flashMessage) {
1731
2104
  renderFlash();
1732
2105
  }
@@ -2122,6 +2495,12 @@ async function pollGitChanges() {
2122
2495
  if (isPolling) return;
2123
2496
  isPolling = true;
2124
2497
  pollingStatus = 'fetching';
2498
+
2499
+ // Casino mode: start slot reels spinning (no sound - too annoying)
2500
+ if (casinoModeEnabled) {
2501
+ casino.startSlotReels(render);
2502
+ }
2503
+
2125
2504
  render();
2126
2505
 
2127
2506
  const fetchStartTime = Date.now();
@@ -2229,9 +2608,33 @@ async function pollGitChanges() {
2229
2608
  for (const branch of updatedBranches) {
2230
2609
  addLog(`Update on ${branch.name}: ${branch.commit}`, 'update');
2231
2610
  }
2611
+
2612
+ // Casino mode: add funny commentary
2613
+ if (casinoModeEnabled) {
2614
+ addLog(`šŸŽ° ${getCasinoMessage('win')}`, 'success');
2615
+ }
2616
+
2232
2617
  const names = notifyBranches.map(b => b.name).join(', ');
2233
2618
  showFlash(names);
2234
2619
  playSound();
2620
+
2621
+ // Casino mode: trigger win effect based on number of updated branches
2622
+ if (casinoModeEnabled) {
2623
+ // Estimate line changes: more branches = bigger "win"
2624
+ // Each branch update counts as ~100 lines (placeholder until we calculate actual diff)
2625
+ const estimatedLines = notifyBranches.length * 100;
2626
+ const winLevel = casino.getWinLevel(estimatedLines);
2627
+ casino.stopSlotReels(true, render, winLevel); // Win - matching symbols + flash + label
2628
+ casino.triggerWin(estimatedLines, 0, render);
2629
+ if (winLevel) {
2630
+ casinoSounds.playForWinLevel(winLevel.key);
2631
+ }
2632
+ casino.recordPoll(true);
2633
+ }
2634
+ } else if (casinoModeEnabled) {
2635
+ // No updates - stop reels and show result briefly
2636
+ casino.stopSlotReels(false, render);
2637
+ casino.recordPoll(false);
2235
2638
  }
2236
2639
 
2237
2640
  // Remember which branch was selected before updating the list
@@ -2271,6 +2674,9 @@ async function pollGitChanges() {
2271
2674
  addLog(`Auto-pulling changes for ${currentBranch}...`, 'update');
2272
2675
  render();
2273
2676
 
2677
+ // Save the old commit for diff calculation (casino mode)
2678
+ const oldCommit = currentInfo.commit;
2679
+
2274
2680
  try {
2275
2681
  await execAsync(`git pull "${REMOTE_NAME}" "${currentBranch}"`);
2276
2682
  addLog(`Pulled successfully from ${currentBranch}`, 'success');
@@ -2282,6 +2688,20 @@ async function pollGitChanges() {
2282
2688
  previousBranchStates.set(currentBranch, newCommit.stdout.trim());
2283
2689
  // Reload browsers
2284
2690
  notifyClients();
2691
+
2692
+ // Casino mode: calculate actual diff and trigger win effect
2693
+ if (casinoModeEnabled && oldCommit) {
2694
+ const diffStats = await getDiffStats(oldCommit, 'HEAD');
2695
+ const totalLines = diffStats.added + diffStats.deleted;
2696
+ if (totalLines > 0) {
2697
+ casino.triggerWin(diffStats.added, diffStats.deleted, render);
2698
+ const winLevel = casino.getWinLevel(totalLines);
2699
+ if (winLevel) {
2700
+ addLog(`šŸŽ° ${winLevel.label} +${diffStats.added}/-${diffStats.deleted} lines`, 'success');
2701
+ casinoSounds.playForWinLevel(winLevel.key);
2702
+ }
2703
+ }
2704
+ }
2285
2705
  } catch (e) {
2286
2706
  const errMsg = e.stderr || e.stdout || e.message || String(e);
2287
2707
  if (isMergeConflict(errMsg)) {
@@ -2293,6 +2713,12 @@ async function pollGitChanges() {
2293
2713
  'Auto-pull resulted in merge conflicts that need manual resolution.',
2294
2714
  'Run: git status to see conflicts'
2295
2715
  );
2716
+ // Casino mode: trigger loss effect
2717
+ if (casinoModeEnabled) {
2718
+ casino.triggerLoss('MERGE CONFLICT!', render);
2719
+ casinoSounds.playLoss();
2720
+ addLog(`šŸ’€ ${getCasinoMessage('loss')}`, 'error');
2721
+ }
2296
2722
  } else if (isAuthError(errMsg)) {
2297
2723
  addLog(`Authentication failed during pull`, 'error');
2298
2724
  addLog(`Check your Git credentials`, 'warning');
@@ -2313,9 +2739,20 @@ async function pollGitChanges() {
2313
2739
  }
2314
2740
 
2315
2741
  pollingStatus = 'idle';
2742
+ // Casino mode: stop slot reels if still spinning (already handled above, just cleanup)
2743
+ if (casinoModeEnabled && casino.isSlotSpinning()) {
2744
+ casino.stopSlotReels(false, render);
2745
+ }
2316
2746
  } catch (err) {
2317
2747
  const errMsg = err.stderr || err.message || String(err);
2318
2748
 
2749
+ // Casino mode: stop slot reels and show loss on error
2750
+ if (casinoModeEnabled) {
2751
+ casino.stopSlotReels(false, render);
2752
+ casino.triggerLoss('BUST!', render);
2753
+ casinoSounds.playLoss();
2754
+ }
2755
+
2319
2756
  // Handle different error types
2320
2757
  if (isNetworkError(errMsg)) {
2321
2758
  consecutiveNetworkFailures++;
@@ -2449,13 +2886,26 @@ const server = http.createServer((req, res) => {
2449
2886
 
2450
2887
  let fileWatcher = null;
2451
2888
  let debounceTimer = null;
2889
+ let ignorePatterns = [];
2452
2890
 
2453
2891
  function setupFileWatcher() {
2454
2892
  if (fileWatcher) fileWatcher.close();
2455
2893
 
2894
+ // Load gitignore patterns before setting up the watcher
2895
+ ignorePatterns = loadGitignorePatterns([STATIC_DIR, PROJECT_ROOT]);
2896
+ if (ignorePatterns.length > 0) {
2897
+ addLog(`Loaded ${ignorePatterns.length} ignore patterns from .gitignore`, 'info');
2898
+ }
2899
+
2456
2900
  try {
2457
2901
  fileWatcher = fs.watch(STATIC_DIR, { recursive: true }, (eventType, filename) => {
2458
2902
  if (!filename) return;
2903
+
2904
+ // Skip ignored files (.git directory and gitignore patterns)
2905
+ if (shouldIgnoreFile(filename, ignorePatterns)) {
2906
+ return;
2907
+ }
2908
+
2459
2909
  clearTimeout(debounceTimer);
2460
2910
  debounceTimer = setTimeout(() => {
2461
2911
  addLog(`File changed: ${filename}`, 'info');
@@ -2716,6 +3166,15 @@ function setupKeyboardInput() {
2716
3166
  }
2717
3167
  break;
2718
3168
 
3169
+ case 'o': // Open live server in browser
3170
+ if (!NO_SERVER) {
3171
+ const serverUrl = `http://localhost:${PORT}`;
3172
+ addLog(`Opening ${serverUrl} in browser...`, 'info');
3173
+ openInBrowser(serverUrl);
3174
+ render();
3175
+ }
3176
+ break;
3177
+
2719
3178
  case 'f':
2720
3179
  addLog('Fetching all branches...', 'update');
2721
3180
  await pollGitChanges();
@@ -2733,6 +3192,18 @@ function setupKeyboardInput() {
2733
3192
  render();
2734
3193
  break;
2735
3194
 
3195
+ case 'c': // Toggle casino mode
3196
+ casinoModeEnabled = casino.toggle();
3197
+ addLog(`Casino mode ${casinoModeEnabled ? 'šŸŽ° ENABLED' : 'disabled'}`, casinoModeEnabled ? 'success' : 'info');
3198
+ if (casinoModeEnabled) {
3199
+ addLog(`Have you noticed this game has that 'variable rewards' thing going on? šŸ¤”šŸ˜‰`, 'info');
3200
+ if (soundEnabled) {
3201
+ casinoSounds.playJackpot();
3202
+ }
3203
+ }
3204
+ render();
3205
+ break;
3206
+
2736
3207
  // Number keys to set visible branch count
2737
3208
  case '1': case '2': case '3': case '4': case '5':
2738
3209
  case '6': case '7': case '8': case '9':
@@ -2850,6 +3321,9 @@ async function start() {
2850
3321
  const config = await ensureConfig(cliArgs);
2851
3322
  applyConfig(config);
2852
3323
 
3324
+ // Set up casino mode render callback for animations
3325
+ casino.setRenderCallback(render);
3326
+
2853
3327
  // Check for remote before starting TUI
2854
3328
  const hasRemote = await checkRemoteExists();
2855
3329
  if (!hasRemote) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.0.0",
3
+ "version": "1.2.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": {
@@ -18,8 +18,11 @@
18
18
  "typecheck": "tsc --noEmit"
19
19
  },
20
20
  "devDependencies": {
21
+ "@semantic-release/changelog": "^6.0.3",
22
+ "@semantic-release/git": "^10.0.1",
21
23
  "@types/node": "^22.0.0",
22
24
  "c8": "^10.1.2",
25
+ "semantic-release": "^25.0.3",
23
26
  "typescript": "^5.7.0"
24
27
  },
25
28
  "keywords": [
@@ -34,7 +37,7 @@
34
37
  "dashboard",
35
38
  "sparklines"
36
39
  ],
37
- "author": "",
40
+ "author": "drummel <drummel@gmail.com>",
38
41
  "license": "MIT",
39
42
  "repository": {
40
43
  "type": "git",
@@ -51,5 +54,36 @@
51
54
  "bin/git-watchtower.js",
52
55
  "README.md",
53
56
  "LICENSE"
54
- ]
57
+ ],
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "registry": "https://registry.npmjs.org/"
61
+ },
62
+ "release": {
63
+ "branches": [
64
+ "main"
65
+ ],
66
+ "plugins": [
67
+ "@semantic-release/commit-analyzer",
68
+ "@semantic-release/release-notes-generator",
69
+ [
70
+ "@semantic-release/changelog",
71
+ {
72
+ "changelogFile": "CHANGELOG.md"
73
+ }
74
+ ],
75
+ "@semantic-release/npm",
76
+ "@semantic-release/github",
77
+ [
78
+ "@semantic-release/git",
79
+ {
80
+ "assets": [
81
+ "package.json",
82
+ "CHANGELOG.md"
83
+ ],
84
+ "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
85
+ }
86
+ ]
87
+ ]
88
+ }
55
89
  }