git-watchtower 2.1.9 → 2.1.11

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.
@@ -397,8 +397,6 @@ const MAX_SERVER_LOG_LINES = 500;
397
397
  const FORCE_KILL_GRACE_MS = 3000;
398
398
  /** Additional grace period added to a command's timeout before SIGKILL. */
399
399
  const SIGKILL_GRACE_AFTER_TIMEOUT_MS = 5000;
400
- /** Delay between stopping and restarting the dev server. */
401
- const SERVER_RESTART_DELAY_MS = 500;
402
400
  /** How long a transient flash message stays on screen. */
403
401
  const FLASH_MESSAGE_DURATION_MS = 3000;
404
402
  /** Debounce window for file watcher events before notifying clients. */
@@ -826,13 +824,19 @@ function stopServerProcess() {
826
824
  return Promise.race([closedPromise, hardCap]);
827
825
  }
828
826
 
829
- function restartServerProcess() {
827
+ async function restartServerProcess() {
830
828
  addLog('Restarting server...', 'update');
831
- stopServerProcess();
832
- setTimeout(() => {
833
- startServerProcess();
834
- render();
835
- }, SERVER_RESTART_DELAY_MS);
829
+ // Await actual exit before respawning. The previous fire-and-forget
830
+ // stopServerProcess() + 500 ms setTimeout was shorter than the
831
+ // FORCE_KILL_GRACE_MS (3 s) SIGKILL escalation, so a dev server with
832
+ // a slow SIGTERM handler would yield EADDRINUSE on the respawn — the
833
+ // new process tried to bind a port the old one still held.
834
+ try {
835
+ await stopServerProcess();
836
+ } catch (_) { /* stopServerProcess never rejects in practice; best-effort */ }
837
+ if (isShuttingDown) return;
838
+ startServerProcess();
839
+ render();
836
840
  }
837
841
 
838
842
  // Network and polling state
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.1.9",
3
+ "version": "2.1.11",
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": {
package/src/git/branch.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Provides branch management and parsing
4
4
  */
5
5
 
6
- const { execGit, fetch, hasUncommittedChanges, getCommitsByDay, log, deleteLocalBranch } = require('./commands');
6
+ const { execGit, fetch, hasRemoteChanges, hasUncommittedChanges, getCommitsByDay, log, deleteLocalBranch } = require('./commands');
7
7
  const { GitError, ValidationError } = require('../utils/errors');
8
8
 
9
9
  // Valid git branch name pattern (conservative)
@@ -91,9 +91,20 @@ async function getAllBranches(options = {}) {
91
91
  const { remoteName = 'origin', fetch: shouldFetch = true, cwd } = options;
92
92
 
93
93
  try {
94
- // Optionally fetch first
94
+ // Optionally fetch first. Probe via `git ls-remote --heads` first
95
+ // (cheap: lists refs without downloading objects) and skip the full
96
+ // fetch when the remote's refs exactly match our local cache. On a
97
+ // mostly-idle repo this turns most poll cycles into a single short
98
+ // advertise instead of a multi-megabyte object negotiation. When
99
+ // refs differ — including the new-branch and deleted-branch cases —
100
+ // we fall through to the regular fetch + prune. A null probe result
101
+ // (network/auth/missing-remote) also falls through, so an unrelated
102
+ // probe failure can't masquerade as "no changes."
95
103
  if (shouldFetch) {
96
- await fetch(remoteName, { prune: true, all: true, cwd });
104
+ const changed = await hasRemoteChanges(remoteName, { cwd });
105
+ if (changed !== false) {
106
+ await fetch(remoteName, { prune: true, all: true, cwd });
107
+ }
97
108
  }
98
109
 
99
110
  const branchList = [];
@@ -201,6 +201,82 @@ async function fetch(remoteName = 'origin', options = {}) {
201
201
  }
202
202
  }
203
203
 
204
+ /**
205
+ * Probe whether the configured remote has any refs that differ from our
206
+ * local `refs/remotes/<remote>/` cache. Uses `git ls-remote --heads`
207
+ * which advertises remote refs over the wire without downloading any
208
+ * objects — much cheaper than `git fetch` on large repos.
209
+ *
210
+ * Comparison is exact across both directions:
211
+ * - sha mismatch on a shared ref → changed
212
+ * - ref missing locally (new branch) → changed
213
+ * - ref missing on remote (deleted, needs prune) → changed
214
+ *
215
+ * Returns `null` when the probe itself failed (network, auth, missing
216
+ * remote). Callers should fall through to a real fetch in that case
217
+ * rather than treating "couldn't tell" as "no changes."
218
+ *
219
+ * @param {string} remoteName
220
+ * @param {Object} [options]
221
+ * @param {string} [options.cwd]
222
+ * @returns {Promise<boolean|null>}
223
+ */
224
+ async function hasRemoteChanges(remoteName, options = {}) {
225
+ const { cwd } = options;
226
+
227
+ // Probe over the wire — list refs only, no object download.
228
+ const probeResult = await execGitOptional(
229
+ ['ls-remote', '--heads', '--quiet', remoteName],
230
+ { cwd, timeout: FETCH_TIMEOUT }
231
+ );
232
+ if (!probeResult) return null;
233
+
234
+ const remoteRefs = new Map();
235
+ for (const line of probeResult.stdout.split('\n').filter(Boolean)) {
236
+ // Format: "<sha>\trefs/heads/<branch>"
237
+ const tab = line.indexOf('\t');
238
+ if (tab === -1) continue;
239
+ const sha = line.slice(0, tab);
240
+ const ref = line.slice(tab + 1);
241
+ if (ref.startsWith('refs/heads/')) {
242
+ remoteRefs.set(ref.slice('refs/heads/'.length), sha);
243
+ }
244
+ }
245
+
246
+ // What we already have locally for this remote.
247
+ const localResult = await execGitOptional(
248
+ ['for-each-ref', '--format=%(refname:short) %(objectname)', `refs/remotes/${remoteName}/`],
249
+ { cwd, timeout: SHORT_TIMEOUT }
250
+ );
251
+ if (!localResult) return null;
252
+
253
+ const localRefs = new Map();
254
+ const prefix = `${remoteName}/`;
255
+ for (const line of localResult.stdout.split('\n').filter(Boolean)) {
256
+ const space = line.indexOf(' ');
257
+ if (space === -1) continue;
258
+ const refShort = line.slice(0, space);
259
+ const sha = line.slice(space + 1);
260
+ if (!refShort.startsWith(prefix)) continue;
261
+ const name = refShort.slice(prefix.length);
262
+ // Skip the symbolic <remote>/HEAD ref — ls-remote --heads doesn't
263
+ // advertise it, so including it would cause a false-positive size
264
+ // mismatch.
265
+ if (name === 'HEAD') continue;
266
+ localRefs.set(name, sha);
267
+ }
268
+
269
+ // Different ref count → addition or deletion on remote.
270
+ if (remoteRefs.size !== localRefs.size) return true;
271
+
272
+ // Same count, any sha mismatch → update on shared ref.
273
+ for (const [name, sha] of remoteRefs) {
274
+ if (localRefs.get(name) !== sha) return true;
275
+ }
276
+
277
+ return false;
278
+ }
279
+
204
280
  /**
205
281
  * Pull from remote
206
282
  * @param {string} remoteName - Remote name
@@ -512,6 +588,7 @@ module.exports = {
512
588
  getRemotes,
513
589
  remoteExists,
514
590
  fetch,
591
+ hasRemoteChanges,
515
592
  pull,
516
593
  log,
517
594
  getCommitsByDay,