git-watchtower 2.1.8 → 2.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.1.8",
3
+ "version": "2.1.10",
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,13 +91,29 @@ 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 = [];
100
- const seenBranches = new Set();
111
+ // O(1) lookup by name. The Map stores the same object references
112
+ // pushed into branchList, so mutating an entry through the Map (when
113
+ // a remote ref matches a local ref) updates the array entry too.
114
+ // Replaces the previous `branchList.find()` per-remote-ref scan,
115
+ // which was O(n²) and noticeable on large monorepos.
116
+ const branchByName = new Map();
101
117
 
102
118
  // Get local branches
103
119
  // Use \x1f (Unit Separator) as delimiter since | can appear in commit subjects
@@ -111,9 +127,8 @@ async function getAllBranches(options = {}) {
111
127
  for (const line of localResult.stdout.split('\n').filter(Boolean)) {
112
128
  const [name, dateStr, commit, ...subjectParts] = line.split(delimiter);
113
129
  const subject = subjectParts.join(delimiter);
114
- if (!seenBranches.has(name) && isValidBranchName(name)) {
115
- seenBranches.add(name);
116
- branchList.push({
130
+ if (!branchByName.has(name) && isValidBranchName(name)) {
131
+ const branch = {
117
132
  name,
118
133
  commit,
119
134
  subject: subject || '',
@@ -121,7 +136,9 @@ async function getAllBranches(options = {}) {
121
136
  isLocal: true,
122
137
  hasRemote: false,
123
138
  hasUpdates: false,
124
- });
139
+ };
140
+ branchByName.set(name, branch);
141
+ branchList.push(branch);
125
142
  }
126
143
  }
127
144
  }
@@ -142,7 +159,7 @@ async function getAllBranches(options = {}) {
142
159
  if (name === 'HEAD') continue;
143
160
  if (!isValidBranchName(name)) continue;
144
161
 
145
- const existing = /** @type {Branch|undefined} */ (branchList.find((b) => b.name === name));
162
+ const existing = /** @type {Branch|undefined} */ (branchByName.get(name));
146
163
  if (existing) {
147
164
  existing.hasRemote = true;
148
165
  existing.remoteCommit = commit;
@@ -154,9 +171,8 @@ async function getAllBranches(options = {}) {
154
171
  existing.date = new Date(dateStr);
155
172
  existing.subject = subject || existing.subject;
156
173
  }
157
- } else if (!seenBranches.has(name)) {
158
- seenBranches.add(name);
159
- branchList.push({
174
+ } else {
175
+ const branch = {
160
176
  name,
161
177
  commit,
162
178
  subject: subject || '',
@@ -164,7 +180,9 @@ async function getAllBranches(options = {}) {
164
180
  isLocal: false,
165
181
  hasRemote: true,
166
182
  hasUpdates: false,
167
- });
183
+ };
184
+ branchByName.set(name, branch);
185
+ branchList.push(branch);
168
186
  }
169
187
  }
170
188
  }
@@ -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,