@applitools/core 4.65.1 → 4.66.0
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/CHANGELOG.md +117 -0
- package/dist/troubleshoot/ufg.js +4 -0
- package/dist/utils/extract-git-info.js +1047 -237
- package/package.json +21 -19
- package/types/utils/extract-git-info.d.ts +153 -15
|
@@ -26,18 +26,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
26
26
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
27
|
};
|
|
28
28
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
-
exports.extractBranchLookupFallbackList = exports.isISODate = exports.extractBranchingTimestamp = exports.extractBuildIdFromCI = exports.extractCIBranchName = exports.extractGitRepo = exports.extractGitBranch = exports.extractLatestCommitInfo = exports.getPrimaryRemoteName = exports.cacheKey = void 0;
|
|
29
|
+
exports.extractBranchLookupFallbackList = exports.getBranchAncestryByLocalBranches = exports.getBranchAncestryByCommits = exports.isISODate = exports.extractBranchingTimestamp = exports.extractBuildIdFromCI = exports.extractCIBranchName = exports.extractGitRepo = exports.extractGitBranch = exports.extractLatestCommitInfo = exports.extractDefaultBranch = exports.checkGitEnvironment = exports.resolveBranchToLocalRef = exports.getPrimaryRemoteName = exports.cacheKey = void 0;
|
|
30
30
|
const utils = __importStar(require("@applitools/utils"));
|
|
31
31
|
const fs_1 = __importDefault(require("fs"));
|
|
32
32
|
const path_1 = __importDefault(require("path"));
|
|
33
|
+
const shell_quote_1 = require("shell-quote");
|
|
33
34
|
const logger_1 = require("@applitools/logger");
|
|
34
|
-
|
|
35
|
+
/** Verbose diagnostic-logging gate; on when RUNNER_DEBUG=1. */
|
|
35
36
|
const isDebugMode = () => process.env.RUNNER_DEBUG === '1';
|
|
36
37
|
exports.cacheKey = 'default';
|
|
37
|
-
/**
|
|
38
|
-
* Get the primary remote name (cached for performance)
|
|
39
|
-
* Prefers 'origin' if it exists, otherwise uses the first available remote
|
|
40
|
-
*/
|
|
38
|
+
/** Primary remote name (cached). Prefers `origin`, else the first remote. */
|
|
41
39
|
exports.getPrimaryRemoteName = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)() }) {
|
|
42
40
|
const result = await executeWithLog('git remote show', { execOptions, logger });
|
|
43
41
|
if (result.stderr) {
|
|
@@ -54,14 +52,316 @@ exports.getPrimaryRemoteName = utils.general.cachify(async function ({ execOptio
|
|
|
54
52
|
cwd: (_a = args[0].execOptions) === null || _a === void 0 ? void 0 : _a.cwd,
|
|
55
53
|
});
|
|
56
54
|
});
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Resolve `branchName` to a local bare ref so later `git merge-base`/`rev-parse`
|
|
57
|
+
* calls don't fatal in single-branch/shallow clones where the branch lives only
|
|
58
|
+
* on the remote.
|
|
59
|
+
*
|
|
60
|
+
* Resolution order (cheap → expensive):
|
|
61
|
+
* 1. `refs/heads/<name>` — already a local branch.
|
|
62
|
+
* 2. `refs/remotes/<remote>/<name>` — remote-tracking ref; alias it locally
|
|
63
|
+
* via `git branch <name> <remote>/<name>`.
|
|
64
|
+
* 3. `git ls-remote --heads <remote> <name>` — remote-only; fetch it in as
|
|
65
|
+
* a local-branch ref.
|
|
66
|
+
* 4. `undefined` — unknown branch.
|
|
67
|
+
*
|
|
68
|
+
* Never throws. NOT cachified — it has filesystem side-effects.
|
|
69
|
+
*/
|
|
70
|
+
async function resolveBranchToLocalRef({ branchName, remoteName, execOptions, logger = (0, logger_1.makeLogger)(), }) {
|
|
71
|
+
// 1+2. Probe local and remote-tracking refs in parallel — independent reads;
|
|
72
|
+
// saves a sequential round-trip in the common CI miss case.
|
|
73
|
+
const [local, tracking] = await Promise.all([
|
|
74
|
+
executeWithLog((0, shell_quote_1.quote)(['git', 'rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`]), {
|
|
75
|
+
execOptions,
|
|
76
|
+
logger,
|
|
77
|
+
}),
|
|
78
|
+
executeWithLog((0, shell_quote_1.quote)(['git', 'rev-parse', '--verify', '--quiet', `refs/remotes/${remoteName}/${branchName}`]), {
|
|
79
|
+
execOptions,
|
|
80
|
+
logger,
|
|
81
|
+
}),
|
|
82
|
+
]);
|
|
83
|
+
// 1. Local branch already exists.
|
|
84
|
+
if (local.code === 0 && local.stdout.trim()) {
|
|
85
|
+
return branchName;
|
|
86
|
+
}
|
|
87
|
+
// 2. Remote-tracking ref exists — create the local-branch alias.
|
|
88
|
+
if (tracking.code === 0 && tracking.stdout.trim()) {
|
|
89
|
+
const create = await executeWithLog((0, shell_quote_1.quote)(['git', 'branch', branchName, `${remoteName}/${branchName}`]), {
|
|
90
|
+
execOptions,
|
|
91
|
+
logger,
|
|
92
|
+
});
|
|
93
|
+
if (create.code === 0) {
|
|
94
|
+
logger.log(`resolveBranchToLocalRef: created local ref refs/heads/${branchName} from ${remoteName}/${branchName}`);
|
|
95
|
+
return branchName;
|
|
96
|
+
}
|
|
97
|
+
// Race or already-exists — recheck local ref before giving up.
|
|
98
|
+
const recheck = await executeWithLog((0, shell_quote_1.quote)(['git', 'rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`]), {
|
|
99
|
+
execOptions,
|
|
100
|
+
logger,
|
|
101
|
+
});
|
|
102
|
+
if (recheck.code === 0 && recheck.stdout.trim())
|
|
103
|
+
return branchName;
|
|
104
|
+
}
|
|
105
|
+
// 3. Branch lives only on the remote — fetch into a local-branch ref.
|
|
106
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
107
|
+
if (env.remoteAccessible) {
|
|
108
|
+
const lsR = await executeWithLog((0, shell_quote_1.quote)(['git', 'ls-remote', '--heads', remoteName, branchName]), {
|
|
109
|
+
execOptions,
|
|
110
|
+
logger,
|
|
111
|
+
});
|
|
112
|
+
if (lsR.code === 0 && lsR.stdout.trim()) {
|
|
113
|
+
const fetched = await executeWithLog((0, shell_quote_1.quote)(['git', 'fetch', remoteName, `${branchName}:${branchName}`, '--filter=tree:0']), { execOptions, logger });
|
|
114
|
+
if (fetched.code === 0) {
|
|
115
|
+
logger.log(`resolveBranchToLocalRef: fetched ${branchName} from ${remoteName} into refs/heads/${branchName}`);
|
|
116
|
+
return branchName;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
exports.resolveBranchToLocalRef = resolveBranchToLocalRef;
|
|
123
|
+
/**
|
|
124
|
+
* One-shot, cached probe of the git environment, staged cheap → expensive:
|
|
125
|
+
* 1. git installed? (`git --version`)
|
|
126
|
+
* 2. inside a work tree? (`git rev-parse --is-inside-work-tree`)
|
|
127
|
+
* 3. remote configured? (`git remote`)
|
|
128
|
+
* 4. remote reachable? (`git ls-remote <r> HEAD`)
|
|
129
|
+
*
|
|
130
|
+
* Outermost guard for any path that shells out to git; each step short-circuits
|
|
131
|
+
* the rest. Callers consult `ok` for local-only features, `remoteAccessible`
|
|
132
|
+
* for network ops. Cached per `cwd`; first call emits a grep-friendly
|
|
133
|
+
* `APPLITOOLS GIT ENVIRONMENT CHECK` banner, later calls return silently.
|
|
134
|
+
*/
|
|
135
|
+
exports.checkGitEnvironment = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)(), }) {
|
|
136
|
+
// INVARIANT: MUST NOT reject. Cachified — a rejected promise would be
|
|
137
|
+
// memoized and re-thrown by every downstream caller. Every path returns via
|
|
138
|
+
// `finalize()` (can't throw) or is caught by the outer safety net below.
|
|
139
|
+
var _a, _b, _c, _d, _e, _f;
|
|
140
|
+
let gitInstalled = false;
|
|
141
|
+
let insideRepo = false;
|
|
142
|
+
let remoteAccessible = false;
|
|
143
|
+
let remoteName;
|
|
144
|
+
const reasons = [];
|
|
145
|
+
const finalize = (extraReason) => {
|
|
146
|
+
if (extraReason)
|
|
147
|
+
reasons.push(extraReason);
|
|
148
|
+
const result = {
|
|
149
|
+
gitInstalled,
|
|
150
|
+
insideRepo,
|
|
151
|
+
remoteAccessible,
|
|
152
|
+
ok: gitInstalled && insideRepo,
|
|
153
|
+
reason: reasons.length ? reasons.join('; ') : 'ok',
|
|
154
|
+
remoteName,
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
logGitEnvironmentBanner(logger, result);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Logger failures must NEVER make the env check itself fail.
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
};
|
|
59
164
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
165
|
+
try {
|
|
166
|
+
// 1. git installed? Non-fatal — failures finalize with a clean result.
|
|
167
|
+
const versionResult = await executeWithLog('git --version', { execOptions, logger });
|
|
168
|
+
if (versionResult.code !== 0) {
|
|
169
|
+
return finalize(`git binary: ${((_a = versionResult.stderr) === null || _a === void 0 ? void 0 : _a.trim()) || `exit ${versionResult.code} (command not found?)`}`);
|
|
170
|
+
}
|
|
171
|
+
gitInstalled = true;
|
|
172
|
+
// 2. Inside a work tree?
|
|
173
|
+
const repoResult = await executeWithLog('git rev-parse --is-inside-work-tree', { execOptions, logger });
|
|
174
|
+
if (repoResult.code !== 0 || repoResult.stdout.trim() !== 'true') {
|
|
175
|
+
return finalize(`repo: ${((_b = repoResult.stderr) === null || _b === void 0 ? void 0 : _b.trim()) || 'not inside a git work tree'}`);
|
|
176
|
+
}
|
|
177
|
+
insideRepo = true;
|
|
178
|
+
// 3. Remote configured?
|
|
179
|
+
const remotesResult = await executeWithLog('git remote', { execOptions, logger });
|
|
180
|
+
const remotesText = remotesResult.stdout.trim();
|
|
181
|
+
if (remotesResult.code !== 0 || !remotesText) {
|
|
182
|
+
return finalize(`remote: ${((_c = remotesResult.stderr) === null || _c === void 0 ? void 0 : _c.trim()) || 'no remote configured'}`);
|
|
183
|
+
}
|
|
184
|
+
// Non-empty text → split+filter yields ≥1 element.
|
|
185
|
+
const remotes = remotesText.split(/\s+/).filter(Boolean);
|
|
186
|
+
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
187
|
+
// 4. Remote reachable?
|
|
188
|
+
const probe = await executeWithLog((0, shell_quote_1.quote)(['git', 'ls-remote', remoteName, 'HEAD']), { execOptions, logger });
|
|
189
|
+
if (probe.code !== 0) {
|
|
190
|
+
return finalize(`remote: ${((_d = probe.stderr) === null || _d === void 0 ? void 0 : _d.trim()) || `ls-remote exit ${probe.code}`}`);
|
|
191
|
+
}
|
|
192
|
+
remoteAccessible = true;
|
|
193
|
+
return finalize();
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
// Catches `executeWithLog` rejections (e.g. spawn ENOENT for a missing
|
|
197
|
+
// git binary). Falls through to a clean finalize.
|
|
198
|
+
return finalize(`probe threw: ${(_e = err === null || err === void 0 ? void 0 : err.message) !== null && _e !== void 0 ? _e : err}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (catastrophic) {
|
|
202
|
+
// Last-resort safety net — `finalize` shouldn't throw, but if anything
|
|
203
|
+
// truly unexpected happens, return an "all-failed" result, never reject.
|
|
204
|
+
return {
|
|
205
|
+
gitInstalled: false,
|
|
206
|
+
insideRepo: false,
|
|
207
|
+
remoteAccessible: false,
|
|
208
|
+
ok: false,
|
|
209
|
+
reason: `checkGitEnvironment catastrophic failure: ${(_f = catastrophic === null || catastrophic === void 0 ? void 0 : catastrophic.message) !== null && _f !== void 0 ? _f : catastrophic}`,
|
|
210
|
+
remoteName: undefined,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}, args => { var _a, _b; return ({ cwd: (_b = (_a = args[0]) === null || _a === void 0 ? void 0 : _a.execOptions) === null || _b === void 0 ? void 0 : _b.cwd }); });
|
|
214
|
+
/**
|
|
215
|
+
* Grep-friendly env banner. The `APPLITOOLS GIT ENVIRONMENT CHECK` prefix is
|
|
216
|
+
* unique to this codepath, so support can find the full verdict in one line.
|
|
217
|
+
*/
|
|
218
|
+
function logGitEnvironmentBanner(logger, r) {
|
|
219
|
+
const sep = '============================================================';
|
|
220
|
+
const allGood = r.gitInstalled && r.insideRepo && r.remoteAccessible;
|
|
221
|
+
// Header — names the first failed sub-check (or "all ok").
|
|
222
|
+
logger.log(sep);
|
|
223
|
+
if (allGood) {
|
|
224
|
+
logger.log('APPLITOOLS GIT ENVIRONMENT CHECK ✓ all ok');
|
|
225
|
+
}
|
|
226
|
+
else if (!r.gitInstalled) {
|
|
227
|
+
logger.log('APPLITOOLS GIT ENVIRONMENT CHECK ✗ GIT NOT INSTALLED');
|
|
228
|
+
}
|
|
229
|
+
else if (!r.insideRepo) {
|
|
230
|
+
logger.log('APPLITOOLS GIT ENVIRONMENT CHECK ✗ NOT IN A GIT REPOSITORY');
|
|
231
|
+
}
|
|
232
|
+
else if (!r.remoteAccessible) {
|
|
233
|
+
logger.log('APPLITOOLS GIT ENVIRONMENT CHECK ✗ REMOTE NOT ACCESSIBLE');
|
|
234
|
+
}
|
|
235
|
+
// Per-condition status lines — show every sub-check that was reached.
|
|
236
|
+
logger.log(` git binary: ${r.gitInstalled ? '✓ installed' : '✗ NOT FOUND'}`);
|
|
237
|
+
if (r.gitInstalled) {
|
|
238
|
+
logger.log(` repo: ${r.insideRepo ? '✓ inside work tree' : '✗ NOT INSIDE A WORK TREE'}`);
|
|
239
|
+
}
|
|
240
|
+
if (r.gitInstalled && r.insideRepo) {
|
|
241
|
+
const remoteLine = r.remoteAccessible
|
|
242
|
+
? `✓ accessible (${r.remoteName})`
|
|
243
|
+
: r.remoteName
|
|
244
|
+
? `✗ NOT ACCESSIBLE (${r.remoteName})`
|
|
245
|
+
: '✗ NONE CONFIGURED';
|
|
246
|
+
logger.log(` remote: ${remoteLine}`);
|
|
247
|
+
}
|
|
248
|
+
// Reason + remediation hints for the triggered failure mode.
|
|
249
|
+
if (!allGood) {
|
|
250
|
+
logger.log(` reason: ${r.reason}`);
|
|
251
|
+
if (!r.gitInstalled) {
|
|
252
|
+
logger.log(' → All git-based SCM features will be SKIPPED.');
|
|
253
|
+
logger.log(' → Install git or add it to PATH to enable branch detection and baseline inheritance.');
|
|
254
|
+
}
|
|
255
|
+
else if (!r.insideRepo) {
|
|
256
|
+
logger.log(' → All git-based SCM features will be SKIPPED.');
|
|
257
|
+
logger.log(' → Run this from inside a git working tree to enable branch detection.');
|
|
258
|
+
}
|
|
259
|
+
else if (!r.remoteAccessible) {
|
|
260
|
+
logger.log(' → All remote-dependent fallbacks will be SKIPPED.');
|
|
261
|
+
logger.log(' → Branch lookup may be incomplete. Verify network / credentials / remote config.');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
logger.log(sep);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Detect the repo's default branch. Fallback chain (short-circuits on success;
|
|
268
|
+
* never throws):
|
|
269
|
+
* 1. `APPLITOOLS_DEFAULT_BRANCH` — explicit override.
|
|
270
|
+
* 2. `git symbolic-ref --short refs/remotes/<remote>/HEAD` — authoritative
|
|
271
|
+
* once fetched; `<remote>/` prefix stripped.
|
|
272
|
+
* 3. `git ls-remote --symref <remote> HEAD` — works when the local symref was
|
|
273
|
+
* never set up (single-branch clones, older CI git).
|
|
274
|
+
* 4. `git config --get init.defaultBranch` — user-global preference.
|
|
275
|
+
* 5. Literal `'main'` — last resort.
|
|
276
|
+
*
|
|
277
|
+
* Steps 2-3 catch non-standard defaults (e.g. `team/.../v1.x-rc`).
|
|
278
|
+
* Cached per `cwd`.
|
|
279
|
+
*/
|
|
280
|
+
exports.extractDefaultBranch = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)() }) {
|
|
281
|
+
var _a;
|
|
282
|
+
logger = logger.extend({ tags: [`extract-default-branch-${utils.general.shortid()}`] });
|
|
283
|
+
// 1. Explicit override — works even without git / outside a repo.
|
|
284
|
+
const envOverride = (_a = process.env.APPLITOOLS_DEFAULT_BRANCH) === null || _a === void 0 ? void 0 : _a.trim();
|
|
285
|
+
if (envOverride) {
|
|
286
|
+
logger.log(`extractDefaultBranch: using APPLITOOLS_DEFAULT_BRANCH="${envOverride}"`);
|
|
287
|
+
return envOverride;
|
|
288
|
+
}
|
|
289
|
+
// Env gate. Steps 2-4 shell out to git; without it, skip to 'main'.
|
|
290
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
291
|
+
if (!env.ok) {
|
|
292
|
+
logger.log("extractDefaultBranch: git environment not ok, falling back to 'main'");
|
|
293
|
+
return 'main';
|
|
294
|
+
}
|
|
295
|
+
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
296
|
+
// 2. git symbolic-ref --short refs/remotes/<remote>/HEAD
|
|
297
|
+
try {
|
|
298
|
+
const r = await executeWithLog((0, shell_quote_1.quote)(['git', 'symbolic-ref', '--short', `refs/remotes/${remoteName}/HEAD`]), {
|
|
62
299
|
execOptions,
|
|
63
300
|
logger,
|
|
64
301
|
});
|
|
302
|
+
if (!r.stderr && r.stdout.trim()) {
|
|
303
|
+
const out = r.stdout.trim();
|
|
304
|
+
const normalized = out.replace(new RegExp(`^${remoteName}/`), '');
|
|
305
|
+
if (normalized) {
|
|
306
|
+
logger.log(`extractDefaultBranch: resolved via symbolic-ref -> "${normalized}"`);
|
|
307
|
+
return normalized;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
// Expected when the symref isn't set up (fresh single-branch clone).
|
|
313
|
+
if (isDebugMode())
|
|
314
|
+
logger.log('extractDefaultBranch: symbolic-ref step failed, continuing', err);
|
|
315
|
+
}
|
|
316
|
+
// 3. git ls-remote --symref <remote> HEAD
|
|
317
|
+
try {
|
|
318
|
+
const r = await executeWithLog((0, shell_quote_1.quote)(['git', 'ls-remote', '--symref', remoteName, 'HEAD']), { execOptions, logger });
|
|
319
|
+
if (!r.stderr && r.stdout) {
|
|
320
|
+
// Output line: "ref: refs/heads/<name>\tHEAD"
|
|
321
|
+
const match = r.stdout.match(/^ref:\s+refs\/heads\/([^\s]+)\s+HEAD/m);
|
|
322
|
+
if (match && match[1]) {
|
|
323
|
+
logger.log(`extractDefaultBranch: resolved via ls-remote --symref -> "${match[1]}"`);
|
|
324
|
+
return match[1];
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
// Expected without remote access.
|
|
330
|
+
if (isDebugMode())
|
|
331
|
+
logger.log('extractDefaultBranch: ls-remote --symref step failed, continuing', err);
|
|
332
|
+
}
|
|
333
|
+
// 4. git config --get init.defaultBranch (user-global preference)
|
|
334
|
+
try {
|
|
335
|
+
const r = await executeWithLog('git config --get init.defaultBranch', { execOptions, logger });
|
|
336
|
+
if (!r.stderr && r.stdout.trim()) {
|
|
337
|
+
logger.log(`extractDefaultBranch: resolved via init.defaultBranch -> "${r.stdout.trim()}"`);
|
|
338
|
+
return r.stdout.trim();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
// No user-global preference — normal.
|
|
343
|
+
if (isDebugMode())
|
|
344
|
+
logger.log('extractDefaultBranch: init.defaultBranch step failed, continuing', err);
|
|
345
|
+
}
|
|
346
|
+
// 5. Last resort
|
|
347
|
+
logger.log('extractDefaultBranch: falling back to literal "main"');
|
|
348
|
+
return 'main';
|
|
349
|
+
}, args => { var _a, _b; return ({ cwd: (_b = (_a = args[0]) === null || _a === void 0 ? void 0 : _a.execOptions) === null || _b === void 0 ? void 0 : _b.cwd }); });
|
|
350
|
+
exports.extractLatestCommitInfo = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)(), }) {
|
|
351
|
+
// Function-entry env gate. Without git or a repo, there is no commit to extract.
|
|
352
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
353
|
+
if (!env.ok) {
|
|
354
|
+
logger.log('extractLatestCommitInfo: git environment not ok, skipping');
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
let result;
|
|
358
|
+
try {
|
|
359
|
+
const githubPullRequestLastCommitSha = await extractGithubPullRequestLastCommitSha();
|
|
360
|
+
const logArgs = ['git', 'log'];
|
|
361
|
+
if (githubPullRequestLastCommitSha)
|
|
362
|
+
logArgs.push(githubPullRequestLastCommitSha);
|
|
363
|
+
logArgs.push('-1', '--format=%aI %H');
|
|
364
|
+
result = await executeWithLog((0, shell_quote_1.quote)(logArgs), { execOptions, logger });
|
|
65
365
|
if (result.stderr) {
|
|
66
366
|
logger.log(`Error during extracting commit information from git`, result.stderr);
|
|
67
367
|
}
|
|
@@ -82,7 +382,7 @@ exports.extractLatestCommitInfo = utils.general.cachify(async function ({ execOp
|
|
|
82
382
|
var _a, _b, _c;
|
|
83
383
|
if (((_a = process.env.GITHUB_EVENT_NAME) === null || _a === void 0 ? void 0 : _a.startsWith('pull_request')) && process.env.GITHUB_EVENT_PATH) {
|
|
84
384
|
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
85
|
-
await executeWithLog(
|
|
385
|
+
await executeWithLog((0, shell_quote_1.quote)(['git', 'fetch', remoteName, '--depth=2']), { execOptions, logger });
|
|
86
386
|
const event = await fs_1.default.promises.readFile(process.env.GITHUB_EVENT_PATH, 'utf-8').then(JSON.parse);
|
|
87
387
|
return (_c = (_b = event === null || event === void 0 ? void 0 : event.pull_request) === null || _b === void 0 ? void 0 : _b.head) === null || _c === void 0 ? void 0 : _c.sha;
|
|
88
388
|
}
|
|
@@ -90,11 +390,18 @@ exports.extractLatestCommitInfo = utils.general.cachify(async function ({ execOp
|
|
|
90
390
|
}, () => exports.cacheKey);
|
|
91
391
|
exports.extractGitBranch = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)() }) {
|
|
92
392
|
// 1. Try CI environment variables first (fast, no subprocess, handles detached HEAD in CI)
|
|
393
|
+
// This step works even without git installed / outside a repo.
|
|
93
394
|
const ciBranch = extractCIBranchName();
|
|
94
395
|
if (ciBranch) {
|
|
95
396
|
logger.log(`Extracted branch name from CI environment: "${ciBranch}"`);
|
|
96
397
|
return ciBranch;
|
|
97
398
|
}
|
|
399
|
+
// Function-entry env gate for the git-using fallback path below.
|
|
400
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
401
|
+
if (!env.ok) {
|
|
402
|
+
logger.log('extractGitBranch: git environment not ok, no branch name available');
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
98
405
|
// 2. Fall back to git command (works when on an actual branch locally)
|
|
99
406
|
const result = await executeWithLog('git branch --show-current', { execOptions, logger });
|
|
100
407
|
if (result.stderr) {
|
|
@@ -110,10 +417,16 @@ exports.extractGitBranch = utils.general.cachify(async function ({ execOptions,
|
|
|
110
417
|
}
|
|
111
418
|
}, args => { var _a, _b, _c; return ({ cwd: (_b = (_a = args[0]) === null || _a === void 0 ? void 0 : _a.execOptions) === null || _b === void 0 ? void 0 : _b.cwd, ignoreGitBranching: (_c = args[0]) === null || _c === void 0 ? void 0 : _c.ignoreGitBranching }); });
|
|
112
419
|
exports.extractGitRepo = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)() }) {
|
|
420
|
+
// Function-entry env gate. Without git or a repo, we can't extract a remote URL.
|
|
421
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
422
|
+
if (!env.ok) {
|
|
423
|
+
logger.log('extractGitRepo: git environment not ok, returning empty repo info');
|
|
424
|
+
return {};
|
|
425
|
+
}
|
|
113
426
|
const remotes = await extractRemotes();
|
|
114
427
|
logger.log(`Extracted remotes from git: ${remotes}`);
|
|
115
428
|
const remote = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
116
|
-
const result = await executeWithLog(
|
|
429
|
+
const result = await executeWithLog((0, shell_quote_1.quote)(['git', 'remote', 'get-url', remote]), { execOptions, logger });
|
|
117
430
|
if (result.stderr) {
|
|
118
431
|
logger.log(`Error during extracting remote url from git`, result.stderr);
|
|
119
432
|
return {};
|
|
@@ -160,67 +473,114 @@ async function extractBuildIdFromCI() {
|
|
|
160
473
|
);
|
|
161
474
|
}
|
|
162
475
|
exports.extractBuildIdFromCI = extractBuildIdFromCI;
|
|
476
|
+
/**
|
|
477
|
+
* Extract the ISO-8601 timestamp at which `branchName` branched off
|
|
478
|
+
* `parentBranchName`. Steps short-circuit on success; returns `undefined` on
|
|
479
|
+
* total failure — never throws.
|
|
480
|
+
* 1. Shallow clone → unshallow so topology is visible to `git merge-base`.
|
|
481
|
+
* 2. `git merge-base <remote>/<branch> <remote>/<parent>` — remote refs first
|
|
482
|
+
* (CI usually only has these).
|
|
483
|
+
* 3. Local refs fallback (`git merge-base <branch> <parent>`).
|
|
484
|
+
* 4. Still none → if the parent is on the remote, fetch it by name and retry;
|
|
485
|
+
* else give up (branch exists nowhere).
|
|
486
|
+
* 5. Resolve the hash's date via `git show -s --format=%aI`.
|
|
487
|
+
*
|
|
488
|
+
* Cached per (branchName, parentBranchName, cwd).
|
|
489
|
+
*/
|
|
163
490
|
exports.extractBranchingTimestamp = utils.general.cachify(async function ({ branchName, parentBranchName, execOptions, logger = (0, logger_1.makeLogger)(), }) {
|
|
164
|
-
var _a;
|
|
165
491
|
logger = logger.extend({ tags: [`extract-branching-timestamp-${utils.general.shortid()}`] });
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
await
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const [, missingBranch] = (_a = result.stderr.match(/Not a valid object name ([^\s]+)/)) !== null && _a !== void 0 ? _a : [];
|
|
193
|
-
if (missingBranch) {
|
|
194
|
-
// Normalize branch name by removing remote prefix
|
|
195
|
-
const normalizedBranchName = missingBranch.replace(new RegExp(`^${remoteName}/`), '');
|
|
196
|
-
if (!remoteBranches.has(normalizedBranchName)) {
|
|
197
|
-
logger.log(`Branch ${missingBranch} not found on remote, skipping fetch`);
|
|
198
|
-
return undefined; // Exit early - no point in fetching non-existent branch
|
|
199
|
-
}
|
|
200
|
-
// Branch exists on remote, proceed with fetch
|
|
201
|
-
logger.log(`Fetching missing branch ${missingBranch} from remote`);
|
|
202
|
-
const command = `HASH=$(git merge-base ${branchName} ${parentBranchName}) && git show -q --format=%aI $HASH`;
|
|
203
|
-
/*
|
|
204
|
-
// --filter=tree:0 creates a treeless clone.
|
|
205
|
-
// These clones download all reachable commits while fetching trees and blobs on-demand.
|
|
206
|
-
// These clones are best for build environments where the repository will be deleted
|
|
207
|
-
// after a single build, but you still need access to commit history.
|
|
208
|
-
*/
|
|
209
|
-
result = await executeWithLog(`git fetch ${remoteName} ${normalizedBranchName}:${normalizedBranchName} --filter=tree:0 && ${command}`, {
|
|
210
|
-
execOptions,
|
|
211
|
-
logger,
|
|
212
|
-
});
|
|
213
|
-
}
|
|
492
|
+
try {
|
|
493
|
+
// Env gate. Without git or a repo, merge-base is impossible.
|
|
494
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
495
|
+
if (!env.ok) {
|
|
496
|
+
logger.log('extractBranchingTimestamp: git environment not ok, skipping');
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
500
|
+
// Step 1: shallow clones lack the history merge-base needs — unshallow.
|
|
501
|
+
const shallowCheckResult = await executeWithLog('git rev-parse --is-shallow-repository', {
|
|
502
|
+
execOptions,
|
|
503
|
+
logger,
|
|
504
|
+
});
|
|
505
|
+
const isShallow = shallowCheckResult.stdout.trim() === 'true';
|
|
506
|
+
if (isShallow) {
|
|
507
|
+
logger.log('extractBranchingTimestamp - Repository is a shallow clone, attempting to unshallow');
|
|
508
|
+
await executeFetchStrategy(isShallow, execOptions, logger);
|
|
509
|
+
}
|
|
510
|
+
// Common-ancestor SHA between two refs, or null on git failure. `null` is
|
|
511
|
+
// expected during the fallback chain, so the log line is verbose-only.
|
|
512
|
+
async function getMergeBase(ref1, ref2) {
|
|
513
|
+
const { stdout, stderr } = await executeWithLog((0, shell_quote_1.quote)(['git', 'merge-base', ref1, ref2]), { execOptions, logger });
|
|
514
|
+
if (stderr) {
|
|
515
|
+
if (isDebugMode())
|
|
516
|
+
logger.log(`getMergeBase failed for ${ref1} and ${ref2}: ${stderr}`);
|
|
517
|
+
return null;
|
|
214
518
|
}
|
|
519
|
+
return stdout.trim();
|
|
520
|
+
}
|
|
521
|
+
// Resolve a commit hash to its author-date (`%aI`, strict ISO-8601), or
|
|
522
|
+
// undefined on failure.
|
|
523
|
+
async function getTimestampForHash(hash) {
|
|
524
|
+
if (!hash)
|
|
525
|
+
return undefined;
|
|
526
|
+
const { stdout, stderr } = await executeWithLog((0, shell_quote_1.quote)(['git', 'show', '-s', '--format=%aI', hash]), {
|
|
527
|
+
execOptions,
|
|
528
|
+
logger,
|
|
529
|
+
});
|
|
530
|
+
const timestamp = stdout.trim();
|
|
531
|
+
if (stderr || !isISODate(timestamp)) {
|
|
532
|
+
logger.log(`Error extracting timestamp for hash ${hash}: stderr: ${stderr}, stdout: ${stdout}`);
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
return timestamp;
|
|
536
|
+
}
|
|
537
|
+
// Steps 2–4: resolve the merge-base, escalating from remote → local → fetch.
|
|
538
|
+
let mergeBaseHash = null;
|
|
539
|
+
// Step 2: remote refs (preferred — CI typically has these populated).
|
|
540
|
+
mergeBaseHash = await getMergeBase(`${remoteName}/${branchName}`, `${remoteName}/${parentBranchName}`);
|
|
541
|
+
// Step 3: local refs fallback.
|
|
542
|
+
if (!mergeBaseHash) {
|
|
543
|
+
mergeBaseHash = await getMergeBase(branchName, parentBranchName);
|
|
544
|
+
}
|
|
545
|
+
// Step 4: still no merge-base — fetch the missing side(s) by name and
|
|
546
|
+
// retry. Narrowed refspecs (single-branch/shallow) mean a plain
|
|
547
|
+
// `fetch origin` won't pick them up, so fetch each name explicitly when
|
|
548
|
+
// it's on the remote (idempotent). `getAllRemoteBranches` returns an
|
|
549
|
+
// empty set when the remote is unreachable, so `.has(...)` falls through.
|
|
550
|
+
if (!mergeBaseHash) {
|
|
551
|
+
const remoteBranches = await getAllRemoteBranches({ execOptions, logger });
|
|
552
|
+
if (!remoteBranches.has(parentBranchName)) {
|
|
553
|
+
logger.log(`Parent branch ${parentBranchName} not found on remote, cannot determine branching point.`);
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
const branchesToFetch = [parentBranchName];
|
|
557
|
+
if (branchName !== parentBranchName && remoteBranches.has(branchName)) {
|
|
558
|
+
branchesToFetch.push(branchName);
|
|
559
|
+
}
|
|
560
|
+
logger.log(`Fetching missing branches [${branchesToFetch.join(', ')}] from remote and retrying merge-base`);
|
|
561
|
+
for (const branch of branchesToFetch) {
|
|
562
|
+
await executeWithLog((0, shell_quote_1.quote)(['git', 'fetch', remoteName, `${branch}:${branch}`, '--filter=tree:0']), {
|
|
563
|
+
execOptions,
|
|
564
|
+
logger,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
mergeBaseHash = await getMergeBase(branchName, parentBranchName);
|
|
568
|
+
}
|
|
569
|
+
if (!mergeBaseHash) {
|
|
570
|
+
logger.log(`Could not find a merge-base for ${branchName} and ${parentBranchName}.`);
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
// Step 5: hash → ISO timestamp. The merge-base IS the divergence moment;
|
|
574
|
+
// its timestamp is what the server's baseline-inheritance cutoff wants.
|
|
575
|
+
const timestamp = await getTimestampForHash(mergeBaseHash);
|
|
576
|
+
if (timestamp) {
|
|
577
|
+
logger.log(`git branching timestamp for parent '${parentBranchName}' successfully extracted: ${timestamp}`);
|
|
578
|
+
return timestamp;
|
|
215
579
|
}
|
|
216
580
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return timestamp;
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
logger.log(`Error during extracting merge timestamp: git branching timestamp is an invalid ISO date string: ${timestamp}. stderr: ${result.stderr}, stdout: ${result.stdout}`);
|
|
581
|
+
catch (err) {
|
|
582
|
+
logger.log('extractBranchingTimestamp: unexpected error, returning undefined', err);
|
|
583
|
+
return undefined;
|
|
224
584
|
}
|
|
225
585
|
}, args => {
|
|
226
586
|
var _a;
|
|
@@ -245,13 +605,71 @@ function isISODate(str) {
|
|
|
245
605
|
return /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\+\d{2}:\d{2})?/.test(str);
|
|
246
606
|
}
|
|
247
607
|
exports.isISODate = isISODate;
|
|
608
|
+
const GIT_PREDICATE_MAX_ATTEMPTS = 3;
|
|
609
|
+
/**
|
|
610
|
+
* Classify one git boolean-predicate execution into ancestor / not-ancestor /
|
|
611
|
+
* error. `code === 0` with no stderr is the clean positive; `code === 1` with
|
|
612
|
+
* empty stderr is the clean negative; everything else (other code, `null` from
|
|
613
|
+
* a timeout-kill, a string spawn-error code, or any non-empty stderr) is a
|
|
614
|
+
* transient error that callers must not treat as a definitive negative.
|
|
615
|
+
*
|
|
616
|
+
* `code` is typed `number` by the process wrapper, but at runtime `exec` may
|
|
617
|
+
* surface `null`/string/undefined — hence the loose comparisons here.
|
|
618
|
+
*/
|
|
619
|
+
function classifyGitPredicate(result) {
|
|
620
|
+
const code = result.code;
|
|
621
|
+
const hasStderr = Boolean(result.stderr && result.stderr.trim());
|
|
622
|
+
if (code === 0 && !hasStderr)
|
|
623
|
+
return true;
|
|
624
|
+
if (code === 1 && !hasStderr)
|
|
625
|
+
return false;
|
|
626
|
+
return 'error';
|
|
627
|
+
}
|
|
248
628
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* @returns Array of results in the same order as input items
|
|
629
|
+
* Run a git boolean predicate with bounded retry. Returns true/false on a clean
|
|
630
|
+
* result, or `'error'` only after the error persists across all attempts. The
|
|
631
|
+
* no-error path runs exactly once (zero added latency); retries fire solely on
|
|
632
|
+
* a transient classification, with exponential backoff + small jitter.
|
|
254
633
|
*/
|
|
634
|
+
async function runGitPredicateWithRetry(command, { execOptions, logger }) {
|
|
635
|
+
let outcome = 'error';
|
|
636
|
+
for (let attempt = 0; attempt < GIT_PREDICATE_MAX_ATTEMPTS; attempt++) {
|
|
637
|
+
const result = await executeWithLog(command, { execOptions, logger });
|
|
638
|
+
outcome = classifyGitPredicate(result);
|
|
639
|
+
if (outcome !== 'error')
|
|
640
|
+
return outcome;
|
|
641
|
+
if (attempt < GIT_PREDICATE_MAX_ATTEMPTS - 1) {
|
|
642
|
+
await gitRetryBackoff(attempt, command, logger);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return outcome;
|
|
646
|
+
}
|
|
647
|
+
/** Exponential backoff with small jitter between predicate retries. */
|
|
648
|
+
async function gitRetryBackoff(attempt, command, logger) {
|
|
649
|
+
const backoff = 50 * 2 ** attempt + Math.floor(Math.random() * 10);
|
|
650
|
+
if (isDebugMode()) {
|
|
651
|
+
logger.log(`git transient error (attempt ${attempt + 1}), retrying in ${backoff}ms: ${command}`);
|
|
652
|
+
}
|
|
653
|
+
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Run a git `--contains`-style listing with the same transient-error retry as
|
|
657
|
+
* the boolean predicates, but distinguishing empty-output (clean, no matching
|
|
658
|
+
* branches) from a stderr/operational error. Returns stdout (possibly empty) on
|
|
659
|
+
* a clean result; returns `null` only when the error persists across attempts.
|
|
660
|
+
*/
|
|
661
|
+
async function runGitContainsWithRetry(command, { execOptions, logger }) {
|
|
662
|
+
for (let attempt = 0; attempt < GIT_PREDICATE_MAX_ATTEMPTS; attempt++) {
|
|
663
|
+
const result = await executeWithLog(command, { execOptions, logger });
|
|
664
|
+
if (!(result.stderr && result.stderr.trim()))
|
|
665
|
+
return result.stdout;
|
|
666
|
+
if (attempt < GIT_PREDICATE_MAX_ATTEMPTS - 1) {
|
|
667
|
+
await gitRetryBackoff(attempt, command, logger);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return '';
|
|
671
|
+
}
|
|
672
|
+
/** Run `fn` over `items` in batches of `concurrency`, preserving order. */
|
|
255
673
|
async function parallelWithLimit(items, concurrency, fn) {
|
|
256
674
|
const results = [];
|
|
257
675
|
for (let i = 0; i < items.length; i += concurrency) {
|
|
@@ -262,9 +680,394 @@ async function parallelWithLimit(items, concurrency, fn) {
|
|
|
262
680
|
return results;
|
|
263
681
|
}
|
|
264
682
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
683
|
+
* Enumerate ancestor branches in MERGE_BASE..gitBranchName by walking each
|
|
684
|
+
* commit in the window and unioning the branches that contain it. Returns a
|
|
685
|
+
* sorted, deduped list (no `refs/` prefix). Never throws — `[]` on git failure.
|
|
686
|
+
*
|
|
687
|
+
* Inclusion contract (fork-depth — shares semantics with
|
|
688
|
+
* {@link getBranchAncestryByLocalBranches}):
|
|
689
|
+
* - `gitBranchName` is **always excluded** by the self check.
|
|
690
|
+
* - `defaultBranch` is **always kept** as the base: it defines the floor
|
|
691
|
+
* (`merge-base(HEAD, defaultBranch)`), so its own merge-base with HEAD equals
|
|
692
|
+
* the floor and would fail the strict fork-depth test by construction.
|
|
693
|
+
* - Descendants of HEAD are dropped (built on top of us; cannot be baselines).
|
|
694
|
+
* - A candidate `C` is kept iff it forked from HEAD's history **strictly later**
|
|
695
|
+
* than the default branch did — i.e. some `merge-base(HEAD, C)` is a strict
|
|
696
|
+
* descendant of the floor. Cousins that share only the floor (diverged no
|
|
697
|
+
* later than the default branch) are dropped. This prunes the over-inclusive
|
|
698
|
+
* candidate seed (the per-commit `--contains` walk pulls in cousins via
|
|
699
|
+
* criss-cross/ghost merges).
|
|
700
|
+
*
|
|
701
|
+
* Use {@link getBranchAncestryByLocalBranches} if you need both refs pinned.
|
|
702
|
+
*/
|
|
703
|
+
async function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOptions, logger = (0, logger_1.makeLogger)(), }) {
|
|
704
|
+
logger = logger.extend({ tags: [`get-branch-ancestry-by-commits-${utils.general.shortid()}`] });
|
|
705
|
+
if (isDebugMode()) {
|
|
706
|
+
logger.log(`[byCommits] Start: gitBranchName=${gitBranchName}, defaultBranch=${defaultBranch}`);
|
|
707
|
+
}
|
|
708
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
709
|
+
if (!env.ok) {
|
|
710
|
+
logger.log('[byCommits] git environment not ok, returning []');
|
|
711
|
+
return [];
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
// Step 1: MERGE_BASE(HEAD, defaultBranch) anchors the ancestry window —
|
|
715
|
+
// commits after it are uniquely ours.
|
|
716
|
+
let mergeBaseResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'merge-base', gitBranchName, defaultBranch]), {
|
|
717
|
+
execOptions,
|
|
718
|
+
logger,
|
|
719
|
+
});
|
|
720
|
+
if (mergeBaseResult.stderr || !mergeBaseResult.stdout.trim()) {
|
|
721
|
+
// Direct callers may pass a defaultBranch that lives only on the remote.
|
|
722
|
+
// Resolve it to a local ref and retry once before giving up.
|
|
723
|
+
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
724
|
+
const resolved = await resolveBranchToLocalRef({ branchName: defaultBranch, remoteName, execOptions, logger });
|
|
725
|
+
if (resolved) {
|
|
726
|
+
mergeBaseResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'merge-base', gitBranchName, defaultBranch]), {
|
|
727
|
+
execOptions,
|
|
728
|
+
logger,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
if (mergeBaseResult.stderr || !mergeBaseResult.stdout.trim()) {
|
|
732
|
+
logger.log(`[byCommits] ✗ merge-base ${gitBranchName} ${defaultBranch} failed (stderr="${mergeBaseResult.stderr}"), returning []`);
|
|
733
|
+
return [];
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const mergeBase = mergeBaseResult.stdout.trim();
|
|
737
|
+
if (isDebugMode())
|
|
738
|
+
logger.log(`[byCommits] Merge-base resolved: ${mergeBase}`);
|
|
739
|
+
// Step 2: enumerate every commit in the window MERGE_BASE..HEAD.
|
|
740
|
+
const logResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'log', '--format=%H', `${mergeBase}..${gitBranchName}`]), {
|
|
741
|
+
execOptions,
|
|
742
|
+
logger,
|
|
743
|
+
});
|
|
744
|
+
if (logResult.stderr) {
|
|
745
|
+
logger.log(`[byCommits] ✗ git log ${mergeBase}..${gitBranchName} failed (stderr="${logResult.stderr}"), returning []`);
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
const commits = logResult.stdout.trim().split('\n').filter(Boolean);
|
|
749
|
+
if (isDebugMode())
|
|
750
|
+
logger.log(`[byCommits] Walking ${commits.length} commit(s) in window ${mergeBase}..${gitBranchName}`);
|
|
751
|
+
// Step 3: union the branches containing each commit → parent candidates.
|
|
752
|
+
const candidates = new Set([defaultBranch, gitBranchName]);
|
|
753
|
+
const containResults = await parallelWithLimit(commits, 10, async (hash) => {
|
|
754
|
+
// Retry on transient error so a hiccup under parallel load doesn't silently
|
|
755
|
+
// drop the branches a commit belongs to; empty output is a clean negative.
|
|
756
|
+
const r = await runGitContainsWithRetry((0, shell_quote_1.quote)(['git', 'branch', '-a', '--contains', hash, '--format=%(refname:short)']), { execOptions, logger });
|
|
757
|
+
return r
|
|
758
|
+
.split('\n')
|
|
759
|
+
.map(line => line.trim())
|
|
760
|
+
.filter(b => b && b !== 'HEAD');
|
|
761
|
+
});
|
|
762
|
+
// Verbose: full per-commit dump (can be hundreds of entries).
|
|
763
|
+
if (isDebugMode()) {
|
|
764
|
+
logger.log(`[byCommits] Branches containing commits in ${mergeBase}..${gitBranchName}:`, containResults);
|
|
765
|
+
}
|
|
766
|
+
for (const list of containResults)
|
|
767
|
+
for (const b of list)
|
|
768
|
+
candidates.add(b);
|
|
769
|
+
// Branches whose tip sits exactly at the merge-base are not inside the
|
|
770
|
+
// exclusive MERGE_BASE..HEAD window, so the per-commit walk never sees them.
|
|
771
|
+
// Seed them explicitly; the fork-depth filter below prunes the ones that are
|
|
772
|
+
// mere cousins (sharing only the floor) and keeps genuine lineage points.
|
|
773
|
+
const atMergeBase = await runGitContainsWithRetry((0, shell_quote_1.quote)(['git', 'branch', '-a', '--contains', mergeBase, '--format=%(refname:short)']), { execOptions, logger });
|
|
774
|
+
for (const b of atMergeBase
|
|
775
|
+
.split('\n')
|
|
776
|
+
.map(l => l.trim())
|
|
777
|
+
.filter(b => b && b !== 'HEAD')) {
|
|
778
|
+
candidates.add(b);
|
|
779
|
+
}
|
|
780
|
+
if (isDebugMode()) {
|
|
781
|
+
logger.log(`[byCommits] Unioned candidates before descendant filter (${candidates.size}): ${Array.from(candidates).join(', ')}`);
|
|
782
|
+
}
|
|
783
|
+
// Step 4: fork-depth filter. `mergeBase` is the floor — the point where HEAD
|
|
784
|
+
// and the default branch diverged. A candidate is a coherent lineage point
|
|
785
|
+
// iff it forked from HEAD's history STRICTLY LATER than the default branch
|
|
786
|
+
// did. Concretely, per candidate C:
|
|
787
|
+
// (a) self — drop C if it normalizes to HEAD.
|
|
788
|
+
// default base — always keep the default branch: it defines the floor, so
|
|
789
|
+
// its own merge-base with HEAD equals the floor and fails
|
|
790
|
+
// the strict-descendant test (c) by construction; it is the
|
|
791
|
+
// base and is always kept.
|
|
792
|
+
// (b) descendant — drop C if HEAD is its ancestor (built on top of us).
|
|
793
|
+
// (c) fork-depth — keep C iff some merge-base(HEAD, C) is a STRICT
|
|
794
|
+
// descendant of the floor. Cousins that share only the floor
|
|
795
|
+
// (diverged no later than the default branch) are dropped.
|
|
796
|
+
// Criss-cross robustness: `git merge-base` without `--all` returns one
|
|
797
|
+
// unspecified base, which would make (c) nondeterministic in criss-cross
|
|
798
|
+
// topologies (e.g. ghost merges). Use `--all` and keep if ANY base is a
|
|
799
|
+
// strict descendant of the floor.
|
|
800
|
+
// Error policy throughout: an operational/transient git error → KEEP. A
|
|
801
|
+
// false-drop loses a real baseline forever; a false-keep is recoverable
|
|
802
|
+
// downstream.
|
|
803
|
+
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
804
|
+
let droppedSelf = 0;
|
|
805
|
+
let droppedDescendants = 0;
|
|
806
|
+
let droppedCousins = 0;
|
|
807
|
+
const finalCandidates = await parallelWithLimit(Array.from(candidates), 10, async (branch) => {
|
|
808
|
+
// `branch` may be local (`staging`) or remote-tracking (`origin/staging`).
|
|
809
|
+
// Use the as-is form for git ops (git resolves both); strip only for the
|
|
810
|
+
// self-comparison and the return value. In single-branch clones,
|
|
811
|
+
// intermediate ancestors exist ONLY as remote-tracking refs.
|
|
812
|
+
const normalizedBranch = branch.replace(new RegExp(`^${remoteName}/`), '');
|
|
813
|
+
if (normalizedBranch === gitBranchName) {
|
|
814
|
+
droppedSelf++;
|
|
815
|
+
return null; // self
|
|
816
|
+
}
|
|
817
|
+
// Default branch always kept (see (c)-by-construction note above). Name
|
|
818
|
+
// compare rather than a first-parent check: the default branch is the base.
|
|
819
|
+
if (normalizedBranch === defaultBranch) {
|
|
820
|
+
if (isDebugMode())
|
|
821
|
+
logger.log(`[byCommits] KEEP base (default branch): ${normalizedBranch}`);
|
|
822
|
+
return normalizedBranch;
|
|
823
|
+
}
|
|
824
|
+
// (b) `git merge-base --is-ancestor HEAD <candidate>` asks "is HEAD an
|
|
825
|
+
// ancestor of the candidate?" → the candidate is a DESCENDANT of HEAD.
|
|
826
|
+
// Clean-true → drop. Clean-false / error → fall through to (c).
|
|
827
|
+
const isDescendant = await runGitPredicateWithRetry((0, shell_quote_1.quote)(['git', 'merge-base', '--is-ancestor', gitBranchName, branch]), { execOptions, logger });
|
|
828
|
+
if (isDescendant === true) {
|
|
829
|
+
droppedDescendants++;
|
|
830
|
+
if (isDebugMode())
|
|
831
|
+
logger.log(`[byCommits] DROP descendant: ${normalizedBranch}`);
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
// (c) fork-depth. Compute ALL merge bases of HEAD and the candidate.
|
|
835
|
+
// Empty/operational error → KEEP conservatively.
|
|
836
|
+
const mbAllResult = await runGitContainsWithRetry((0, shell_quote_1.quote)(['git', 'merge-base', '--all', gitBranchName, branch]), {
|
|
837
|
+
execOptions,
|
|
838
|
+
logger,
|
|
839
|
+
});
|
|
840
|
+
const mbCs = mbAllResult
|
|
841
|
+
.split('\n')
|
|
842
|
+
.map(line => line.trim())
|
|
843
|
+
.filter(Boolean);
|
|
844
|
+
if (mbCs.length === 0) {
|
|
845
|
+
if (isDebugMode())
|
|
846
|
+
logger.log(`[byCommits] KEEP (no merge-base resolved, conservative): ${normalizedBranch}`);
|
|
847
|
+
return normalizedBranch;
|
|
848
|
+
}
|
|
849
|
+
// Keep iff ANY base is a STRICT descendant of the floor: base != floor AND
|
|
850
|
+
// floor is an ancestor of base. A base == floor (or below it) means C
|
|
851
|
+
// diverged no later than the default branch → cousin for that base. On a
|
|
852
|
+
// transient error probing a base → treat as strict (conservative keep).
|
|
853
|
+
let anyStrictlyAboveFloor = false;
|
|
854
|
+
for (const mbC of mbCs) {
|
|
855
|
+
if (mbC === mergeBase)
|
|
856
|
+
continue; // base == floor → cousin via this base
|
|
857
|
+
const floorIsAncestorOfMbC = await runGitPredicateWithRetry((0, shell_quote_1.quote)(['git', 'merge-base', '--is-ancestor', mergeBase, mbC]), { execOptions, logger });
|
|
858
|
+
if (floorIsAncestorOfMbC === true || floorIsAncestorOfMbC === 'error') {
|
|
859
|
+
anyStrictlyAboveFloor = true;
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
// clean-false → mbC is strictly BELOW the floor (older) → not a fork point
|
|
863
|
+
// via this base; keep checking the remaining bases.
|
|
864
|
+
}
|
|
865
|
+
if (!anyStrictlyAboveFloor) {
|
|
866
|
+
droppedCousins++;
|
|
867
|
+
if (isDebugMode())
|
|
868
|
+
logger.log(`[byCommits] DROP cousin (shares only the floor): ${normalizedBranch}`);
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
if (isDebugMode())
|
|
872
|
+
logger.log(`[byCommits] KEEP lineage point: ${normalizedBranch}`);
|
|
873
|
+
return normalizedBranch;
|
|
874
|
+
});
|
|
875
|
+
const finalCleanBranches = finalCandidates.filter((b) => b !== null);
|
|
876
|
+
const result = Array.from(new Set(finalCleanBranches)).sort();
|
|
877
|
+
if (isDebugMode()) {
|
|
878
|
+
logger.log(`[byCommits] Done. dropped(self=${droppedSelf}, descendants=${droppedDescendants}, cousins=${droppedCousins}) → ${result.length} lineage point(s): ${result.join(', ')}`);
|
|
879
|
+
}
|
|
880
|
+
return result;
|
|
881
|
+
}
|
|
882
|
+
catch (err) {
|
|
883
|
+
logger.log('[byCommits] ✗ ERROR', err);
|
|
884
|
+
return [];
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
exports.getBranchAncestryByCommits = getBranchAncestryByCommits;
|
|
888
|
+
/**
|
|
889
|
+
* Enumerate ancestor branches by iterating every branch ref and applying
|
|
890
|
+
* Rule 1 (fork-depth: candidate must have forked from HEAD strictly later than
|
|
891
|
+
* the default branch did — some merge-base with HEAD is a strict descendant of
|
|
892
|
+
* the primary MERGE_BASE) and Rule 2 (HEAD must not be an ancestor of the
|
|
893
|
+
* candidate). Rule 1 shares the fork-depth semantics of
|
|
894
|
+
* {@link getBranchAncestryByCommits}: cousins sharing only the floor are dropped
|
|
895
|
+
* in BOTH strategies, while the default branch and genuine lineage points are
|
|
896
|
+
* kept in both.
|
|
897
|
+
*
|
|
898
|
+
* Walks `refs/heads/` AND `refs/remotes/<remote>/` (stripping the prefix), since
|
|
899
|
+
* CI typically has one local branch and dozens of remote refs.
|
|
900
|
+
*
|
|
901
|
+
* Returns a sorted, deduped list. Never throws — `[]` on git failure. Always
|
|
902
|
+
* includes `defaultBranch` and `gitBranchName`.
|
|
903
|
+
*/
|
|
904
|
+
async function getBranchAncestryByLocalBranches({ gitBranchName, defaultBranch, execOptions, logger = (0, logger_1.makeLogger)(), }) {
|
|
905
|
+
logger = logger.extend({ tags: [`get-branch-ancestry-by-local-branches-${utils.general.shortid()}`] });
|
|
906
|
+
if (isDebugMode()) {
|
|
907
|
+
logger.log(`[byLocalBranches] Start: gitBranchName=${gitBranchName}, defaultBranch=${defaultBranch}`);
|
|
908
|
+
}
|
|
909
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
910
|
+
if (!env.ok) {
|
|
911
|
+
logger.log('[byLocalBranches] git environment not ok, returning []');
|
|
912
|
+
return [];
|
|
913
|
+
}
|
|
914
|
+
// Per-rule counters for the final summary.
|
|
915
|
+
let droppedNoMb = 0;
|
|
916
|
+
let droppedRule1 = 0;
|
|
917
|
+
let droppedRule2 = 0;
|
|
918
|
+
let pinned = 0;
|
|
919
|
+
try {
|
|
920
|
+
// 1. Primary MERGE_BASE of CURRENT and DEFAULT (used by Rule 1).
|
|
921
|
+
let mergeBaseResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'merge-base', gitBranchName, defaultBranch]), {
|
|
922
|
+
execOptions,
|
|
923
|
+
logger,
|
|
924
|
+
});
|
|
925
|
+
if (mergeBaseResult.stderr || !mergeBaseResult.stdout.trim()) {
|
|
926
|
+
// Direct callers may pass a defaultBranch that lives only on the remote.
|
|
927
|
+
// Resolve it to a local ref and retry once before giving up.
|
|
928
|
+
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
929
|
+
const resolved = await resolveBranchToLocalRef({ branchName: defaultBranch, remoteName, execOptions, logger });
|
|
930
|
+
if (resolved) {
|
|
931
|
+
mergeBaseResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'merge-base', gitBranchName, defaultBranch]), {
|
|
932
|
+
execOptions,
|
|
933
|
+
logger,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
if (mergeBaseResult.stderr || !mergeBaseResult.stdout.trim()) {
|
|
937
|
+
logger.log(`[byLocalBranches] ✗ merge-base ${gitBranchName} ${defaultBranch} failed (stderr="${mergeBaseResult.stderr}"), returning []`);
|
|
938
|
+
return [];
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const mergeBase = mergeBaseResult.stdout.trim();
|
|
942
|
+
if (isDebugMode())
|
|
943
|
+
logger.log(`[byLocalBranches] Primary merge-base: ${mergeBase}`);
|
|
944
|
+
// 2. Enumerate candidate branches from BOTH local AND remote refs.
|
|
945
|
+
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
946
|
+
const refsResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'for-each-ref', '--format=%(refname:short)', 'refs/heads/', `refs/remotes/${remoteName}/`]), { execOptions, logger });
|
|
947
|
+
if (refsResult.stderr) {
|
|
948
|
+
logger.log(`[byLocalBranches] ✗ for-each-ref failed (stderr="${refsResult.stderr}"), returning []`);
|
|
949
|
+
return [];
|
|
950
|
+
}
|
|
951
|
+
// Map: normalized name → as-is ref git can resolve. In single-branch clones
|
|
952
|
+
// intermediate ancestors exist ONLY as remote-tracking refs, so the stripped
|
|
953
|
+
// form would fatal merge-base. Prefer local form when both exist (for-each-ref
|
|
954
|
+
// emits refs/heads/ first).
|
|
955
|
+
const candidateBranches = new Map();
|
|
956
|
+
candidateBranches.set(defaultBranch, defaultBranch);
|
|
957
|
+
candidateBranches.set(gitBranchName, gitBranchName);
|
|
958
|
+
for (const raw of refsResult.stdout.split('\n')) {
|
|
959
|
+
const trimmed = raw.trim();
|
|
960
|
+
if (!trimmed)
|
|
961
|
+
continue;
|
|
962
|
+
const normalized = trimmed.replace(new RegExp(`^${remoteName}/`), '');
|
|
963
|
+
if (!normalized || normalized === 'HEAD')
|
|
964
|
+
continue;
|
|
965
|
+
if (!candidateBranches.has(normalized)) {
|
|
966
|
+
candidateBranches.set(normalized, trimmed);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (isDebugMode()) {
|
|
970
|
+
logger.log(`[byLocalBranches] Enumerated ${candidateBranches.size} candidate(s): ${Array.from(candidateBranches.keys()).join(', ')}`);
|
|
971
|
+
}
|
|
972
|
+
// 3. Apply Rule 1 & 2 to every non-pinned candidate (defaultBranch and
|
|
973
|
+
// gitBranchName always pass through). Entry is [normalized, refForGitOps].
|
|
974
|
+
const results = await parallelWithLimit(Array.from(candidateBranches.entries()), 10, async ([branch, refToUse]) => {
|
|
975
|
+
if (branch === defaultBranch || branch === gitBranchName) {
|
|
976
|
+
pinned++;
|
|
977
|
+
if (isDebugMode())
|
|
978
|
+
logger.log(`[byLocalBranches] KEEP pinned: ${branch}`);
|
|
979
|
+
return branch;
|
|
980
|
+
}
|
|
981
|
+
// Rule 1 (fork-depth — shares semantics with getBranchAncestryByCommits):
|
|
982
|
+
// keep a branch only if it forked from HEAD's history STRICTLY
|
|
983
|
+
// LATER than the default branch did — i.e. some merge-base(HEAD, C)
|
|
984
|
+
// is a STRICT descendant of the primary MERGE_BASE (the floor).
|
|
985
|
+
// A branch sharing ONLY the floor is a cousin and is dropped; both
|
|
986
|
+
// strategies now prune cousins identically AND share the same
|
|
987
|
+
// error→KEEP policy on the floor-ancestor probe (a transient error
|
|
988
|
+
// keeps conservatively). `git merge-base --all` (vs the single best
|
|
989
|
+
// base) gives criss-cross robustness: keep if ANY base is strictly
|
|
990
|
+
// above the floor.
|
|
991
|
+
const branchMbR = await runGitContainsWithRetry((0, shell_quote_1.quote)(['git', 'merge-base', '--all', refToUse, gitBranchName]), {
|
|
992
|
+
execOptions,
|
|
993
|
+
logger,
|
|
994
|
+
});
|
|
995
|
+
const branchMbs = branchMbR
|
|
996
|
+
.split('\n')
|
|
997
|
+
.map(line => line.trim())
|
|
998
|
+
.filter(Boolean);
|
|
999
|
+
if (branchMbs.length === 0) {
|
|
1000
|
+
droppedNoMb++;
|
|
1001
|
+
if (isDebugMode()) {
|
|
1002
|
+
logger.log(`[byLocalBranches] DROP no-merge-base: ${branch}`);
|
|
1003
|
+
}
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
// Keep iff ANY base is a STRICT descendant of the floor. A transient error
|
|
1007
|
+
// probing a base counts as strictly-above-floor (conservative KEEP) — the
|
|
1008
|
+
// same error→KEEP policy as getBranchAncestryByCommits; a false-drop loses a
|
|
1009
|
+
// real baseline while a false-keep is recoverable downstream.
|
|
1010
|
+
let anyStrictlyAboveFloor = false;
|
|
1011
|
+
for (const branchMb of branchMbs) {
|
|
1012
|
+
if (branchMb === mergeBase)
|
|
1013
|
+
continue; // base == floor → cousin via this base
|
|
1014
|
+
const r1 = await runGitPredicateWithRetry((0, shell_quote_1.quote)(['git', 'merge-base', '--is-ancestor', mergeBase, branchMb]), {
|
|
1015
|
+
execOptions,
|
|
1016
|
+
logger,
|
|
1017
|
+
});
|
|
1018
|
+
if (r1 === true || r1 === 'error') {
|
|
1019
|
+
anyStrictlyAboveFloor = true;
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
// clean-false → base strictly BELOW the floor (diverged before our
|
|
1023
|
+
// window); keep checking the remaining bases.
|
|
1024
|
+
}
|
|
1025
|
+
if (!anyStrictlyAboveFloor) {
|
|
1026
|
+
droppedRule1++;
|
|
1027
|
+
if (isDebugMode()) {
|
|
1028
|
+
logger.log(`[byLocalBranches] DROP Rule 1 (cousin — shares only the floor ${mergeBase}): ${branch} — bases=${branchMbs.join(',')}`);
|
|
1029
|
+
}
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
const branchMb = branchMbs[0];
|
|
1033
|
+
// Rule 2: skip branches ahead of CURRENT (HEAD is their ancestor —
|
|
1034
|
+
// descendants, not parents).
|
|
1035
|
+
const r2 = await runGitPredicateWithRetry((0, shell_quote_1.quote)(['git', 'merge-base', '--is-ancestor', gitBranchName, refToUse]), {
|
|
1036
|
+
execOptions,
|
|
1037
|
+
logger,
|
|
1038
|
+
});
|
|
1039
|
+
if (r2 === true) {
|
|
1040
|
+
droppedRule2++;
|
|
1041
|
+
if (isDebugMode()) {
|
|
1042
|
+
logger.log(`[byLocalBranches] DROP Rule 2 (descendant): ${branch} — ${gitBranchName} is its ancestor`);
|
|
1043
|
+
}
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
if (r2 === 'error') {
|
|
1047
|
+
droppedRule2++;
|
|
1048
|
+
if (isDebugMode())
|
|
1049
|
+
logger.log(`[byLocalBranches] DROP Rule 2 (transient error): ${branch}`);
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
if (isDebugMode())
|
|
1053
|
+
logger.log(`[byLocalBranches] KEEP ancestor: ${branch} (mb=${branchMb})`);
|
|
1054
|
+
return branch;
|
|
1055
|
+
});
|
|
1056
|
+
const result = Array.from(new Set(results.filter((b) => b !== null))).sort();
|
|
1057
|
+
if (isDebugMode()) {
|
|
1058
|
+
logger.log(`[byLocalBranches] Done. pinned=${pinned}, dropped(no-mb=${droppedNoMb}, rule1=${droppedRule1}, rule2=${droppedRule2}) → ${result.length} ancestor(s): ${result.join(', ')}`);
|
|
1059
|
+
}
|
|
1060
|
+
return result;
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
logger.log('[byLocalBranches] ✗ ERROR', err);
|
|
1064
|
+
return [];
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
exports.getBranchAncestryByLocalBranches = getBranchAncestryByLocalBranches;
|
|
1068
|
+
/**
|
|
1069
|
+
* All branches on the primary remote via `git ls-remote` (fast, no fetch).
|
|
1070
|
+
* Cached with a 5-minute TTL.
|
|
268
1071
|
*/
|
|
269
1072
|
const getAllRemoteBranches = utils.general.cachify(async function ({ execOptions, logger }) {
|
|
270
1073
|
var _a;
|
|
@@ -272,11 +1075,16 @@ const getAllRemoteBranches = utils.general.cachify(async function ({ execOptions
|
|
|
272
1075
|
logger.log('[getAllRemoteBranches] Starting git ls-remote to fetch all remote branches...');
|
|
273
1076
|
logger.log('[getAllRemoteBranches] execOptions.cwd:', (execOptions === null || execOptions === void 0 ? void 0 : execOptions.cwd) || 'undefined');
|
|
274
1077
|
}
|
|
1078
|
+
// Short-circuit when the remote isn't reachable — ls-remote would fail.
|
|
1079
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
1080
|
+
if (!env.remoteAccessible) {
|
|
1081
|
+
logger.log('[getAllRemoteBranches] Skipping ls-remote (remote not accessible), returning empty set');
|
|
1082
|
+
return new Set();
|
|
1083
|
+
}
|
|
275
1084
|
try {
|
|
276
|
-
// Get the primary remote name (cached)
|
|
277
1085
|
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
278
1086
|
const startTime = Date.now();
|
|
279
|
-
const result = await executeWithLog(
|
|
1087
|
+
const result = await executeWithLog((0, shell_quote_1.quote)(['git', 'ls-remote', '--heads', remoteName]), {
|
|
280
1088
|
execOptions,
|
|
281
1089
|
logger,
|
|
282
1090
|
});
|
|
@@ -299,7 +1107,6 @@ const getAllRemoteBranches = utils.general.cachify(async function ({ execOptions
|
|
|
299
1107
|
const branchName = match[1];
|
|
300
1108
|
branches.add(branchName);
|
|
301
1109
|
if (isDebugMode() && index < 5) {
|
|
302
|
-
// Log first 5 branches for debugging
|
|
303
1110
|
logger.log(`[getAllRemoteBranches] Found branch: ${branchName}`);
|
|
304
1111
|
}
|
|
305
1112
|
}
|
|
@@ -329,26 +1136,32 @@ const getAllRemoteBranches = utils.general.cachify(async function ({ execOptions
|
|
|
329
1136
|
return new Set();
|
|
330
1137
|
}
|
|
331
1138
|
},
|
|
332
|
-
//
|
|
1139
|
+
// Cache by cwd only, not by logger instance.
|
|
333
1140
|
args => { var _a; return ({ cwd: (_a = args[0].execOptions) === null || _a === void 0 ? void 0 : _a.cwd }); }, 5 * 60 * 1000);
|
|
334
1141
|
/**
|
|
335
|
-
* Determine fetch strategy and execute appropriate fetch operation
|
|
1142
|
+
* Determine fetch strategy and execute appropriate fetch operation.
|
|
1143
|
+
* No-op when the remote isn't reachable.
|
|
336
1144
|
*/
|
|
337
1145
|
async function executeFetchStrategy(isShallow, execOptions, logger) {
|
|
1146
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
1147
|
+
if (!env.remoteAccessible) {
|
|
1148
|
+
logger.log('executeFetchStrategy: skipping fetch (remote not accessible)');
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
338
1151
|
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
339
1152
|
if (isShallow) {
|
|
340
|
-
// Shallow clone
|
|
1153
|
+
// Shallow clone → unshallow for topology discovery.
|
|
341
1154
|
logger.log(`Shallow repository detected, unshallowing to enable topology discovery...`);
|
|
342
|
-
await executeWithLog(
|
|
1155
|
+
await executeWithLog((0, shell_quote_1.quote)(['git', 'fetch', remoteName, '--unshallow', '--filter=blob:none']), {
|
|
343
1156
|
execOptions,
|
|
344
1157
|
logger,
|
|
345
1158
|
});
|
|
346
1159
|
logger.log(`Repository unshallowed successfully`);
|
|
347
1160
|
}
|
|
348
1161
|
else {
|
|
349
|
-
//
|
|
1162
|
+
// Single-branch clone → fetch all remote branches for topology discovery.
|
|
350
1163
|
logger.log(`Non-shallow clone detected, fetching all remote branches for topology discovery...`);
|
|
351
|
-
await executeWithLog(
|
|
1164
|
+
await executeWithLog((0, shell_quote_1.quote)(['git', 'fetch', remoteName, '--filter=tree:0']), {
|
|
352
1165
|
execOptions,
|
|
353
1166
|
logger,
|
|
354
1167
|
});
|
|
@@ -356,21 +1169,20 @@ async function executeFetchStrategy(isShallow, execOptions, logger) {
|
|
|
356
1169
|
}
|
|
357
1170
|
}
|
|
358
1171
|
/**
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* @param branchName - The current branch being analyzed
|
|
363
|
-
* @param branchesToFetch - Array of ancestor branch names that need to be fetched (empty = fetch all)
|
|
364
|
-
* @param isShallow - Whether the repository is a shallow clone
|
|
365
|
-
* @param execOptions - Git execution options
|
|
366
|
-
* @param logger - Logger instance
|
|
1172
|
+
* If the current branch tracks a remote, fetch all remote branches so ancestor
|
|
1173
|
+
* checks have the full topology, and populate `refs/remotes/<remote>/HEAD`.
|
|
367
1174
|
*/
|
|
368
1175
|
async function ensureRemoteBranchesAvailable(branchName, isShallow, execOptions, logger) {
|
|
1176
|
+
// Skip when the remote is unreachable — nothing here would do useful work.
|
|
1177
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
1178
|
+
if (!env.remoteAccessible) {
|
|
1179
|
+
logger.log('ensureRemoteBranchesAvailable: skipping (remote not accessible)');
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
369
1182
|
try {
|
|
370
|
-
// Get the primary remote name (cached)
|
|
371
1183
|
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
372
|
-
//
|
|
373
|
-
const symbolicResult = await executeWithLog(
|
|
1184
|
+
// Does the current branch track a remote branch?
|
|
1185
|
+
const symbolicResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'rev-parse', '--abbrev-ref', `${branchName}@{upstream}`]), {
|
|
374
1186
|
execOptions,
|
|
375
1187
|
logger,
|
|
376
1188
|
});
|
|
@@ -379,91 +1191,112 @@ async function ensureRemoteBranchesAvailable(branchName, isShallow, execOptions,
|
|
|
379
1191
|
return;
|
|
380
1192
|
}
|
|
381
1193
|
logger.log(`Detected remote branch scenario, fetching ancestor branches...`);
|
|
382
|
-
//
|
|
383
|
-
await executeWithLog(
|
|
384
|
-
execOptions,
|
|
385
|
-
logger,
|
|
386
|
-
});
|
|
387
|
-
// Execute appropriate fetch strategy
|
|
1194
|
+
// Widen the refspec to fetch all remote branches, then fetch.
|
|
1195
|
+
await executeWithLog((0, shell_quote_1.quote)(['git', 'config', `remote.${remoteName}.fetch`, `+refs/heads/*:refs/remotes/${remoteName}/*`]), { execOptions, logger });
|
|
388
1196
|
await executeFetchStrategy(isShallow, execOptions, logger);
|
|
389
1197
|
logger.log(`Remote branches fetched successfully for complete ancestor check`);
|
|
1198
|
+
// Single-branch clones / actions/checkout@v4 never write
|
|
1199
|
+
// refs/remotes/<remote>/HEAD. Populate it so extractDefaultBranch step 2
|
|
1200
|
+
// (symbolic-ref) succeeds. Non-fatal — steps 3-5 still cover us.
|
|
1201
|
+
try {
|
|
1202
|
+
await executeWithLog((0, shell_quote_1.quote)(['git', 'remote', 'set-head', remoteName, '-a']), { execOptions, logger });
|
|
1203
|
+
}
|
|
1204
|
+
catch (err) {
|
|
1205
|
+
logger.log('ensureRemoteBranchesAvailable: remote set-head -a failed (continuing)', err);
|
|
1206
|
+
}
|
|
390
1207
|
}
|
|
391
1208
|
catch (err) {
|
|
392
1209
|
logger.log('Note: Could not determine if branch is remote, continuing with topology discovery', err);
|
|
393
1210
|
}
|
|
394
1211
|
}
|
|
395
1212
|
/**
|
|
396
|
-
*
|
|
1213
|
+
* List ancestor branches for `gitBranchName`, ordered by most-recent timestamp.
|
|
397
1214
|
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* 5.
|
|
404
|
-
* 6.
|
|
1215
|
+
* 1. Shallow-clone gate (skip via `enableShallowClone`, from server account-info).
|
|
1216
|
+
* 2. Fetch remote branches if needed (single-branch/shallow clones).
|
|
1217
|
+
* 3. Detect the actual default branch (`extractDefaultBranch` — non-standard names).
|
|
1218
|
+
* 4. Discover ancestors via `APPLITOOLS_BRANCH_ANCESTRY_STRATEGY`:
|
|
1219
|
+
* `commits` (default, walks MERGE_BASE..HEAD) | `local` (iterates refs).
|
|
1220
|
+
* 5. Extract each branch's branching timestamp.
|
|
1221
|
+
* 6. Sort by timestamp desc, tie-break by name.
|
|
1222
|
+
* 7. Root-branch safety net: fall back to the detected default branch.
|
|
405
1223
|
*
|
|
406
|
-
* @
|
|
407
|
-
* @param execOptions - Git execution options (e.g., cwd)
|
|
408
|
-
* @param logger - Logger instance for debugging
|
|
409
|
-
* @returns Array of ancestor branches with timestamps, or undefined if skipped
|
|
1224
|
+
* @returns ancestor branches with timestamps, or undefined if skipped.
|
|
410
1225
|
*/
|
|
411
1226
|
exports.extractBranchLookupFallbackList = utils.general.cachify(async function ({ gitBranchName, execOptions, logger = (0, logger_1.makeLogger)(), enableShallowClone = true, }) {
|
|
412
1227
|
const functionStartTime = Date.now();
|
|
413
1228
|
logger = logger.extend({ tags: [`extract-branch-fallback-list-${utils.general.shortid()}`] });
|
|
414
|
-
|
|
1229
|
+
if (isDebugMode())
|
|
1230
|
+
logger.log(`[PERF] extractBranchLookupFallbackList started for branch: ${gitBranchName}`);
|
|
1231
|
+
// Env gate. Everything downstream shells out to git.
|
|
1232
|
+
const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
|
|
1233
|
+
if (!env.ok) {
|
|
1234
|
+
logger.log('extractBranchLookupFallbackList: git environment not ok, skipping');
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
415
1237
|
try {
|
|
416
|
-
// 1.
|
|
417
|
-
//
|
|
1238
|
+
// 1. Shallow-clone gate: when the caller opted out AND the clone is
|
|
1239
|
+
// shallow, skip the expensive unshallow + topology discovery.
|
|
418
1240
|
const shallowCheckStartTime = Date.now();
|
|
419
1241
|
const shallowCheckResult = await executeWithLog('git rev-parse --is-shallow-repository', {
|
|
420
1242
|
execOptions,
|
|
421
1243
|
logger,
|
|
422
1244
|
});
|
|
423
|
-
|
|
1245
|
+
if (isDebugMode())
|
|
1246
|
+
logger.log(`[PERF] Shallow check took ${Date.now() - shallowCheckStartTime}ms`);
|
|
424
1247
|
const isShallow = shallowCheckResult.stdout.trim() === 'true';
|
|
425
1248
|
if (!enableShallowClone && isShallow) {
|
|
426
|
-
logger.log('Shallow clone detected and
|
|
1249
|
+
logger.log('Shallow clone detected and enableShallowClone=false, skipping branch lookup');
|
|
427
1250
|
return undefined;
|
|
428
1251
|
}
|
|
429
|
-
// 2.
|
|
430
|
-
//
|
|
1252
|
+
// 2. Fetch the other branches' refs so subsequent merge-base calls work
|
|
1253
|
+
// (single-branch/shallow clones don't have them locally).
|
|
431
1254
|
const ensureRemoteStartTime = Date.now();
|
|
432
1255
|
await ensureRemoteBranchesAvailable(gitBranchName, isShallow, execOptions, logger);
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
// Instead of listing all remote branches and checking each one, we walk the git graph
|
|
436
|
-
// only along the current branch's history using --first-parent and --simplify-by-decoration
|
|
437
|
-
const topologyStartTime = Date.now();
|
|
438
|
-
logger.log(`Discovering ancestor branches using git topology for ${gitBranchName}...`);
|
|
439
|
-
// Get the primary remote name (cached)
|
|
1256
|
+
if (isDebugMode())
|
|
1257
|
+
logger.log(`[PERF] ensureRemoteBranchesAvailable took ${Date.now() - ensureRemoteStartTime}ms`);
|
|
440
1258
|
const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
1259
|
+
// 3. Detect the repo's actual default branch (non-standard names).
|
|
1260
|
+
const defaultBranch = await (0, exports.extractDefaultBranch)({ execOptions, logger });
|
|
1261
|
+
logger.log(`Default branch detected: ${defaultBranch}`);
|
|
1262
|
+
// 3a. Ensure it resolves as a local ref — extractDefaultBranch may return
|
|
1263
|
+
// a remote-only name or the literal `'main'`, and without a local ref
|
|
1264
|
+
// `git merge-base <feature> <defaultBranch>` fatals (empty ancestry).
|
|
1265
|
+
const resolvedDefaultBranch = await resolveBranchToLocalRef({
|
|
1266
|
+
branchName: defaultBranch,
|
|
1267
|
+
remoteName,
|
|
1268
|
+
execOptions,
|
|
1269
|
+
logger,
|
|
1270
|
+
});
|
|
1271
|
+
if (!resolvedDefaultBranch) {
|
|
1272
|
+
logger.log(`extractBranchLookupFallbackList: default branch "${defaultBranch}" not resolvable locally or on remote, skipping ancestry`);
|
|
1273
|
+
return undefined;
|
|
1274
|
+
}
|
|
1275
|
+
// 4. Discover ancestors: APPLITOOLS_BRANCH_ANCESTRY_STRATEGY=commits|local.
|
|
1276
|
+
const ancestryStrategy = (process.env.APPLITOOLS_BRANCH_ANCESTRY_STRATEGY || 'commits').trim().toLowerCase();
|
|
1277
|
+
const ancestryDriver = ancestryStrategy === 'local' ? getBranchAncestryByLocalBranches : getBranchAncestryByCommits;
|
|
1278
|
+
const ancestryStartTime = Date.now();
|
|
1279
|
+
logger.log(`Discovering ancestor branches via "${ancestryStrategy}" strategy for ${gitBranchName} (default=${defaultBranch})...`);
|
|
1280
|
+
const ancestry = await ancestryDriver({ gitBranchName, defaultBranch, execOptions, logger });
|
|
1281
|
+
if (isDebugMode())
|
|
1282
|
+
logger.log(`[PERF] Ancestry discovery took ${Date.now() - ancestryStartTime}ms`);
|
|
1283
|
+
if (isDebugMode()) {
|
|
1284
|
+
logger.log(`Ancestry discovery found ${ancestry.length} candidate branches: ${ancestry.join(', ')}`);
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
logger.log(`Ancestry discovery found ${ancestry.length} candidate branches`);
|
|
456
1288
|
}
|
|
457
|
-
|
|
458
|
-
|
|
1289
|
+
// Strip the current branch (caller adds it back via timestamp logic).
|
|
1290
|
+
let allBranches = ancestry.filter(b => b !== gitBranchName);
|
|
459
1291
|
if (isDebugMode()) {
|
|
460
|
-
logger.log(`
|
|
1292
|
+
logger.log(`Ancestry discovered ${allBranches.length} potential ancestor branches: ${allBranches.join(', ')}`);
|
|
461
1293
|
}
|
|
462
1294
|
else {
|
|
463
|
-
logger.log(`
|
|
1295
|
+
logger.log(`Ancestry discovered ${allBranches.length} potential ancestor branches`);
|
|
464
1296
|
}
|
|
465
|
-
//
|
|
466
|
-
//
|
|
1297
|
+
// 4b. Drop branches found locally but absent on the remote — working
|
|
1298
|
+
// against them wastes time and surfaces confusing errors. Cheap
|
|
1299
|
+
// (`getAllRemoteBranches` is cached).
|
|
467
1300
|
const remoteBranchesStartTime = Date.now();
|
|
468
1301
|
if (isDebugMode()) {
|
|
469
1302
|
logger.log('[Remote Filtering] ========================================');
|
|
@@ -486,7 +1319,6 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
|
|
|
486
1319
|
const beforeFilter = allBranches.length;
|
|
487
1320
|
if (isDebugMode()) {
|
|
488
1321
|
logger.log('[Remote Filtering] Filtering branches against remote...');
|
|
489
|
-
// Log each branch check only in debug mode
|
|
490
1322
|
allBranches.forEach(branch => {
|
|
491
1323
|
const exists = remoteBranches.has(branch);
|
|
492
1324
|
logger.log(`[Remote Filtering] Branch "${branch}": ${exists ? '✓ EXISTS' : '✗ MISSING'} on remote`);
|
|
@@ -516,76 +1348,60 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
|
|
|
516
1348
|
logger.log('[Remote Filtering] Continuing with all discovered branches');
|
|
517
1349
|
}
|
|
518
1350
|
}
|
|
519
|
-
logger.log(`[Remote Filtering] [PERF] Remote branch filtering took ${Date.now() - remoteBranchesStartTime}ms`);
|
|
520
1351
|
if (isDebugMode()) {
|
|
1352
|
+
logger.log(`[Remote Filtering] [PERF] Remote branch filtering took ${Date.now() - remoteBranchesStartTime}ms`);
|
|
521
1353
|
logger.log('[Remote Filtering] ========================================');
|
|
522
1354
|
}
|
|
523
|
-
//
|
|
524
|
-
// A branch is a true ancestor if it's in the ancestry path of the current branch
|
|
525
|
-
// Use git merge-base --is-ancestor to check if candidate is an ancestor of current branch
|
|
526
|
-
const filteringStartTime = Date.now();
|
|
527
|
-
logger.log(`Filtering out sibling branches to keep only true ancestors...`);
|
|
528
|
-
// PERFORMANCE: Check ancestors in parallel with concurrency limit to avoid overwhelming git
|
|
529
|
-
const ANCESTOR_CHECK_CONCURRENCY = 10;
|
|
530
|
-
const ancestorChecks = await parallelWithLimit(allBranches, ANCESTOR_CHECK_CONCURRENCY, async (candidateBranch) => {
|
|
531
|
-
try {
|
|
532
|
-
// Check if candidate branch is an ancestor of current branch
|
|
533
|
-
// git merge-base --is-ancestor <commit> <commit> exits with status 0 if true, 1 if false
|
|
534
|
-
const isAncestorResult = await executeWithLog(`git merge-base --is-ancestor ${candidateBranch} ${gitBranchName}`, {
|
|
535
|
-
execOptions,
|
|
536
|
-
logger,
|
|
537
|
-
});
|
|
538
|
-
// Exit code 0 means it's an ancestor, exit code 1 means it's not
|
|
539
|
-
if (isAncestorResult.code === 0) {
|
|
540
|
-
logger.log(`✓ ${candidateBranch} is a true ancestor`);
|
|
541
|
-
return { branch: candidateBranch, isAncestor: true };
|
|
542
|
-
}
|
|
543
|
-
else if (isAncestorResult.code === 1) {
|
|
544
|
-
logger.log(`✗ ${candidateBranch} is a sibling, not an ancestor`);
|
|
545
|
-
return { branch: candidateBranch, isAncestor: false };
|
|
546
|
-
}
|
|
547
|
-
else {
|
|
548
|
-
logger.log(`⚠ Could not determine if ${candidateBranch} is an ancestor (exit code: ${isAncestorResult.code}), including it to be safe`);
|
|
549
|
-
return { branch: candidateBranch, isAncestor: true };
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
catch (err) {
|
|
553
|
-
logger.log(`Error checking if ${candidateBranch} is a true ancestor:`, err);
|
|
554
|
-
// If we can't determine, include it to be safe (better to have false positives than miss ancestors)
|
|
555
|
-
return { branch: candidateBranch, isAncestor: true };
|
|
556
|
-
}
|
|
557
|
-
});
|
|
558
|
-
const trueAncestors = ancestorChecks.filter(result => result.isAncestor).map(result => result.branch);
|
|
559
|
-
logger.log(`[PERF] Sibling filtering took ${Date.now() - filteringStartTime}ms`);
|
|
560
|
-
logger.log(`Filtered to ${trueAncestors.length} true ancestors: ${trueAncestors.join(', ')}`);
|
|
561
|
-
// 5. For each true ancestor branch, calculate the branching timestamp
|
|
1355
|
+
// 5. Compute each surviving ancestor's branching timestamp in parallel.
|
|
562
1356
|
const timestampStartTime = Date.now();
|
|
563
|
-
const branchesWithTimestamps = await Promise.all(
|
|
564
|
-
|
|
1357
|
+
const branchesWithTimestamps = await Promise.all(allBranches.map(async (ancestorBranch) => {
|
|
1358
|
+
let timestamp = await (0, exports.extractBranchingTimestamp)({
|
|
565
1359
|
branchName: gitBranchName,
|
|
566
1360
|
parentBranchName: ancestorBranch,
|
|
567
1361
|
execOptions,
|
|
568
1362
|
logger,
|
|
569
1363
|
});
|
|
1364
|
+
// These branches are already confirmed ancestors. A missing timestamp
|
|
1365
|
+
// here is almost always a transient git hiccup under parallel load, so
|
|
1366
|
+
// retry once before considering a fallback.
|
|
1367
|
+
if (!timestamp) {
|
|
1368
|
+
timestamp = await (0, exports.extractBranchingTimestamp)({
|
|
1369
|
+
branchName: gitBranchName,
|
|
1370
|
+
parentBranchName: ancestorBranch,
|
|
1371
|
+
execOptions,
|
|
1372
|
+
logger,
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
// Still no merge-base timestamp: keep the confirmed ancestor rather
|
|
1376
|
+
// than silently dropping it, falling back to the ancestor tip's
|
|
1377
|
+
// commit date so the server still receives it in the lookup list.
|
|
1378
|
+
if (!timestamp) {
|
|
1379
|
+
const tip = await executeWithLog((0, shell_quote_1.quote)(['git', 'show', '-s', '--format=%aI', ancestorBranch]), {
|
|
1380
|
+
execOptions,
|
|
1381
|
+
logger,
|
|
1382
|
+
});
|
|
1383
|
+
const tipTimestamp = tip.stdout.trim();
|
|
1384
|
+
if (!tip.stderr && isISODate(tipTimestamp)) {
|
|
1385
|
+
logger.log(`No branching timestamp for confirmed ancestor '${ancestorBranch}', using tip commit date ${tipTimestamp}`);
|
|
1386
|
+
timestamp = tipTimestamp;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
570
1389
|
return timestamp ? { branchName: ancestorBranch, latestViableTimestamp: timestamp } : null;
|
|
571
1390
|
}));
|
|
572
|
-
|
|
573
|
-
|
|
1391
|
+
if (isDebugMode())
|
|
1392
|
+
logger.log(`[PERF] Timestamp extraction took ${Date.now() - timestampStartTime}ms`);
|
|
1393
|
+
// 6. Drop nulls; sort by timestamp desc, tie-break by branch name.
|
|
574
1394
|
const validBranches = branchesWithTimestamps
|
|
575
1395
|
.filter((item) => item !== null)
|
|
576
1396
|
.sort((a, b) => {
|
|
577
|
-
// Sort descending by timestamp (newest first)
|
|
578
1397
|
const timeDiff = new Date(b.latestViableTimestamp).getTime() - new Date(a.latestViableTimestamp).getTime();
|
|
579
|
-
// If timestamps are equal, sort alphabetically by branch name
|
|
580
1398
|
if (timeDiff === 0) {
|
|
581
1399
|
return a.branchName.localeCompare(b.branchName);
|
|
582
1400
|
}
|
|
583
1401
|
return timeDiff;
|
|
584
1402
|
});
|
|
585
|
-
// 7. Root
|
|
586
|
-
// The root branch is the branch that contains the first commit in the repository
|
|
1403
|
+
// 7. Root-branch safety net (branch containing the repo's first commit).
|
|
587
1404
|
try {
|
|
588
|
-
// Find the first commit (root commit)
|
|
589
1405
|
const firstCommitResult = await executeWithLog('git rev-list --max-parents=0 HEAD', {
|
|
590
1406
|
execOptions,
|
|
591
1407
|
logger,
|
|
@@ -593,45 +1409,29 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
|
|
|
593
1409
|
if (firstCommitResult.stdout.trim()) {
|
|
594
1410
|
const firstCommitHash = firstCommitResult.stdout.trim().split('\n')[0];
|
|
595
1411
|
logger.log(`Found root commit: ${firstCommitHash}`);
|
|
596
|
-
//
|
|
597
|
-
|
|
598
|
-
let branchesContainingRootResult = await executeWithLog(`git branch -r --contains ${firstCommitHash}`, {
|
|
599
|
-
execOptions,
|
|
600
|
-
logger,
|
|
601
|
-
});
|
|
602
|
-
// If no remote branches found, try local branches
|
|
1412
|
+
// Which branches contain the root commit? Remote refs first, local next.
|
|
1413
|
+
let branchesContainingRootResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'branch', '-r', '--contains', firstCommitHash]), { execOptions, logger });
|
|
603
1414
|
if (!branchesContainingRootResult.stdout.trim()) {
|
|
604
1415
|
logger.log('No remote branches contain root commit, trying local branches');
|
|
605
|
-
branchesContainingRootResult = await executeWithLog(
|
|
606
|
-
execOptions,
|
|
607
|
-
logger,
|
|
608
|
-
});
|
|
1416
|
+
branchesContainingRootResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'branch', '--contains', firstCommitHash]), { execOptions, logger });
|
|
609
1417
|
}
|
|
610
1418
|
if (branchesContainingRootResult.stdout.trim()) {
|
|
611
1419
|
const branchesContainingRoot = branchesContainingRootResult.stdout
|
|
612
1420
|
.split('\n')
|
|
613
1421
|
.map(line => line.trim())
|
|
614
1422
|
.filter(line => line && !line.includes('->') && !line.includes('HEAD'))
|
|
615
|
-
.map(line => line.replace(new RegExp(`^${remoteName}/`), '').replace(/^\* /, '')); //
|
|
616
|
-
//
|
|
617
|
-
const commonRootNames = ['main', 'master', 'develop', 'trunk'];
|
|
1423
|
+
.map(line => line.replace(new RegExp(`^${remoteName}/`), '').replace(/^\* /, '')); // strip remote + '* ' prefixes
|
|
1424
|
+
// Prefer the detected default branch over a heuristic.
|
|
618
1425
|
let rootBranch = null;
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
logger.log(`Found root branch using common name: ${rootBranch}`);
|
|
623
|
-
break;
|
|
624
|
-
}
|
|
1426
|
+
if (branchesContainingRoot.includes(defaultBranch)) {
|
|
1427
|
+
rootBranch = defaultBranch;
|
|
1428
|
+
logger.log(`Found root branch via detected defaultBranch: ${rootBranch}`);
|
|
625
1429
|
}
|
|
626
|
-
//
|
|
1430
|
+
// Otherwise fall back to the oldest root-containing branch.
|
|
627
1431
|
if (!rootBranch && branchesContainingRoot.length > 0) {
|
|
628
|
-
// Find the branch with the oldest creation timestamp among those containing root commit
|
|
629
1432
|
const branchTimestamps = await Promise.all(branchesContainingRoot.slice(0, 10).map(async (branch) => {
|
|
630
1433
|
try {
|
|
631
|
-
const branchTimestampResult = await executeWithLog(
|
|
632
|
-
execOptions,
|
|
633
|
-
logger,
|
|
634
|
-
});
|
|
1434
|
+
const branchTimestampResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'log', '-1', '--format=%aI', branch]), { execOptions, logger });
|
|
635
1435
|
return {
|
|
636
1436
|
branch,
|
|
637
1437
|
timestamp: branchTimestampResult.stdout.trim(),
|
|
@@ -649,20 +1449,28 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
|
|
|
649
1449
|
logger.log(`Found root branch by oldest timestamp: ${rootBranch}`);
|
|
650
1450
|
}
|
|
651
1451
|
}
|
|
652
|
-
// Add root branch if found and not already
|
|
1452
|
+
// Add the root branch if found and not already present.
|
|
653
1453
|
if (rootBranch && rootBranch !== gitBranchName) {
|
|
654
1454
|
const alreadyIncluded = validBranches.some(b => b.branchName === rootBranch);
|
|
655
1455
|
if (!alreadyIncluded) {
|
|
656
|
-
// Verify
|
|
657
|
-
//
|
|
1456
|
+
// Verify it's a true ancestor — guards against sibling branches
|
|
1457
|
+
// that merely contain the root commit. The pinned base is the
|
|
1458
|
+
// exception: it can advance past the branch point (no longer a
|
|
1459
|
+
// strict ancestor), so accept it when it shares history with HEAD.
|
|
658
1460
|
try {
|
|
659
|
-
const isAncestorResult = await executeWithLog(
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
1461
|
+
const isAncestorResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'merge-base', '--is-ancestor', rootBranch, gitBranchName]), { execOptions, logger });
|
|
1462
|
+
let accepted = isAncestorResult.code === 0;
|
|
1463
|
+
if (!accepted && rootBranch === defaultBranch) {
|
|
1464
|
+
const sharedHistory = await executeWithLog((0, shell_quote_1.quote)(['git', 'merge-base', rootBranch, gitBranchName]), { execOptions, logger });
|
|
1465
|
+
if (!sharedHistory.stderr && sharedHistory.stdout.trim()) {
|
|
1466
|
+
accepted = true;
|
|
1467
|
+
logger.log(`Root branch ${rootBranch} is the advanced base, adding it as final fallback`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
if (accepted) {
|
|
1471
|
+
if (isAncestorResult.code === 0) {
|
|
1472
|
+
logger.log(`Root branch ${rootBranch} is a true ancestor, adding it as final fallback`);
|
|
1473
|
+
}
|
|
666
1474
|
const rootTimestamp = await (0, exports.extractBranchingTimestamp)({
|
|
667
1475
|
branchName: gitBranchName,
|
|
668
1476
|
parentBranchName: rootBranch,
|
|
@@ -682,8 +1490,8 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
|
|
|
682
1490
|
}
|
|
683
1491
|
}
|
|
684
1492
|
catch (err) {
|
|
1493
|
+
// Can't determine → don't add it (conservative).
|
|
685
1494
|
logger.log(`Error checking if root branch ${rootBranch} is an ancestor:`, err);
|
|
686
|
-
// If we can't determine, don't add it (better to be conservative)
|
|
687
1495
|
}
|
|
688
1496
|
}
|
|
689
1497
|
else {
|
|
@@ -696,7 +1504,9 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
|
|
|
696
1504
|
catch (err) {
|
|
697
1505
|
logger.log('Failed to detect and add root branch, continuing without it', err);
|
|
698
1506
|
}
|
|
699
|
-
|
|
1507
|
+
if (isDebugMode()) {
|
|
1508
|
+
logger.log(`[PERF] extractBranchLookupFallbackList completed in ${Date.now() - functionStartTime}ms total`);
|
|
1509
|
+
}
|
|
700
1510
|
logger.log('Successfully extracted branch lookup fallback list', JSON.stringify(validBranches));
|
|
701
1511
|
return validBranches.length > 0 ? validBranches : undefined;
|
|
702
1512
|
}
|