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 +1 -1
- package/src/git/branch.js +31 -13
- package/src/git/commands.js +77 -0
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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 (!
|
|
115
|
-
|
|
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} */ (
|
|
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
|
|
158
|
-
|
|
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
|
}
|
package/src/git/commands.js
CHANGED
|
@@ -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,
|