git-watchtower 2.3.11 → 2.3.13

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.11",
3
+ "version": "2.3.13",
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": {
@@ -110,6 +110,13 @@ function disable() {
110
110
  // up to ~15 frames × 120 ms = 1.8 s after disable, and lossMessage stayed
111
111
  // set so isLossAnimating() reported true into the next session.
112
112
  resetLossState();
113
+ // Drop the marquee render callback so a stale closure to the previous
114
+ // session's render() doesn't survive across enable/disable cycles. In
115
+ // production this is mostly hygiene (bin/git-watchtower.js wires the
116
+ // callback exactly once at startup against a singleton render fn), but
117
+ // tests that re-use the casino module saw the previous test's callback
118
+ // persist into the next setRenderCallback assignment.
119
+ marqueeCallback = null;
113
120
  }
114
121
 
115
122
  /**
@@ -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/server/web.js CHANGED
@@ -46,6 +46,36 @@ const SSE_KEEPALIVE_INTERVAL = 15000;
46
46
  */
47
47
  const MAX_STALLED_PUSHES = 60;
48
48
 
49
+ /**
50
+ * Content-Security-Policy header for the dashboard HTML.
51
+ *
52
+ * The dashboard uses inline <script> and inline <style> blocks (the whole
53
+ * UI is bundled at build time), and makes XHR / EventSource calls to
54
+ * /api/... on the same origin. No external resources are loaded.
55
+ *
56
+ * `default-src 'none'` denies everything not explicitly allowed.
57
+ * `script-src` / `style-src` need 'unsafe-inline' for the bundled blocks.
58
+ * `connect-src 'self'` lets XHR + SSE hit /api/... .
59
+ * `base-uri 'none'` blocks <base> injection from rewriting URLs.
60
+ * `form-action 'none'` blocks any rogue form posts.
61
+ * `frame-ancestors 'none'` blocks embedding (defense vs. clickjacking).
62
+ *
63
+ * Defense-in-depth — the dashboard already escapes user-supplied content
64
+ * everywhere we render it, but a future regression that forgets escHtml
65
+ * is mitigated here.
66
+ */
67
+ const CONTENT_SECURITY_POLICY = [
68
+ "default-src 'none'",
69
+ "script-src 'self' 'unsafe-inline'",
70
+ "style-src 'self' 'unsafe-inline'",
71
+ "connect-src 'self'",
72
+ "img-src 'self' data:",
73
+ "font-src 'self'",
74
+ "base-uri 'none'",
75
+ "form-action 'none'",
76
+ "frame-ancestors 'none'",
77
+ ].join('; ');
78
+
49
79
  /**
50
80
  * Actions the web dashboard is allowed to POST to /api/action. Every entry
51
81
  * here MUST be matched by a `case` in `handleWebAction` in bin/git-watchtower.js
@@ -474,7 +504,12 @@ class WebDashboardServer {
474
504
 
475
505
  // Routes
476
506
  if (pathname === '/' && req.method === 'GET') {
477
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
507
+ res.writeHead(200, {
508
+ 'Content-Type': 'text/html; charset=utf-8',
509
+ 'Content-Security-Policy': CONTENT_SECURITY_POLICY,
510
+ 'X-Content-Type-Options': 'nosniff',
511
+ 'Referrer-Policy': 'no-referrer',
512
+ });
478
513
  res.end(this._cachedHtml);
479
514
  return;
480
515
  }
@@ -673,4 +708,5 @@ module.exports = {
673
708
  STATE_PUSH_INTERVAL,
674
709
  MAX_STALLED_PUSHES,
675
710
  ALLOWED_ACTIONS,
711
+ CONTENT_SECURITY_POLICY,
676
712
  };