git-watchtower 2.3.10 → 2.3.12

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.
@@ -63,6 +63,7 @@ const readline = require('readline');
63
63
  // Casino mode - Vegas-style feedback effects
64
64
  const casino = require('../src/casino');
65
65
  const casinoSounds = require('../src/casino/sounds');
66
+ const { sumPollChurn } = require('../src/casino/poll-churn');
66
67
 
67
68
  // Gitignore utilities for file watcher
68
69
  const { loadGitignorePatterns, shouldIgnoreFile } = require('../src/utils/gitignore');
@@ -1995,8 +1996,13 @@ async function pollGitChanges() {
1995
1996
  // Keep deleted branches in the list (don't remove them)
1996
1997
  const pollFilteredBranches = allBranches;
1997
1998
 
1998
- // Detect updates on other branches (for flash notification)
1999
+ // Detect updates on other branches (for flash notification).
2000
+ // We also capture each updated branch's pre-update commit so the casino
2001
+ // win calculation can compute REAL line churn via git diff, instead of
2002
+ // the old `notifyBranches.length * 100` placeholder that surfaced as
2003
+ // inflated dashboard numbers.
1999
2004
  const updatedBranches = [];
2005
+ const updatedBranchPrevCommits = new Map();
2000
2006
  const currentBranchName = store.get('currentBranch');
2001
2007
  const activeBranchNames = new Set();
2002
2008
  for (const branch of pollFilteredBranches) {
@@ -2007,6 +2013,7 @@ async function pollGitChanges() {
2007
2013
  const prevCommit = previousBranchStates.get(branch.name);
2008
2014
  if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranchName) {
2009
2015
  updatedBranches.push(branch);
2016
+ updatedBranchPrevCommits.set(branch.name, prevCommit);
2010
2017
  branch.justUpdated = true;
2011
2018
  }
2012
2019
  previousBranchStates.set(branch.name, branch.commit);
@@ -2041,14 +2048,25 @@ async function pollGitChanges() {
2041
2048
  showFlash(names);
2042
2049
  playSound();
2043
2050
 
2044
- // Casino mode: trigger win effect based on number of updated branches
2051
+ // Casino mode: trigger win effect based on REAL line churn from
2052
+ // each updated branch's prev → new commit. New branches (no prev
2053
+ // commit) contribute 0 — the win still fires from the slot animation
2054
+ // path, but no fake volume is added to totalLinesAdded.
2045
2055
  if (casinoOn) {
2046
- // Estimate line changes: more branches = bigger "win"
2047
- // Each branch update counts as ~100 lines (placeholder until we calculate actual diff)
2048
- const estimatedLines = notifyBranches.length * 100;
2049
- const winLevel = casino.getWinLevel(estimatedLines);
2056
+ const churn = await sumPollChurn(
2057
+ updatedBranches,
2058
+ updatedBranchPrevCommits,
2059
+ getDiffStats
2060
+ );
2061
+ const totalLines = churn.added + churn.deleted;
2062
+ // getWinLevel(0) returns null and would skip the slot result label
2063
+ // and the sound. Use max(1, totalLines) so a no-line update (e.g.
2064
+ // a tag-only commit, an empty merge) still registers as a "small"
2065
+ // win — the user did get a notification, after all.
2066
+ const winLines = Math.max(1, totalLines);
2067
+ const winLevel = casino.getWinLevel(winLines);
2050
2068
  casino.stopSlotReels(true, render, winLevel); // Win - matching symbols + flash + label
2051
- casino.triggerWin(estimatedLines, 0, render);
2069
+ casino.triggerWin(churn.added, churn.deleted, render);
2052
2070
  if (winLevel) {
2053
2071
  casinoSounds.playForWinLevel(winLevel.key);
2054
2072
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.3.10",
3
+ "version": "2.3.12",
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": {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Casino mode "win" line-count aggregation.
3
+ *
4
+ * The polling loop used to fake the line-change total as
5
+ * `notifyBranches.length * 100` — a placeholder that surfaced as inflated
6
+ * (and incorrect) numbers in the dashboard's "+lines" / netWinnings
7
+ * displays. This module sums the REAL diff between each updated branch's
8
+ * previous commit and its new commit so the casino stats track actual
9
+ * code churn.
10
+ *
11
+ * @module casino/poll-churn
12
+ */
13
+
14
+ /**
15
+ * Sum the diff churn across a list of updated branches by running the
16
+ * caller-supplied diff function on each (prev → new) pair in parallel.
17
+ * Branches missing a prev commit (e.g. brand-new branches) contribute 0,
18
+ * so a notification still fires but no fake line count is invented.
19
+ *
20
+ * @param {Array<{name: string, commit: string}>} updatedBranches
21
+ * @param {Map<string, string>} prevCommits - map of branch.name → previous commit hash
22
+ * @param {(from: string, to: string) => Promise<{added: number, deleted: number}>} diffFn
23
+ * @returns {Promise<{added: number, deleted: number}>}
24
+ */
25
+ async function sumPollChurn(updatedBranches, prevCommits, diffFn) {
26
+ if (!updatedBranches || updatedBranches.length === 0) {
27
+ return { added: 0, deleted: 0 };
28
+ }
29
+ const results = await Promise.all(
30
+ updatedBranches.map(async (branch) => {
31
+ const prev = prevCommits ? prevCommits.get(branch.name) : null;
32
+ if (!prev || !branch.commit) return { added: 0, deleted: 0 };
33
+ try {
34
+ const r = await diffFn(prev, branch.commit);
35
+ return {
36
+ added: Number.isFinite(r && r.added) ? r.added : 0,
37
+ deleted: Number.isFinite(r && r.deleted) ? r.deleted : 0,
38
+ };
39
+ } catch (e) {
40
+ // diffFn already returns { added: 0, deleted: 0 } on git failure
41
+ // (see src/git/commands.js#getDiffStats), but guard against a
42
+ // throwing test stub or future caller passing a stricter fn.
43
+ return { added: 0, deleted: 0 };
44
+ }
45
+ })
46
+ );
47
+ return results.reduce(
48
+ (acc, d) => ({ added: acc.added + d.added, deleted: acc.deleted + d.deleted }),
49
+ { added: 0, deleted: 0 }
50
+ );
51
+ }
52
+
53
+ module.exports = { sumPollChurn };
package/src/ui/ansi.js CHANGED
@@ -358,12 +358,106 @@ function sanitizeForRender(str) {
358
358
  }
359
359
 
360
360
  /**
361
- * Get the visible length of a string (excluding ANSI codes)
362
- * @param {string} str - String potentially containing ANSI codes
363
- * @returns {number} Visible character count
361
+ * Display width of a single Unicode code point in terminal columns.
362
+ * Returns 0 for combining marks / variation selectors / ZWJ, 2 for East
363
+ * Asian Wide / Fullwidth and emoji-presentation code points, 1 otherwise.
364
+ * Used by visibleLength's grapheme iterator.
365
+ * @param {number} cp
366
+ * @returns {number}
367
+ * @private
368
+ */
369
+ function _codePointWidth(cp) {
370
+ if (cp == null) return 0;
371
+ // C0 / DEL — defensively zero-width (stripAnsi already drops them).
372
+ if (cp < 0x20 || cp === 0x7f) return 0;
373
+
374
+ // Combining marks, variation selectors, ZWJ — zero-width.
375
+ if (
376
+ (cp >= 0x0300 && cp <= 0x036F) || // Combining Diacritical Marks
377
+ (cp >= 0x1AB0 && cp <= 0x1AFF) || // Combining Diacritical Marks Extended
378
+ (cp >= 0x1DC0 && cp <= 0x1DFF) || // Combining Diacritical Marks Supplement
379
+ (cp >= 0x20D0 && cp <= 0x20FF) || // Combining Diacritical Marks for Symbols
380
+ (cp >= 0xFE00 && cp <= 0xFE0F) || // Variation Selectors (incl. emoji presentation)
381
+ (cp >= 0xFE20 && cp <= 0xFE2F) || // Combining Half Marks
382
+ cp === 0x200D // ZWJ
383
+ ) return 0;
384
+
385
+ // East Asian Wide / Fullwidth / emoji — two columns.
386
+ if (
387
+ (cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
388
+ (cp >= 0x2E80 && cp <= 0x303E) || // CJK Radicals etc
389
+ (cp >= 0x3041 && cp <= 0x33FF) || // CJK Symbols / Hiragana / Katakana / Bopomofo / Hangul / CJK
390
+ (cp >= 0x3400 && cp <= 0x4DBF) || // CJK Unified Ideographs Ext A
391
+ (cp >= 0x4E00 && cp <= 0x9FFF) || // CJK Unified Ideographs
392
+ (cp >= 0xA000 && cp <= 0xA4CF) || // Yi Syllables
393
+ (cp >= 0xAC00 && cp <= 0xD7A3) || // Hangul Syllables
394
+ (cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs
395
+ (cp >= 0xFE30 && cp <= 0xFE4F) || // CJK Compatibility Forms
396
+ (cp >= 0xFF00 && cp <= 0xFF60) || // Fullwidth Forms
397
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || // Fullwidth Signs
398
+ (cp >= 0x1F300 && cp <= 0x1F5FF) || // Misc Symbols and Pictographs
399
+ (cp >= 0x1F600 && cp <= 0x1F64F) || // Emoticons
400
+ (cp >= 0x1F680 && cp <= 0x1F6FF) || // Transport
401
+ (cp >= 0x1F900 && cp <= 0x1F9FF) || // Supplemental Symbols and Pictographs
402
+ (cp >= 0x1FA00 && cp <= 0x1FAFF) || // Symbols and Pictographs Extended-A
403
+ (cp >= 0x1F1E6 && cp <= 0x1F1FF) || // Regional indicators (flags)
404
+ (cp >= 0x1F3FB && cp <= 0x1F3FF) || // Emoji modifiers (skin tone)
405
+ (cp >= 0x20000 && cp <= 0x2FFFD) || // CJK Ideographs Extension B-F
406
+ (cp >= 0x30000 && cp <= 0x3FFFD) // CJK Ideographs Extension G-H
407
+ ) return 2;
408
+
409
+ return 1;
410
+ }
411
+
412
+ // Reused grapheme segmenter — instantiating per-call would be wasteful for
413
+ // strings hit on every render. Default locale is fine; we only care about
414
+ // boundaries, not script-aware ordering.
415
+ const _graphemeSegmenter = (typeof Intl !== 'undefined' && Intl.Segmenter)
416
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
417
+ : null;
418
+
419
+ /**
420
+ * Get the visible width of a string in terminal columns, excluding ANSI
421
+ * codes and counting wide characters (CJK ideographs, emoji, fullwidth)
422
+ * as 2 columns. Combining marks, variation selectors, and ZWJ are 0-width.
423
+ * Grapheme clusters that contain at least one wide code point clamp to 2
424
+ * — so a ZWJ family (e.g. 👨‍👩‍👧, which is 5 code points = 13 UTF-16 units)
425
+ * counts as 2, matching how terminals render it.
426
+ *
427
+ * Without this, branch names with emoji/CJK misalign sparklines, badges
428
+ * and box borders because the renderer's `padRight`/truncation math drifts
429
+ * by one or more columns per non-ASCII character.
430
+ *
431
+ * @param {string} str - String potentially containing ANSI codes and unicode
432
+ * @returns {number} Visible width in terminal columns
364
433
  */
365
434
  function visibleLength(str) {
366
- return stripAnsi(str).length;
435
+ const stripped = stripAnsi(str);
436
+ // ASCII fast path — every char is 1 column, regex avoids the Segmenter
437
+ // setup cost on the renderer's hot path.
438
+ if (/^[\x20-\x7e]*$/.test(stripped)) return stripped.length;
439
+ if (!_graphemeSegmenter) {
440
+ // Pre-Intl.Segmenter fallback (shouldn't hit on Node 16+).
441
+ let width = 0;
442
+ for (const ch of stripped) width += _codePointWidth(ch.codePointAt(0));
443
+ return width;
444
+ }
445
+ let total = 0;
446
+ for (const { segment } of _graphemeSegmenter.segment(stripped)) {
447
+ let clusterTotal = 0;
448
+ let clusterHasWide = false;
449
+ for (const ch of segment) {
450
+ const w = _codePointWidth(ch.codePointAt(0));
451
+ if (w === 2) clusterHasWide = true;
452
+ clusterTotal += w;
453
+ }
454
+ // ZWJ-joined emoji clusters (e.g. family / flag / skin-tone) sum to
455
+ // more than 2 by code point, but render as a single 2-column glyph.
456
+ // Cap them at 2; non-wide clusters (combining marks on a base char)
457
+ // still take at least 1 column.
458
+ total += clusterHasWide ? 2 : Math.max(1, clusterTotal);
459
+ }
460
+ return total;
367
461
  }
368
462
 
369
463
  /**
@@ -382,14 +476,39 @@ function truncate(str, maxLen, suffix = '…') {
382
476
  // raw input with cursor/screen-control sequences in it. SGR survives.
383
477
  const safeStr = sanitizeForRender(str);
384
478
  const visible = stripAnsi(safeStr);
385
- if (visible.length <= maxLen) {
479
+
480
+ // Compare in display columns, not UTF-16 code units, so a string of CJK
481
+ // ideographs (e.g. "中文测试" — 4 code units, 8 columns) is correctly
482
+ // recognised as needing truncation in a 4-column slot.
483
+ if (visibleLength(visible) <= maxLen) {
386
484
  return safeStr;
387
485
  }
388
486
 
389
- // Long-string path: drop ALL escapes (including SGR), truncate, append
390
- // ellipsis + reset. Existing behaviour, kept for layout determinism.
391
- const truncated = visible.slice(0, maxLen - suffix.length);
392
- return truncated + suffix + ansi.reset;
487
+ // Long-string path: drop ALL escapes (including SGR), accumulate
488
+ // graphemes up to the column budget, append ellipsis + reset. Walking
489
+ // graphemes (not code units) keeps wide characters from being split
490
+ // mid-glyph and keeps the truncated width <= maxLen even for emoji
491
+ // and CJK input.
492
+ const suffixWidth = visibleLength(suffix);
493
+ const budget = Math.max(0, maxLen - suffixWidth);
494
+ let acc = '';
495
+ let used = 0;
496
+ if (_graphemeSegmenter) {
497
+ for (const { segment } of _graphemeSegmenter.segment(visible)) {
498
+ const w = visibleLength(segment);
499
+ if (used + w > budget) break;
500
+ acc += segment;
501
+ used += w;
502
+ }
503
+ } else {
504
+ for (const ch of visible) {
505
+ const w = _codePointWidth(ch.codePointAt(0));
506
+ if (used + w > budget) break;
507
+ acc += ch;
508
+ used += w;
509
+ }
510
+ }
511
+ return acc + suffix + ansi.reset;
393
512
  }
394
513
 
395
514
  /**